e63b4b9927
Prettier by default tries to preserve whitespace around inline tags, which can prevent problems such as a space before the period in '<a>text</a>.': https://prettier.io/blog/2018/11/07/1.15.0.html#whitespace-sensitive-formatting Unfortunately only standard HTML block elements are excluded from this behaviour, so all of our Svelte components are treated the same way, even if they are block-based, or used in a way where the extra whitespace doesn't matter. This makes the code somewhat harder to read. Changing this option does carry the risk that rogue spaces will creep into our UI in the future as code is formatted, but as there don't appear to be any such issues with this initial reformat, I think the improved readability may justify the relatively small risk.
336 lines
11 KiB
Svelte
336 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}
|
|
let:position={positionFloating}
|
|
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">
|
|
<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">
|
|
<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>
|