anki/ts/editor/image-overlay/ImageOverlay.svelte
Damien Elmes c8458fce16
Update to Svelte 4, and update most other JS deps (#2565)
* eslint-plugin-svelte3 -> eslint-plugin-svelte

The former is deprecated, and blocks an update to Svelte 4.

Also drop unused svelte2tsx and types package.

* Drop unused symbols code for now

It may be added back in the future, but for now dropping it will save
200k from our editor bundle.

* Remove sass and caniuse-lite pins

The latter no longer seems to be required. The former was added to
suppress deprecation warnings when compiling the old bootstrap version
we have pinned. Those are hidden by the build tool now (though we really
need to address them at one point: https://github.com/ankitects/anki/issues/1385)

Also removed unused files section.

* Prevent proto compile from looking in node_modules/@types/sass

When deps are updated, tsc aborts because @types/sass is a dummy package
without an index.d.ts file.

* Filter Svelte warnings out of ./run

* Update to latest Bootstrap

This fixes the deprecation warnings we were getting during build:
bootstrap doesn't accept runtime CSS variables being set in Sass, as
it wants to apply transforms to the colors.

Closes #1385

* Start port to Svelte 4

- svelte-check tests have a bunch of failures; ./run works
- Svelte no longer exposes internals, so we can't use create_in_transition
- Also update esbuild and related components like esbuild-svelte

* Fix test failures

Had to add some more a11y warning ignores - have added
https://github.com/ankitects/anki/issues/2564 to address that in the
future.

* Remove some dependency pins

+ Remove sass, we don't need it directly

* Bump remaining JS deps that have a current semver

* Upgrade dprint/license-checker/marked

The new helper method avoids marked printing deprecation warnings to
the console.

Also remove unused lodash/long types, and move lodahs-es to devdeps

* Upgrade eslint and fluent packages

* Update @floating-ui/dom

The only dependencies remaining are currently blocked:

- Jest 29 gives some error about require vs import; may not be worth
investigating if we switch to Deno for the tests
- CodeMirror 6 is a big API change and will need work.

* Roll dprint back to an earlier version

GitHub dropped support for Ubuntu 18 runners, causing dprint's artifacts
to require a glibc version greater than what Anki CI currently has.
2023-07-01 16:21:53 +10:00

335 lines
11 KiB
Svelte

<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts" context="module">
import { writable } from "svelte/store";
export const shrinkImagesByDefault = writable(true);
</script>
<script lang="ts">
import { on } from "@tslib/events";
import * as tr from "@tslib/ftl";
import { removeStyleProperties } from "@tslib/styling";
import type { Callback } from "@tslib/typing";
import { tick } from "svelte";
import ButtonToolbar from "../../components/ButtonToolbar.svelte";
import Popover from "../../components/Popover.svelte";
import WithFloating from "../../components/WithFloating.svelte";
import WithOverlay from "../../components/WithOverlay.svelte";
import type { EditingInputAPI } from "../EditingArea.svelte";
import HandleBackground from "../HandleBackground.svelte";
import HandleControl from "../HandleControl.svelte";
import HandleLabel from "../HandleLabel.svelte";
import { context } from "../NoteEditor.svelte";
import { editingInputIsRichText } from "../rich-text-input";
import FloatButtons from "./FloatButtons.svelte";
import SizeSelect from "./SizeSelect.svelte";
export let maxWidth: number;
export let maxHeight: number;
(<[string, number][]>[
["--editor-shrink-max-width", maxWidth],
["--editor-shrink-max-height", maxHeight],
["--editor-default-max-width", maxWidth],
["--editor-default-max-height", maxHeight],
]).forEach(([prop, value]) =>
document.documentElement.style.setProperty(prop, `${value}px`),
);
$: document.documentElement.classList.toggle(
"shrink-image",
$shrinkImagesByDefault,
);
const { focusedInput } = context.get();
let cleanup: Callback;
async function initialize(input: EditingInputAPI | null): Promise<void> {
cleanup?.();
if (!input || !editingInputIsRichText(input)) {
return;
}
cleanup = on(await input.element, "click", maybeShowHandle);
}
$: initialize($focusedInput);
let activeImage: HTMLImageElement | null = null;
/**
* Returns the value if set, otherwise null.
*/
function getBooleanDatasetAttribute(
element: HTMLElement | SVGElement,
attribute: string,
): boolean | null {
return attribute in element.dataset
? element.dataset[attribute] !== "false"
: null;
}
let isSizeConstrained = false;
$: {
if (activeImage) {
isSizeConstrained =
getBooleanDatasetAttribute(activeImage, "editorShrink") ??
$shrinkImagesByDefault;
}
}
async function resetHandle(): Promise<void> {
activeImage = null;
await tick();
}
let naturalWidth: number;
let naturalHeight: number;
let aspectRatio: number;
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);
}
}
async function maybeShowHandle(event: Event): Promise<void> {
if (event.target instanceof HTMLImageElement) {
const image = event.target;
if (!image.dataset.anki) {
activeImage = image;
naturalWidth = activeImage?.naturalWidth;
naturalHeight = activeImage?.naturalHeight;
aspectRatio =
naturalWidth && naturalHeight ? naturalWidth / naturalHeight : NaN;
updateDimensions();
}
}
}
let customDimensions: boolean = false;
let actualWidth = "";
let actualHeight = "";
/* 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);
}
let minResizeWidth: number;
let minResizeHeight: number;
$: [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 {
const height = Math.max(Math.trunc(dragHeight), minResizeHeight);
width = Math.trunc(naturalWidth! * (height / naturalHeight!));
}
/**
* Image resizing add-ons previously used image.style.width/height to set the
* preferred dimension of an image. In these cases, if we'd only set
* image.[dimension], there would be no visible effect on the image.
* To avoid confusion with users we'll clear image.style.[dimension] (for now).
*/
removeStyleProperties(activeImage!, "width", "height");
activeImage!.width = width;
}
function toggleActualSize(): void {
if (isSizeConstrained) {
activeImage!.dataset.editorShrink = "false";
} else {
activeImage!.dataset.editorShrink = "true";
}
isSizeConstrained = !isSizeConstrained;
}
function clearActualSize(): void {
activeImage!.removeAttribute("width");
}
let shrinkingDisabled: boolean;
$: shrinkingDisabled =
Number(actualWidth) <= maxWidth && Number(actualHeight) <= maxHeight;
let restoringDisabled: boolean;
$: restoringDisabled = !activeImage?.hasAttribute("width") ?? true;
const widthObserver = new MutationObserver(() => {
restoringDisabled = !activeImage!.hasAttribute("width");
updateDimensions();
});
$: activeImage
? widthObserver.observe(activeImage, {
attributes: true,
attributeFilter: ["width"],
})
: widthObserver.disconnect();
let imageOverlay: HTMLElement;
</script>
<div bind:this={imageOverlay} class="image-overlay">
{#if activeImage}
<WithOverlay reference={activeImage} inline let:position={positionOverlay}>
<WithFloating
reference={activeImage}
offset={20}
inline
hideIfReferenceHidden
portalTarget={document.body}
on:close={async ({ detail }) => {
const { reason, originalEvent } = detail;
if (reason === "outsideClick") {
// If the click is still in the overlay, we do not want
// to reset the handle either
if (!originalEvent.path.includes(imageOverlay)) {
await resetHandle();
}
} else {
await resetHandle();
}
}}
>
<Popover slot="floating" let:position={positionFloating}>
<ButtonToolbar>
<FloatButtons
image={activeImage}
on:update={async () => {
positionOverlay();
positionFloating();
}}
/>
<SizeSelect
{shrinkingDisabled}
{restoringDisabled}
{isSizeConstrained}
on:imagetoggle={() => {
toggleActualSize();
positionOverlay();
}}
on:imageclear={() => {
clearActualSize();
positionOverlay();
}}
/>
</ButtonToolbar>
</Popover>
</WithFloating>
<svelte:fragment slot="overlay" let:position={positionOverlay}>
<HandleBackground
on:dblclick={() => {
if (shrinkingDisabled) {
return;
}
toggleActualSize();
positionOverlay();
}}
/>
<HandleLabel>
{#if isSizeConstrained}
<span>{`(${tr.editingDoubleClickToExpand()})`}</span>
{:else}
<span>{actualWidth}&times;{actualHeight}</span>
{#if customDimensions}
<span>
(Original: {naturalWidth}&times;{naturalHeight})
</span>
{/if}
{/if}
</HandleLabel>
<HandleControl
active={!isSizeConstrained}
activeSize={8}
offsetX={5}
offsetY={5}
on:pointerclick={(event) => {
if (!isSizeConstrained) {
setPointerCapture(event);
}
}}
on:pointermove={(event) => {
resize(event);
}}
/>
</svelte:fragment>
</WithOverlay>
{/if}
</div>