Anki/ts/editor/image-overlay/ImageOverlay.svelte
Henrik Giesel 3642dc6245
Use WithFloating for MathjaxOverlay (#2011)
* Allow passing in reference into WithFloating as prop

* Fix WithAutocomplete

* Fix WithFloating for MathjaxOverlay

* Add resize-store

* Allow passing debug=True to jest_test for debugger support (#2013)

* Disable auto-closing of HTML tags

https://forums.ankiweb.net/t/set-html-editor-as-a-default-editor-instead-of-visual-editor/20988/3

Closes #1963

* Add slight margin to MathjaxEditor

* Enable passing offset and shift to WithFloating

* Hide overflow of mathjax editor

* Add automatic hide functionality to sveltelib/position

* Last polishes for Surrounder class (#2017)

* Make private properties in Surrounder truly private

* Fix remove logic of Surrounder

* No reason for toggleTriggerRemove to be async

* Allow using alt-shift to set all remove formats but this one

* modifyFormat => updateFormat

* Fix formatting

* Fix field descriptions blocking cursor from being set (#2018)

- happens when focus is in HTML editor

* Remove hiding functionality again until it's really useful

* Add support for autoPlacement

* Implement new WithFloating that supports manually calling position()

* Implement hide mechanisms

* Add option in math dropdown to toggle MathJax rendering (#2014)

* Add option in math dropdown to toggle MathJax rendering

Closes #1942

* Hackily redraw the page when toggling MathJax

* Add Fluent string

* Default input setting in fields dialog (#1987) (kleinerpirat)

* Introduce field setting to use plain text editor by default (kleinerpirat)

* Remove leftover function from #1476

* Use boolean instead of string

* Simplify clear_other_field_duplicates

* Convert plain text key to camelCase

* Move HTML item below the existing checkbox, instead of to the right (dae)

Showing it on the right is more space efficient, but feels a bit
cluttered IMHO.

* Fix not being able to scroll when mouse hovers PlainTextInput (#2019)

* Remove overscroll-behavior: none for * (all elements)

* Revert "Remove overscroll-behavior: none for * (all elements)"

This reverts commit 189358908c.

* Use body instead of *, but keep CSS rule

* Unify two CSS rules

* Remove console.logs

* Reposition mathjax menu on switching between inline/block

* Implement WithOverlay

* Implement FloatingArrow

* Display overlay with padding and brighter background

* Rename to MathjaxOverlay

* Simplify MathjaxOverlay component overall

* Rename ImageHandle to image overlay

* Generally fix ImageOverlay again

* Increase z-index of StickyContainer

* Fix setting block or inline on mathjax

* Add reasons in closing-{click,keyup}

* Have both WithFloating and WithOverlay use a simple show flag instead of a store

* Remove subscribe-trigger

* Fix clicking from one mathjax element to another

* Check before executing cleanup

* Do not wait for elements to mount before slotting in With{Floating,Overlay}

* Allow using reference slot for WithFloating and WithOveray

* Add inline argument to options

* Add support for inline slot in WithOvelay

* Use WithFloating for RemoveFormatButton

* Remove last uses of DropdownMenu and WithDropdown

* Remove all of the bootstrap dropdown components

* Fix closing behavior of several buttons and ImageOverlay

* Increase popover padding to 6px

* Find a different way to create some padding at the bottom of the fields

...before the tag editor

@kleinerpirat I think is what this css what trying to achieve?

* Satisfy tests

* Use removeStyleProperties in ImageOverlay

* Use notify function in WithOverlay and WithFloating

* Do not use portal for WithFloating and WithOverlay

Allows for scrolling

* Set hidden to default false in Rich/Plain TextInput

* Reset handle when changing mathjax elements via click

* Restrict size of empty mathjax image

* Prevent sticky labels from obscuring menus

* Remove several overflow-hidden

* Fix empty string being falsy bug when editing mathjax

* Do not import portal anymore

* Use { reason, originalEvent } instead of symbol as update to modified event store

* Fix closing behavior of image overlay (do not close after resize)

* Simplify Collapsible

* Use removeStyleProperties in Collapsible

* Satisfy eslint

* Fix latex shortcuts being mounted

* Fix mathjax overlay not focusable in first field

* Neither hide image overlay on escaped

* Fix Block ButtonDropdown wrapping

* Bring back portal to fix tag editor
2022-09-05 17:20:00 +10:00

317 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">
import { onMount, 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 { on } from "../../lib/events";
import * as tr from "../../lib/ftl";
import { removeStyleProperties } from "../../lib/styling";
import HandleBackground from "../HandleBackground.svelte";
import HandleControl from "../HandleControl.svelte";
import HandleLabel from "../HandleLabel.svelte";
import { context } from "../rich-text-input";
import FloatButtons from "./FloatButtons.svelte";
import SizeSelect from "./SizeSelect.svelte";
export let maxWidth: number;
export let maxHeight: number;
const { element } = context.get();
let activeImage: HTMLImageElement | null = null;
/**
* For element dataset attributes which work like the contenteditable attribute
*/
function isDatasetAttributeFlagSet(
element: HTMLElement | SVGElement,
attribute: string,
): boolean {
return attribute in element.dataset && element.dataset[attribute] !== "false";
}
$: isSizeConstrained = activeImage
? isDatasetAttributeFlagSet(activeImage, "editorShrink")
: false;
async function resetHandle(): Promise<void> {
activeImage = null;
await tick();
}
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;
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: () => Promise<void>;
async function updateSizesWithDimensions() {
await updateSelection?.();
updateDimensions();
}
/* window resizing */
const resizeObserver = new ResizeObserver(async () => {
await updateSizesWithDimensions();
});
$: observes = Boolean(activeImage);
async function toggleResizeObserver(observes: boolean) {
const container = await element;
if (observes) {
resizeObserver.observe(container);
} else {
resizeObserver.unobserve(container);
}
}
$: toggleResizeObserver(observes);
/* 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) {
delete activeImage!.dataset.editorShrink;
} else {
activeImage!.dataset.editorShrink = "true";
}
isSizeConstrained = !isSizeConstrained;
}
function clearActualSize(): void {
activeImage!.removeAttribute("width");
}
onMount(async () => {
const container = await element;
container.style.setProperty("--editor-shrink-max-width", `${maxWidth}px`);
container.style.setProperty("--editor-shrink-max-height", `${maxHeight}px`);
return on(container, "click", maybeShowHandle);
});
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")),
);
$: 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}
placement="auto"
offset={20}
inline
hideIfReferenceHidden
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}&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>