From 9ca13ca3bc093ee1e5e0f8e835ee8a9b69659a82 Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Mon, 15 Aug 2022 05:34:16 +0200 Subject: [PATCH] Refactor how toolbar buttons get to surround within editor fields (#1931) * Export surrounder directly from RichTextInput * Change wording in editor/surround * Remove empty line * Change wording * Fix interfaces * Add field description directly in NoteEditor * Strip description logic from ContentEditable * Make RichTextInput position: relative * Make attachToShadow an async function * Apply field styling to field description * Show FieldDescription only if content empty * Remove descriptionStore and descriptionKey * Revert "Make attachToShadow an async function" This reverts commit b62705eadf7335c7ee0c6c8797047e1f1ccdbf44. SvelteActionReturnType does not accept Promise * Fix mess after merge commit * Require registering surround formats --- ts/domlib/surround/tree/formatting-node.ts | 5 +- ts/editor/EditorField.svelte | 7 +- ts/editor/FieldDescription.svelte | 48 +++++ ts/editor/NoteEditor.svelte | 22 +- ts/editor/editor-toolbar/BoldButton.svelte | 45 ++-- ts/editor/editor-toolbar/EditorToolbar.svelte | 9 +- .../HighlightColorButton.svelte | 45 ++-- ts/editor/editor-toolbar/ItalicButton.svelte | 45 ++-- .../editor-toolbar/RemoveFormatButton.svelte | 79 +++---- .../editor-toolbar/SubscriptButton.svelte | 73 +++---- .../editor-toolbar/SuperscriptButton.svelte | 71 +++---- .../editor-toolbar/TextColorButton.svelte | 45 ++-- .../editor-toolbar/UnderlineButton.svelte | 49 +++-- ts/editor/editor-toolbar/index.ts | 6 +- ts/editor/image-overlay/index.ts | 4 +- ts/editor/mathjax-overlay/index.ts | 4 +- ts/editor/plain-text-input/index.ts | 8 +- .../rich-text-input/RichTextInput.svelte | 56 ++--- ts/editor/rich-text-input/index.ts | 9 +- ts/editor/surround.ts | 194 +++++++++++++----- 20 files changed, 460 insertions(+), 364 deletions(-) create mode 100644 ts/editor/FieldDescription.svelte diff --git a/ts/domlib/surround/tree/formatting-node.ts b/ts/domlib/surround/tree/formatting-node.ts index 6250026c6..f5c43604f 100644 --- a/ts/domlib/surround/tree/formatting-node.ts +++ b/ts/domlib/surround/tree/formatting-node.ts @@ -169,14 +169,15 @@ export class FormattingNode extends TreeNode { * When surrounding "inside" with a bold format in the following case: * `inside` * The formatting node would sit above the span (it ascends above both - * the span and the em tag), and both tags are extensions to this node. + * the em and the span tag), and its extensions are the span tag and the + * em tag (in this order). * * @example * When a format only wants to add a class, it would typically look for an * extension first. When applying class="myclass" to "inside" in the * following case: * `inside` - * It would typically become: + * It should typically become: * `inside` */ extensions: (HTMLElement | SVGElement)[] = []; diff --git a/ts/editor/EditorField.svelte b/ts/editor/EditorField.svelte index 29788cbbf..447460e10 100644 --- a/ts/editor/EditorField.svelte +++ b/ts/editor/EditorField.svelte @@ -44,7 +44,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { Writable } from "svelte/store"; import { writable } from "svelte/store"; - import { descriptionKey, directionKey } from "../lib/context-keys"; + import { directionKey } from "../lib/context-keys"; import { promiseWithResolver } from "../lib/promise"; import type { Destroyable } from "./destroyable"; import EditingArea from "./EditingArea.svelte"; @@ -60,11 +60,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html $: $directionStore = field.direction; - const descriptionStore = writable(); - setContext(descriptionKey, descriptionStore); - - $: $descriptionStore = field.description; - const editingArea: Partial = {}; const [element, elementResolve] = promiseWithResolver(); diff --git a/ts/editor/FieldDescription.svelte b/ts/editor/FieldDescription.svelte new file mode 100644 index 000000000..af2cd4270 --- /dev/null +++ b/ts/editor/FieldDescription.svelte @@ -0,0 +1,48 @@ + + + +{#if empty} +
+ +
+{/if} + + diff --git a/ts/editor/NoteEditor.svelte b/ts/editor/NoteEditor.svelte index 8aa9261fb..2048c8268 100644 --- a/ts/editor/NoteEditor.svelte +++ b/ts/editor/NoteEditor.svelte @@ -46,20 +46,21 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import DecoratedElements from "./DecoratedElements.svelte"; import { clearableArray } from "./destroyable"; import DuplicateLink from "./DuplicateLink.svelte"; - import { EditorToolbar } from "./editor-toolbar"; + import EditorToolbar from "./editor-toolbar"; import type { FieldData } from "./EditorField.svelte"; import EditorField from "./EditorField.svelte"; + import FieldDescription from "./FieldDescription.svelte"; import Fields from "./Fields.svelte"; import FieldsEditor from "./FieldsEditor.svelte"; import FrameElement from "./FrameElement.svelte"; import { alertIcon } from "./icons"; - import { ImageHandle } from "./image-overlay"; - import { MathjaxHandle } from "./mathjax-overlay"; + import ImageHandle from "./image-overlay"; + import MathjaxHandle from "./mathjax-overlay"; import MathjaxElement from "./MathjaxElement.svelte"; import Notification from "./Notification.svelte"; - import { PlainTextInput } from "./plain-text-input"; + import PlainTextInput from "./plain-text-input"; import PlainTextBadge from "./PlainTextBadge.svelte"; - import { editingInputIsRichText, RichTextInput } from "./rich-text-input"; + import RichTextInput, { editingInputIsRichText } from "./rich-text-input"; import RichTextBadge from "./RichTextBadge.svelte"; function quoteFontFamily(fontFamily: string): string { @@ -302,9 +303,11 @@ the AddCards dialog) should be implemented in the user of this component. {#each fieldsData as field, index} + {@const content = fieldStores[index]} + { $focusedField = fields[index]; @@ -313,9 +316,7 @@ the AddCards dialog) should be implemented in the user of this component. on:focusout={() => { $focusedField = null; bridgeCommand( - `blur:${index}:${getNoteId()}:${get( - fieldStores[index], - )}`, + `blur:${index}:${getNoteId()}:${get(content)}`, ); }} --label-color={cols[index] === "dupe" @@ -361,6 +362,9 @@ the AddCards dialog) should be implemented in the user of this component. > + + {field.description} + - + { + export interface RemoveFormat { name: string; + key: string; show: boolean; active: boolean; - format: SurroundFormat; } export interface EditorToolbarAPI { @@ -30,7 +29,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html inlineButtons: DefaultSlotInterface; blockButtons: DefaultSlotInterface; templateButtons: DefaultSlotInterface; - removeFormats: Writable[]>; + removeFormats: Writable; } /* Our dynamic components */ @@ -69,7 +68,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html const inlineButtons = {} as DefaultSlotInterface; const blockButtons = {} as DefaultSlotInterface; const templateButtons = {} as DefaultSlotInterface; - const removeFormats = writable[]>([]); + const removeFormats = writable([]); let apiPartial: Partial = {}; export { apiPartial as api }; diff --git a/ts/editor/editor-toolbar/HighlightColorButton.svelte b/ts/editor/editor-toolbar/HighlightColorButton.svelte index d6514a043..4644fcece 100644 --- a/ts/editor/editor-toolbar/HighlightColorButton.svelte +++ b/ts/editor/editor-toolbar/HighlightColorButton.svelte @@ -3,20 +3,16 @@ Copyright: Ankitects Pty Ltd and contributors License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html --> diff --git a/ts/editor/editor-toolbar/ItalicButton.svelte b/ts/editor/editor-toolbar/ItalicButton.svelte index 965453292..81a066bc4 100644 --- a/ts/editor/editor-toolbar/ItalicButton.svelte +++ b/ts/editor/editor-toolbar/ItalicButton.svelte @@ -3,6 +3,8 @@ Copyright: Ankitects Pty Ltd and contributors License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html --> - + - - - - + { makeSub(); updateState(event); - updateStateByKey("super", event); + updateStateByKey("superscript", event); }} > {@html subscriptIcon} @@ -102,7 +93,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html on:action={(event) => { makeSub(); updateState(event); - updateStateByKey("super", event); + updateStateByKey("superscript", event); }} /> diff --git a/ts/editor/editor-toolbar/SuperscriptButton.svelte b/ts/editor/editor-toolbar/SuperscriptButton.svelte index fbcd2810e..b1c9f2ada 100644 --- a/ts/editor/editor-toolbar/SuperscriptButton.svelte +++ b/ts/editor/editor-toolbar/SuperscriptButton.svelte @@ -2,9 +2,21 @@ Copyright: Ankitects Pty Ltd and contributors License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html --> - - - - + { makeSuper(); updateState(event); - updateStateByKey("sub", event); + updateStateByKey("subscript", event); }} > {@html superscriptIcon} @@ -102,7 +93,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html on:action={(event) => { makeSuper(); updateState(event); - updateStateByKey("sub", event); + updateStateByKey("subscript", event); }} /> diff --git a/ts/editor/editor-toolbar/TextColorButton.svelte b/ts/editor/editor-toolbar/TextColorButton.svelte index d51ee0b8e..98e30ebac 100644 --- a/ts/editor/editor-toolbar/TextColorButton.svelte +++ b/ts/editor/editor-toolbar/TextColorButton.svelte @@ -3,23 +3,19 @@ Copyright: Ankitects Pty Ltd and contributors License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html --> diff --git a/ts/editor/editor-toolbar/UnderlineButton.svelte b/ts/editor/editor-toolbar/UnderlineButton.svelte index 47ea38ff7..a75882264 100644 --- a/ts/editor/editor-toolbar/UnderlineButton.svelte +++ b/ts/editor/editor-toolbar/UnderlineButton.svelte @@ -3,15 +3,16 @@ Copyright: Ankitects Pty Ltd and contributors License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html --> - + ; @@ -21,7 +22,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html customStyles: Promise; } - export function editingInputIsRichText( + function editingInputIsRichText( editingInput: EditingInputAPI | null, ): editingInput is RichTextInputAPI { return editingInput?.name === "rich-text"; @@ -30,20 +31,28 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { registerPackage } from "../../lib/runtime-require"; import contextProperty from "../../sveltelib/context-property"; import lifecycleHooks from "../../sveltelib/lifecycle-hooks"; + import { Surrounder } from "../surround"; const key = Symbol("richText"); const [context, setContextProperty] = contextProperty(key); const [globalInputHandler, setupGlobalInputHandler] = useInputHandler(); const [lifecycle, instances, setupLifecycleHooks] = lifecycleHooks(); + const surrounder = Surrounder.make(); registerPackage("anki/RichTextInput", { context, + surrounder, lifecycle, instances, }); - export { context, globalInputHandler as inputHandler }; + export { + context, + editingInputIsRichText, + globalInputHandler as inputHandler, + surrounder, + }; -
- {#if $content.length === 0} -
- {$description} -
- {/if} - +
.rich-text-input { position: relative; - margin: 6px; + padding: 6px; } - .rich-text-placeholder { - position: absolute; - color: var(--disabled); - - /* Adopts same size as the content editable element */ - width: 100%; - height: 100%; - /* Keep text on single line and hide overflow */ - white-space: nowrap; - overflow-x: hidden; - text-overflow: ellipsis; + .hidden { + display: none; } diff --git a/ts/editor/rich-text-input/index.ts b/ts/editor/rich-text-input/index.ts index 3b4858434..80b9576cb 100644 --- a/ts/editor/rich-text-input/index.ts +++ b/ts/editor/rich-text-input/index.ts @@ -1,9 +1,8 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +import { default as RichTextInput } from "./RichTextInput.svelte"; + export type { RichTextInputAPI } from "./RichTextInput.svelte"; -export { - context, - editingInputIsRichText, - default as RichTextInput, -} from "./RichTextInput.svelte"; +export default RichTextInput; +export * from "./RichTextInput.svelte"; diff --git a/ts/editor/surround.ts b/ts/editor/surround.ts index 9044dde34..911535fda 100644 --- a/ts/editor/surround.ts +++ b/ts/editor/surround.ts @@ -1,7 +1,8 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -import { get } from "svelte/store"; +import type { Writable } from "svelte/store"; +import { get, writable } from "svelte/store"; import type { Matcher } from "../domlib/find-above"; import { findClosest } from "../domlib/find-above"; @@ -10,7 +11,11 @@ import { boolMatcher, reformat, surround, unsurround } from "../domlib/surround" import { getRange, getSelection } from "../lib/cross-browser"; import { registerPackage } from "../lib/runtime-require"; import type { TriggerItem } from "../sveltelib/handler-list"; -import type { RichTextInputAPI } from "./rich-text-input"; +import type { InputHandlerAPI } from "../sveltelib/input-handler"; + +function isValid(value: T | undefined): value is T { + return Boolean(value); +} function isSurroundedInner( range: AbstractRange, @@ -57,35 +62,54 @@ function removeFormats( return surroundRange; } -export class Surrounder { - static make(): Surrounder { +export interface SurroundedAPI { + element: Promise; + inputHandler: InputHandlerAPI; +} + +export class Surrounder { + static make(): Surrounder { return new Surrounder(); } - private api: RichTextInputAPI | null = null; - private trigger: TriggerItem<{ event: InputEvent; text: Text }> | null = null; + private api: SurroundedAPI | null = null; + private triggers: Map> = + new Map(); - set richText(api: RichTextInputAPI) { + active: Writable = writable(false); + + enable(api: SurroundedAPI): void { this.api = api; - this.trigger = api.inputHandler.insertText.trigger({ once: true }); + this.active.set(true); + + for (const key of this.formats.keys()) { + this.triggers.set( + key, + this.api.inputHandler.insertText.trigger({ once: true }), + ); + } } /** * After calling disable, using any of the surrounding methods will throw an - * exception. Make sure to set the rich text before trying to use them again. + * exception. Make sure to set the input before trying to use them again. */ disable(): void { this.api = null; - this.trigger?.off(); - this.trigger = null; + this.active.set(false); + + for (const [key, trigger] of this.triggers) { + trigger.off(); + this.triggers.delete(key); + } } private async _assert_base(): Promise { if (!this.api) { - throw new Error("No rich text set"); + throw new Error("Surrounder: No input set"); } - return await this.api.element; + return this.api.element; } private _toggleTrigger( @@ -93,12 +117,13 @@ export class Surrounder { selection: Selection, matcher: Matcher, format: SurroundFormat, + trigger: TriggerItem<{ event: InputEvent; text: Text }>, exclusive: SurroundFormat[] = [], ): void { - if (get(this.trigger!.active)) { - this.trigger!.off(); + if (get(trigger.active)) { + trigger.off(); } else { - this.trigger!.on(async ({ text }) => { + trigger.on(async ({ text }) => { const range = new Range(); range.selectNode(text); @@ -114,9 +139,10 @@ export class Surrounder { base: HTMLElement, selection: Selection, format: SurroundFormat, + trigger: TriggerItem<{ event: InputEvent; text: Text }>, exclusive: SurroundFormat[] = [], ): void { - this.trigger!.on(async ({ text }) => { + trigger.on(async ({ text }) => { const range = new Range(); range.selectNode(text); @@ -132,68 +158,121 @@ export class Surrounder { base: HTMLElement, selection: Selection, remove: SurroundFormat[], + triggers: TriggerItem<{ event: InputEvent; text: Text }>[], reformat: SurroundFormat[] = [], ): void { - this.trigger!.on(async ({ text }) => { - const range = new Range(); - range.selectNode(text); + triggers.map((trigger) => + trigger.on(async ({ text }) => { + const range = new Range(); + range.selectNode(text); - const clearedRange = removeFormats(range, base, remove, reformat); - selection.removeAllRanges(); - selection.addRange(clearedRange); - selection.collapseToEnd(); - }); + const clearedRange = removeFormats(range, base, remove, reformat); + selection.removeAllRanges(); + selection.addRange(clearedRange); + selection.collapseToEnd(); + }), + ); + } + + private formats: Map> = new Map(); + + /** + * Register a surround format under a certain name. + * This name is then used with the surround functions to actually apply or + * remove the given format + */ + registerFormat(key: string, format: SurroundFormat): () => void { + this.formats.set(key, format); + + if (this.api) { + this.triggers.set( + key, + this.api.inputHandler.insertText.trigger({ once: true }), + ); + } + + return () => this.formats.delete(key); } /** - * Use the surround command on the current range of the RichTextInput. + * Check if a surround format under the given key is registered. + */ + hasFormat(key: string): boolean { + return this.formats.has(key); + } + + /** + * Use the surround command on the current range of the input. * If the range is already surrounded, it will unsurround instead. */ - async surround( - format: SurroundFormat, - exclusive: SurroundFormat[] = [], - ): Promise { + async surround(formatName: string, exclusiveNames: string[] = []): Promise { const base = await this._assert_base(); const selection = getSelection(base)!; const range = getRange(selection); - const matcher = boolMatcher(format); + const format = this.formats.get(formatName); + const trigger = this.triggers.get(formatName); - if (!range) { + if (!format || !range || !trigger) { return; } + const matcher = boolMatcher(format); + + const exclusives = exclusiveNames + .map((name) => this.formats.get(name)) + .filter(isValid); + if (range.collapsed) { - return this._toggleTrigger(base, selection, matcher, format, exclusive); + return this._toggleTrigger( + base, + selection, + matcher, + format, + trigger, + exclusives, + ); } - const clearedRange = removeFormats(range, base, exclusive); + const clearedRange = removeFormats(range, base, exclusives); const matches = isSurroundedInner(clearedRange, base, matcher); surroundAndSelect(matches, clearedRange, base, format, selection); } /** - * Use the surround command on the current range of the RichTextInput. + * Use the surround command on the current range of the input. * If the range is already surrounded, it will overwrite the format. * This might be better suited if the surrounding is parameterized (like * text color). */ - async overwriteSurround( - format: SurroundFormat, - exclusive: SurroundFormat[] = [], + async overwriteSurround( + formatName: string, + exclusiveNames: string[] = [], ): Promise { const base = await this._assert_base(); const selection = getSelection(base)!; const range = getRange(selection); + const format = this.formats.get(formatName); + const trigger = this.triggers.get(formatName); - if (!range) { + if (!format || !range || !trigger) { return; } + const exclusives = exclusiveNames + .map((name) => this.formats.get(name)) + .filter(isValid); + if (range.collapsed) { - return this._toggleTriggerOverwrite(base, selection, format, exclusive); + return this._toggleTriggerOverwrite( + base, + selection, + format, + trigger, + exclusives, + ); } - const clearedRange = removeFormats(range, base, exclusive); + const clearedRange = removeFormats(range, base, exclusives); const surroundedRange = surround(clearedRange, base, format); selection.removeAllRanges(); selection.addRange(surroundedRange); @@ -205,26 +284,25 @@ export class Surrounder { * provided format, OR if a surround trigger is active (surround on next * text insert). */ - async isSurrounded(format: SurroundFormat): Promise { + async isSurrounded(formatName: string): Promise { const base = await this._assert_base(); const selection = getSelection(base)!; const range = getRange(selection); + const format = this.formats.get(formatName); + const trigger = this.triggers.get(formatName); - if (!range) { + if (!format || !range || !trigger) { return false; } const isSurrounded = isSurroundedInner(range, base, boolMatcher(format)); - return get(this.trigger!.active) ? !isSurrounded : isSurrounded; + return get(trigger.active) ? !isSurrounded : isSurrounded; } /** * Clear/Reformat the provided formats in the current range. */ - async remove( - formats: SurroundFormat[], - reformats: SurroundFormat[] = [], - ): Promise { + async remove(formatNames: string[], reformatNames: string[] = []): Promise { const base = await this._assert_base(); const selection = getSelection(base)!; const range = getRange(selection); @@ -233,8 +311,26 @@ export class Surrounder { return; } + const formats = formatNames + .map((name) => this.formats.get(name)) + .filter(isValid); + + const triggers = formatNames + .map((name) => this.triggers.get(name)) + .filter(isValid); + + const reformats = reformatNames + .map((name) => this.formats.get(name)) + .filter(isValid); + if (range.collapsed) { - return this._toggleTriggerRemove(base, selection, formats, reformats); + return this._toggleTriggerRemove( + base, + selection, + formats, + triggers, + reformats, + ); } const surroundedRange = removeFormats(range, base, formats, reformats);