mirror of
https://github.com/ankitects/anki.git
synced 2025-09-24 08:46:37 -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-align-left = Align left
|
||||
editing-align-right = Align right
|
||||
|
@ -15,6 +16,9 @@ editing-cut = Cut
|
|||
editing-edit-current = Edit Current
|
||||
editing-edit-html = Edit HTML
|
||||
editing-fields = Fields
|
||||
editing-float-left = Float left
|
||||
editing-float-right = Float right
|
||||
editing-float-none = No float
|
||||
editing-html-editor = HTML Editor
|
||||
editing-indent = Increase indent
|
||||
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 let size: number | undefined = undefined;
|
||||
|
||||
export let wrap: boolean | undefined = undefined;
|
||||
|
||||
$: 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) {
|
||||
return ButtonPosition.Standalone;
|
||||
} else if (index === 0) {
|
||||
return ButtonPosition.Leftmost;
|
||||
return ButtonPosition.InlineStart;
|
||||
} else if (index === $items.length - 1) {
|
||||
return ButtonPosition.Rightmost;
|
||||
return ButtonPosition.InlineEnd;
|
||||
} else {
|
||||
return ButtonPosition.Center;
|
||||
}
|
||||
|
@ -95,7 +96,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
<div
|
||||
bind:this={buttonGroupRef}
|
||||
{id}
|
||||
class={`btn-group ${className}`}
|
||||
class="btn-group {className}"
|
||||
{style}
|
||||
dir="ltr"
|
||||
role="group"
|
||||
|
@ -110,6 +111,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
|
||||
<style lang="scss">
|
||||
div {
|
||||
flex-direction: row;
|
||||
flex-wrap: var(--buttons-wrap);
|
||||
padding: calc(var(--buttons-size) / 10);
|
||||
margin: 0;
|
||||
|
|
|
@ -20,21 +20,24 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
let position_: ButtonPosition;
|
||||
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_) {
|
||||
case ButtonPosition.Standalone:
|
||||
style = `--border-left-radius: ${radius}; --border-right-radius: ${radius}; `;
|
||||
break;
|
||||
case ButtonPosition.Leftmost:
|
||||
style = `--border-left-radius: ${radius}; --border-right-radius: 0; `;
|
||||
case ButtonPosition.InlineStart:
|
||||
style = leftStyle;
|
||||
break;
|
||||
case ButtonPosition.Center:
|
||||
style = "--border-left-radius: 0; --border-right-radius: 0; ";
|
||||
break;
|
||||
case ButtonPosition.Rightmost:
|
||||
style = `--border-left-radius: 0; --border-right-radius: ${radius}; `;
|
||||
case ButtonPosition.InlineEnd:
|
||||
style = rightStyle;
|
||||
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 widthMultiplier: number = 1;
|
||||
export let flipX: boolean = false;
|
||||
|
||||
let buttonRef: HTMLButtonElement;
|
||||
|
||||
|
@ -44,7 +45,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
on:click
|
||||
on:mousedown|preventDefault
|
||||
>
|
||||
<span style={`--width-multiplier: ${widthMultiplier};`}> <slot /> </span>
|
||||
<span class:flip-x={flipX} style={`--width-multiplier: ${widthMultiplier};`}>
|
||||
<slot />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
|
@ -80,6 +83,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
fill: currentColor;
|
||||
vertical-align: unset;
|
||||
}
|
||||
|
||||
&.flip-x > :global(svg),
|
||||
&.flip-x > :global(img) {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
}
|
||||
|
||||
.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 { dropdownKey } from "./context-keys";
|
||||
|
||||
export let autoOpen = false;
|
||||
export let autoClose: boolean | "inside" | "outside" = true;
|
||||
|
||||
export let placement = "bottom-start";
|
||||
|
||||
setContext(dropdownKey, {
|
||||
dropdown: true,
|
||||
"data-bs-toggle": "dropdown",
|
||||
});
|
||||
|
||||
let dropdown: Dropdown;
|
||||
let dropdownObject: Dropdown;
|
||||
|
||||
const noop = () => {};
|
||||
function createDropdown(toggle: HTMLElement): Dropdown {
|
||||
/* avoid focusing element toggle on menu activation */
|
||||
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());
|
||||
</script>
|
||||
|
||||
<div class="dropdown">
|
||||
<slot {createDropdown} />
|
||||
<slot {createDropdown} {dropdownObject} />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
@ -5,9 +5,9 @@ import type { Registration } from "./registration";
|
|||
|
||||
export enum ButtonPosition {
|
||||
Standalone,
|
||||
Leftmost,
|
||||
InlineStart,
|
||||
Center,
|
||||
Rightmost,
|
||||
InlineEnd,
|
||||
}
|
||||
|
||||
export interface ButtonRegistration extends Registration {
|
||||
|
|
|
@ -118,6 +118,14 @@ copy_mdi_icons(
|
|||
"format-color-text.svg",
|
||||
"format-color-highlight.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"],
|
||||
)
|
||||
|
|
|
@ -3,7 +3,6 @@ Copyright: Ankitects Pty Ltd and contributors
|
|||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="typescript">
|
||||
import type { EditingArea } from "./editing-area";
|
||||
import * as tr from "lib/i18n";
|
||||
|
||||
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 CommandIconButton from "./CommandIconButton.svelte";
|
||||
|
||||
import { getListItem } from "./helpers";
|
||||
import { getCurrentField, getListItem } from "./helpers";
|
||||
import {
|
||||
ulIcon,
|
||||
olIcon,
|
||||
|
@ -31,8 +30,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
export let api = {};
|
||||
|
||||
function outdentListItem() {
|
||||
const currentField = document.activeElement as EditingArea;
|
||||
if (getListItem(currentField.shadowRoot!)) {
|
||||
const currentField = getCurrentField();
|
||||
if (getListItem(currentField.editableContainer.shadowRoot!)) {
|
||||
document.execCommand("outdent");
|
||||
} else {
|
||||
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() {
|
||||
const currentField = document.activeElement as EditingArea;
|
||||
if (getListItem(currentField.shadowRoot!)) {
|
||||
const currentField = getCurrentField();
|
||||
if (getListItem(currentField.editableContainer.shadowRoot!)) {
|
||||
document.execCommand("indent");
|
||||
} else {
|
||||
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 ClozeButton from "./ClozeButton.svelte";
|
||||
|
||||
import { getCurrentField } from ".";
|
||||
import { appendInParentheses } from "./helpers";
|
||||
import { getCurrentField, appendInParentheses } from "./helpers";
|
||||
import { wrapCurrent } from "./wrap";
|
||||
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 { getCurrentField } from ".";
|
||||
import { getCurrentField } from "./helpers";
|
||||
import { bridgeCommand } from "./lib";
|
||||
import { getNoteId } from "./note-id";
|
||||
|
||||
|
@ -23,9 +23,10 @@ function clearChangeTimer(): void {
|
|||
|
||||
export function saveField(currentField: EditingArea, type: "blur" | "key"): void {
|
||||
clearChangeTimer();
|
||||
bridgeCommand(
|
||||
`${type}:${currentField.ord}:${getNoteId()}:${currentField.fieldHTML}`
|
||||
);
|
||||
const command = `${type}:${currentField.ord}:${getNoteId()}:${
|
||||
currentField.fieldHTML
|
||||
}`;
|
||||
bridgeCommand(command);
|
||||
}
|
||||
|
||||
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;
|
||||
padding: 6px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
* {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
|
|
@ -3,8 +3,12 @@
|
|||
|
||||
/* eslint
|
||||
@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 { Codable } from "./codable";
|
||||
|
||||
|
@ -12,43 +16,71 @@ import { updateActiveButtons } from "./toolbar";
|
|||
import { bridgeCommand } from "./lib";
|
||||
import { onInput, onKey, onKeyUp } from "./input-handlers";
|
||||
import { onFocus, onBlur } from "./focus-handlers";
|
||||
import { nightModeKey } from "components/context-keys";
|
||||
|
||||
function onCutOrCopy(): void {
|
||||
bridgeCommand("cutOrCopy");
|
||||
}
|
||||
|
||||
export class EditingArea extends HTMLDivElement {
|
||||
imageHandle: Promise<ImageHandle>;
|
||||
editableContainer: EditableContainer;
|
||||
editable: Editable;
|
||||
codable: Codable;
|
||||
baseStyle: HTMLStyleElement;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.className = "field";
|
||||
|
||||
if (document.documentElement.classList.contains("night-mode")) {
|
||||
this.classList.add("night-mode");
|
||||
}
|
||||
this.editableContainer = document.createElement("div", {
|
||||
is: "anki-editable-container",
|
||||
}) as EditableContainer;
|
||||
|
||||
const rootStyle = document.createElement("link");
|
||||
rootStyle.setAttribute("rel", "stylesheet");
|
||||
rootStyle.setAttribute("href", "./_anki/css/editable.css");
|
||||
this.shadowRoot!.appendChild(rootStyle);
|
||||
|
||||
this.baseStyle = document.createElement("style");
|
||||
this.baseStyle.setAttribute("rel", "stylesheet");
|
||||
this.shadowRoot!.appendChild(this.baseStyle);
|
||||
const imageStyle = document.createElement("style");
|
||||
imageStyle.setAttribute("rel", "stylesheet");
|
||||
imageStyle.id = "imageHandleStyle";
|
||||
|
||||
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", {
|
||||
is: "anki-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.showImageHandle = this.showImageHandle.bind(this);
|
||||
}
|
||||
|
||||
get activeInput(): Editable | Codable {
|
||||
|
@ -60,7 +92,7 @@ export class EditingArea extends HTMLDivElement {
|
|||
}
|
||||
|
||||
set fieldHTML(content: string) {
|
||||
this.activeInput.fieldHTML = content;
|
||||
this.imageHandle.then(() => (this.activeInput.fieldHTML = content));
|
||||
}
|
||||
|
||||
get fieldHTML(): string {
|
||||
|
@ -68,30 +100,29 @@ export class EditingArea extends HTMLDivElement {
|
|||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this.addEventListener("keydown", onKey);
|
||||
this.addEventListener("keydown", this.onKey);
|
||||
this.addEventListener("keyup", onKeyUp);
|
||||
this.addEventListener("input", onInput);
|
||||
this.addEventListener("focus", onFocus);
|
||||
this.addEventListener("blur", onBlur);
|
||||
this.addEventListener("focusin", this.onFocus);
|
||||
this.addEventListener("focusout", this.onBlur);
|
||||
this.addEventListener("paste", this.onPaste);
|
||||
this.addEventListener("copy", onCutOrCopy);
|
||||
this.addEventListener("oncut", onCutOrCopy);
|
||||
this.addEventListener("mouseup", updateActiveButtons);
|
||||
|
||||
const baseStyleSheet = this.baseStyle.sheet as CSSStyleSheet;
|
||||
baseStyleSheet.insertRule("anki-editable {}", 0);
|
||||
this.editable.addEventListener("click", this.showImageHandle);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.removeEventListener("keydown", onKey);
|
||||
this.removeEventListener("keydown", this.onKey);
|
||||
this.removeEventListener("keyup", onKeyUp);
|
||||
this.removeEventListener("input", onInput);
|
||||
this.removeEventListener("focus", onFocus);
|
||||
this.removeEventListener("blur", onBlur);
|
||||
this.removeEventListener("focusin", this.onFocus);
|
||||
this.removeEventListener("focusout", this.onBlur);
|
||||
this.removeEventListener("paste", this.onPaste);
|
||||
this.removeEventListener("copy", onCutOrCopy);
|
||||
this.removeEventListener("oncut", onCutOrCopy);
|
||||
this.removeEventListener("mouseup", updateActiveButtons);
|
||||
this.editable.removeEventListener("click", this.showImageHandle);
|
||||
}
|
||||
|
||||
initialize(color: string, content: string): void {
|
||||
|
@ -100,9 +131,7 @@ export class EditingArea extends HTMLDivElement {
|
|||
}
|
||||
|
||||
setBaseColor(color: string): void {
|
||||
const styleSheet = this.baseStyle.sheet as CSSStyleSheet;
|
||||
const firstRule = styleSheet.cssRules[0] as CSSStyleRule;
|
||||
firstRule.style.color = color;
|
||||
this.editableContainer.setBaseColor(color);
|
||||
}
|
||||
|
||||
quoteFontFamily(fontFamily: string): string {
|
||||
|
@ -114,17 +143,15 @@ export class EditingArea extends HTMLDivElement {
|
|||
}
|
||||
|
||||
setBaseStyling(fontFamily: string, fontSize: string, direction: string): void {
|
||||
const styleSheet = this.baseStyle.sheet as CSSStyleSheet;
|
||||
const firstRule = styleSheet.cssRules[0] as CSSStyleRule;
|
||||
firstRule.style.fontFamily = this.quoteFontFamily(fontFamily);
|
||||
firstRule.style.fontSize = fontSize;
|
||||
firstRule.style.direction = direction;
|
||||
this.editableContainer.setBaseStyling(
|
||||
this.quoteFontFamily(fontFamily),
|
||||
fontSize,
|
||||
direction
|
||||
);
|
||||
}
|
||||
|
||||
isRightToLeft(): boolean {
|
||||
const styleSheet = this.baseStyle.sheet as CSSStyleSheet;
|
||||
const firstRule = styleSheet.cssRules[0] as CSSStyleRule;
|
||||
return firstRule.style.direction === "rtl";
|
||||
return this.editableContainer.isRightToLeft();
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
|
@ -140,25 +167,62 @@ export class EditingArea extends HTMLDivElement {
|
|||
}
|
||||
|
||||
hasFocus(): boolean {
|
||||
return document.activeElement === this;
|
||||
return document.activeElement?.closest(".field") === this;
|
||||
}
|
||||
|
||||
getSelection(): Selection {
|
||||
return this.shadowRoot!.getSelection()!;
|
||||
const root = this.activeInput.getRootNode() as Document | ShadowRoot;
|
||||
return root.getSelection()!;
|
||||
}
|
||||
|
||||
surroundSelection(before: string, after: string): void {
|
||||
this.activeInput.surroundSelection(before, after);
|
||||
}
|
||||
|
||||
onFocus(event: FocusEvent): void {
|
||||
onFocus(event);
|
||||
}
|
||||
|
||||
onBlur(event: FocusEvent): void {
|
||||
this.resetImageHandle();
|
||||
onBlur(event);
|
||||
}
|
||||
|
||||
onEnter(event: KeyboardEvent): void {
|
||||
this.activeInput.onEnter(event);
|
||||
}
|
||||
|
||||
onKey(event: KeyboardEvent): void {
|
||||
this.resetImageHandle();
|
||||
onKey(event);
|
||||
}
|
||||
|
||||
onPaste(event: ClipboardEvent): void {
|
||||
this.resetImageHandle();
|
||||
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 {
|
||||
const hadFocus = this.hasFocus();
|
||||
|
||||
|
@ -166,6 +230,7 @@ export class EditingArea extends HTMLDivElement {
|
|||
this.fieldHTML = this.codable.teardown();
|
||||
this.editable.hidden = false;
|
||||
} else {
|
||||
this.resetImageHandle();
|
||||
this.editable.hidden = true;
|
||||
this.codable.setup(this.editable.fieldHTML);
|
||||
}
|
||||
|
|
|
@ -10,6 +10,8 @@ export class EditorField extends HTMLDivElement {
|
|||
|
||||
constructor() {
|
||||
super();
|
||||
this.classList.add("editor-field");
|
||||
|
||||
this.labelContainer = document.createElement("div", {
|
||||
is: "anki-label-container",
|
||||
}) as LabelContainer;
|
||||
|
@ -19,6 +21,8 @@ export class EditorField extends HTMLDivElement {
|
|||
is: "anki-editing-area",
|
||||
}) as EditingArea;
|
||||
this.appendChild(this.editingArea);
|
||||
|
||||
this.focusIfNotFocused = this.focusIfNotFocused.bind(this);
|
||||
}
|
||||
|
||||
static get observedAttributes(): string[] {
|
||||
|
@ -29,6 +33,21 @@ export class EditorField extends HTMLDivElement {
|
|||
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 {
|
||||
switch (name) {
|
||||
case "ord":
|
||||
|
|
|
@ -10,13 +10,30 @@
|
|||
|
||||
#fields {
|
||||
display: flex;
|
||||
overflow-x: hidden;
|
||||
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 {
|
||||
border: 1px solid var(--border);
|
||||
position: relative;
|
||||
|
||||
background: var(--frame-bg);
|
||||
border-radius: 0 0 5px 5px;
|
||||
|
||||
&.dupe {
|
||||
// this works around the background colour persisting in copy+paste
|
||||
|
@ -26,8 +43,16 @@
|
|||
}
|
||||
|
||||
.fname {
|
||||
vertical-align: middle;
|
||||
padding: 0;
|
||||
border-width: 0 0 1px;
|
||||
border-style: dashed;
|
||||
border-color: var(--border-color);
|
||||
border-radius: 5px 5px 0 0;
|
||||
|
||||
padding: 0px 6px;
|
||||
}
|
||||
|
||||
.fieldname {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#dupes,
|
||||
|
@ -62,3 +87,13 @@
|
|||
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 { bridgeCommand } from "./lib";
|
||||
import { getCurrentField } from "./helpers";
|
||||
|
||||
export function onFocus(evt: FocusEvent): void {
|
||||
const currentField = evt.currentTarget as EditingArea;
|
||||
currentField.focus();
|
||||
|
||||
if (currentField.shadowRoot!.getSelection()!.anchorNode === null) {
|
||||
// selection is not inside editable after focusing
|
||||
currentField.caretToEnd();
|
||||
}
|
||||
currentField.caretToEnd();
|
||||
|
||||
bridgeCommand(`focus:${currentField.ord}`);
|
||||
fieldFocused.set(true);
|
||||
|
@ -26,7 +23,7 @@ export function onFocus(evt: FocusEvent): void {
|
|||
|
||||
export function onBlur(evt: FocusEvent): void {
|
||||
const previousFocus = evt.currentTarget as EditingArea;
|
||||
const currentFieldUnchanged = previousFocus === document.activeElement;
|
||||
const currentFieldUnchanged = previousFocus === getCurrentField();
|
||||
|
||||
saveField(previousFocus, currentFieldUnchanged ? "key" : "blur");
|
||||
fieldFocused.set(false);
|
||||
|
|
|
@ -5,6 +5,12 @@
|
|||
@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 {
|
||||
return node.nodeType === Node.ELEMENT_NODE;
|
||||
}
|
||||
|
|
|
@ -33,3 +33,11 @@ export { default as xmlIcon } from "./xml.svg";
|
|||
|
||||
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>';
|
||||
|
||||
// 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 { LabelContainer } from "./label-container";
|
||||
import { EditingArea } from "./editing-area";
|
||||
import { EditableContainer } from "./editable-container";
|
||||
import { Editable } from "./editable";
|
||||
import { Codable } from "./codable";
|
||||
import { initToolbar, fieldFocused } from "./toolbar";
|
||||
import { getCurrentField } from "./helpers";
|
||||
|
||||
export { setNoteId, getNoteId } from "./note-id";
|
||||
export { saveNow } from "./change-timer";
|
||||
export { wrap, wrapIntoText } from "./wrap";
|
||||
export { editorToolbar } from "./toolbar";
|
||||
export { activateStickyShortcuts } from "./label-container";
|
||||
export { getCurrentField } from "./helpers";
|
||||
export { components } from "./Components.svelte";
|
||||
|
||||
declare global {
|
||||
|
@ -44,6 +47,7 @@ declare global {
|
|||
}
|
||||
|
||||
customElements.define("anki-editable", Editable);
|
||||
customElements.define("anki-editable-container", EditableContainer, { extends: "div" });
|
||||
customElements.define("anki-codable", Codable, { extends: "textarea" });
|
||||
customElements.define("anki-editing-area", EditingArea, { extends: "div" });
|
||||
customElements.define("anki-label-container", LabelContainer, { extends: "div" });
|
||||
|
@ -53,12 +57,6 @@ if (isApplePlatform()) {
|
|||
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 {
|
||||
const field = getEditorField(n);
|
||||
|
||||
|
|
|
@ -32,12 +32,14 @@ export function activateStickyShortcuts(): void {
|
|||
}
|
||||
|
||||
export class LabelContainer extends HTMLDivElement {
|
||||
sticky: HTMLSpanElement;
|
||||
label: HTMLSpanElement;
|
||||
fieldState: HTMLSpanElement;
|
||||
|
||||
sticky: HTMLSpanElement;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.className = "d-flex justify-content-between";
|
||||
this.className = "fname d-flex justify-content-between";
|
||||
|
||||
i18n.then(() => {
|
||||
this.title = appendInParentheses(tr.editingToggleSticky(), "F9");
|
||||
|
@ -47,25 +49,36 @@ export class LabelContainer extends HTMLDivElement {
|
|||
this.label.className = "fieldname";
|
||||
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.className = "icon pin-icon me-1";
|
||||
this.sticky.className = "icon pin-icon";
|
||||
this.sticky.innerHTML = pinIcon;
|
||||
this.sticky.hidden = true;
|
||||
this.appendChild(this.sticky);
|
||||
this.fieldState.appendChild(this.sticky);
|
||||
|
||||
this.setSticky = this.setSticky.bind(this);
|
||||
this.hoverIcon = this.hoverIcon.bind(this);
|
||||
this.removeHoverIcon = this.removeHoverIcon.bind(this);
|
||||
this.toggleSticky = this.toggleSticky.bind(this);
|
||||
this.keepFocus = this.keepFocus.bind(this);
|
||||
}
|
||||
|
||||
keepFocus(event: Event): void {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this.addEventListener("mousedown", this.keepFocus);
|
||||
this.sticky.addEventListener("click", this.toggleSticky);
|
||||
this.sticky.addEventListener("mouseenter", this.hoverIcon);
|
||||
this.sticky.addEventListener("mouseleave", this.removeHoverIcon);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.removeEventListener("mousedown", this.keepFocus);
|
||||
this.sticky.removeEventListener("click", this.toggleSticky);
|
||||
this.sticky.removeEventListener("mouseenter", this.hoverIcon);
|
||||
this.sticky.removeEventListener("mouseleave", this.removeHoverIcon);
|
||||
|
|
|
@ -5,7 +5,8 @@
|
|||
@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 {
|
||||
const match = text.match(/^(\s*)([^]*?)(\s*)$/)!;
|
||||
|
|
|
@ -19,7 +19,7 @@ body.nightMode {
|
|||
}
|
||||
|
||||
img {
|
||||
max-width: 95%;
|
||||
max-width: 100%;
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
|
|
|
@ -36,6 +36,8 @@
|
|||
--suspended-bg: #ffffb2;
|
||||
--marked-bg: #cce;
|
||||
--tooltip-bg: #fcfcfc;
|
||||
--focus-border: #0969da;
|
||||
--focus-shadow: rgba(9 105 218 / 0.3);
|
||||
}
|
||||
|
||||
:root[class*="night-mode"] {
|
||||
|
@ -73,4 +75,6 @@
|
|||
--suspended-bg: #aaaa33;
|
||||
--marked-bg: #77c;
|
||||
--tooltip-bg: #272727;
|
||||
--focus-border: #316dca;
|
||||
--focus-shadow: #143d79;
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
font-size: 14px;
|
||||
|
||||
-webkit-appearance: none;
|
||||
border-radius: 3px;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
@ -37,7 +37,7 @@
|
|||
box-shadow: 0 0 3px fusion-vars.$button-outline;
|
||||
border: 1px solid fusion-vars.$button-border;
|
||||
|
||||
border-radius: 2px;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
|
|
Loading…
Reference in a new issue