Manage CSSStyleSheet from within SizeSelect where each image has one rule

This commit is contained in:
Henrik Giesel 2021-07-28 20:17:31 +02:00 committed by Damien Elmes
parent e581d593d3
commit d3e46e9da4
5 changed files with 329 additions and 116 deletions

View file

@ -2,33 +2,25 @@
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
<script lang="ts">
import ImageHandleFloat from "./ImageHandleFloat.svelte";
import ImageHandleSizeSelect from "./ImageHandleSizeSelect.svelte";
import { onDestroy, getContext } from "svelte";
import { nightModeKey } from "components/context-keys";
export let image: HTMLImageElement | null = null;
export let imageRule: CSSStyleRule | null = null;
export let container: HTMLElement;
export let sheet: CSSStyleSheet;
export let activeImage: HTMLImageElement | null = null;
export let isRtl: boolean = false;
export let container: HTMLElement;
$: selector = `:not([src="${image?.getAttribute("src")}"])`;
$: naturalWidth = image?.naturalWidth;
$: naturalHeight = image?.naturalHeight;
$: naturalWidth = activeImage?.naturalWidth;
$: naturalHeight = activeImage?.naturalHeight;
$: aspectRatio = naturalWidth && naturalHeight ? naturalWidth / naturalHeight : NaN;
$: showDimensions = image
? parseInt(getComputedStyle(image).getPropertyValue("height")) >= 50
: false;
$: showDimensions = activeImage ? Number(activeImage!.height) >= 50 : false;
$: showFloat = image
? parseInt(getComputedStyle(image).getPropertyValue("width")) >= 100
: false;
$: active = imageRule?.selectorText.includes(selector) as boolean;
$: showFloat = activeImage ? Number(activeImage!.width) >= 100 : false;
let actualWidth = "";
let actualHeight = "";
@ -42,39 +34,48 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let width = 0;
let height = 0;
$: if (image) {
$: if (activeImage) {
updateSizes();
} else {
resetSizes();
}
const observer = new ResizeObserver(() => {
if (image) {
const resizeObserver = new ResizeObserver(() => {
if (activeImage) {
updateSizes();
}
});
function startObserving() {
observer.observe(container);
resizeObserver.observe(container);
}
function stopObserving() {
observer.unobserve(container);
resizeObserver.unobserve(container);
}
startObserving();
function resetSizes() {
top = 0;
left = 0;
width = 0;
height = 0;
}
function updateSizes() {
const containerRect = container.getBoundingClientRect();
const imageRect = image!.getBoundingClientRect();
const imageRect = activeImage!.getBoundingClientRect();
containerTop = containerRect.top;
containerLeft = containerRect.left;
top = imageRect!.top - containerTop;
left = imageRect!.left - containerLeft;
width = image!.clientWidth;
height = image!.clientHeight;
width = activeImage!.clientWidth;
height = activeImage!.clientHeight;
/* we do not want the actual width, but rather the intended display width */
const widthProperty = image!.style.width;
const widthProperty = activeImage!.style.width;
let inPixel = false;
customDimensions = false;
@ -91,7 +92,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
actualWidth = String(naturalWidth);
}
const heightProperty = image!.style.height;
const heightProperty = activeImage!.style.height;
if (inPixel || heightProperty === "auto") {
actualHeight = String(Math.trunc(Number(actualWidth) / aspectRatio));
} else if (heightProperty) {
@ -104,14 +105,43 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
}
function setPointerCapture(event: PointerEvent): void {
if (!active || event.pointerId !== 1) {
return;
}
/* 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;
stopObserving();
(event.target as Element).setPointerCapture(event.pointerId);
}
const setPointerCapture =
(north: boolean, west: boolean) =>
(event: PointerEvent): void => {
if (!active || event.pointerId !== 1) {
return;
}
const containerRect = container.getBoundingClientRect();
const imageRect = activeImage!.getBoundingClientRect();
const originalContainerY = containerRect.top;
const originalContainerX = containerRect.left;
const originalY = imageRect!.top - containerTop;
const originalX = imageRect!.left - containerLeft;
getDragWidth = (event) =>
west
? activeImage!.clientWidth -
event.clientX +
(originalContainerX + originalX)
: event.clientX - originalContainerX - originalX;
getDragHeight = (event) =>
north
? activeImage!.clientHeight -
event.clientY +
(originalContainerY + originalY)
: event.clientY - originalContainerY - originalY;
stopObserving();
(event.target as Element).setPointerCapture(event.pointerId);
};
function resize(event: PointerEvent): void {
const element = event.target! as Element;
@ -120,8 +150,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
return;
}
const dragWidth = event.clientX - containerLeft - left;
const dragHeight = event.clientY - containerTop - top;
const dragWidth = getDragWidth(event);
const dragHeight = getDragHeight(event);
const widthIncrease = dragWidth / naturalWidth!;
const heightIncrease = dragHeight / naturalHeight!;
@ -137,11 +167,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
showDimensions = height >= 50;
showFloat = width >= 100;
image!.style.width = `${width}px`;
image!.style.removeProperty("height");
activeImage!.width = width;
}
let sizeSelect: any;
let active = false;
function onDblclick() {
sizeSelect.toggleActualSize();
@ -149,35 +179,26 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const nightMode = getContext(nightModeKey);
onDestroy(() => observer.disconnect());
onDestroy(() => resizeObserver.disconnect());
</script>
{#if image && imageRule}
<div
style="--top: {top}px; --left: {left}px; --width: {width}px; --height: {height}px;"
class="image-handle-selection"
>
<div
style="--top: {top}px; --left: {left}px; --width: {width}px; --height: {height}px;"
class="image-handle-selection"
>
{#if activeImage}
<div
class="image-handle-bg"
on:mousedown|preventDefault
on:dblclick={onDblclick}
/>
{#if showFloat}
<div class="image-handle-float" class:is-rtl={isRtl}>
<ImageHandleFloat {isRtl} bind:float={image.style.float} />
<div class="image-handle-float" class:is-rtl={isRtl} on:click={updateSizes}>
<ImageHandleFloat {activeImage} {isRtl} />
</div>
{/if}
<div class="image-handle-size-select" class:is-rtl={isRtl}>
<ImageHandleSizeSelect
bind:this={sizeSelect}
bind:active
{image}
{imageRule}
{selector}
{isRtl}
on:update={updateSizes}
/>
</div>
{#if showDimensions}
<div class="image-handle-dimensions" class:is-rtl={isRtl}>
<span>{actualWidth}&times;{actualHeight}</span>
@ -186,22 +207,47 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
>{/if}
</div>
{/if}
{/if}
{#if sheet}
<div class="image-handle-size-select" class:is-rtl={isRtl}>
<ImageHandleSizeSelect
bind:this={sizeSelect}
bind:active
{container}
{sheet}
{activeImage}
{isRtl}
on:update={updateSizes}
/>
</div>
{/if}
{#if activeImage}
<div
class:nightMode
class:active
class="image-handle-control image-handle-control-nw"
on:mousedown|preventDefault
on:pointerdown={setPointerCapture(true, true)}
on:pointerup={startObserving}
on:pointermove={resize}
/>
<div
class:nightMode
class:active
class="image-handle-control image-handle-control-ne"
on:mousedown|preventDefault
on:pointerdown={setPointerCapture(true, false)}
on:pointerup={startObserving}
on:pointermove={resize}
/>
<div
class:nightMode
class:active
class="image-handle-control image-handle-control-sw"
on:mousedown|preventDefault
on:pointerdown={setPointerCapture}
on:pointerdown={setPointerCapture(false, true)}
on:pointerup={startObserving}
on:pointermove={resize}
/>
@ -210,12 +256,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
class:active
class="image-handle-control image-handle-control-se"
on:mousedown|preventDefault
on:pointerdown={setPointerCapture}
on:pointerdown={setPointerCapture(false, false)}
on:pointerup={startObserving}
on:pointermove={resize}
/>
</div>
{/if}
{/if}
</div>
<style lang="scss">
div {
@ -301,6 +347,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
left: -5px;
border-bottom: none;
border-right: none;
&.active {
cursor: nw-resize;
}
}
.image-handle-control-ne {
@ -308,6 +358,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
right: -5px;
border-bottom: none;
border-left: none;
&.active {
cursor: ne-resize;
}
}
.image-handle-control-sw {

View file

@ -11,7 +11,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { floatNoneIcon, floatLeftIcon, floatRightIcon } from "./icons";
export let float: string;
export let activeImage: HTMLImageElement;
export let isRtl: boolean;
const leftValues = {
@ -33,18 +33,20 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonGroupItem>
<IconButton
tooltip={tr.editingFloatNone()}
active={float === "" || float === "none"}
active={activeImage.style.float === "" ||
activeImage.style.float === "none"}
flipX={isRtl}
on:click={() => (float = "")}>{@html floatNoneIcon}</IconButton
on:click={() => (activeImage.style.float = "")}
>{@html floatNoneIcon}</IconButton
>
</ButtonGroupItem>
<ButtonGroupItem>
<IconButton
tooltip={inlineStart.label}
active={float === inlineStart.position}
active={activeImage.style.float === inlineStart.position}
flipX={isRtl}
on:click={() => (float = inlineStart.position)}
on:click={() => (activeImage.style.float = inlineStart.position)}
>{@html floatLeftIcon}</IconButton
>
</ButtonGroupItem>
@ -52,9 +54,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonGroupItem>
<IconButton
tooltip={inlineEnd.label}
active={float === inlineEnd.position}
active={activeImage.style.float === inlineEnd.position}
flipX={isRtl}
on:click={() => (float = inlineEnd.position)}
on:click={() => (activeImage.style.float = inlineEnd.position)}
>{@html floatRightIcon}</IconButton
>
</ButtonGroupItem>

View file

@ -9,43 +9,184 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import ButtonGroupItem from "components/ButtonGroupItem.svelte";
import IconButton from "components/IconButton.svelte";
import { createEventDispatcher } from "svelte";
import { createEventDispatcher, onDestroy } from "svelte";
import { sizeActual, sizeMinimized } from "./icons";
import { nodeIsElement } from "./helpers";
export let container: HTMLElement;
export let sheet: CSSStyleSheet;
export let activeImage: HTMLImageElement | null;
export let active: boolean = false;
export let image: HTMLImageElement;
export let imageRule: CSSStyleRule;
export let selector: string;
export let active: boolean;
export let isRtl: boolean;
export let maxWidth = 250;
export let maxHeight = 125;
$: icon = active ? sizeActual : sizeMinimized;
const dispatch = createEventDispatcher();
export function toggleActualSize() {
if (!image.hasAttribute("src")) {
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(" > ");
}
let 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") {
result.push(node as HTMLImageElement);
} else {
result.push(...filterImages(node.children));
}
}
return result;
}
function setImageRule(image: HTMLImageElement, rule: CSSStyleRule): void {
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;
}
if (active) {
imageRule.selectorText = imageRule.selectorText.replace(selector, "");
active = false;
images.push(image);
const index = sheet.insertRule(
`${createPath(image)} {}`,
sheet.cssRules.length
);
const rule = sheet.cssRules[index] as CSSStyleRule;
setImageRule(image, rule);
images = images;
}
function addImageOnLoad(image: HTMLImageElement): void {
if (image.complete && image.naturalWidth !== 0 && image.naturalHeight !== 0) {
addImage(image);
} else {
imageRule.selectorText += selector;
active = true;
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);
}
images = images;
});
$: if (container) {
mutationObserver.observe(container, { childList: true, subtree: true });
}
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);
}
onDestroy(() => mutationObserver.disconnect());
</script>
<ButtonGroup size={1.6}>
<ButtonGroupItem>
<IconButton
{active}
flipX={isRtl}
tooltip={tr.editingActualSize()}
on:click={toggleActualSize}>{@html icon}</IconButton
>
</ButtonGroupItem>
</ButtonGroup>
{#if activeImage}
<ButtonGroup size={1.6}>
<ButtonGroupItem>
<IconButton
{active}
flipX={isRtl}
tooltip={tr.editingActualSize()}
on:click={toggleActualSize}>{@html icon}</IconButton
>
</ButtonGroupItem>
</ButtonGroup>
{/if}

View file

@ -8,7 +8,7 @@
export class EditableContainer extends HTMLDivElement {
baseStyle: HTMLStyleElement;
baseRule?: CSSStyleRule;
imageRule?: CSSStyleRule;
imageStyle?: HTMLStyleElement;
constructor() {
super();
@ -25,6 +25,7 @@ export class EditableContainer extends HTMLDivElement {
this.baseStyle = document.createElement("style");
this.baseStyle.setAttribute("rel", "stylesheet");
this.baseStyle.id = "baseStyle";
shadow.appendChild(this.baseStyle);
}
@ -32,11 +33,6 @@ export class EditableContainer extends HTMLDivElement {
const sheet = this.baseStyle.sheet as CSSStyleSheet;
const baseIndex = sheet.insertRule("anki-editable {}");
this.baseRule = sheet.cssRules[baseIndex] as CSSStyleRule;
const imageIndex = sheet.insertRule("anki-editable img {}");
this.imageRule = sheet.cssRules[imageIndex] as CSSStyleRule;
this.imageRule.style.setProperty("max-width", "min(250px, 100%)", "important");
this.imageRule.style.setProperty("max-height", "200px", "important");
}
initialize(color: string): void {

View file

@ -23,7 +23,7 @@ function onCutOrCopy(): void {
}
export class EditingArea extends HTMLDivElement {
imageHandle: ImageHandle;
imageHandle: Promise<ImageHandle>;
editableContainer: EditableContainer;
editable: Editable;
codable: Codable;
@ -35,9 +35,12 @@ export class EditingArea extends HTMLDivElement {
this.editableContainer = document.createElement("div", {
is: "anki-editable-container",
}) as EditableContainer;
const imageStyle = document.createElement("style");
imageStyle.setAttribute("rel", "stylesheet");
imageStyle.id = "imageHandleStyle";
this.editable = document.createElement("anki-editable") as Editable;
this.editableContainer.shadowRoot!.appendChild(this.editable);
this.appendChild(this.editableContainer);
const context = new Map();
context.set(
@ -45,14 +48,28 @@ export class EditingArea extends HTMLDivElement {
document.documentElement.classList.contains("night-mode")
);
this.imageHandle = new ImageHandle({
target: this,
anchor: this.editableContainer,
props: {
container: this.editable,
},
context,
} as any);
let imageHandleResolve: (value: ImageHandle) => void;
this.imageHandle = new Promise<ImageHandle>((resolve) => {
imageHandleResolve = resolve;
});
imageStyle.addEventListener("load", () =>
imageHandleResolve(
new ImageHandle({
target: this,
anchor: this.editableContainer,
props: {
container: this.editable,
sheet: imageStyle.sheet,
},
context,
} as any)
)
);
this.editableContainer.shadowRoot!.appendChild(imageStyle);
this.editableContainer.shadowRoot!.appendChild(this.editable);
this.appendChild(this.editableContainer);
this.codable = document.createElement("textarea", {
is: "anki-codable",
@ -75,7 +92,7 @@ export class EditingArea extends HTMLDivElement {
}
set fieldHTML(content: string) {
this.activeInput.fieldHTML = content;
this.imageHandle.then(() => (this.activeInput.fieldHTML = content));
}
get fieldHTML(): string {
@ -186,19 +203,21 @@ export class EditingArea extends HTMLDivElement {
}
resetImageHandle(): void {
(this.imageHandle as any).$set({
image: null,
imageRule: null,
});
this.imageHandle.then((imageHandle) =>
(imageHandle as any).$set({
activeImage: null,
})
);
}
showImageHandle(event: MouseEvent): void {
if (event.target instanceof HTMLImageElement) {
(this.imageHandle as any).$set({
image: event.target,
imageRule: this.editableContainer.imageRule,
isRtl: this.isRightToLeft(),
});
this.imageHandle.then((imageHandle) =>
(imageHandle as any).$set({
activeImage: event.target,
isRtl: this.isRightToLeft(),
})
);
} else {
this.resetImageHandle();
}
@ -211,6 +230,7 @@ export class EditingArea extends HTMLDivElement {
this.fieldHTML = this.codable.teardown();
this.editable.hidden = false;
} else {
this.resetImageHandle();
this.editable.hidden = true;
this.codable.setup(this.editable.fieldHTML);
}