mirror of
https://github.com/ankitects/anki.git
synced 2025-09-25 17:26:36 -04:00
Merge pull request #1307 from hgiesel/editablecontainer
Image Resizer + Maximum image size
This commit is contained in:
commit
46985f0084
32 changed files with 1128 additions and 88 deletions
|
@ -1,3 +1,4 @@
|
||||||
|
editing-actual-size = Toggle actual size
|
||||||
editing-add-media = Add Media
|
editing-add-media = Add Media
|
||||||
editing-align-left = Align left
|
editing-align-left = Align left
|
||||||
editing-align-right = Align right
|
editing-align-right = Align right
|
||||||
|
@ -15,6 +16,9 @@ editing-cut = Cut
|
||||||
editing-edit-current = Edit Current
|
editing-edit-current = Edit Current
|
||||||
editing-edit-html = Edit HTML
|
editing-edit-html = Edit HTML
|
||||||
editing-fields = Fields
|
editing-fields = Fields
|
||||||
|
editing-float-left = Float left
|
||||||
|
editing-float-right = Float right
|
||||||
|
editing-float-none = No float
|
||||||
editing-html-editor = HTML Editor
|
editing-html-editor = HTML Editor
|
||||||
editing-indent = Increase indent
|
editing-indent = Increase indent
|
||||||
editing-italic-text = Italic text
|
editing-italic-text = Italic text
|
||||||
|
|
|
@ -19,6 +19,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
export { className as class };
|
export { className as class };
|
||||||
|
|
||||||
export let size: number | undefined = undefined;
|
export let size: number | undefined = undefined;
|
||||||
|
|
||||||
export let wrap: boolean | undefined = undefined;
|
export let wrap: boolean | undefined = undefined;
|
||||||
|
|
||||||
$: buttonSize = size ? `--buttons-size: ${size}rem; ` : "";
|
$: buttonSize = size ? `--buttons-size: ${size}rem; ` : "";
|
||||||
|
@ -45,9 +46,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
if ($items.length === 1) {
|
if ($items.length === 1) {
|
||||||
return ButtonPosition.Standalone;
|
return ButtonPosition.Standalone;
|
||||||
} else if (index === 0) {
|
} else if (index === 0) {
|
||||||
return ButtonPosition.Leftmost;
|
return ButtonPosition.InlineStart;
|
||||||
} else if (index === $items.length - 1) {
|
} else if (index === $items.length - 1) {
|
||||||
return ButtonPosition.Rightmost;
|
return ButtonPosition.InlineEnd;
|
||||||
} else {
|
} else {
|
||||||
return ButtonPosition.Center;
|
return ButtonPosition.Center;
|
||||||
}
|
}
|
||||||
|
@ -95,7 +96,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<div
|
<div
|
||||||
bind:this={buttonGroupRef}
|
bind:this={buttonGroupRef}
|
||||||
{id}
|
{id}
|
||||||
class={`btn-group ${className}`}
|
class="btn-group {className}"
|
||||||
{style}
|
{style}
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
role="group"
|
role="group"
|
||||||
|
@ -110,6 +111,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
div {
|
div {
|
||||||
|
flex-direction: row;
|
||||||
flex-wrap: var(--buttons-wrap);
|
flex-wrap: var(--buttons-wrap);
|
||||||
padding: calc(var(--buttons-size) / 10);
|
padding: calc(var(--buttons-size) / 10);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
|
@ -20,21 +20,24 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
let position_: ButtonPosition;
|
let position_: ButtonPosition;
|
||||||
let style: string;
|
let style: string;
|
||||||
|
|
||||||
const radius = "calc(var(--buttons-size) / 7.5)";
|
const radius = "5px";
|
||||||
|
|
||||||
|
const leftStyle = `--border-left-radius: ${radius}; --border-right-radius: 0; `;
|
||||||
|
const rightStyle = `--border-left-radius: 0; --border-right-radius: ${radius}; `;
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
switch (position_) {
|
switch (position_) {
|
||||||
case ButtonPosition.Standalone:
|
case ButtonPosition.Standalone:
|
||||||
style = `--border-left-radius: ${radius}; --border-right-radius: ${radius}; `;
|
style = `--border-left-radius: ${radius}; --border-right-radius: ${radius}; `;
|
||||||
break;
|
break;
|
||||||
case ButtonPosition.Leftmost:
|
case ButtonPosition.InlineStart:
|
||||||
style = `--border-left-radius: ${radius}; --border-right-radius: 0; `;
|
style = leftStyle;
|
||||||
break;
|
break;
|
||||||
case ButtonPosition.Center:
|
case ButtonPosition.Center:
|
||||||
style = "--border-left-radius: 0; --border-right-radius: 0; ";
|
style = "--border-left-radius: 0; --border-right-radius: 0; ";
|
||||||
break;
|
break;
|
||||||
case ButtonPosition.Rightmost:
|
case ButtonPosition.InlineEnd:
|
||||||
style = `--border-left-radius: 0; --border-right-radius: ${radius}; `;
|
style = rightStyle;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
export let iconSize: number = 75;
|
export let iconSize: number = 75;
|
||||||
export let widthMultiplier: number = 1;
|
export let widthMultiplier: number = 1;
|
||||||
|
export let flipX: boolean = false;
|
||||||
|
|
||||||
let buttonRef: HTMLButtonElement;
|
let buttonRef: HTMLButtonElement;
|
||||||
|
|
||||||
|
@ -44,7 +45,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
on:click
|
on:click
|
||||||
on:mousedown|preventDefault
|
on:mousedown|preventDefault
|
||||||
>
|
>
|
||||||
<span style={`--width-multiplier: ${widthMultiplier};`}> <slot /> </span>
|
<span class:flip-x={flipX} style={`--width-multiplier: ${widthMultiplier};`}>
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
@ -80,6 +83,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
vertical-align: unset;
|
vertical-align: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.flip-x > :global(svg),
|
||||||
|
&.flip-x > :global(img) {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-toggle::after {
|
.dropdown-toggle::after {
|
||||||
|
|
|
@ -8,27 +8,51 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import { setContext, onDestroy } from "svelte";
|
import { setContext, onDestroy } from "svelte";
|
||||||
import { dropdownKey } from "./context-keys";
|
import { dropdownKey } from "./context-keys";
|
||||||
|
|
||||||
|
export let autoOpen = false;
|
||||||
|
export let autoClose: boolean | "inside" | "outside" = true;
|
||||||
|
|
||||||
|
export let placement = "bottom-start";
|
||||||
|
|
||||||
setContext(dropdownKey, {
|
setContext(dropdownKey, {
|
||||||
dropdown: true,
|
dropdown: true,
|
||||||
"data-bs-toggle": "dropdown",
|
"data-bs-toggle": "dropdown",
|
||||||
});
|
});
|
||||||
|
|
||||||
let dropdown: Dropdown;
|
let dropdown: Dropdown;
|
||||||
|
let dropdownObject: Dropdown;
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
function createDropdown(toggle: HTMLElement): Dropdown {
|
function createDropdown(toggle: HTMLElement): Dropdown {
|
||||||
/* avoid focusing element toggle on menu activation */
|
/* avoid focusing element toggle on menu activation */
|
||||||
toggle.focus = noop;
|
toggle.focus = noop;
|
||||||
dropdown = new Dropdown(toggle, {} as any);
|
dropdown = new Dropdown(toggle, {
|
||||||
|
autoClose,
|
||||||
|
popperConfig: (defaultConfig: Record<string, any>) => ({
|
||||||
|
...defaultConfig,
|
||||||
|
placement,
|
||||||
|
}),
|
||||||
|
} as any);
|
||||||
|
|
||||||
return dropdown;
|
if (autoOpen) {
|
||||||
|
dropdown.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
dropdownObject = {
|
||||||
|
show: dropdown.show.bind(dropdown),
|
||||||
|
toggle: dropdown.toggle.bind(dropdown),
|
||||||
|
hide: dropdown.hide.bind(dropdown),
|
||||||
|
update: dropdown.update.bind(dropdown),
|
||||||
|
dispose: dropdown.dispose.bind(dropdown),
|
||||||
|
};
|
||||||
|
|
||||||
|
return dropdownObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
onDestroy(() => dropdown?.dispose());
|
onDestroy(() => dropdown?.dispose());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<slot {createDropdown} />
|
<slot {createDropdown} {dropdownObject} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
|
@ -5,9 +5,9 @@ import type { Registration } from "./registration";
|
||||||
|
|
||||||
export enum ButtonPosition {
|
export enum ButtonPosition {
|
||||||
Standalone,
|
Standalone,
|
||||||
Leftmost,
|
InlineStart,
|
||||||
Center,
|
Center,
|
||||||
Rightmost,
|
InlineEnd,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ButtonRegistration extends Registration {
|
export interface ButtonRegistration extends Registration {
|
||||||
|
|
|
@ -118,6 +118,14 @@ copy_mdi_icons(
|
||||||
"format-color-text.svg",
|
"format-color-text.svg",
|
||||||
"format-color-highlight.svg",
|
"format-color-highlight.svg",
|
||||||
"color-helper.svg",
|
"color-helper.svg",
|
||||||
|
|
||||||
|
# image handle
|
||||||
|
"format-float-none.svg",
|
||||||
|
"format-float-left.svg",
|
||||||
|
"format-float-right.svg",
|
||||||
|
|
||||||
|
"image-size-select-large.svg",
|
||||||
|
"image-size-select-actual.svg",
|
||||||
],
|
],
|
||||||
visibility = ["//visibility:public"],
|
visibility = ["//visibility:public"],
|
||||||
)
|
)
|
||||||
|
|
|
@ -3,7 +3,6 @@ Copyright: Ankitects Pty Ltd and contributors
|
||||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
-->
|
-->
|
||||||
<script lang="typescript">
|
<script lang="typescript">
|
||||||
import type { EditingArea } from "./editing-area";
|
|
||||||
import * as tr from "lib/i18n";
|
import * as tr from "lib/i18n";
|
||||||
|
|
||||||
import ButtonGroup from "components/ButtonGroup.svelte";
|
import ButtonGroup from "components/ButtonGroup.svelte";
|
||||||
|
@ -15,7 +14,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import OnlyEditable from "./OnlyEditable.svelte";
|
import OnlyEditable from "./OnlyEditable.svelte";
|
||||||
import CommandIconButton from "./CommandIconButton.svelte";
|
import CommandIconButton from "./CommandIconButton.svelte";
|
||||||
|
|
||||||
import { getListItem } from "./helpers";
|
import { getCurrentField, getListItem } from "./helpers";
|
||||||
import {
|
import {
|
||||||
ulIcon,
|
ulIcon,
|
||||||
olIcon,
|
olIcon,
|
||||||
|
@ -31,8 +30,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
export let api = {};
|
export let api = {};
|
||||||
|
|
||||||
function outdentListItem() {
|
function outdentListItem() {
|
||||||
const currentField = document.activeElement as EditingArea;
|
const currentField = getCurrentField();
|
||||||
if (getListItem(currentField.shadowRoot!)) {
|
if (getListItem(currentField.editableContainer.shadowRoot!)) {
|
||||||
document.execCommand("outdent");
|
document.execCommand("outdent");
|
||||||
} else {
|
} else {
|
||||||
alert("Indent/unindent currently only works with lists.");
|
alert("Indent/unindent currently only works with lists.");
|
||||||
|
@ -40,8 +39,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
}
|
}
|
||||||
|
|
||||||
function indentListItem() {
|
function indentListItem() {
|
||||||
const currentField = document.activeElement as EditingArea;
|
const currentField = getCurrentField();
|
||||||
if (getListItem(currentField.shadowRoot!)) {
|
if (getListItem(currentField.editableContainer.shadowRoot!)) {
|
||||||
document.execCommand("indent");
|
document.execCommand("indent");
|
||||||
} else {
|
} else {
|
||||||
alert("Indent/unindent currently only works with lists.");
|
alert("Indent/unindent currently only works with lists.");
|
||||||
|
|
23
ts/editor/HandleBackground.svelte
Normal file
23
ts/editor/HandleBackground.svelte
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, createEventDispatcher } from "svelte";
|
||||||
|
|
||||||
|
let background: HTMLDivElement;
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
onMount(() => dispatch("mount", { background }));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={background} on:mousedown|preventDefault on:dblclick />
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
div {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: black;
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
</style>
|
139
ts/editor/HandleControl.svelte
Normal file
139
ts/editor/HandleControl.svelte
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher, getContext } from "svelte";
|
||||||
|
import { nightModeKey } from "components/context-keys";
|
||||||
|
|
||||||
|
export let offsetX = 0;
|
||||||
|
export let offsetY = 0;
|
||||||
|
|
||||||
|
export let active = false;
|
||||||
|
export let activeSize = 5;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
const nightMode = getContext(nightModeKey);
|
||||||
|
|
||||||
|
const onPointerdown =
|
||||||
|
(north: boolean, west: boolean) =>
|
||||||
|
(event: PointerEvent): void => {
|
||||||
|
dispatch("pointerclick", { north, west, originalEvent: event });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="d-contents"
|
||||||
|
style="--offsetX: {offsetX}px; --offsetY: {offsetY}px; --activeSize: {activeSize}px;"
|
||||||
|
>
|
||||||
|
<div class:nightMode class="bordered" on:mousedown|preventDefault />
|
||||||
|
<div
|
||||||
|
class:nightMode
|
||||||
|
class:active
|
||||||
|
class="control nw"
|
||||||
|
on:mousedown|preventDefault
|
||||||
|
on:pointerdown={onPointerdown(true, true)}
|
||||||
|
on:pointermove
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class:nightMode
|
||||||
|
class:active
|
||||||
|
class="control ne"
|
||||||
|
on:mousedown|preventDefault
|
||||||
|
on:pointerdown={onPointerdown(true, false)}
|
||||||
|
on:pointermove
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class:nightMode
|
||||||
|
class:active
|
||||||
|
class="control sw"
|
||||||
|
on:mousedown|preventDefault
|
||||||
|
on:pointerdown={onPointerdown(false, true)}
|
||||||
|
on:pointermove
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class:nightMode
|
||||||
|
class:active
|
||||||
|
class="control se"
|
||||||
|
on:mousedown|preventDefault
|
||||||
|
on:pointerdown={onPointerdown(false, false)}
|
||||||
|
on:pointermove
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.d-contents {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bordered {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
top: calc(0px - var(--activeSize) + var(--offsetY));
|
||||||
|
bottom: calc(0px - var(--activeSize) + var(--offsetY));
|
||||||
|
left: calc(0px - var(--activeSize) + var(--offsetX));
|
||||||
|
right: calc(0px - var(--activeSize) + var(--offsetX));
|
||||||
|
|
||||||
|
pointer-events: none;
|
||||||
|
border: 2px dashed black;
|
||||||
|
|
||||||
|
&.nightMode {
|
||||||
|
border-color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
width: var(--activeSize);
|
||||||
|
height: var(--activeSize);
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.nightMode {
|
||||||
|
border-color: white;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.nw {
|
||||||
|
top: calc(0px - var(--offsetY));
|
||||||
|
left: calc(0px - var(--offsetX));
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
cursor: nw-resize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ne {
|
||||||
|
top: calc(0px - var(--offsetY));
|
||||||
|
right: calc(0px - var(--offsetX));
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
cursor: ne-resize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sw {
|
||||||
|
bottom: calc(0px - var(--offsetY));
|
||||||
|
left: calc(0px - var(--offsetX));
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
cursor: sw-resize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.se {
|
||||||
|
bottom: calc(0px - var(--offsetY));
|
||||||
|
right: calc(0px - var(--offsetX));
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
cursor: se-resize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
69
ts/editor/HandleLabel.svelte
Normal file
69
ts/editor/HandleLabel.svelte
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { afterUpdate, createEventDispatcher, onMount } from "svelte";
|
||||||
|
|
||||||
|
export let isRtl: boolean;
|
||||||
|
|
||||||
|
let dimensions: HTMLDivElement;
|
||||||
|
let overflowFix = 0;
|
||||||
|
|
||||||
|
function updateOverflow(dimensions: HTMLDivElement) {
|
||||||
|
const boundingClientRect = dimensions.getBoundingClientRect();
|
||||||
|
const overflow = isRtl
|
||||||
|
? window.innerWidth - boundingClientRect.x - boundingClientRect.width
|
||||||
|
: boundingClientRect.x;
|
||||||
|
|
||||||
|
overflowFix = Math.min(0, overflowFix + overflow, overflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOverflowAsync(dimensions: HTMLDivElement) {
|
||||||
|
setTimeout(() => updateOverflow(dimensions));
|
||||||
|
}
|
||||||
|
|
||||||
|
afterUpdate(() => updateOverflow(dimensions));
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
onMount(() => dispatch("mount"));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={dimensions}
|
||||||
|
class="image-handle-dimensions"
|
||||||
|
class:is-rtl={isRtl}
|
||||||
|
style="--overflow-fix: {overflowFix}px"
|
||||||
|
use:updateOverflowAsync
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
div {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
font-size: 13px;
|
||||||
|
color: white;
|
||||||
|
background-color: rgba(0 0 0 / 0.3);
|
||||||
|
border-color: black;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0 5px;
|
||||||
|
|
||||||
|
bottom: 3px;
|
||||||
|
right: 3px;
|
||||||
|
margin-left: 3px;
|
||||||
|
margin-right: var(--overflow-fix, 0);
|
||||||
|
|
||||||
|
&.is-rtl {
|
||||||
|
right: auto;
|
||||||
|
left: 3px;
|
||||||
|
margin-right: 3px;
|
||||||
|
margin-left: var(--overflow-fix, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
56
ts/editor/HandleSelection.svelte
Normal file
56
ts/editor/HandleSelection.svelte
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, createEventDispatcher } from "svelte";
|
||||||
|
export let container: HTMLElement;
|
||||||
|
export let image: HTMLImageElement;
|
||||||
|
|
||||||
|
export let offsetX = 0;
|
||||||
|
export let offsetY = 0;
|
||||||
|
|
||||||
|
let left: number;
|
||||||
|
let top: number;
|
||||||
|
let width: number;
|
||||||
|
let height: number;
|
||||||
|
|
||||||
|
export function updateSelection(_div: HTMLDivElement): void {
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const imageRect = image!.getBoundingClientRect();
|
||||||
|
|
||||||
|
const containerLeft = containerRect.left;
|
||||||
|
const containerTop = containerRect.top;
|
||||||
|
|
||||||
|
left = imageRect!.left - containerLeft;
|
||||||
|
top = imageRect!.top - containerTop;
|
||||||
|
width = image!.clientWidth;
|
||||||
|
height = image!.clientHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
let selection: HTMLDivElement;
|
||||||
|
|
||||||
|
onMount(() => dispatch("mount", { selection }));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={selection}
|
||||||
|
use:updateSelection
|
||||||
|
on:click={(event) =>
|
||||||
|
/* prevent triggering Bootstrap dropdown */ event.stopImmediatePropagation()}
|
||||||
|
style="--left: {left}px; --top: {top}px; --width: {width}px; --height: {height}px; --offsetX: {offsetX}px; --offsetY: {offsetY}px;"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
div {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
left: calc(var(--left, 0px) - var(--offsetX, 0px));
|
||||||
|
top: calc(var(--top, 0px) - var(--offsetY, 0px));
|
||||||
|
width: calc(var(--width) + 2 * var(--offsetX, 0px));
|
||||||
|
height: calc(var(--height) + 2 * var(--offsetY, 0px));
|
||||||
|
}
|
||||||
|
</style>
|
210
ts/editor/ImageHandle.svelte
Normal file
210
ts/editor/ImageHandle.svelte
Normal file
|
@ -0,0 +1,210 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import WithDropdown from "components/WithDropdown.svelte";
|
||||||
|
import ButtonDropdown from "components/ButtonDropdown.svelte";
|
||||||
|
import Item from "components/Item.svelte";
|
||||||
|
|
||||||
|
import WithImageConstrained from "./WithImageConstrained.svelte";
|
||||||
|
import HandleBackground from "./HandleBackground.svelte";
|
||||||
|
import HandleSelection from "./HandleSelection.svelte";
|
||||||
|
import HandleControl from "./HandleControl.svelte";
|
||||||
|
import HandleLabel from "./HandleLabel.svelte";
|
||||||
|
import ImageHandleFloatButtons from "./ImageHandleFloatButtons.svelte";
|
||||||
|
import ImageHandleSizeSelect from "./ImageHandleSizeSelect.svelte";
|
||||||
|
|
||||||
|
import { onDestroy } from "svelte";
|
||||||
|
|
||||||
|
export let container: HTMLElement;
|
||||||
|
export let sheet: CSSStyleSheet;
|
||||||
|
export let activeImage: HTMLImageElement | null = null;
|
||||||
|
export let isRtl: boolean = false;
|
||||||
|
|
||||||
|
$: naturalWidth = activeImage?.naturalWidth;
|
||||||
|
$: naturalHeight = activeImage?.naturalHeight;
|
||||||
|
$: aspectRatio = naturalWidth && naturalHeight ? naturalWidth / naturalHeight : NaN;
|
||||||
|
|
||||||
|
let customDimensions: boolean = false;
|
||||||
|
let actualWidth = "";
|
||||||
|
let actualHeight = "";
|
||||||
|
|
||||||
|
function updateDimensions() {
|
||||||
|
/* we do not want the actual width, but rather the intended display width */
|
||||||
|
const widthAttribute = activeImage!.getAttribute("width");
|
||||||
|
customDimensions = false;
|
||||||
|
|
||||||
|
if (widthAttribute) {
|
||||||
|
actualWidth = widthAttribute;
|
||||||
|
customDimensions = true;
|
||||||
|
} else {
|
||||||
|
actualWidth = String(naturalWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
const heightAttribute = activeImage!.getAttribute("height");
|
||||||
|
if (heightAttribute) {
|
||||||
|
actualHeight = heightAttribute;
|
||||||
|
customDimensions = true;
|
||||||
|
} else if (customDimensions) {
|
||||||
|
actualHeight = String(Math.trunc(Number(actualWidth) / aspectRatio));
|
||||||
|
} else {
|
||||||
|
actualHeight = String(naturalHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let updateSelection: () => void;
|
||||||
|
|
||||||
|
async function updateSizesWithDimensions() {
|
||||||
|
updateSelection();
|
||||||
|
updateDimensions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* window resizing */
|
||||||
|
const resizeObserver = new ResizeObserver(async () => {
|
||||||
|
await updateSizesWithDimensions();
|
||||||
|
});
|
||||||
|
|
||||||
|
$: observes = Boolean(activeImage);
|
||||||
|
$: if (observes) {
|
||||||
|
resizeObserver.observe(container);
|
||||||
|
} else {
|
||||||
|
resizeObserver.unobserve(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* memoized position of image on resize start
|
||||||
|
* prevents frantic behavior when image shift into the next/previous line */
|
||||||
|
let getDragWidth: (event: PointerEvent) => number;
|
||||||
|
let getDragHeight: (event: PointerEvent) => number;
|
||||||
|
|
||||||
|
function setPointerCapture({ detail }: CustomEvent): void {
|
||||||
|
const pointerId = detail.originalEvent.pointerId;
|
||||||
|
|
||||||
|
if (pointerId !== 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageRect = activeImage!.getBoundingClientRect();
|
||||||
|
|
||||||
|
const imageLeft = imageRect!.left;
|
||||||
|
const imageRight = imageRect!.right;
|
||||||
|
const [multX, imageX] = detail.west ? [-1, imageRight] : [1, -imageLeft];
|
||||||
|
|
||||||
|
getDragWidth = ({ clientX }) => multX * clientX + imageX;
|
||||||
|
|
||||||
|
const imageTop = imageRect!.top;
|
||||||
|
const imageBottom = imageRect!.bottom;
|
||||||
|
const [multY, imageY] = detail.north ? [-1, imageBottom] : [1, -imageTop];
|
||||||
|
|
||||||
|
getDragHeight = ({ clientY }) => multY * clientY + imageY;
|
||||||
|
|
||||||
|
const target = detail.originalEvent.target as Element;
|
||||||
|
target.setPointerCapture(pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: [minResizeWidth, minResizeHeight] =
|
||||||
|
aspectRatio > 1 ? [5 * aspectRatio, 5] : [5, 5 / aspectRatio];
|
||||||
|
|
||||||
|
async function resize(event: PointerEvent) {
|
||||||
|
const element = event.target! as Element;
|
||||||
|
|
||||||
|
if (!element.hasPointerCapture(event.pointerId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragWidth = getDragWidth(event);
|
||||||
|
const dragHeight = getDragHeight(event);
|
||||||
|
|
||||||
|
const widthIncrease = dragWidth / naturalWidth!;
|
||||||
|
const heightIncrease = dragHeight / naturalHeight!;
|
||||||
|
|
||||||
|
let width: number;
|
||||||
|
|
||||||
|
if (widthIncrease > heightIncrease) {
|
||||||
|
width = Math.max(Math.trunc(dragWidth), minResizeWidth);
|
||||||
|
} else {
|
||||||
|
let height = Math.max(Math.trunc(dragHeight), minResizeHeight);
|
||||||
|
width = Math.trunc(naturalWidth! * (height / naturalHeight!));
|
||||||
|
}
|
||||||
|
|
||||||
|
activeImage!.width = width;
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => resizeObserver.disconnect());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if sheet}
|
||||||
|
<WithDropdown
|
||||||
|
placement="bottom"
|
||||||
|
autoOpen={true}
|
||||||
|
autoClose={false}
|
||||||
|
let:createDropdown
|
||||||
|
let:dropdownObject
|
||||||
|
>
|
||||||
|
<WithImageConstrained
|
||||||
|
{sheet}
|
||||||
|
{container}
|
||||||
|
{activeImage}
|
||||||
|
on:update={() => {
|
||||||
|
updateSizesWithDimensions();
|
||||||
|
dropdownObject.update();
|
||||||
|
}}
|
||||||
|
let:toggleActualSize
|
||||||
|
let:active
|
||||||
|
>
|
||||||
|
{#if activeImage}
|
||||||
|
<HandleSelection
|
||||||
|
bind:updateSelection
|
||||||
|
{container}
|
||||||
|
image={activeImage}
|
||||||
|
on:mount={(event) => createDropdown(event.detail.selection)}
|
||||||
|
>
|
||||||
|
<HandleBackground on:dblclick={toggleActualSize} />
|
||||||
|
|
||||||
|
<HandleLabel {isRtl} on:mount={updateDimensions}>
|
||||||
|
<span>{actualWidth}×{actualHeight}</span>
|
||||||
|
{#if customDimensions}
|
||||||
|
<span>(Original: {naturalWidth}×{naturalHeight})</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</HandleLabel>
|
||||||
|
|
||||||
|
<HandleControl
|
||||||
|
{active}
|
||||||
|
activeSize={8}
|
||||||
|
offsetX={5}
|
||||||
|
offsetY={5}
|
||||||
|
on:pointerclick={(event) => {
|
||||||
|
if (active) {
|
||||||
|
setPointerCapture(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
on:pointermove={(event) => {
|
||||||
|
resize(event);
|
||||||
|
updateSizesWithDimensions();
|
||||||
|
dropdownObject.update();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</HandleSelection>
|
||||||
|
<ButtonDropdown>
|
||||||
|
<div on:click={updateSizesWithDimensions}>
|
||||||
|
<Item>
|
||||||
|
<ImageHandleFloatButtons
|
||||||
|
image={activeImage}
|
||||||
|
{isRtl}
|
||||||
|
on:update={dropdownObject.update}
|
||||||
|
/>
|
||||||
|
</Item>
|
||||||
|
<Item>
|
||||||
|
<ImageHandleSizeSelect
|
||||||
|
{active}
|
||||||
|
{isRtl}
|
||||||
|
on:click={toggleActualSize}
|
||||||
|
/>
|
||||||
|
</Item>
|
||||||
|
</div>
|
||||||
|
</ButtonDropdown>
|
||||||
|
{/if}
|
||||||
|
</WithImageConstrained>
|
||||||
|
</WithDropdown>
|
||||||
|
{/if}
|
61
ts/editor/ImageHandleFloatButtons.svelte
Normal file
61
ts/editor/ImageHandleFloatButtons.svelte
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="typescript">
|
||||||
|
import * as tr from "lib/i18n";
|
||||||
|
|
||||||
|
import ButtonGroup from "components/ButtonGroup.svelte";
|
||||||
|
import ButtonGroupItem from "components/ButtonGroupItem.svelte";
|
||||||
|
import IconButton from "components/IconButton.svelte";
|
||||||
|
|
||||||
|
import { createEventDispatcher } from "svelte";
|
||||||
|
import { floatNoneIcon, floatLeftIcon, floatRightIcon } from "./icons";
|
||||||
|
|
||||||
|
export let image: HTMLImageElement;
|
||||||
|
export let isRtl: boolean;
|
||||||
|
|
||||||
|
const [inlineStartIcon, inlineEndIcon] = isRtl
|
||||||
|
? [floatRightIcon, floatLeftIcon]
|
||||||
|
: [floatLeftIcon, floatRightIcon];
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ButtonGroup size={1.6} wrap={false}>
|
||||||
|
<ButtonGroupItem>
|
||||||
|
<IconButton
|
||||||
|
tooltip={tr.editingFloatLeft()}
|
||||||
|
active={image.style.float === "left"}
|
||||||
|
flipX={isRtl}
|
||||||
|
on:click={() => {
|
||||||
|
image.style.float = "left";
|
||||||
|
setTimeout(() => dispatch("update"));
|
||||||
|
}}>{@html inlineStartIcon}</IconButton
|
||||||
|
>
|
||||||
|
</ButtonGroupItem>
|
||||||
|
|
||||||
|
<ButtonGroupItem>
|
||||||
|
<IconButton
|
||||||
|
tooltip={tr.editingFloatNone()}
|
||||||
|
active={image.style.float === "" || image.style.float === "none"}
|
||||||
|
flipX={isRtl}
|
||||||
|
on:click={() => {
|
||||||
|
image.style.float = "";
|
||||||
|
setTimeout(() => dispatch("update"));
|
||||||
|
}}>{@html floatNoneIcon}</IconButton
|
||||||
|
>
|
||||||
|
</ButtonGroupItem>
|
||||||
|
|
||||||
|
<ButtonGroupItem>
|
||||||
|
<IconButton
|
||||||
|
tooltip={tr.editingFloatRight()}
|
||||||
|
active={image.style.float === "right"}
|
||||||
|
flipX={isRtl}
|
||||||
|
on:click={() => {
|
||||||
|
image.style.float = "right";
|
||||||
|
setTimeout(() => dispatch("update"));
|
||||||
|
}}>{@html inlineEndIcon}</IconButton
|
||||||
|
>
|
||||||
|
</ButtonGroupItem>
|
||||||
|
</ButtonGroup>
|
26
ts/editor/ImageHandleSizeSelect.svelte
Normal file
26
ts/editor/ImageHandleSizeSelect.svelte
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="typescript">
|
||||||
|
import * as tr from "lib/i18n";
|
||||||
|
|
||||||
|
import ButtonGroup from "components/ButtonGroup.svelte";
|
||||||
|
import ButtonGroupItem from "components/ButtonGroupItem.svelte";
|
||||||
|
import IconButton from "components/IconButton.svelte";
|
||||||
|
|
||||||
|
import { sizeActual, sizeMinimized } from "./icons";
|
||||||
|
|
||||||
|
export let active: boolean;
|
||||||
|
export let isRtl: boolean;
|
||||||
|
|
||||||
|
$: icon = active ? sizeActual : sizeMinimized;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ButtonGroup size={1.6}>
|
||||||
|
<ButtonGroupItem>
|
||||||
|
<IconButton {active} flipX={isRtl} tooltip={tr.editingActualSize()} on:click
|
||||||
|
>{@html icon}</IconButton
|
||||||
|
>
|
||||||
|
</ButtonGroupItem>
|
||||||
|
</ButtonGroup>
|
|
@ -18,8 +18,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import OnlyEditable from "./OnlyEditable.svelte";
|
import OnlyEditable from "./OnlyEditable.svelte";
|
||||||
import ClozeButton from "./ClozeButton.svelte";
|
import ClozeButton from "./ClozeButton.svelte";
|
||||||
|
|
||||||
import { getCurrentField } from ".";
|
import { getCurrentField, appendInParentheses } from "./helpers";
|
||||||
import { appendInParentheses } from "./helpers";
|
|
||||||
import { wrapCurrent } from "./wrap";
|
import { wrapCurrent } from "./wrap";
|
||||||
import { paperclipIcon, micIcon, functionIcon, xmlIcon } from "./icons";
|
import { paperclipIcon, micIcon, functionIcon, xmlIcon } from "./icons";
|
||||||
|
|
||||||
|
|
199
ts/editor/WithImageConstrained.svelte
Normal file
199
ts/editor/WithImageConstrained.svelte
Normal file
|
@ -0,0 +1,199 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="typescript">
|
||||||
|
import { createEventDispatcher, onDestroy } from "svelte";
|
||||||
|
import { nodeIsElement } from "./helpers";
|
||||||
|
|
||||||
|
export let container: HTMLElement;
|
||||||
|
export let sheet: CSSStyleSheet;
|
||||||
|
|
||||||
|
export let activeImage: HTMLImageElement | null;
|
||||||
|
let active: boolean = false;
|
||||||
|
|
||||||
|
$: {
|
||||||
|
const index = images.indexOf(activeImage!);
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
const rule = sheet.cssRules[index] as CSSStyleRule;
|
||||||
|
active = rule.cssText.endsWith("{ }");
|
||||||
|
} else {
|
||||||
|
activeImage = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export let maxWidth = 250;
|
||||||
|
export let maxHeight = 125;
|
||||||
|
|
||||||
|
$: restrictionAspectRatio = maxWidth / maxHeight;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
function createPathRecursive(tokens: string[], element: Element): string[] {
|
||||||
|
const tagName = element.tagName.toLowerCase();
|
||||||
|
|
||||||
|
if (!element.parentElement) {
|
||||||
|
const nth =
|
||||||
|
Array.prototype.indexOf.call(
|
||||||
|
(element.parentNode! as Document | ShadowRoot).children,
|
||||||
|
element
|
||||||
|
) + 1;
|
||||||
|
return [`${tagName}:nth-child(${nth})`, ...tokens];
|
||||||
|
} else {
|
||||||
|
const nth =
|
||||||
|
Array.prototype.indexOf.call(element.parentElement.children, element) +
|
||||||
|
1;
|
||||||
|
return createPathRecursive(
|
||||||
|
[`${tagName}:nth-child(${nth})`, ...tokens],
|
||||||
|
element.parentElement
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPath(element: Element): string {
|
||||||
|
return createPathRecursive([], element).join(" > ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const images: HTMLImageElement[] = [];
|
||||||
|
|
||||||
|
$: for (const [index, image] of images.entries()) {
|
||||||
|
const rule = sheet.cssRules[index] as CSSStyleRule;
|
||||||
|
rule.selectorText = createPath(image);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterImages(nodes: HTMLCollection | Node[]): HTMLImageElement[] {
|
||||||
|
const result: HTMLImageElement[] = [];
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (!nodeIsElement(node)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.tagName === "IMG") {
|
||||||
|
result.push(node as HTMLImageElement);
|
||||||
|
} else {
|
||||||
|
result.push(...filterImages(node.children));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setImageRule(image: HTMLImageElement, rule: CSSStyleRule): void {
|
||||||
|
const aspectRatio = image.naturalWidth / image.naturalHeight;
|
||||||
|
|
||||||
|
if (restrictionAspectRatio - aspectRatio > 1) {
|
||||||
|
// restricted by height
|
||||||
|
rule.style.setProperty("width", "auto", "important");
|
||||||
|
|
||||||
|
const width = Number(image.getAttribute("width")) || image.width;
|
||||||
|
const height = Number(image.getAttribute("height")) || width / aspectRatio;
|
||||||
|
rule.style.setProperty(
|
||||||
|
"height",
|
||||||
|
height < maxHeight ? `${height}px` : "auto",
|
||||||
|
"important"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// square or restricted by width
|
||||||
|
const width = Number(image.getAttribute("width")) || image.width;
|
||||||
|
rule.style.setProperty(
|
||||||
|
"width",
|
||||||
|
width < maxWidth ? `${width}px` : "auto",
|
||||||
|
"important"
|
||||||
|
);
|
||||||
|
|
||||||
|
rule.style.setProperty("height", "auto", "important");
|
||||||
|
}
|
||||||
|
|
||||||
|
rule.style.setProperty("max-width", `min(${maxWidth}px, 100%)`, "important");
|
||||||
|
rule.style.setProperty("max-height", `${maxHeight}px`, "important");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetImageRule(rule: CSSStyleRule): void {
|
||||||
|
rule.style.removeProperty("width");
|
||||||
|
rule.style.removeProperty("height");
|
||||||
|
rule.style.removeProperty("max-width");
|
||||||
|
rule.style.removeProperty("max-height");
|
||||||
|
}
|
||||||
|
|
||||||
|
function addImage(image: HTMLImageElement): void {
|
||||||
|
if (!container.contains(image)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
images.push(image);
|
||||||
|
const index = sheet.insertRule(
|
||||||
|
`${createPath(image)} {}`,
|
||||||
|
sheet.cssRules.length
|
||||||
|
);
|
||||||
|
const rule = sheet.cssRules[index] as CSSStyleRule;
|
||||||
|
setImageRule(image, rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addImageOnLoad(image: HTMLImageElement): void {
|
||||||
|
if (image.complete && image.naturalWidth !== 0 && image.naturalHeight !== 0) {
|
||||||
|
addImage(image);
|
||||||
|
} else {
|
||||||
|
image.addEventListener("load", () => {
|
||||||
|
addImage(image);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImage(image: HTMLImageElement): void {
|
||||||
|
const index = images.indexOf(image);
|
||||||
|
if (index < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
images.splice(index, 1);
|
||||||
|
sheet.deleteRule(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutationObserver = new MutationObserver((mutations) => {
|
||||||
|
const addedImages = mutations.flatMap((mutation) =>
|
||||||
|
filterImages([...mutation.addedNodes])
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const image of addedImages) {
|
||||||
|
addImageOnLoad(image);
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedImages = mutations.flatMap((mutation) =>
|
||||||
|
filterImages([...mutation.removedNodes])
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const image of removedImages) {
|
||||||
|
removeImage(image);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$: if (container) {
|
||||||
|
mutationObserver.observe(container, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleActualSize() {
|
||||||
|
const index = images.indexOf(activeImage!);
|
||||||
|
if (index === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rule = sheet.cssRules[index] as CSSStyleRule;
|
||||||
|
active = !active;
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
resetImageRule(rule);
|
||||||
|
} else {
|
||||||
|
setImageRule(activeImage!, rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch("update", active);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => mutationObserver.disconnect());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if activeImage}
|
||||||
|
<slot {toggleActualSize} {active} />
|
||||||
|
{/if}
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
import type { EditingArea } from "./editing-area";
|
import type { EditingArea } from "./editing-area";
|
||||||
|
|
||||||
import { getCurrentField } from ".";
|
import { getCurrentField } from "./helpers";
|
||||||
import { bridgeCommand } from "./lib";
|
import { bridgeCommand } from "./lib";
|
||||||
import { getNoteId } from "./note-id";
|
import { getNoteId } from "./note-id";
|
||||||
|
|
||||||
|
@ -23,9 +23,10 @@ function clearChangeTimer(): void {
|
||||||
|
|
||||||
export function saveField(currentField: EditingArea, type: "blur" | "key"): void {
|
export function saveField(currentField: EditingArea, type: "blur" | "key"): void {
|
||||||
clearChangeTimer();
|
clearChangeTimer();
|
||||||
bridgeCommand(
|
const command = `${type}:${currentField.ord}:${getNoteId()}:${
|
||||||
`${type}:${currentField.ord}:${getNoteId()}:${currentField.fieldHTML}`
|
currentField.fieldHTML
|
||||||
);
|
}`;
|
||||||
|
bridgeCommand(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveNow(keepFocus: boolean): void {
|
export function saveNow(keepFocus: boolean): void {
|
||||||
|
|
59
ts/editor/editable-container.ts
Normal file
59
ts/editor/editable-container.ts
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
/* eslint
|
||||||
|
@typescript-eslint/no-non-null-assertion: "off",
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class EditableContainer extends HTMLDivElement {
|
||||||
|
baseStyle: HTMLStyleElement;
|
||||||
|
baseRule?: CSSStyleRule;
|
||||||
|
imageStyle?: HTMLStyleElement;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const shadow = this.attachShadow({ mode: "open" });
|
||||||
|
|
||||||
|
if (document.documentElement.classList.contains("night-mode")) {
|
||||||
|
this.classList.add("night-mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootStyle = document.createElement("link");
|
||||||
|
rootStyle.setAttribute("rel", "stylesheet");
|
||||||
|
rootStyle.setAttribute("href", "./_anki/css/editable.css");
|
||||||
|
shadow.appendChild(rootStyle);
|
||||||
|
|
||||||
|
this.baseStyle = document.createElement("style");
|
||||||
|
this.baseStyle.setAttribute("rel", "stylesheet");
|
||||||
|
this.baseStyle.id = "baseStyle";
|
||||||
|
shadow.appendChild(this.baseStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback(): void {
|
||||||
|
const sheet = this.baseStyle.sheet as CSSStyleSheet;
|
||||||
|
const baseIndex = sheet.insertRule("anki-editable {}");
|
||||||
|
this.baseRule = sheet.cssRules[baseIndex] as CSSStyleRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize(color: string): void {
|
||||||
|
this.setBaseColor(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
setBaseColor(color: string): void {
|
||||||
|
if (this.baseRule) {
|
||||||
|
this.baseRule.style.color = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setBaseStyling(fontFamily: string, fontSize: string, direction: string): void {
|
||||||
|
if (this.baseRule) {
|
||||||
|
this.baseRule.style.fontFamily = fontFamily;
|
||||||
|
this.baseRule.style.fontSize = fontSize;
|
||||||
|
this.baseRule.style.direction = direction;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isRightToLeft(): boolean {
|
||||||
|
return this.baseRule!.style.direction === "rtl";
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,6 +6,10 @@ anki-editable {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,8 +3,12 @@
|
||||||
|
|
||||||
/* eslint
|
/* eslint
|
||||||
@typescript-eslint/no-non-null-assertion: "off",
|
@typescript-eslint/no-non-null-assertion: "off",
|
||||||
|
@typescript-eslint/no-explicit-any: "off",
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import ImageHandle from "./ImageHandle.svelte";
|
||||||
|
|
||||||
|
import type { EditableContainer } from "./editable-container";
|
||||||
import type { Editable } from "./editable";
|
import type { Editable } from "./editable";
|
||||||
import type { Codable } from "./codable";
|
import type { Codable } from "./codable";
|
||||||
|
|
||||||
|
@ -12,43 +16,71 @@ import { updateActiveButtons } from "./toolbar";
|
||||||
import { bridgeCommand } from "./lib";
|
import { bridgeCommand } from "./lib";
|
||||||
import { onInput, onKey, onKeyUp } from "./input-handlers";
|
import { onInput, onKey, onKeyUp } from "./input-handlers";
|
||||||
import { onFocus, onBlur } from "./focus-handlers";
|
import { onFocus, onBlur } from "./focus-handlers";
|
||||||
|
import { nightModeKey } from "components/context-keys";
|
||||||
|
|
||||||
function onCutOrCopy(): void {
|
function onCutOrCopy(): void {
|
||||||
bridgeCommand("cutOrCopy");
|
bridgeCommand("cutOrCopy");
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EditingArea extends HTMLDivElement {
|
export class EditingArea extends HTMLDivElement {
|
||||||
|
imageHandle: Promise<ImageHandle>;
|
||||||
|
editableContainer: EditableContainer;
|
||||||
editable: Editable;
|
editable: Editable;
|
||||||
codable: Codable;
|
codable: Codable;
|
||||||
baseStyle: HTMLStyleElement;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: "open" });
|
|
||||||
this.className = "field";
|
this.className = "field";
|
||||||
|
|
||||||
if (document.documentElement.classList.contains("night-mode")) {
|
this.editableContainer = document.createElement("div", {
|
||||||
this.classList.add("night-mode");
|
is: "anki-editable-container",
|
||||||
}
|
}) as EditableContainer;
|
||||||
|
|
||||||
const rootStyle = document.createElement("link");
|
const imageStyle = document.createElement("style");
|
||||||
rootStyle.setAttribute("rel", "stylesheet");
|
imageStyle.setAttribute("rel", "stylesheet");
|
||||||
rootStyle.setAttribute("href", "./_anki/css/editable.css");
|
imageStyle.id = "imageHandleStyle";
|
||||||
this.shadowRoot!.appendChild(rootStyle);
|
|
||||||
|
|
||||||
this.baseStyle = document.createElement("style");
|
|
||||||
this.baseStyle.setAttribute("rel", "stylesheet");
|
|
||||||
this.shadowRoot!.appendChild(this.baseStyle);
|
|
||||||
|
|
||||||
this.editable = document.createElement("anki-editable") as Editable;
|
this.editable = document.createElement("anki-editable") as Editable;
|
||||||
this.shadowRoot!.appendChild(this.editable);
|
|
||||||
|
const context = new Map();
|
||||||
|
context.set(
|
||||||
|
nightModeKey,
|
||||||
|
document.documentElement.classList.contains("night-mode")
|
||||||
|
);
|
||||||
|
|
||||||
|
let imageHandleResolve: (value: ImageHandle) => void;
|
||||||
|
this.imageHandle = new Promise<ImageHandle>((resolve) => {
|
||||||
|
imageHandleResolve = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
imageStyle.addEventListener("load", () =>
|
||||||
|
imageHandleResolve(
|
||||||
|
new ImageHandle({
|
||||||
|
target: this,
|
||||||
|
anchor: this.editableContainer,
|
||||||
|
props: {
|
||||||
|
container: this.editable,
|
||||||
|
sheet: imageStyle.sheet,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
} as any)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.editableContainer.shadowRoot!.appendChild(imageStyle);
|
||||||
|
this.editableContainer.shadowRoot!.appendChild(this.editable);
|
||||||
|
this.appendChild(this.editableContainer);
|
||||||
|
|
||||||
this.codable = document.createElement("textarea", {
|
this.codable = document.createElement("textarea", {
|
||||||
is: "anki-codable",
|
is: "anki-codable",
|
||||||
}) as Codable;
|
}) as Codable;
|
||||||
this.shadowRoot!.appendChild(this.codable);
|
this.appendChild(this.codable);
|
||||||
|
|
||||||
|
this.onFocus = this.onFocus.bind(this);
|
||||||
|
this.onBlur = this.onBlur.bind(this);
|
||||||
|
this.onKey = this.onKey.bind(this);
|
||||||
this.onPaste = this.onPaste.bind(this);
|
this.onPaste = this.onPaste.bind(this);
|
||||||
|
this.showImageHandle = this.showImageHandle.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
get activeInput(): Editable | Codable {
|
get activeInput(): Editable | Codable {
|
||||||
|
@ -60,7 +92,7 @@ export class EditingArea extends HTMLDivElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
set fieldHTML(content: string) {
|
set fieldHTML(content: string) {
|
||||||
this.activeInput.fieldHTML = content;
|
this.imageHandle.then(() => (this.activeInput.fieldHTML = content));
|
||||||
}
|
}
|
||||||
|
|
||||||
get fieldHTML(): string {
|
get fieldHTML(): string {
|
||||||
|
@ -68,30 +100,29 @@ export class EditingArea extends HTMLDivElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
this.addEventListener("keydown", onKey);
|
this.addEventListener("keydown", this.onKey);
|
||||||
this.addEventListener("keyup", onKeyUp);
|
this.addEventListener("keyup", onKeyUp);
|
||||||
this.addEventListener("input", onInput);
|
this.addEventListener("input", onInput);
|
||||||
this.addEventListener("focus", onFocus);
|
this.addEventListener("focusin", this.onFocus);
|
||||||
this.addEventListener("blur", onBlur);
|
this.addEventListener("focusout", this.onBlur);
|
||||||
this.addEventListener("paste", this.onPaste);
|
this.addEventListener("paste", this.onPaste);
|
||||||
this.addEventListener("copy", onCutOrCopy);
|
this.addEventListener("copy", onCutOrCopy);
|
||||||
this.addEventListener("oncut", onCutOrCopy);
|
this.addEventListener("oncut", onCutOrCopy);
|
||||||
this.addEventListener("mouseup", updateActiveButtons);
|
this.addEventListener("mouseup", updateActiveButtons);
|
||||||
|
this.editable.addEventListener("click", this.showImageHandle);
|
||||||
const baseStyleSheet = this.baseStyle.sheet as CSSStyleSheet;
|
|
||||||
baseStyleSheet.insertRule("anki-editable {}", 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
this.removeEventListener("keydown", onKey);
|
this.removeEventListener("keydown", this.onKey);
|
||||||
this.removeEventListener("keyup", onKeyUp);
|
this.removeEventListener("keyup", onKeyUp);
|
||||||
this.removeEventListener("input", onInput);
|
this.removeEventListener("input", onInput);
|
||||||
this.removeEventListener("focus", onFocus);
|
this.removeEventListener("focusin", this.onFocus);
|
||||||
this.removeEventListener("blur", onBlur);
|
this.removeEventListener("focusout", this.onBlur);
|
||||||
this.removeEventListener("paste", this.onPaste);
|
this.removeEventListener("paste", this.onPaste);
|
||||||
this.removeEventListener("copy", onCutOrCopy);
|
this.removeEventListener("copy", onCutOrCopy);
|
||||||
this.removeEventListener("oncut", onCutOrCopy);
|
this.removeEventListener("oncut", onCutOrCopy);
|
||||||
this.removeEventListener("mouseup", updateActiveButtons);
|
this.removeEventListener("mouseup", updateActiveButtons);
|
||||||
|
this.editable.removeEventListener("click", this.showImageHandle);
|
||||||
}
|
}
|
||||||
|
|
||||||
initialize(color: string, content: string): void {
|
initialize(color: string, content: string): void {
|
||||||
|
@ -100,9 +131,7 @@ export class EditingArea extends HTMLDivElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
setBaseColor(color: string): void {
|
setBaseColor(color: string): void {
|
||||||
const styleSheet = this.baseStyle.sheet as CSSStyleSheet;
|
this.editableContainer.setBaseColor(color);
|
||||||
const firstRule = styleSheet.cssRules[0] as CSSStyleRule;
|
|
||||||
firstRule.style.color = color;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
quoteFontFamily(fontFamily: string): string {
|
quoteFontFamily(fontFamily: string): string {
|
||||||
|
@ -114,17 +143,15 @@ export class EditingArea extends HTMLDivElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
setBaseStyling(fontFamily: string, fontSize: string, direction: string): void {
|
setBaseStyling(fontFamily: string, fontSize: string, direction: string): void {
|
||||||
const styleSheet = this.baseStyle.sheet as CSSStyleSheet;
|
this.editableContainer.setBaseStyling(
|
||||||
const firstRule = styleSheet.cssRules[0] as CSSStyleRule;
|
this.quoteFontFamily(fontFamily),
|
||||||
firstRule.style.fontFamily = this.quoteFontFamily(fontFamily);
|
fontSize,
|
||||||
firstRule.style.fontSize = fontSize;
|
direction
|
||||||
firstRule.style.direction = direction;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
isRightToLeft(): boolean {
|
isRightToLeft(): boolean {
|
||||||
const styleSheet = this.baseStyle.sheet as CSSStyleSheet;
|
return this.editableContainer.isRightToLeft();
|
||||||
const firstRule = styleSheet.cssRules[0] as CSSStyleRule;
|
|
||||||
return firstRule.style.direction === "rtl";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
focus(): void {
|
focus(): void {
|
||||||
|
@ -140,25 +167,62 @@ export class EditingArea extends HTMLDivElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
hasFocus(): boolean {
|
hasFocus(): boolean {
|
||||||
return document.activeElement === this;
|
return document.activeElement?.closest(".field") === this;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSelection(): Selection {
|
getSelection(): Selection {
|
||||||
return this.shadowRoot!.getSelection()!;
|
const root = this.activeInput.getRootNode() as Document | ShadowRoot;
|
||||||
|
return root.getSelection()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
surroundSelection(before: string, after: string): void {
|
surroundSelection(before: string, after: string): void {
|
||||||
this.activeInput.surroundSelection(before, after);
|
this.activeInput.surroundSelection(before, after);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onFocus(event: FocusEvent): void {
|
||||||
|
onFocus(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
onBlur(event: FocusEvent): void {
|
||||||
|
this.resetImageHandle();
|
||||||
|
onBlur(event);
|
||||||
|
}
|
||||||
|
|
||||||
onEnter(event: KeyboardEvent): void {
|
onEnter(event: KeyboardEvent): void {
|
||||||
this.activeInput.onEnter(event);
|
this.activeInput.onEnter(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onKey(event: KeyboardEvent): void {
|
||||||
|
this.resetImageHandle();
|
||||||
|
onKey(event);
|
||||||
|
}
|
||||||
|
|
||||||
onPaste(event: ClipboardEvent): void {
|
onPaste(event: ClipboardEvent): void {
|
||||||
|
this.resetImageHandle();
|
||||||
this.activeInput.onPaste(event);
|
this.activeInput.onPaste(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetImageHandle(): void {
|
||||||
|
this.imageHandle.then((imageHandle) =>
|
||||||
|
(imageHandle as any).$set({
|
||||||
|
activeImage: null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
showImageHandle(event: MouseEvent): void {
|
||||||
|
if (event.target instanceof HTMLImageElement) {
|
||||||
|
this.imageHandle.then((imageHandle) =>
|
||||||
|
(imageHandle as any).$set({
|
||||||
|
activeImage: event.target,
|
||||||
|
isRtl: this.isRightToLeft(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.resetImageHandle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
toggleHtmlEdit(): void {
|
toggleHtmlEdit(): void {
|
||||||
const hadFocus = this.hasFocus();
|
const hadFocus = this.hasFocus();
|
||||||
|
|
||||||
|
@ -166,6 +230,7 @@ export class EditingArea extends HTMLDivElement {
|
||||||
this.fieldHTML = this.codable.teardown();
|
this.fieldHTML = this.codable.teardown();
|
||||||
this.editable.hidden = false;
|
this.editable.hidden = false;
|
||||||
} else {
|
} else {
|
||||||
|
this.resetImageHandle();
|
||||||
this.editable.hidden = true;
|
this.editable.hidden = true;
|
||||||
this.codable.setup(this.editable.fieldHTML);
|
this.codable.setup(this.editable.fieldHTML);
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,8 @@ export class EditorField extends HTMLDivElement {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
this.classList.add("editor-field");
|
||||||
|
|
||||||
this.labelContainer = document.createElement("div", {
|
this.labelContainer = document.createElement("div", {
|
||||||
is: "anki-label-container",
|
is: "anki-label-container",
|
||||||
}) as LabelContainer;
|
}) as LabelContainer;
|
||||||
|
@ -19,6 +21,8 @@ export class EditorField extends HTMLDivElement {
|
||||||
is: "anki-editing-area",
|
is: "anki-editing-area",
|
||||||
}) as EditingArea;
|
}) as EditingArea;
|
||||||
this.appendChild(this.editingArea);
|
this.appendChild(this.editingArea);
|
||||||
|
|
||||||
|
this.focusIfNotFocused = this.focusIfNotFocused.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
static get observedAttributes(): string[] {
|
static get observedAttributes(): string[] {
|
||||||
|
@ -29,6 +33,21 @@ export class EditorField extends HTMLDivElement {
|
||||||
this.setAttribute("ord", String(n));
|
this.setAttribute("ord", String(n));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
focusIfNotFocused(): void {
|
||||||
|
if (!this.editingArea.hasFocus()) {
|
||||||
|
this.editingArea.focus();
|
||||||
|
this.editingArea.caretToEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback(): void {
|
||||||
|
this.labelContainer.addEventListener("mousedown", this.focusIfNotFocused);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
this.labelContainer.removeEventListener("mousedown", this.focusIfNotFocused);
|
||||||
|
}
|
||||||
|
|
||||||
attributeChangedCallback(name: string, _oldValue: string, newValue: string): void {
|
attributeChangedCallback(name: string, _oldValue: string, newValue: string): void {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "ord":
|
case "ord":
|
||||||
|
|
|
@ -10,13 +10,30 @@
|
||||||
|
|
||||||
#fields {
|
#fields {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
overflow-x: hidden;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin: 5px;
|
margin: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-field {
|
||||||
|
margin: 3px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
--border-color: var(--border);
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
box-shadow: 0 0 0 3px var(--focus-shadow);
|
||||||
|
|
||||||
|
--border-color: var(--focus-border);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
border: 1px solid var(--border);
|
position: relative;
|
||||||
|
|
||||||
background: var(--frame-bg);
|
background: var(--frame-bg);
|
||||||
|
border-radius: 0 0 5px 5px;
|
||||||
|
|
||||||
&.dupe {
|
&.dupe {
|
||||||
// this works around the background colour persisting in copy+paste
|
// this works around the background colour persisting in copy+paste
|
||||||
|
@ -26,8 +43,16 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.fname {
|
.fname {
|
||||||
vertical-align: middle;
|
border-width: 0 0 1px;
|
||||||
padding: 0;
|
border-style: dashed;
|
||||||
|
border-color: var(--border-color);
|
||||||
|
border-radius: 5px 5px 0 0;
|
||||||
|
|
||||||
|
padding: 0px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldname {
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#dupes,
|
#dupes,
|
||||||
|
@ -62,3 +87,13 @@
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@import "ts/sass/codemirror/lib/codemirror";
|
||||||
|
@import "ts/sass/codemirror/theme/monokai";
|
||||||
|
@import "ts/sass/codemirror/addon/fold/foldgutter";
|
||||||
|
|
||||||
|
.CodeMirror {
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0 0 5px 5px;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
|
@ -10,15 +10,12 @@ import type { EditingArea } from "./editing-area";
|
||||||
|
|
||||||
import { saveField } from "./change-timer";
|
import { saveField } from "./change-timer";
|
||||||
import { bridgeCommand } from "./lib";
|
import { bridgeCommand } from "./lib";
|
||||||
|
import { getCurrentField } from "./helpers";
|
||||||
|
|
||||||
export function onFocus(evt: FocusEvent): void {
|
export function onFocus(evt: FocusEvent): void {
|
||||||
const currentField = evt.currentTarget as EditingArea;
|
const currentField = evt.currentTarget as EditingArea;
|
||||||
currentField.focus();
|
currentField.focus();
|
||||||
|
|
||||||
if (currentField.shadowRoot!.getSelection()!.anchorNode === null) {
|
|
||||||
// selection is not inside editable after focusing
|
|
||||||
currentField.caretToEnd();
|
currentField.caretToEnd();
|
||||||
}
|
|
||||||
|
|
||||||
bridgeCommand(`focus:${currentField.ord}`);
|
bridgeCommand(`focus:${currentField.ord}`);
|
||||||
fieldFocused.set(true);
|
fieldFocused.set(true);
|
||||||
|
@ -26,7 +23,7 @@ export function onFocus(evt: FocusEvent): void {
|
||||||
|
|
||||||
export function onBlur(evt: FocusEvent): void {
|
export function onBlur(evt: FocusEvent): void {
|
||||||
const previousFocus = evt.currentTarget as EditingArea;
|
const previousFocus = evt.currentTarget as EditingArea;
|
||||||
const currentFieldUnchanged = previousFocus === document.activeElement;
|
const currentFieldUnchanged = previousFocus === getCurrentField();
|
||||||
|
|
||||||
saveField(previousFocus, currentFieldUnchanged ? "key" : "blur");
|
saveField(previousFocus, currentFieldUnchanged ? "key" : "blur");
|
||||||
fieldFocused.set(false);
|
fieldFocused.set(false);
|
||||||
|
|
|
@ -5,6 +5,12 @@
|
||||||
@typescript-eslint/no-non-null-assertion: "off",
|
@typescript-eslint/no-non-null-assertion: "off",
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { EditingArea } from "./editing-area";
|
||||||
|
|
||||||
|
export function getCurrentField(): EditingArea | null {
|
||||||
|
return document.activeElement?.closest(".field") ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export function nodeIsElement(node: Node): node is Element {
|
export function nodeIsElement(node: Node): node is Element {
|
||||||
return node.nodeType === Node.ELEMENT_NODE;
|
return node.nodeType === Node.ELEMENT_NODE;
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,3 +33,11 @@ export { default as xmlIcon } from "./xml.svg";
|
||||||
|
|
||||||
export const arrowIcon =
|
export const arrowIcon =
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="transparent" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2 5l6 6 6-6"/></svg>';
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="transparent" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2 5l6 6 6-6"/></svg>';
|
||||||
|
|
||||||
|
// image handle
|
||||||
|
export { default as floatNoneIcon } from "./format-float-none.svg";
|
||||||
|
export { default as floatLeftIcon } from "./format-float-left.svg";
|
||||||
|
export { default as floatRightIcon } from "./format-float-right.svg";
|
||||||
|
|
||||||
|
export { default as sizeActual } from "./image-size-select-actual.svg";
|
||||||
|
export { default as sizeMinimized } from "./image-size-select-large.svg";
|
||||||
|
|
|
@ -23,15 +23,18 @@ import { saveField } from "./change-timer";
|
||||||
import { EditorField } from "./editor-field";
|
import { EditorField } from "./editor-field";
|
||||||
import { LabelContainer } from "./label-container";
|
import { LabelContainer } from "./label-container";
|
||||||
import { EditingArea } from "./editing-area";
|
import { EditingArea } from "./editing-area";
|
||||||
|
import { EditableContainer } from "./editable-container";
|
||||||
import { Editable } from "./editable";
|
import { Editable } from "./editable";
|
||||||
import { Codable } from "./codable";
|
import { Codable } from "./codable";
|
||||||
import { initToolbar, fieldFocused } from "./toolbar";
|
import { initToolbar, fieldFocused } from "./toolbar";
|
||||||
|
import { getCurrentField } from "./helpers";
|
||||||
|
|
||||||
export { setNoteId, getNoteId } from "./note-id";
|
export { setNoteId, getNoteId } from "./note-id";
|
||||||
export { saveNow } from "./change-timer";
|
export { saveNow } from "./change-timer";
|
||||||
export { wrap, wrapIntoText } from "./wrap";
|
export { wrap, wrapIntoText } from "./wrap";
|
||||||
export { editorToolbar } from "./toolbar";
|
export { editorToolbar } from "./toolbar";
|
||||||
export { activateStickyShortcuts } from "./label-container";
|
export { activateStickyShortcuts } from "./label-container";
|
||||||
|
export { getCurrentField } from "./helpers";
|
||||||
export { components } from "./Components.svelte";
|
export { components } from "./Components.svelte";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
@ -44,6 +47,7 @@ declare global {
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("anki-editable", Editable);
|
customElements.define("anki-editable", Editable);
|
||||||
|
customElements.define("anki-editable-container", EditableContainer, { extends: "div" });
|
||||||
customElements.define("anki-codable", Codable, { extends: "textarea" });
|
customElements.define("anki-codable", Codable, { extends: "textarea" });
|
||||||
customElements.define("anki-editing-area", EditingArea, { extends: "div" });
|
customElements.define("anki-editing-area", EditingArea, { extends: "div" });
|
||||||
customElements.define("anki-label-container", LabelContainer, { extends: "div" });
|
customElements.define("anki-label-container", LabelContainer, { extends: "div" });
|
||||||
|
@ -53,12 +57,6 @@ if (isApplePlatform()) {
|
||||||
registerShortcut(() => bridgeCommand("paste"), "Control+Shift+V");
|
registerShortcut(() => bridgeCommand("paste"), "Control+Shift+V");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCurrentField(): EditingArea | null {
|
|
||||||
return document.activeElement instanceof EditingArea
|
|
||||||
? document.activeElement
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function focusField(n: number): void {
|
export function focusField(n: number): void {
|
||||||
const field = getEditorField(n);
|
const field = getEditorField(n);
|
||||||
|
|
||||||
|
|
|
@ -32,12 +32,14 @@ export function activateStickyShortcuts(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LabelContainer extends HTMLDivElement {
|
export class LabelContainer extends HTMLDivElement {
|
||||||
sticky: HTMLSpanElement;
|
|
||||||
label: HTMLSpanElement;
|
label: HTMLSpanElement;
|
||||||
|
fieldState: HTMLSpanElement;
|
||||||
|
|
||||||
|
sticky: HTMLSpanElement;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.className = "d-flex justify-content-between";
|
this.className = "fname d-flex justify-content-between";
|
||||||
|
|
||||||
i18n.then(() => {
|
i18n.then(() => {
|
||||||
this.title = appendInParentheses(tr.editingToggleSticky(), "F9");
|
this.title = appendInParentheses(tr.editingToggleSticky(), "F9");
|
||||||
|
@ -47,25 +49,36 @@ export class LabelContainer extends HTMLDivElement {
|
||||||
this.label.className = "fieldname";
|
this.label.className = "fieldname";
|
||||||
this.appendChild(this.label);
|
this.appendChild(this.label);
|
||||||
|
|
||||||
|
this.fieldState = document.createElement("span");
|
||||||
|
this.fieldState.className = "field-state d-flex justify-content-between";
|
||||||
|
this.appendChild(this.fieldState);
|
||||||
|
|
||||||
this.sticky = document.createElement("span");
|
this.sticky = document.createElement("span");
|
||||||
this.sticky.className = "icon pin-icon me-1";
|
this.sticky.className = "icon pin-icon";
|
||||||
this.sticky.innerHTML = pinIcon;
|
this.sticky.innerHTML = pinIcon;
|
||||||
this.sticky.hidden = true;
|
this.sticky.hidden = true;
|
||||||
this.appendChild(this.sticky);
|
this.fieldState.appendChild(this.sticky);
|
||||||
|
|
||||||
this.setSticky = this.setSticky.bind(this);
|
this.setSticky = this.setSticky.bind(this);
|
||||||
this.hoverIcon = this.hoverIcon.bind(this);
|
this.hoverIcon = this.hoverIcon.bind(this);
|
||||||
this.removeHoverIcon = this.removeHoverIcon.bind(this);
|
this.removeHoverIcon = this.removeHoverIcon.bind(this);
|
||||||
this.toggleSticky = this.toggleSticky.bind(this);
|
this.toggleSticky = this.toggleSticky.bind(this);
|
||||||
|
this.keepFocus = this.keepFocus.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
keepFocus(event: Event): void {
|
||||||
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
|
this.addEventListener("mousedown", this.keepFocus);
|
||||||
this.sticky.addEventListener("click", this.toggleSticky);
|
this.sticky.addEventListener("click", this.toggleSticky);
|
||||||
this.sticky.addEventListener("mouseenter", this.hoverIcon);
|
this.sticky.addEventListener("mouseenter", this.hoverIcon);
|
||||||
this.sticky.addEventListener("mouseleave", this.removeHoverIcon);
|
this.sticky.addEventListener("mouseleave", this.removeHoverIcon);
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
|
this.removeEventListener("mousedown", this.keepFocus);
|
||||||
this.sticky.removeEventListener("click", this.toggleSticky);
|
this.sticky.removeEventListener("click", this.toggleSticky);
|
||||||
this.sticky.removeEventListener("mouseenter", this.hoverIcon);
|
this.sticky.removeEventListener("mouseenter", this.hoverIcon);
|
||||||
this.sticky.removeEventListener("mouseleave", this.removeHoverIcon);
|
this.sticky.removeEventListener("mouseleave", this.removeHoverIcon);
|
||||||
|
|
|
@ -5,7 +5,8 @@
|
||||||
@typescript-eslint/no-non-null-assertion: "off",
|
@typescript-eslint/no-non-null-assertion: "off",
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getCurrentField, setFormat } from ".";
|
import { getCurrentField } from "./helpers";
|
||||||
|
import { setFormat } from ".";
|
||||||
|
|
||||||
function wrappedExceptForWhitespace(text: string, front: string, back: string): string {
|
function wrappedExceptForWhitespace(text: string, front: string, back: string): string {
|
||||||
const match = text.match(/^(\s*)([^]*?)(\s*)$/)!;
|
const match = text.match(/^(\s*)([^]*?)(\s*)$/)!;
|
||||||
|
|
|
@ -19,7 +19,7 @@ body.nightMode {
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
max-width: 95%;
|
max-width: 100%;
|
||||||
max-height: 95vh;
|
max-height: 95vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,8 @@
|
||||||
--suspended-bg: #ffffb2;
|
--suspended-bg: #ffffb2;
|
||||||
--marked-bg: #cce;
|
--marked-bg: #cce;
|
||||||
--tooltip-bg: #fcfcfc;
|
--tooltip-bg: #fcfcfc;
|
||||||
|
--focus-border: #0969da;
|
||||||
|
--focus-shadow: rgba(9 105 218 / 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[class*="night-mode"] {
|
:root[class*="night-mode"] {
|
||||||
|
@ -73,4 +75,6 @@
|
||||||
--suspended-bg: #aaaa33;
|
--suspended-bg: #aaaa33;
|
||||||
--marked-bg: #77c;
|
--marked-bg: #77c;
|
||||||
--tooltip-bg: #272727;
|
--tooltip-bg: #272727;
|
||||||
|
--focus-border: #316dca;
|
||||||
|
--focus-shadow: #143d79;
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
border-radius: 3px;
|
border-radius: 5px;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
@ -37,7 +37,7 @@
|
||||||
box-shadow: 0 0 3px fusion-vars.$button-outline;
|
box-shadow: 0 0 3px fusion-vars.$button-outline;
|
||||||
border: 1px solid fusion-vars.$button-border;
|
border: 1px solid fusion-vars.$button-border;
|
||||||
|
|
||||||
border-radius: 2px;
|
border-radius: 5px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
padding-top: 3px;
|
padding-top: 3px;
|
||||||
padding-bottom: 3px;
|
padding-bottom: 3px;
|
||||||
|
|
Loading…
Reference in a new issue