diff --git a/ts/components/WithState.svelte b/ts/components/WithState.svelte index d77ac6ccc..4f14af126 100644 --- a/ts/components/WithState.svelte +++ b/ts/components/WithState.svelte @@ -6,14 +6,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { writable } from "svelte/store"; type KeyType = Symbol | string; - type UpdaterMap = Map boolean>; - type StateMap = Map; + type UpdaterMap = Map Promise>; + type StateMap = Map>; - const updaterMap = new Map() as UpdaterMap; - const stateMap = new Map() as StateMap; + const updaterMap: UpdaterMap = new Map(); + const stateMap: StateMap = new Map(); const stateStore = writable(stateMap); - function updateAllStateWithCallback(callback: (key: KeyType) => boolean): void { + function updateAllStateWithCallback( + callback: (key: KeyType) => Promise, + ): void { stateStore.update((map: StateMap): StateMap => { const newMap = new Map() as StateMap; @@ -26,13 +28,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } export function updateAllState(event: Event): void { - updateAllStateWithCallback((key: KeyType): boolean => - updaterMap.get(key)!(event), + updateAllStateWithCallback( + (key: KeyType): Promise => updaterMap.get(key)!(event), ); } export function resetAllState(state: boolean): void { - updateAllStateWithCallback((): boolean => state); + updateAllStateWithCallback((): Promise => Promise.resolve(state)); } function updateStateByKey(key: KeyType, event: Event): void { @@ -45,18 +47,30 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html diff --git a/ts/editor/BUILD.bazel b/ts/editor/BUILD.bazel index 615971dc4..49dfffe89 100644 --- a/ts/editor/BUILD.bazel +++ b/ts/editor/BUILD.bazel @@ -25,6 +25,7 @@ _ts_deps = [ "//ts/editable:editable_ts", "//ts/html-filter", "//ts/lib", + "//ts/domlib", "//ts/sveltelib", "@npm//@fluent", "@npm//@types/codemirror", diff --git a/ts/editor/BoldButton.svelte b/ts/editor/BoldButton.svelte new file mode 100644 index 000000000..c804af439 --- /dev/null +++ b/ts/editor/BoldButton.svelte @@ -0,0 +1,93 @@ + + + + + { + makeBold(); + updateState(event); + }} + > + {@html boldIcon} + + + { + makeBold(); + updateState(event); + }} + /> + diff --git a/ts/editor/CommandIconButton.svelte b/ts/editor/CommandIconButton.svelte index 40be0c403..0a9f6c2c2 100644 --- a/ts/editor/CommandIconButton.svelte +++ b/ts/editor/CommandIconButton.svelte @@ -19,6 +19,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export let withoutState = false; const { focusInRichText } = getNoteEditor(); + $: disabled = !$focusInRichText; @@ -29,7 +30,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {:else if withoutShortcut} queryCommandState(key)} + update={async () => queryCommandState(key)} let:state={active} let:updateState > @@ -60,7 +61,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html queryCommandState(key)} + update={async () => queryCommandState(key)} let:state={active} let:updateState > diff --git a/ts/editor/FormatInlineButtons.svelte b/ts/editor/FormatInlineButtons.svelte index eb85580b5..748c92a43 100644 --- a/ts/editor/FormatInlineButtons.svelte +++ b/ts/editor/FormatInlineButtons.svelte @@ -6,43 +6,27 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import ButtonGroup from "../components/ButtonGroup.svelte"; import ButtonGroupItem from "../components/ButtonGroupItem.svelte"; import CommandIconButton from "./CommandIconButton.svelte"; + import BoldButton from "./BoldButton.svelte"; + import ItalicButton from "./ItalicButton.svelte"; + import UnderlineButton from "./UnderlineButton.svelte"; import * as tr from "../lib/ftl"; - import { - boldIcon, - italicIcon, - underlineIcon, - superscriptIcon, - subscriptIcon, - eraserIcon, - } from "./icons"; + import { superscriptIcon, subscriptIcon, eraserIcon } from "./icons"; export let api = {}; - {@html boldIcon} + - {@html italicIcon} + - {@html underlineIcon} + diff --git a/ts/editor/ItalicButton.svelte b/ts/editor/ItalicButton.svelte new file mode 100644 index 000000000..d23574823 --- /dev/null +++ b/ts/editor/ItalicButton.svelte @@ -0,0 +1,92 @@ + + + + + { + makeItalic(); + updateState(event); + }} + > + {@html italicIcon} + + + { + makeItalic(); + updateState(event); + }} + /> + diff --git a/ts/editor/RichTextInput.svelte b/ts/editor/RichTextInput.svelte index d164bdcba..201e2cf4c 100644 --- a/ts/editor/RichTextInput.svelte +++ b/ts/editor/RichTextInput.svelte @@ -6,14 +6,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type CustomStyles from "./CustomStyles.svelte"; import type { EditingInputAPI } from "./EditingArea.svelte"; import contextProperty from "../sveltelib/context-property"; + import type { OnInsertCallback } from "../sveltelib/input-manager"; export interface RichTextInputAPI extends EditingInputAPI { name: "rich-text"; + shadowRoot: Promise; + element: Promise; moveCaretToEnd(): void; refocus(): void; toggle(): boolean; surround(before: string, after: string): void; preventResubscription(): () => void; + triggerOnInsert(callback: OnInsertCallback): () => void; } export interface RichTextInputContextAPI { @@ -120,8 +124,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html content.set(output); } + const [shadowPromise, shadowResolve] = promiseWithResolver(); + function attachShadow(element: Element): void { - element.attachShadow({ mode: "open" }); + shadowResolve(element.attachShadow({ mode: "open" })); } const [richTextPromise, richTextResolve] = promiseWithResolver(); @@ -151,8 +157,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } import getDOMMirror from "../sveltelib/mirror-dom"; + import getInputManager from "../sveltelib/input-manager"; const { mirror, preventResubscription } = getDOMMirror(); + const { manager, triggerOnInsert } = getInputManager(); function moveCaretToEnd() { richTextPromise.then(caretToEnd); @@ -169,6 +177,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html nodes, resolve, mirror, + inputManager: manager, }, context: allContexts, }), @@ -177,6 +186,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export const api: RichTextInputAPI = { name: "rich-text", + shadowRoot: shadowPromise, + element: richTextPromise, focus() { richTextPromise.then((richText) => richText.focus()); }, @@ -198,6 +209,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html ); }, preventResubscription, + triggerOnInsert, }; function pushUpdate(): void { diff --git a/ts/editor/UnderlineButton.svelte b/ts/editor/UnderlineButton.svelte new file mode 100644 index 000000000..efdb6f685 --- /dev/null +++ b/ts/editor/UnderlineButton.svelte @@ -0,0 +1,76 @@ + + + + + { + makeUnderline(); + updateState(event); + }} + > + {@html underlineIcon} + + + { + makeUnderline(); + updateState(event); + }} + /> + diff --git a/ts/editor/index.ts b/ts/editor/index.ts index 2c639b1fa..26a23e605 100644 --- a/ts/editor/index.ts +++ b/ts/editor/index.ts @@ -10,6 +10,7 @@ import "./editor-base.css"; import "../sveltelib/export-runtime"; import "../lib/register-package"; +import "../domlib/surround"; import { filterHTML } from "../html-filter"; import { execCommand } from "./helpers"; diff --git a/ts/editor/surround.ts b/ts/editor/surround.ts new file mode 100644 index 000000000..df7532b75 --- /dev/null +++ b/ts/editor/surround.ts @@ -0,0 +1,87 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import type { RichTextInputAPI } from "./RichTextInput.svelte"; +import { getSelection } from "../lib/cross-browser"; +import { surroundNoSplitting, unsurround, findClosest } from "../domlib/surround"; +import type { ElementMatcher, ElementClearer } from "../domlib/surround"; + +export function isSurroundedInner( + range: AbstractRange, + base: HTMLElement, + matcher: ElementMatcher, +): boolean { + return Boolean( + findClosest(range.startContainer, base, matcher) || + findClosest(range.endContainer, base, matcher), + ); +} + +export async function isSurrounded( + input: RichTextInputAPI, + matcher: ElementMatcher, +): Promise { + const base = await input.element; + const selection = getSelection(base)!; + const range = selection.getRangeAt(0); + + return isSurroundedInner(range, base, matcher); +} + +function surroundAndSelect( + matches: boolean, + range: Range, + selection: Selection, + surroundElement: Element, + base: HTMLElement, + matcher: ElementMatcher, + clearer: ElementClearer, +): void { + const { surroundedRange } = matches + ? unsurround(range, surroundElement, base, matcher, clearer) + : surroundNoSplitting(range, surroundElement, base, matcher, clearer); + + selection.removeAllRanges(); + selection.addRange(surroundedRange); +} + +export async function surroundCommand( + input: RichTextInputAPI, + surroundElement: Element, + matcher: ElementMatcher, + clearer: ElementClearer = () => false, +): Promise { + const base = await input.element; + const selection = getSelection(base)!; + const range = selection.getRangeAt(0); + + if (range.collapsed) { + input.triggerOnInsert(async ({ node }): Promise => { + range.selectNode(node); + + const matches = Boolean(findClosest(node, base, matcher)); + surroundAndSelect( + matches, + range, + selection, + surroundElement, + base, + matcher, + clearer, + ); + + selection.collapseToEnd(); + }); + } else { + const matches = isSurroundedInner(range, base, matcher); + surroundAndSelect( + matches, + range, + selection, + surroundElement, + base, + matcher, + clearer, + ); + } +} diff --git a/ts/editor/tsconfig.json b/ts/editor/tsconfig.json index b3f6b5498..830ad7ccc 100644 --- a/ts/editor/tsconfig.json +++ b/ts/editor/tsconfig.json @@ -3,6 +3,7 @@ "include": ["*", "image-overlay/*", "mathjax-overlay/*"], "references": [ { "path": "../components" }, + { "path": "../domlib" }, { "path": "../lib" }, { "path": "../sveltelib" }, { "path": "../editable" }, diff --git a/ts/jest.bzl b/ts/jest.bzl index 8753b7d85..142530c7c 100644 --- a/ts/jest.bzl +++ b/ts/jest.bzl @@ -2,7 +2,7 @@ load("@npm//@bazel/esbuild:index.bzl", "esbuild") load("@npm//jest-cli:index.bzl", _jest_test = "jest_test") def jest_test(deps, name = "jest", protobuf = False, env = "node"): - ts_sources = native.glob(["*.test.ts"]) + ts_sources = native.glob(["**/*.test.ts"]) # bundle each test file up with its dependencies for jest bundled_srcs = [] diff --git a/ts/lib/BUILD.bazel b/ts/lib/BUILD.bazel index 9992053cc..1e4badde2 100644 --- a/ts/lib/BUILD.bazel +++ b/ts/lib/BUILD.bazel @@ -64,6 +64,7 @@ prettier_test() eslint_test() jest_test( + env = "jsdom", deps = [ ":lib", ], diff --git a/ts/lib/cross-browser.ts b/ts/lib/cross-browser.ts index fed436223..c6e8baa01 100644 --- a/ts/lib/cross-browser.ts +++ b/ts/lib/cross-browser.ts @@ -11,5 +11,5 @@ export function getSelection(element: Node): Selection | null { return root.getSelection(); } - return null; + return document.getSelection(); } diff --git a/ts/lib/dom.ts b/ts/lib/dom.ts index 0a9b26c32..81f3b5f43 100644 --- a/ts/lib/dom.ts +++ b/ts/lib/dom.ts @@ -5,8 +5,12 @@ export function nodeIsElement(node: Node): node is Element { return node.nodeType === Node.ELEMENT_NODE; } +export function nodeIsText(node: Node): node is Text { + return node.nodeType === Node.TEXT_NODE; +} + // https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements -const BLOCK_TAGS = [ +const BLOCK_ELEMENTS = [ "ADDRESS", "ARTICLE", "ASIDE", @@ -43,7 +47,29 @@ const BLOCK_TAGS = [ ]; export function elementIsBlock(element: Element): boolean { - return BLOCK_TAGS.includes(element.tagName); + return BLOCK_ELEMENTS.includes(element.tagName); +} + +// https://developer.mozilla.org/en-US/docs/Glossary/Empty_element +const EMPTY_ELEMENTS = [ + "AREA", + "BASE", + "BR", + "COL", + "EMBED", + "HR", + "IMG", + "INPUT", + "LINK", + "META", + "PARAM", + "SOURCE", + "TRACK", + "WBR", +]; + +export function elementIsEmpty(element: Element): boolean { + return EMPTY_ELEMENTS.includes(element.tagName); } export function nodeContainsInlineContent(node: Node): boolean { @@ -68,6 +94,12 @@ export function fragmentToString(fragment: DocumentFragment): string { return html; } +export const NO_SPLIT_TAGS = ["RUBY"]; + +export function elementShouldNotBeSplit(element: Element): boolean { + return elementIsBlock(element) || NO_SPLIT_TAGS.includes(element.tagName); +} + export function caretToEnd(node: Node): void { const range = new Range(); range.selectNodeContents(node); diff --git a/ts/lib/events.ts b/ts/lib/events.ts index a63f7ca86..37cbbd751 100644 --- a/ts/lib/events.ts +++ b/ts/lib/events.ts @@ -1,12 +1,33 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -export function on( +type EventTargetToMap = A extends HTMLElement + ? HTMLElementEventMap + : A extends Document + ? DocumentEventMap + : A extends Window + ? WindowEventMap + : A extends FileReader + ? FileReaderEventMap + : A extends Element + ? ElementEventMap + : A extends Animation + ? AnimationEventMap + : A extends EventSource + ? EventSourceEventMap + : A extends AbortSignal + ? AbortSignalEventMap + : A extends AbstractWorker + ? AbstractWorkerEventMap + : never; + +export function on>( target: T, - eventType: string, - listener: L, - options: AddEventListenerOptions = {}, + eventType: Exclude, + handler: (this: T, event: EventTargetToMap[K]) => void, + options?: AddEventListenerOptions, ): () => void { - target.addEventListener(eventType, listener, options); - return () => target.removeEventListener(eventType, listener, options); + target.addEventListener(eventType, handler as EventListener, options); + return () => + target.removeEventListener(eventType, handler as EventListener, options); } diff --git a/ts/lib/keys.ts b/ts/lib/keys.ts index ae7f0579e..c74fdd5da 100644 --- a/ts/lib/keys.ts +++ b/ts/lib/keys.ts @@ -16,6 +16,13 @@ function translateModifierToPlatform(modifier: Modifier): string { return platformModifiers[allModifiers.indexOf(modifier)]; } +const GENERAL_KEY = 0; +const NUMPAD_KEY = 3; + +export function checkIfInputKey(event: KeyboardEvent): boolean { + return event.location === GENERAL_KEY || event.location === NUMPAD_KEY; +} + export const checkModifiers = (required: Modifier[], optional: Modifier[] = []) => (event: KeyboardEvent): boolean => { diff --git a/ts/lib/node.ts b/ts/lib/node.ts new file mode 100644 index 000000000..b984f79ed --- /dev/null +++ b/ts/lib/node.ts @@ -0,0 +1,14 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +export function isOnlyChild(node: Node): boolean { + return node.parentNode!.childNodes.length === 1; +} + +export function hasOnlyChild(node: Node): boolean { + return node.childNodes.length === 1; +} + +export function ascend(node: Node): Node { + return node.parentNode!; +} diff --git a/ts/lib/shadow-dom.d.ts b/ts/lib/shadow-dom.d.ts index 54afa8757..efe062d4d 100644 --- a/ts/lib/shadow-dom.d.ts +++ b/ts/lib/shadow-dom.d.ts @@ -6,6 +6,6 @@ declare global { } interface Node { - getRootNode(options?: GetRootNodeOptions): DocumentOrShadowRoot; + getRootNode(options?: GetRootNodeOptions): Document | ShadowRoot; } } diff --git a/ts/lib/shortcuts.ts b/ts/lib/shortcuts.ts index fad8d8cdf..d2bd1a46f 100644 --- a/ts/lib/shortcuts.ts +++ b/ts/lib/shortcuts.ts @@ -4,7 +4,12 @@ import type { Modifier } from "./keys"; import { registerPackage } from "./register-package"; -import { modifiersToPlatformString, keyToPlatformString, checkModifiers } from "./keys"; +import { + modifiersToPlatformString, + keyToPlatformString, + checkModifiers, + checkIfInputKey, +} from "./keys"; const keyCodeLookup = { Backspace: 8, @@ -112,9 +117,6 @@ function keyCombinationToCheck( return check(keyCode, modifiers); } -const GENERAL_KEY = 0; -const NUMPAD_KEY = 3; - function innerShortcut( target: EventTarget | Document, lastEvent: KeyboardEvent, @@ -131,10 +133,7 @@ function innerShortcut( if (nextCheck(event)) { innerShortcut(target, event, callback, ...restChecks); clearTimeout(interval); - } else if ( - event.location === GENERAL_KEY || - event.location === NUMPAD_KEY - ) { + } else if (checkIfInputKey(event)) { // Any non-modifier key will cancel the shortcut sequence document.removeEventListener("keydown", handler); } diff --git a/ts/lib/tsconfig.json b/ts/lib/tsconfig.json index 39f73a570..210e7bdd4 100644 --- a/ts/lib/tsconfig.json +++ b/ts/lib/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../tsconfig.json", - "include": ["*", "i18n/*"], + "include": ["*", "i18n/*", "location/*", "surround/*"], "references": [], "compilerOptions": { "types": ["jest"] diff --git a/ts/sveltelib/input-manager.ts b/ts/sveltelib/input-manager.ts new file mode 100644 index 000000000..f94a3722b --- /dev/null +++ b/ts/sveltelib/input-manager.ts @@ -0,0 +1,101 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import { on } from "../lib/events"; +import { nodeIsText } from "../lib/dom"; +import { getSelection } from "../lib/cross-browser"; + +export type OnInsertCallback = ({ node: Node }) => Promise; + +interface InputManager { + manager(element: HTMLElement): { destroy(): void }; + triggerOnInsert(callback: OnInsertCallback): () => void; +} + +export function getInputManager(): InputManager { + const onInsertText: OnInsertCallback[] = []; + + function cancelInsertText(): void { + onInsertText.length = 0; + } + + function cancelIfInsertText(event: KeyboardEvent): void { + if (event.key.length !== 1) { + cancelInsertText(); + } + } + + async function onBeforeInput(event: InputEvent): Promise { + if (event.inputType === "insertText" && onInsertText.length > 0) { + const nbsp = " "; + const textContent = event.data === " " ? nbsp : event.data ?? nbsp; + const node = new Text(textContent); + + const selection = getSelection(event.target! as Node)!; + const range = selection.getRangeAt(0); + + range.deleteContents(); + + if (nodeIsText(range.startContainer) && range.startOffset === 0) { + const parent = range.startContainer.parentNode!; + parent.insertBefore(node, range.startContainer); + } else if ( + nodeIsText(range.endContainer) && + range.endOffset === range.endContainer.length + ) { + const parent = range.endContainer.parentNode!; + parent.insertBefore(node, range.endContainer.nextSibling!); + } else { + range.insertNode(node); + } + + range.selectNode(node); + range.collapse(false); + + for (const callback of onInsertText) { + await callback({ node }); + } + + event.preventDefault(); + } + + cancelInsertText(); + } + + function manager(element: HTMLElement): { destroy(): void } { + const removeBeforeInput = on(element, "beforeinput", onBeforeInput); + const removePointerDown = on(element, "pointerdown", cancelInsertText); + const removeBlur = on(element, "blur", cancelInsertText); + const removeKeyDown = on( + element, + "keydown", + cancelIfInsertText as EventListener, + ); + + return { + destroy() { + removeBeforeInput(); + removePointerDown(); + removeBlur(); + removeKeyDown(); + }, + }; + } + + function triggerOnInsert(callback: OnInsertCallback): () => void { + onInsertText.push(callback); + return () => { + const index = onInsertText.indexOf(callback); + if (index > 0) { + onInsertText.splice(index, 1); + } + }; + } + + return { + manager, + triggerOnInsert, + }; +} + +export default getInputManager;