Change how resizable images work (#1859)
* Add ResizableImage.svelte in ts/editable * Set image constrained via attributes instead of managed style sheet * Implement new constrained size method * Turn WithImageConstrained.svelte into normal ts file * Introduce removeStyleProperties Removes "style" attribute if all style properties were cleared * Avoid --editor-width and use one variable set on container * Disable shrinking if already smaller than shrunken size * Add button to restore image to original size * Don't allow restoring original size if no custom width set * Bottom-center HandleLabel * Satisfy svelte-check
This commit is contained in:
parent
fb5521eeed
commit
de2cc20c59
@ -15,6 +15,7 @@ editing-customize-card-templates = Customize Card Templates
|
||||
editing-customize-fields = Customize Fields
|
||||
editing-cut = Cut
|
||||
editing-double-click-image = double-click image
|
||||
editing-double-click-to-expand = (double-click to expand)
|
||||
editing-edit-current = Edit Current
|
||||
editing-edit-html = Edit HTML
|
||||
editing-fields = Fields
|
||||
@ -38,6 +39,7 @@ editing-outdent = Decrease indent
|
||||
editing-paste = Paste
|
||||
editing-record-audio = Record audio
|
||||
editing-remove-formatting = Remove formatting
|
||||
editing-restore-original-size = Restore original size
|
||||
editing-select-remove-formatting = Select formatting to remove
|
||||
editing-show-duplicates = Show Duplicates
|
||||
editing-subscript = Subscript
|
||||
|
11
ts/editable/ResizableImage.svelte
Normal file
11
ts/editable/ResizableImage.svelte
Normal file
@ -0,0 +1,11 @@
|
||||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<style lang="scss">
|
||||
:global([data-editor-shrink]:not([data-editor-shrink="false"])) {
|
||||
max-width: var(--editor-shrink-max-width);
|
||||
max-height: var(--editor-shrink-max-height);
|
||||
width: auto;
|
||||
}
|
||||
</style>
|
@ -5,3 +5,4 @@ import "./editable-base.css";
|
||||
/* only imported for the CSS */
|
||||
import "./ContentEditable.svelte";
|
||||
import "./Mathjax.svelte";
|
||||
import "./ResizableImage.svelte";
|
||||
|
@ -4,53 +4,32 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { afterUpdate, createEventDispatcher, onMount } from "svelte";
|
||||
import { createEventDispatcher, onMount } from "svelte";
|
||||
import type { Readable } from "svelte/store";
|
||||
|
||||
import { directionKey } from "../lib/context-keys";
|
||||
|
||||
let dimensions: HTMLDivElement;
|
||||
let overflowFix = 0;
|
||||
|
||||
const direction = getContext<Readable<"ltr" | "rtl">>(directionKey);
|
||||
|
||||
function updateOverflow(dimensions: HTMLDivElement): void {
|
||||
const boundingClientRect = dimensions.getBoundingClientRect();
|
||||
const overflow =
|
||||
$direction === "ltr"
|
||||
? boundingClientRect.x
|
||||
: window.innerWidth - boundingClientRect.x - boundingClientRect.width;
|
||||
|
||||
overflowFix = Math.min(0, overflowFix + overflow, overflow);
|
||||
}
|
||||
|
||||
afterUpdate(() => updateOverflow(dimensions));
|
||||
|
||||
function updateOverflowAsync(dimensions: HTMLDivElement): void {
|
||||
setTimeout(() => updateOverflow(dimensions));
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
onMount(() => dispatch("mount"));
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={dimensions}
|
||||
class="image-handle-dimensions"
|
||||
class:is-rtl={$direction === "rtl"}
|
||||
style="--overflow-fix: {overflowFix}px"
|
||||
use:updateOverflowAsync
|
||||
>
|
||||
<div class="image-handle-dimensions" class:is-rtl={$direction === "rtl"}>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
position: absolute;
|
||||
width: fit-content;
|
||||
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 3px;
|
||||
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
font-size: 13px;
|
||||
color: white;
|
||||
@ -59,16 +38,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
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);
|
||||
}
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
@ -339,7 +339,7 @@ the AddCards dialog) should be implemented in the user of this component.
|
||||
}}
|
||||
bind:this={richTextInputs[index]}
|
||||
>
|
||||
<ImageHandle />
|
||||
<ImageHandle maxWidth={250} maxHeight={125} />
|
||||
<MathjaxHandle />
|
||||
</RichTextInput>
|
||||
|
||||
|
@ -9,9 +9,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
import type { MatchType } from "../../domlib/surround";
|
||||
import * as tr from "../../lib/ftl";
|
||||
import { getPlatformString } from "../../lib/shortcuts";
|
||||
import { removeStyleProperties } from "../../lib/styling";
|
||||
import { context as noteEditorContext } from "../NoteEditor.svelte";
|
||||
import { editingInputIsRichText } from "../rich-text-input";
|
||||
import { removeEmptyStyle, Surrounder } from "../surround";
|
||||
import { Surrounder } from "../surround";
|
||||
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
|
||||
import { boldIcon } from "./icons";
|
||||
|
||||
@ -25,9 +26,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
const fontWeight = element.style.fontWeight;
|
||||
if (fontWeight === "bold" || Number(fontWeight) >= 400) {
|
||||
return match.clear((): void => {
|
||||
element.style.removeProperty("font-weight");
|
||||
|
||||
if (removeEmptyStyle(element) && element.className.length === 0) {
|
||||
if (
|
||||
removeStyleProperties(element, "font-weight") &&
|
||||
element.className.length === 0
|
||||
) {
|
||||
match.remove();
|
||||
}
|
||||
});
|
||||
|
@ -11,9 +11,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
} from "../../domlib/surround";
|
||||
import { bridgeCommand } from "../../lib/bridgecommand";
|
||||
import * as tr from "../../lib/ftl";
|
||||
import { removeStyleProperties } from "../../lib/styling";
|
||||
import { context as noteEditorContext } from "../NoteEditor.svelte";
|
||||
import { editingInputIsRichText } from "../rich-text-input";
|
||||
import { removeEmptyStyle, Surrounder } from "../surround";
|
||||
import { Surrounder } from "../surround";
|
||||
import ColorPicker from "./ColorPicker.svelte";
|
||||
import type { RemoveFormat } from "./EditorToolbar.svelte";
|
||||
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
|
||||
@ -45,9 +46,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
match.setCache(value);
|
||||
match.clear((): void => {
|
||||
element.style.removeProperty("background-color");
|
||||
|
||||
if (removeEmptyStyle(element) && element.className.length === 0) {
|
||||
if (
|
||||
removeStyleProperties(element, "background-color") &&
|
||||
element.className.length === 0
|
||||
) {
|
||||
match.remove();
|
||||
}
|
||||
});
|
||||
|
@ -9,9 +9,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
import type { MatchType } from "../../domlib/surround";
|
||||
import * as tr from "../../lib/ftl";
|
||||
import { getPlatformString } from "../../lib/shortcuts";
|
||||
import { removeStyleProperties } from "../../lib/styling";
|
||||
import { context as noteEditorContext } from "../NoteEditor.svelte";
|
||||
import { editingInputIsRichText } from "../rich-text-input";
|
||||
import { removeEmptyStyle, Surrounder } from "../surround";
|
||||
import { Surrounder } from "../surround";
|
||||
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
|
||||
import { italicIcon } from "./icons";
|
||||
|
||||
@ -24,9 +25,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
if (["italic", "oblique"].includes(element.style.fontStyle)) {
|
||||
return match.clear((): void => {
|
||||
element.style.removeProperty("font-style");
|
||||
|
||||
if (removeEmptyStyle(element) && element.className.length === 0) {
|
||||
if (
|
||||
removeStyleProperties(element, "font-style") &&
|
||||
element.className.length === 0
|
||||
) {
|
||||
return match.remove();
|
||||
}
|
||||
});
|
||||
|
@ -4,7 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script context="module" lang="ts">
|
||||
import type { MatchType } from "../../domlib/surround";
|
||||
import { removeEmptyStyle } from "../surround";
|
||||
import { removeStyleProperties } from "../../lib/styling";
|
||||
|
||||
const surroundElement = document.createElement("sub");
|
||||
|
||||
@ -15,9 +15,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
if (element.style.verticalAlign === "sub") {
|
||||
return match.clear((): void => {
|
||||
element.style.removeProperty("vertical-align");
|
||||
|
||||
if (removeEmptyStyle(element) && element.className.length === 0) {
|
||||
if (
|
||||
removeStyleProperties(element, "vertical-align") &&
|
||||
element.className.length === 0
|
||||
) {
|
||||
return match.remove();
|
||||
}
|
||||
});
|
||||
|
@ -4,7 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script context="module" lang="ts">
|
||||
import type { MatchType } from "../../domlib/surround";
|
||||
import { removeEmptyStyle } from "../surround";
|
||||
import { removeStyleProperties } from "../../lib/styling";
|
||||
|
||||
const surroundElement = document.createElement("sup");
|
||||
|
||||
@ -15,9 +15,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
if (element.style.verticalAlign === "super") {
|
||||
return match.clear((): void => {
|
||||
element.style.removeProperty("vertical-align");
|
||||
|
||||
if (removeEmptyStyle(element) && element.className.length === 0) {
|
||||
if (
|
||||
removeStyleProperties(element, "vertical-align") &&
|
||||
element.className.length === 0
|
||||
) {
|
||||
return match.remove();
|
||||
}
|
||||
});
|
||||
|
@ -13,10 +13,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
import { bridgeCommand } from "../../lib/bridgecommand";
|
||||
import * as tr from "../../lib/ftl";
|
||||
import { getPlatformString } from "../../lib/shortcuts";
|
||||
import { removeStyleProperties } from "../../lib/styling";
|
||||
import { withFontColor } from "../helpers";
|
||||
import { context as noteEditorContext } from "../NoteEditor.svelte";
|
||||
import { editingInputIsRichText } from "../rich-text-input";
|
||||
import { removeEmptyStyle, Surrounder } from "../surround";
|
||||
import { Surrounder } from "../surround";
|
||||
import ColorPicker from "./ColorPicker.svelte";
|
||||
import type { RemoveFormat } from "./EditorToolbar.svelte";
|
||||
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
|
||||
@ -59,9 +60,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
match.setCache(value);
|
||||
match.clear((): void => {
|
||||
element.style.removeProperty("color");
|
||||
|
||||
if (removeEmptyStyle(element) && element.className.length === 0) {
|
||||
if (
|
||||
removeStyleProperties(element, "color") &&
|
||||
element.className.length === 0
|
||||
) {
|
||||
match.remove();
|
||||
}
|
||||
});
|
||||
|
@ -7,6 +7,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import ButtonDropdown from "../../components/ButtonDropdown.svelte";
|
||||
import WithDropdown from "../../components/WithDropdown.svelte";
|
||||
import * as tr from "../../lib/ftl";
|
||||
import HandleBackground from "../HandleBackground.svelte";
|
||||
import HandleControl from "../HandleControl.svelte";
|
||||
import HandleLabel from "../HandleLabel.svelte";
|
||||
@ -14,16 +15,33 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
import { context } from "../rich-text-input";
|
||||
import FloatButtons from "./FloatButtons.svelte";
|
||||
import SizeSelect from "./SizeSelect.svelte";
|
||||
import WithImageConstrained from "./WithImageConstrained.svelte";
|
||||
|
||||
const { container, styles } = context.get();
|
||||
export let maxWidth: number;
|
||||
export let maxHeight: number;
|
||||
|
||||
const sheetPromise = styles
|
||||
.addStyleTag("imageOverlay")
|
||||
.then((styleObject) => styleObject.element.sheet!);
|
||||
const { container } = context.get();
|
||||
|
||||
$: {
|
||||
container.style.setProperty("--editor-shrink-max-width", `${maxWidth}px`);
|
||||
container.style.setProperty("--editor-shrink-max-height", `${maxHeight}px`);
|
||||
}
|
||||
|
||||
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();
|
||||
@ -166,6 +184,20 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
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");
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
resizeObserver.disconnect();
|
||||
container.removeEventListener("click", maybeShowHandle);
|
||||
@ -173,6 +205,24 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
container.removeEventListener("key", resetHandle);
|
||||
container.removeEventListener("paste", resetHandle);
|
||||
});
|
||||
|
||||
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();
|
||||
</script>
|
||||
|
||||
<WithDropdown
|
||||
@ -183,62 +233,69 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
let:createDropdown
|
||||
let:dropdownObject
|
||||
>
|
||||
{#await sheetPromise then sheet}
|
||||
<WithImageConstrained
|
||||
{sheet}
|
||||
{#if activeImage}
|
||||
<HandleSelection
|
||||
bind:updateSelection
|
||||
{container}
|
||||
{activeImage}
|
||||
maxWidth={250}
|
||||
maxHeight={125}
|
||||
on:update={() => {
|
||||
updateSizesWithDimensions();
|
||||
dropdownObject.update();
|
||||
}}
|
||||
let:toggleActualSize
|
||||
let:active
|
||||
image={activeImage}
|
||||
on:mount={(event) => createDropdown(event.detail.selection)}
|
||||
>
|
||||
{#if activeImage}
|
||||
<HandleSelection
|
||||
bind:updateSelection
|
||||
{container}
|
||||
image={activeImage}
|
||||
on:mount={(event) => createDropdown(event.detail.selection)}
|
||||
>
|
||||
<HandleBackground on:dblclick={toggleActualSize} />
|
||||
<HandleBackground
|
||||
on:dblclick={() => {
|
||||
if (shrinkingDisabled) {
|
||||
return;
|
||||
}
|
||||
toggleActualSize();
|
||||
updateSizesWithDimensions();
|
||||
dropdownObject.update();
|
||||
}}
|
||||
/>
|
||||
|
||||
<HandleLabel on:mount={updateDimensions}>
|
||||
<span>{actualWidth}×{actualHeight}</span>
|
||||
{#if customDimensions}
|
||||
<span>(Original: {naturalWidth}×{naturalHeight})</span
|
||||
>
|
||||
{/if}
|
||||
</HandleLabel>
|
||||
<HandleLabel on:mount={updateDimensions}>
|
||||
{#if isSizeConstrained}
|
||||
<span>{tr.editingDoubleClickToExpand()}</span>
|
||||
{:else}
|
||||
<span>{actualWidth}×{actualHeight}</span>
|
||||
{#if customDimensions}
|
||||
<span>(Original: {naturalWidth}×{naturalHeight})</span>
|
||||
{/if}
|
||||
{/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 on:click={updateSizesWithDimensions}>
|
||||
<FloatButtons
|
||||
image={activeImage}
|
||||
on:update={dropdownObject.update}
|
||||
/>
|
||||
<SizeSelect {active} on:click={toggleActualSize} />
|
||||
</ButtonDropdown>
|
||||
{/if}
|
||||
</WithImageConstrained>
|
||||
{/await}
|
||||
<HandleControl
|
||||
active={!isSizeConstrained}
|
||||
activeSize={8}
|
||||
offsetX={5}
|
||||
offsetY={5}
|
||||
on:pointerclick={(event) => {
|
||||
if (!isSizeConstrained) {
|
||||
setPointerCapture(event);
|
||||
}
|
||||
}}
|
||||
on:pointermove={(event) => {
|
||||
resize(event);
|
||||
updateSizesWithDimensions();
|
||||
dropdownObject.update();
|
||||
}}
|
||||
/>
|
||||
</HandleSelection>
|
||||
<ButtonDropdown on:click={updateSizesWithDimensions}>
|
||||
<FloatButtons image={activeImage} on:update={dropdownObject.update} />
|
||||
<SizeSelect
|
||||
{shrinkingDisabled}
|
||||
{restoringDisabled}
|
||||
{isSizeConstrained}
|
||||
on:imagetoggle={() => {
|
||||
toggleActualSize();
|
||||
updateSizesWithDimensions();
|
||||
dropdownObject.update();
|
||||
}}
|
||||
on:imageclear={() => {
|
||||
clearActualSize();
|
||||
updateSizesWithDimensions();
|
||||
dropdownObject.update();
|
||||
}}
|
||||
/>
|
||||
</ButtonDropdown>
|
||||
{/if}
|
||||
</WithDropdown>
|
||||
|
@ -3,29 +3,38 @@ Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { createEventDispatcher, getContext } from "svelte";
|
||||
import type { Readable } from "svelte/store";
|
||||
|
||||
import ButtonGroup from "../../components/ButtonGroup.svelte";
|
||||
import IconButton from "../../components/IconButton.svelte";
|
||||
import { directionKey } from "../../lib/context-keys";
|
||||
import * as tr from "../../lib/ftl";
|
||||
import { sizeActual, sizeMinimized } from "./icons";
|
||||
import { sizeActual, sizeClear, sizeMinimized } from "./icons";
|
||||
|
||||
export let active: boolean;
|
||||
export let isSizeConstrained: boolean;
|
||||
export let shrinkingDisabled: boolean;
|
||||
export let restoringDisabled: boolean;
|
||||
|
||||
$: icon = active ? sizeActual : sizeMinimized;
|
||||
$: icon = isSizeConstrained ? sizeMinimized : sizeActual;
|
||||
|
||||
const direction = getContext<Readable<"ltr" | "rtl">>(directionKey);
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
<ButtonGroup size={1.6}>
|
||||
<IconButton
|
||||
{active}
|
||||
disabled={shrinkingDisabled}
|
||||
flipX={$direction === "rtl"}
|
||||
tooltip="{tr.editingActualSize()} ({tr.editingDoubleClickImage()})"
|
||||
on:click
|
||||
--border-left-radius="5px"
|
||||
--border-right-radius="5px">{@html icon}</IconButton
|
||||
on:click={() => dispatch("imagetoggle")}
|
||||
--border-left-radius="5px">{@html icon}</IconButton
|
||||
>
|
||||
|
||||
<IconButton
|
||||
disabled={restoringDisabled}
|
||||
tooltip={tr.editingRestoreOriginalSize()}
|
||||
on:click={() => dispatch("imageclear")}
|
||||
--border-right-radius="5px">{@html sizeClear}</IconButton
|
||||
>
|
||||
</ButtonGroup>
|
||||
|
@ -1,203 +0,0 @@
|
||||
<!--
|
||||
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, onDestroy } from "svelte";
|
||||
|
||||
import { nodeIsElement } from "../../lib/dom";
|
||||
|
||||
export let activeImage: HTMLImageElement | null;
|
||||
export let container: HTMLElement;
|
||||
export let sheet: CSSStyleSheet;
|
||||
|
||||
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: number;
|
||||
export let maxHeight: number;
|
||||
|
||||
$: 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(" > ");
|
||||
}
|
||||
|
||||
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" && !(node as HTMLElement).dataset.anki) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
mutationObserver.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
for (const image of filterImages([...container.childNodes])) {
|
||||
addImageOnLoad(image);
|
||||
}
|
||||
|
||||
onDestroy(() => mutationObserver.disconnect());
|
||||
|
||||
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);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if activeImage}
|
||||
<slot {toggleActualSize} {active} />
|
||||
{/if}
|
@ -4,5 +4,6 @@
|
||||
export { default as floatLeftIcon } from "@mdi/svg/svg/format-float-left.svg";
|
||||
export { default as floatNoneIcon } from "@mdi/svg/svg/format-float-none.svg";
|
||||
export { default as floatRightIcon } from "@mdi/svg/svg/format-float-right.svg";
|
||||
export { default as sizeClear } from "@mdi/svg/svg/image-remove.svg";
|
||||
export { default as sizeActual } from "@mdi/svg/svg/image-size-select-actual.svg";
|
||||
export { default as sizeMinimized } from "@mdi/svg/svg/image-size-select-large.svg";
|
||||
|
@ -243,19 +243,6 @@ export class Surrounder {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns True, if element has no style attribute (anymore).
|
||||
*/
|
||||
export function removeEmptyStyle(element: HTMLElement | SVGElement): boolean {
|
||||
if (element.style.cssText.length === 0) {
|
||||
element.removeAttribute("style");
|
||||
// Calling `.hasAttribute` right after `.removeAttribute` might return true.
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
registerPackage("anki/surround", {
|
||||
Surrounder,
|
||||
});
|
||||
|
31
ts/lib/styling.ts
Normal file
31
ts/lib/styling.ts
Normal file
@ -0,0 +1,31 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
/**
|
||||
* @returns True, if element has no style attribute (anymore).
|
||||
*/
|
||||
function removeEmptyStyle(element: HTMLElement | SVGElement): boolean {
|
||||
if (element.style.cssText.length === 0) {
|
||||
element.removeAttribute("style");
|
||||
// Calling `.hasAttribute` right after `.removeAttribute` might return true.
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Will remove the style attribute, if all properties were removed.
|
||||
*
|
||||
* @returns True, if element has no style attributes anymore
|
||||
*/
|
||||
export function removeStyleProperties(
|
||||
element: HTMLElement | SVGElement,
|
||||
...props: string[]
|
||||
): boolean {
|
||||
for (const prop of props) {
|
||||
element.style.removeProperty(prop);
|
||||
}
|
||||
|
||||
return removeEmptyStyle(element);
|
||||
}
|
Loading…
Reference in New Issue
Block a user