c8458fce16
* 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.
335 lines
11 KiB
Svelte
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}×{actualHeight}</span>
|
|
{#if customDimensions}
|
|
<span>
|
|
(Original: {naturalWidth}×{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>
|