Merge pull request #1307 from hgiesel/editablecontainer

Image Resizer + Maximum image size
This commit is contained in:
Damien Elmes 2021-09-06 21:25:27 +10:00 committed by GitHub
commit 46985f0084
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1128 additions and 88 deletions

View file

@ -1,3 +1,4 @@
editing-actual-size = Toggle actual size
editing-add-media = Add Media
editing-align-left = Align left
editing-align-right = Align right
@ -15,6 +16,9 @@ editing-cut = Cut
editing-edit-current = Edit Current
editing-edit-html = Edit HTML
editing-fields = Fields
editing-float-left = Float left
editing-float-right = Float right
editing-float-none = No float
editing-html-editor = HTML Editor
editing-indent = Increase indent
editing-italic-text = Italic text

View file

@ -19,6 +19,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export { className as class };
export let size: number | undefined = undefined;
export let wrap: boolean | undefined = undefined;
$: buttonSize = size ? `--buttons-size: ${size}rem; ` : "";
@ -45,9 +46,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
if ($items.length === 1) {
return ButtonPosition.Standalone;
} else if (index === 0) {
return ButtonPosition.Leftmost;
return ButtonPosition.InlineStart;
} else if (index === $items.length - 1) {
return ButtonPosition.Rightmost;
return ButtonPosition.InlineEnd;
} else {
return ButtonPosition.Center;
}
@ -95,7 +96,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<div
bind:this={buttonGroupRef}
{id}
class={`btn-group ${className}`}
class="btn-group {className}"
{style}
dir="ltr"
role="group"
@ -110,6 +111,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style lang="scss">
div {
flex-direction: row;
flex-wrap: var(--buttons-wrap);
padding: calc(var(--buttons-size) / 10);
margin: 0;

View file

@ -20,21 +20,24 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let position_: ButtonPosition;
let style: string;
const radius = "calc(var(--buttons-size) / 7.5)";
const radius = "5px";
const leftStyle = `--border-left-radius: ${radius}; --border-right-radius: 0; `;
const rightStyle = `--border-left-radius: 0; --border-right-radius: ${radius}; `;
$: {
switch (position_) {
case ButtonPosition.Standalone:
style = `--border-left-radius: ${radius}; --border-right-radius: ${radius}; `;
break;
case ButtonPosition.Leftmost:
style = `--border-left-radius: ${radius}; --border-right-radius: 0; `;
case ButtonPosition.InlineStart:
style = leftStyle;
break;
case ButtonPosition.Center:
style = "--border-left-radius: 0; --border-right-radius: 0; ";
break;
case ButtonPosition.Rightmost:
style = `--border-left-radius: 0; --border-right-radius: ${radius}; `;
case ButtonPosition.InlineEnd:
style = rightStyle;
break;
}
}

View file

@ -18,6 +18,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let iconSize: number = 75;
export let widthMultiplier: number = 1;
export let flipX: boolean = false;
let buttonRef: HTMLButtonElement;
@ -44,7 +45,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
on:click
on:mousedown|preventDefault
>
<span style={`--width-multiplier: ${widthMultiplier};`}> <slot /> </span>
<span class:flip-x={flipX} style={`--width-multiplier: ${widthMultiplier};`}>
<slot />
</span>
</button>
<style lang="scss">
@ -80,6 +83,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
fill: currentColor;
vertical-align: unset;
}
&.flip-x > :global(svg),
&.flip-x > :global(img) {
transform: scaleX(-1);
}
}
.dropdown-toggle::after {

View file

@ -8,27 +8,51 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { setContext, onDestroy } from "svelte";
import { dropdownKey } from "./context-keys";
export let autoOpen = false;
export let autoClose: boolean | "inside" | "outside" = true;
export let placement = "bottom-start";
setContext(dropdownKey, {
dropdown: true,
"data-bs-toggle": "dropdown",
});
let dropdown: Dropdown;
let dropdownObject: Dropdown;
const noop = () => {};
function createDropdown(toggle: HTMLElement): Dropdown {
/* avoid focusing element toggle on menu activation */
toggle.focus = noop;
dropdown = new Dropdown(toggle, {} as any);
dropdown = new Dropdown(toggle, {
autoClose,
popperConfig: (defaultConfig: Record<string, any>) => ({
...defaultConfig,
placement,
}),
} as any);
return dropdown;
if (autoOpen) {
dropdown.show();
}
dropdownObject = {
show: dropdown.show.bind(dropdown),
toggle: dropdown.toggle.bind(dropdown),
hide: dropdown.hide.bind(dropdown),
update: dropdown.update.bind(dropdown),
dispose: dropdown.dispose.bind(dropdown),
};
return dropdownObject;
}
onDestroy(() => dropdown?.dispose());
</script>
<div class="dropdown">
<slot {createDropdown} />
<slot {createDropdown} {dropdownObject} />
</div>
<style lang="scss">

View file

@ -5,9 +5,9 @@ import type { Registration } from "./registration";
export enum ButtonPosition {
Standalone,
Leftmost,
InlineStart,
Center,
Rightmost,
InlineEnd,
}
export interface ButtonRegistration extends Registration {

View file

@ -118,6 +118,14 @@ copy_mdi_icons(
"format-color-text.svg",
"format-color-highlight.svg",
"color-helper.svg",
# image handle
"format-float-none.svg",
"format-float-left.svg",
"format-float-right.svg",
"image-size-select-large.svg",
"image-size-select-actual.svg",
],
visibility = ["//visibility:public"],
)

View file

@ -3,7 +3,6 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import type { EditingArea } from "./editing-area";
import * as tr from "lib/i18n";
import ButtonGroup from "components/ButtonGroup.svelte";
@ -15,7 +14,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import OnlyEditable from "./OnlyEditable.svelte";
import CommandIconButton from "./CommandIconButton.svelte";
import { getListItem } from "./helpers";
import { getCurrentField, getListItem } from "./helpers";
import {
ulIcon,
olIcon,
@ -31,8 +30,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let api = {};
function outdentListItem() {
const currentField = document.activeElement as EditingArea;
if (getListItem(currentField.shadowRoot!)) {
const currentField = getCurrentField();
if (getListItem(currentField.editableContainer.shadowRoot!)) {
document.execCommand("outdent");
} else {
alert("Indent/unindent currently only works with lists.");
@ -40,8 +39,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
function indentListItem() {
const currentField = document.activeElement as EditingArea;
if (getListItem(currentField.shadowRoot!)) {
const currentField = getCurrentField();
if (getListItem(currentField.editableContainer.shadowRoot!)) {
document.execCommand("indent");
} else {
alert("Indent/unindent currently only works with lists.");

View file

@ -0,0 +1,23 @@
<!--
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, createEventDispatcher } from "svelte";
let background: HTMLDivElement;
const dispatch = createEventDispatcher();
onMount(() => dispatch("mount", { background }));
</script>
<div bind:this={background} on:mousedown|preventDefault on:dblclick />
<style lang="scss">
div {
width: 100%;
height: 100%;
background-color: black;
opacity: 0.2;
}
</style>

View file

@ -0,0 +1,139 @@
<!--
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, getContext } from "svelte";
import { nightModeKey } from "components/context-keys";
export let offsetX = 0;
export let offsetY = 0;
export let active = false;
export let activeSize = 5;
const dispatch = createEventDispatcher();
const nightMode = getContext(nightModeKey);
const onPointerdown =
(north: boolean, west: boolean) =>
(event: PointerEvent): void => {
dispatch("pointerclick", { north, west, originalEvent: event });
};
</script>
<div
class="d-contents"
style="--offsetX: {offsetX}px; --offsetY: {offsetY}px; --activeSize: {activeSize}px;"
>
<div class:nightMode class="bordered" on:mousedown|preventDefault />
<div
class:nightMode
class:active
class="control nw"
on:mousedown|preventDefault
on:pointerdown={onPointerdown(true, true)}
on:pointermove
/>
<div
class:nightMode
class:active
class="control ne"
on:mousedown|preventDefault
on:pointerdown={onPointerdown(true, false)}
on:pointermove
/>
<div
class:nightMode
class:active
class="control sw"
on:mousedown|preventDefault
on:pointerdown={onPointerdown(false, true)}
on:pointermove
/>
<div
class:nightMode
class:active
class="control se"
on:mousedown|preventDefault
on:pointerdown={onPointerdown(false, false)}
on:pointermove
/>
</div>
<style lang="scss">
.d-contents {
display: contents;
}
.bordered {
position: absolute;
top: calc(0px - var(--activeSize) + var(--offsetY));
bottom: calc(0px - var(--activeSize) + var(--offsetY));
left: calc(0px - var(--activeSize) + var(--offsetX));
right: calc(0px - var(--activeSize) + var(--offsetX));
pointer-events: none;
border: 2px dashed black;
&.nightMode {
border-color: white;
}
}
.control {
position: absolute;
width: var(--activeSize);
height: var(--activeSize);
&.active {
background-color: black;
}
&.nightMode {
border-color: white;
&.active {
background-color: white;
}
}
&.nw {
top: calc(0px - var(--offsetY));
left: calc(0px - var(--offsetX));
&.active {
cursor: nw-resize;
}
}
&.ne {
top: calc(0px - var(--offsetY));
right: calc(0px - var(--offsetX));
&.active {
cursor: ne-resize;
}
}
&.sw {
bottom: calc(0px - var(--offsetY));
left: calc(0px - var(--offsetX));
&.active {
cursor: sw-resize;
}
}
&.se {
bottom: calc(0px - var(--offsetY));
right: calc(0px - var(--offsetX));
&.active {
cursor: se-resize;
}
}
}
</style>

View file

@ -0,0 +1,69 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { afterUpdate, createEventDispatcher, onMount } from "svelte";
export let isRtl: boolean;
let dimensions: HTMLDivElement;
let overflowFix = 0;
function updateOverflow(dimensions: HTMLDivElement) {
const boundingClientRect = dimensions.getBoundingClientRect();
const overflow = isRtl
? window.innerWidth - boundingClientRect.x - boundingClientRect.width
: boundingClientRect.x;
overflowFix = Math.min(0, overflowFix + overflow, overflow);
}
function updateOverflowAsync(dimensions: HTMLDivElement) {
setTimeout(() => updateOverflow(dimensions));
}
afterUpdate(() => updateOverflow(dimensions));
const dispatch = createEventDispatcher();
onMount(() => dispatch("mount"));
</script>
<div
bind:this={dimensions}
class="image-handle-dimensions"
class:is-rtl={isRtl}
style="--overflow-fix: {overflowFix}px"
use:updateOverflowAsync
>
<slot />
</div>
<style lang="scss">
div {
position: absolute;
pointer-events: none;
user-select: none;
font-size: 13px;
color: white;
background-color: rgba(0 0 0 / 0.3);
border-color: black;
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);
}
}
</style>

View file

@ -0,0 +1,56 @@
<!--
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, createEventDispatcher } from "svelte";
export let container: HTMLElement;
export let image: HTMLImageElement;
export let offsetX = 0;
export let offsetY = 0;
let left: number;
let top: number;
let width: number;
let height: number;
export function updateSelection(_div: HTMLDivElement): void {
const containerRect = container.getBoundingClientRect();
const imageRect = image!.getBoundingClientRect();
const containerLeft = containerRect.left;
const containerTop = containerRect.top;
left = imageRect!.left - containerLeft;
top = imageRect!.top - containerTop;
width = image!.clientWidth;
height = image!.clientHeight;
}
const dispatch = createEventDispatcher();
let selection: HTMLDivElement;
onMount(() => dispatch("mount", { selection }));
</script>
<div
bind:this={selection}
use:updateSelection
on:click={(event) =>
/* prevent triggering Bootstrap dropdown */ event.stopImmediatePropagation()}
style="--left: {left}px; --top: {top}px; --width: {width}px; --height: {height}px; --offsetX: {offsetX}px; --offsetY: {offsetY}px;"
>
<slot />
</div>
<style lang="scss">
div {
position: absolute;
left: calc(var(--left, 0px) - var(--offsetX, 0px));
top: calc(var(--top, 0px) - var(--offsetY, 0px));
width: calc(var(--width) + 2 * var(--offsetX, 0px));
height: calc(var(--height) + 2 * var(--offsetY, 0px));
}
</style>

View file

@ -0,0 +1,210 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import WithDropdown from "components/WithDropdown.svelte";
import ButtonDropdown from "components/ButtonDropdown.svelte";
import Item from "components/Item.svelte";
import WithImageConstrained from "./WithImageConstrained.svelte";
import HandleBackground from "./HandleBackground.svelte";
import HandleSelection from "./HandleSelection.svelte";
import HandleControl from "./HandleControl.svelte";
import HandleLabel from "./HandleLabel.svelte";
import ImageHandleFloatButtons from "./ImageHandleFloatButtons.svelte";
import ImageHandleSizeSelect from "./ImageHandleSizeSelect.svelte";
import { onDestroy } from "svelte";
export let container: HTMLElement;
export let sheet: CSSStyleSheet;
export let activeImage: HTMLImageElement | null = null;
export let isRtl: boolean = false;
$: 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: () => void;
async function updateSizesWithDimensions() {
updateSelection();
updateDimensions();
}
/* window resizing */
const resizeObserver = new ResizeObserver(async () => {
await updateSizesWithDimensions();
});
$: observes = Boolean(activeImage);
$: if (observes) {
resizeObserver.observe(container);
} else {
resizeObserver.unobserve(container);
}
/* 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);
}
$: [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 {
let height = Math.max(Math.trunc(dragHeight), minResizeHeight);
width = Math.trunc(naturalWidth! * (height / naturalHeight!));
}
activeImage!.width = width;
}
onDestroy(() => resizeObserver.disconnect());
</script>
{#if sheet}
<WithDropdown
placement="bottom"
autoOpen={true}
autoClose={false}
let:createDropdown
let:dropdownObject
>
<WithImageConstrained
{sheet}
{container}
{activeImage}
on:update={() => {
updateSizesWithDimensions();
dropdownObject.update();
}}
let:toggleActualSize
let:active
>
{#if activeImage}
<HandleSelection
bind:updateSelection
{container}
image={activeImage}
on:mount={(event) => createDropdown(event.detail.selection)}
>
<HandleBackground on:dblclick={toggleActualSize} />
<HandleLabel {isRtl} on:mount={updateDimensions}>
<span>{actualWidth}&times;{actualHeight}</span>
{#if customDimensions}
<span>(Original: {naturalWidth}&times;{naturalHeight})</span
>
{/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>
<div on:click={updateSizesWithDimensions}>
<Item>
<ImageHandleFloatButtons
image={activeImage}
{isRtl}
on:update={dropdownObject.update}
/>
</Item>
<Item>
<ImageHandleSizeSelect
{active}
{isRtl}
on:click={toggleActualSize}
/>
</Item>
</div>
</ButtonDropdown>
{/if}
</WithImageConstrained>
</WithDropdown>
{/if}

View file

@ -0,0 +1,61 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import * as tr from "lib/i18n";
import ButtonGroup from "components/ButtonGroup.svelte";
import ButtonGroupItem from "components/ButtonGroupItem.svelte";
import IconButton from "components/IconButton.svelte";
import { createEventDispatcher } from "svelte";
import { floatNoneIcon, floatLeftIcon, floatRightIcon } from "./icons";
export let image: HTMLImageElement;
export let isRtl: boolean;
const [inlineStartIcon, inlineEndIcon] = isRtl
? [floatRightIcon, floatLeftIcon]
: [floatLeftIcon, floatRightIcon];
const dispatch = createEventDispatcher();
</script>
<ButtonGroup size={1.6} wrap={false}>
<ButtonGroupItem>
<IconButton
tooltip={tr.editingFloatLeft()}
active={image.style.float === "left"}
flipX={isRtl}
on:click={() => {
image.style.float = "left";
setTimeout(() => dispatch("update"));
}}>{@html inlineStartIcon}</IconButton
>
</ButtonGroupItem>
<ButtonGroupItem>
<IconButton
tooltip={tr.editingFloatNone()}
active={image.style.float === "" || image.style.float === "none"}
flipX={isRtl}
on:click={() => {
image.style.float = "";
setTimeout(() => dispatch("update"));
}}>{@html floatNoneIcon}</IconButton
>
</ButtonGroupItem>
<ButtonGroupItem>
<IconButton
tooltip={tr.editingFloatRight()}
active={image.style.float === "right"}
flipX={isRtl}
on:click={() => {
image.style.float = "right";
setTimeout(() => dispatch("update"));
}}>{@html inlineEndIcon}</IconButton
>
</ButtonGroupItem>
</ButtonGroup>

View file

@ -0,0 +1,26 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import * as tr from "lib/i18n";
import ButtonGroup from "components/ButtonGroup.svelte";
import ButtonGroupItem from "components/ButtonGroupItem.svelte";
import IconButton from "components/IconButton.svelte";
import { sizeActual, sizeMinimized } from "./icons";
export let active: boolean;
export let isRtl: boolean;
$: icon = active ? sizeActual : sizeMinimized;
</script>
<ButtonGroup size={1.6}>
<ButtonGroupItem>
<IconButton {active} flipX={isRtl} tooltip={tr.editingActualSize()} on:click
>{@html icon}</IconButton
>
</ButtonGroupItem>
</ButtonGroup>

View file

@ -18,8 +18,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import OnlyEditable from "./OnlyEditable.svelte";
import ClozeButton from "./ClozeButton.svelte";
import { getCurrentField } from ".";
import { appendInParentheses } from "./helpers";
import { getCurrentField, appendInParentheses } from "./helpers";
import { wrapCurrent } from "./wrap";
import { paperclipIcon, micIcon, functionIcon, xmlIcon } from "./icons";

View file

@ -0,0 +1,199 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import { createEventDispatcher, onDestroy } from "svelte";
import { nodeIsElement } from "./helpers";
export let container: HTMLElement;
export let sheet: CSSStyleSheet;
export let activeImage: HTMLImageElement | null;
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 = 250;
export let maxHeight = 125;
$: 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(" > ");
}
export 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") {
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);
}
});
$: 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>
{#if activeImage}
<slot {toggleActualSize} {active} />
{/if}

View file

@ -3,7 +3,7 @@
import type { EditingArea } from "./editing-area";
import { getCurrentField } from ".";
import { getCurrentField } from "./helpers";
import { bridgeCommand } from "./lib";
import { getNoteId } from "./note-id";
@ -23,9 +23,10 @@ function clearChangeTimer(): void {
export function saveField(currentField: EditingArea, type: "blur" | "key"): void {
clearChangeTimer();
bridgeCommand(
`${type}:${currentField.ord}:${getNoteId()}:${currentField.fieldHTML}`
);
const command = `${type}:${currentField.ord}:${getNoteId()}:${
currentField.fieldHTML
}`;
bridgeCommand(command);
}
export function saveNow(keepFocus: boolean): void {

View file

@ -0,0 +1,59 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
*/
export class EditableContainer extends HTMLDivElement {
baseStyle: HTMLStyleElement;
baseRule?: CSSStyleRule;
imageStyle?: HTMLStyleElement;
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
if (document.documentElement.classList.contains("night-mode")) {
this.classList.add("night-mode");
}
const rootStyle = document.createElement("link");
rootStyle.setAttribute("rel", "stylesheet");
rootStyle.setAttribute("href", "./_anki/css/editable.css");
shadow.appendChild(rootStyle);
this.baseStyle = document.createElement("style");
this.baseStyle.setAttribute("rel", "stylesheet");
this.baseStyle.id = "baseStyle";
shadow.appendChild(this.baseStyle);
}
connectedCallback(): void {
const sheet = this.baseStyle.sheet as CSSStyleSheet;
const baseIndex = sheet.insertRule("anki-editable {}");
this.baseRule = sheet.cssRules[baseIndex] as CSSStyleRule;
}
initialize(color: string): void {
this.setBaseColor(color);
}
setBaseColor(color: string): void {
if (this.baseRule) {
this.baseRule.style.color = color;
}
}
setBaseStyling(fontFamily: string, fontSize: string, direction: string): void {
if (this.baseRule) {
this.baseRule.style.fontFamily = fontFamily;
this.baseRule.style.fontSize = fontSize;
this.baseRule.style.direction = direction;
}
}
isRightToLeft(): boolean {
return this.baseRule!.style.direction === "rtl";
}
}

View file

@ -6,6 +6,10 @@ anki-editable {
overflow: auto;
padding: 6px;
&:focus {
outline: none;
}
* {
max-width: 100%;
}

View file

@ -3,8 +3,12 @@
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
@typescript-eslint/no-explicit-any: "off",
*/
import ImageHandle from "./ImageHandle.svelte";
import type { EditableContainer } from "./editable-container";
import type { Editable } from "./editable";
import type { Codable } from "./codable";
@ -12,43 +16,71 @@ import { updateActiveButtons } from "./toolbar";
import { bridgeCommand } from "./lib";
import { onInput, onKey, onKeyUp } from "./input-handlers";
import { onFocus, onBlur } from "./focus-handlers";
import { nightModeKey } from "components/context-keys";
function onCutOrCopy(): void {
bridgeCommand("cutOrCopy");
}
export class EditingArea extends HTMLDivElement {
imageHandle: Promise<ImageHandle>;
editableContainer: EditableContainer;
editable: Editable;
codable: Codable;
baseStyle: HTMLStyleElement;
constructor() {
super();
this.attachShadow({ mode: "open" });
this.className = "field";
if (document.documentElement.classList.contains("night-mode")) {
this.classList.add("night-mode");
}
this.editableContainer = document.createElement("div", {
is: "anki-editable-container",
}) as EditableContainer;
const rootStyle = document.createElement("link");
rootStyle.setAttribute("rel", "stylesheet");
rootStyle.setAttribute("href", "./_anki/css/editable.css");
this.shadowRoot!.appendChild(rootStyle);
this.baseStyle = document.createElement("style");
this.baseStyle.setAttribute("rel", "stylesheet");
this.shadowRoot!.appendChild(this.baseStyle);
const imageStyle = document.createElement("style");
imageStyle.setAttribute("rel", "stylesheet");
imageStyle.id = "imageHandleStyle";
this.editable = document.createElement("anki-editable") as Editable;
this.shadowRoot!.appendChild(this.editable);
const context = new Map();
context.set(
nightModeKey,
document.documentElement.classList.contains("night-mode")
);
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",
}) as Codable;
this.shadowRoot!.appendChild(this.codable);
this.appendChild(this.codable);
this.onFocus = this.onFocus.bind(this);
this.onBlur = this.onBlur.bind(this);
this.onKey = this.onKey.bind(this);
this.onPaste = this.onPaste.bind(this);
this.showImageHandle = this.showImageHandle.bind(this);
}
get activeInput(): Editable | Codable {
@ -60,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 {
@ -68,30 +100,29 @@ export class EditingArea extends HTMLDivElement {
}
connectedCallback(): void {
this.addEventListener("keydown", onKey);
this.addEventListener("keydown", this.onKey);
this.addEventListener("keyup", onKeyUp);
this.addEventListener("input", onInput);
this.addEventListener("focus", onFocus);
this.addEventListener("blur", onBlur);
this.addEventListener("focusin", this.onFocus);
this.addEventListener("focusout", this.onBlur);
this.addEventListener("paste", this.onPaste);
this.addEventListener("copy", onCutOrCopy);
this.addEventListener("oncut", onCutOrCopy);
this.addEventListener("mouseup", updateActiveButtons);
const baseStyleSheet = this.baseStyle.sheet as CSSStyleSheet;
baseStyleSheet.insertRule("anki-editable {}", 0);
this.editable.addEventListener("click", this.showImageHandle);
}
disconnectedCallback(): void {
this.removeEventListener("keydown", onKey);
this.removeEventListener("keydown", this.onKey);
this.removeEventListener("keyup", onKeyUp);
this.removeEventListener("input", onInput);
this.removeEventListener("focus", onFocus);
this.removeEventListener("blur", onBlur);
this.removeEventListener("focusin", this.onFocus);
this.removeEventListener("focusout", this.onBlur);
this.removeEventListener("paste", this.onPaste);
this.removeEventListener("copy", onCutOrCopy);
this.removeEventListener("oncut", onCutOrCopy);
this.removeEventListener("mouseup", updateActiveButtons);
this.editable.removeEventListener("click", this.showImageHandle);
}
initialize(color: string, content: string): void {
@ -100,9 +131,7 @@ export class EditingArea extends HTMLDivElement {
}
setBaseColor(color: string): void {
const styleSheet = this.baseStyle.sheet as CSSStyleSheet;
const firstRule = styleSheet.cssRules[0] as CSSStyleRule;
firstRule.style.color = color;
this.editableContainer.setBaseColor(color);
}
quoteFontFamily(fontFamily: string): string {
@ -114,17 +143,15 @@ export class EditingArea extends HTMLDivElement {
}
setBaseStyling(fontFamily: string, fontSize: string, direction: string): void {
const styleSheet = this.baseStyle.sheet as CSSStyleSheet;
const firstRule = styleSheet.cssRules[0] as CSSStyleRule;
firstRule.style.fontFamily = this.quoteFontFamily(fontFamily);
firstRule.style.fontSize = fontSize;
firstRule.style.direction = direction;
this.editableContainer.setBaseStyling(
this.quoteFontFamily(fontFamily),
fontSize,
direction
);
}
isRightToLeft(): boolean {
const styleSheet = this.baseStyle.sheet as CSSStyleSheet;
const firstRule = styleSheet.cssRules[0] as CSSStyleRule;
return firstRule.style.direction === "rtl";
return this.editableContainer.isRightToLeft();
}
focus(): void {
@ -140,25 +167,62 @@ export class EditingArea extends HTMLDivElement {
}
hasFocus(): boolean {
return document.activeElement === this;
return document.activeElement?.closest(".field") === this;
}
getSelection(): Selection {
return this.shadowRoot!.getSelection()!;
const root = this.activeInput.getRootNode() as Document | ShadowRoot;
return root.getSelection()!;
}
surroundSelection(before: string, after: string): void {
this.activeInput.surroundSelection(before, after);
}
onFocus(event: FocusEvent): void {
onFocus(event);
}
onBlur(event: FocusEvent): void {
this.resetImageHandle();
onBlur(event);
}
onEnter(event: KeyboardEvent): void {
this.activeInput.onEnter(event);
}
onKey(event: KeyboardEvent): void {
this.resetImageHandle();
onKey(event);
}
onPaste(event: ClipboardEvent): void {
this.resetImageHandle();
this.activeInput.onPaste(event);
}
resetImageHandle(): void {
this.imageHandle.then((imageHandle) =>
(imageHandle as any).$set({
activeImage: null,
})
);
}
showImageHandle(event: MouseEvent): void {
if (event.target instanceof HTMLImageElement) {
this.imageHandle.then((imageHandle) =>
(imageHandle as any).$set({
activeImage: event.target,
isRtl: this.isRightToLeft(),
})
);
} else {
this.resetImageHandle();
}
}
toggleHtmlEdit(): void {
const hadFocus = this.hasFocus();
@ -166,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);
}

View file

@ -10,6 +10,8 @@ export class EditorField extends HTMLDivElement {
constructor() {
super();
this.classList.add("editor-field");
this.labelContainer = document.createElement("div", {
is: "anki-label-container",
}) as LabelContainer;
@ -19,6 +21,8 @@ export class EditorField extends HTMLDivElement {
is: "anki-editing-area",
}) as EditingArea;
this.appendChild(this.editingArea);
this.focusIfNotFocused = this.focusIfNotFocused.bind(this);
}
static get observedAttributes(): string[] {
@ -29,6 +33,21 @@ export class EditorField extends HTMLDivElement {
this.setAttribute("ord", String(n));
}
focusIfNotFocused(): void {
if (!this.editingArea.hasFocus()) {
this.editingArea.focus();
this.editingArea.caretToEnd();
}
}
connectedCallback(): void {
this.labelContainer.addEventListener("mousedown", this.focusIfNotFocused);
}
disconnectedCallback(): void {
this.labelContainer.removeEventListener("mousedown", this.focusIfNotFocused);
}
attributeChangedCallback(name: string, _oldValue: string, newValue: string): void {
switch (name) {
case "ord":

View file

@ -10,13 +10,30 @@
#fields {
display: flex;
overflow-x: hidden;
flex-direction: column;
margin: 5px;
margin: 3px 0;
}
.editor-field {
margin: 3px;
border-radius: 5px;
border: 1px solid var(--border-color);
--border-color: var(--border);
&:focus-within {
box-shadow: 0 0 0 3px var(--focus-shadow);
--border-color: var(--focus-border);
}
}
.field {
border: 1px solid var(--border);
position: relative;
background: var(--frame-bg);
border-radius: 0 0 5px 5px;
&.dupe {
// this works around the background colour persisting in copy+paste
@ -26,8 +43,16 @@
}
.fname {
vertical-align: middle;
padding: 0;
border-width: 0 0 1px;
border-style: dashed;
border-color: var(--border-color);
border-radius: 5px 5px 0 0;
padding: 0px 6px;
}
.fieldname {
user-select: none;
}
#dupes,
@ -62,3 +87,13 @@
opacity: 0.5;
}
}
@import "ts/sass/codemirror/lib/codemirror";
@import "ts/sass/codemirror/theme/monokai";
@import "ts/sass/codemirror/addon/fold/foldgutter";
.CodeMirror {
height: auto;
border-radius: 0 0 5px 5px;
padding: 6px 0;
}

View file

@ -10,15 +10,12 @@ import type { EditingArea } from "./editing-area";
import { saveField } from "./change-timer";
import { bridgeCommand } from "./lib";
import { getCurrentField } from "./helpers";
export function onFocus(evt: FocusEvent): void {
const currentField = evt.currentTarget as EditingArea;
currentField.focus();
if (currentField.shadowRoot!.getSelection()!.anchorNode === null) {
// selection is not inside editable after focusing
currentField.caretToEnd();
}
currentField.caretToEnd();
bridgeCommand(`focus:${currentField.ord}`);
fieldFocused.set(true);
@ -26,7 +23,7 @@ export function onFocus(evt: FocusEvent): void {
export function onBlur(evt: FocusEvent): void {
const previousFocus = evt.currentTarget as EditingArea;
const currentFieldUnchanged = previousFocus === document.activeElement;
const currentFieldUnchanged = previousFocus === getCurrentField();
saveField(previousFocus, currentFieldUnchanged ? "key" : "blur");
fieldFocused.set(false);

View file

@ -5,6 +5,12 @@
@typescript-eslint/no-non-null-assertion: "off",
*/
import type { EditingArea } from "./editing-area";
export function getCurrentField(): EditingArea | null {
return document.activeElement?.closest(".field") ?? null;
}
export function nodeIsElement(node: Node): node is Element {
return node.nodeType === Node.ELEMENT_NODE;
}

View file

@ -33,3 +33,11 @@ export { default as xmlIcon } from "./xml.svg";
export const arrowIcon =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="transparent" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2 5l6 6 6-6"/></svg>';
// image handle
export { default as floatNoneIcon } from "./format-float-none.svg";
export { default as floatLeftIcon } from "./format-float-left.svg";
export { default as floatRightIcon } from "./format-float-right.svg";
export { default as sizeActual } from "./image-size-select-actual.svg";
export { default as sizeMinimized } from "./image-size-select-large.svg";

View file

@ -23,15 +23,18 @@ import { saveField } from "./change-timer";
import { EditorField } from "./editor-field";
import { LabelContainer } from "./label-container";
import { EditingArea } from "./editing-area";
import { EditableContainer } from "./editable-container";
import { Editable } from "./editable";
import { Codable } from "./codable";
import { initToolbar, fieldFocused } from "./toolbar";
import { getCurrentField } from "./helpers";
export { setNoteId, getNoteId } from "./note-id";
export { saveNow } from "./change-timer";
export { wrap, wrapIntoText } from "./wrap";
export { editorToolbar } from "./toolbar";
export { activateStickyShortcuts } from "./label-container";
export { getCurrentField } from "./helpers";
export { components } from "./Components.svelte";
declare global {
@ -44,6 +47,7 @@ declare global {
}
customElements.define("anki-editable", Editable);
customElements.define("anki-editable-container", EditableContainer, { extends: "div" });
customElements.define("anki-codable", Codable, { extends: "textarea" });
customElements.define("anki-editing-area", EditingArea, { extends: "div" });
customElements.define("anki-label-container", LabelContainer, { extends: "div" });
@ -53,12 +57,6 @@ if (isApplePlatform()) {
registerShortcut(() => bridgeCommand("paste"), "Control+Shift+V");
}
export function getCurrentField(): EditingArea | null {
return document.activeElement instanceof EditingArea
? document.activeElement
: null;
}
export function focusField(n: number): void {
const field = getEditorField(n);

View file

@ -32,12 +32,14 @@ export function activateStickyShortcuts(): void {
}
export class LabelContainer extends HTMLDivElement {
sticky: HTMLSpanElement;
label: HTMLSpanElement;
fieldState: HTMLSpanElement;
sticky: HTMLSpanElement;
constructor() {
super();
this.className = "d-flex justify-content-between";
this.className = "fname d-flex justify-content-between";
i18n.then(() => {
this.title = appendInParentheses(tr.editingToggleSticky(), "F9");
@ -47,25 +49,36 @@ export class LabelContainer extends HTMLDivElement {
this.label.className = "fieldname";
this.appendChild(this.label);
this.fieldState = document.createElement("span");
this.fieldState.className = "field-state d-flex justify-content-between";
this.appendChild(this.fieldState);
this.sticky = document.createElement("span");
this.sticky.className = "icon pin-icon me-1";
this.sticky.className = "icon pin-icon";
this.sticky.innerHTML = pinIcon;
this.sticky.hidden = true;
this.appendChild(this.sticky);
this.fieldState.appendChild(this.sticky);
this.setSticky = this.setSticky.bind(this);
this.hoverIcon = this.hoverIcon.bind(this);
this.removeHoverIcon = this.removeHoverIcon.bind(this);
this.toggleSticky = this.toggleSticky.bind(this);
this.keepFocus = this.keepFocus.bind(this);
}
keepFocus(event: Event): void {
event.preventDefault();
}
connectedCallback(): void {
this.addEventListener("mousedown", this.keepFocus);
this.sticky.addEventListener("click", this.toggleSticky);
this.sticky.addEventListener("mouseenter", this.hoverIcon);
this.sticky.addEventListener("mouseleave", this.removeHoverIcon);
}
disconnectedCallback(): void {
this.removeEventListener("mousedown", this.keepFocus);
this.sticky.removeEventListener("click", this.toggleSticky);
this.sticky.removeEventListener("mouseenter", this.hoverIcon);
this.sticky.removeEventListener("mouseleave", this.removeHoverIcon);

View file

@ -5,7 +5,8 @@
@typescript-eslint/no-non-null-assertion: "off",
*/
import { getCurrentField, setFormat } from ".";
import { getCurrentField } from "./helpers";
import { setFormat } from ".";
function wrappedExceptForWhitespace(text: string, front: string, back: string): string {
const match = text.match(/^(\s*)([^]*?)(\s*)$/)!;

View file

@ -19,7 +19,7 @@ body.nightMode {
}
img {
max-width: 95%;
max-width: 100%;
max-height: 95vh;
}

View file

@ -36,6 +36,8 @@
--suspended-bg: #ffffb2;
--marked-bg: #cce;
--tooltip-bg: #fcfcfc;
--focus-border: #0969da;
--focus-shadow: rgba(9 105 218 / 0.3);
}
:root[class*="night-mode"] {
@ -73,4 +75,6 @@
--suspended-bg: #aaaa33;
--marked-bg: #77c;
--tooltip-bg: #272727;
--focus-border: #316dca;
--focus-shadow: #143d79;
}

View file

@ -17,7 +17,7 @@
font-size: 14px;
-webkit-appearance: none;
border-radius: 3px;
border-radius: 5px;
padding: 5px;
border: 1px solid var(--border);
}
@ -37,7 +37,7 @@
box-shadow: 0 0 3px fusion-vars.$button-outline;
border: 1px solid fusion-vars.$button-border;
border-radius: 2px;
border-radius: 5px;
padding: 10px;
padding-top: 3px;
padding-bottom: 3px;