Fix insert media always insert at the start (on Windows) (#1684)

* Move Trigger into its own file

* Try implement HandlerList

* Implement new input handler and handler-list

* Use new refocus HandlerList in TextColorButton

* Fix TextColorButton on windows

* Move ColorPicker to editor-toolbar

* Change trigger behavior of overwriteSurround

* Fix mathjax-overlay flushCaret

* Insert image via bridgeCommand return value

* Fix invoking color picker with F8

* Have remove format work even when collapsed

* Satisfy formatter

* Insert media via callback resolved from python

* Replace print with web.eval

* Fix python formatting

* remove unused function (dae)
This commit is contained in:
Henrik Giesel 2022-02-25 01:59:06 +01:00 committed by GitHub
parent a0d0f2f8fd
commit 0d83581ab0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 574 additions and 373 deletions

View file

@ -460,7 +460,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
self._save_current_note()
elif cmd in self._links:
self._links[cmd](self)
return self._links[cmd](self)
else:
print("uncaught cmd", cmd)
@ -703,16 +703,21 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
filter=filter,
key="media",
)
self.parentWindow.activateWindow()
def addMedia(self, path: str, canDelete: bool = False) -> None:
"""canDelete is a legacy arg and is ignored."""
try:
html = self._addMedia(path)
except Exception as e:
showWarning(str(e))
return
self.web.eval(f"setFormat('inserthtml', {json.dumps(html)});")
self.web.eval(
f'require("anki/TemplateButtons").mediaResolve({json.dumps(html)})'
)
def _addMedia(self, path: str, canDelete: bool = False) -> str:
"""Add to media folder and return local img or sound tag."""

View file

@ -1,59 +1,59 @@
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "round-1024-512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
"images": [
{
"idiom": "mac",
"scale": "1x",
"size": "16x16"
},
{
"idiom": "mac",
"scale": "2x",
"size": "16x16"
},
{
"idiom": "mac",
"scale": "1x",
"size": "32x32"
},
{
"idiom": "mac",
"scale": "2x",
"size": "32x32"
},
{
"idiom": "mac",
"scale": "1x",
"size": "128x128"
},
{
"idiom": "mac",
"scale": "2x",
"size": "128x128"
},
{
"idiom": "mac",
"scale": "1x",
"size": "256x256"
},
{
"idiom": "mac",
"scale": "2x",
"size": "256x256"
},
{
"filename": "round-1024-512.png",
"idiom": "mac",
"scale": "1x",
"size": "512x512"
},
{
"idiom": "mac",
"scale": "2x",
"size": "512x512"
}
],
"info": {
"author": "xcode",
"version": 1
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,6 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
"info": {
"author": "xcode",
"version": 1
}
}

View file

@ -11,10 +11,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { updateAllState } from "../components/WithState.svelte";
import actionList from "../sveltelib/action-list";
import type { InputManagerAction } from "../sveltelib/input-manager";
import type { MirrorAction } from "../sveltelib/mirror-dom";
import type { MirrorAction } from "../sveltelib/dom-mirror";
import type { SetupInputHandlerAction } from "../sveltelib/input-handler";
import type { ContentEditableAPI } from "./content-editable";
import { customFocusHandling, preventBuiltinShortcuts } from "./content-editable";
import { preventBuiltinShortcuts, useFocusHandler } from "./content-editable";
export let resolve: (editable: HTMLElement) => void;
@ -24,15 +24,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const mirrorAction = actionList(mirrors);
const mirrorOptions = { store: nodes };
export let managers: InputManagerAction[];
export let inputHandlers: SetupInputHandlerAction[];
const managerAction = actionList(managers);
const inputHandlerAction = actionList(inputHandlers);
export let api: Partial<ContentEditableAPI>;
const { setupFocusHandling, flushCaret } = customFocusHandling();
const [focusHandler, setupFocusHandling] = useFocusHandler();
Object.assign(api, { flushCaret });
Object.assign(api, { focusHandler });
</script>
<anki-editable
@ -41,7 +41,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use:setupFocusHandling
use:preventBuiltinShortcuts
use:mirrorAction={mirrorOptions}
use:managerAction={{}}
use:inputHandlerAction={{}}
on:focus
on:blur
on:click={updateAllState}

View file

@ -8,55 +8,87 @@ import { bridgeCommand } from "../lib/bridgecommand";
import { on, preventDefault } from "../lib/events";
import { isApplePlatform } from "../lib/platform";
import { registerShortcut } from "../lib/shortcuts";
import type { Callback } from "../lib/typing";
import { HandlerList } from "../sveltelib/handler-list";
/**
* Workaround: If you try to invoke an IME after calling
* `placeCaretAfterContent` on a cE element, the IME will immediately
* end and the input character will be duplicated
*/
function safePlaceCaretAfterContent(editable: HTMLElement): void {
/**
* Workaround: If you try to invoke an IME after calling
* `placeCaretAfterContent` on a cE element, the IME will immediately
* end and the input character will be duplicated
*/
placeCaretAfterContent(editable);
restoreSelection(editable, saveSelection(editable)!);
}
function onFocus(location: SelectionLocation | null): () => void {
return function (this: HTMLElement): void {
if (!location) {
safePlaceCaretAfterContent(this);
return;
}
try {
restoreSelection(this, location);
} catch {
safePlaceCaretAfterContent(this);
}
};
}
interface CustomFocusHandlingAPI {
setupFocusHandling(element: HTMLElement): { destroy(): void };
flushCaret(): void;
}
export function customFocusHandling(): CustomFocusHandlingAPI {
const focusHandlingEvents: (() => void)[] = [];
function flushEvents(): void {
let removeEvent: (() => void) | undefined;
while ((removeEvent = focusHandlingEvents.pop())) {
removeEvent();
}
function restoreCaret(element: HTMLElement, location: SelectionLocation | null): void {
if (!location) {
return safePlaceCaretAfterContent(element);
}
try {
restoreSelection(element, location);
} catch {
safePlaceCaretAfterContent(element);
}
}
type SetupFocusHandlerAction = (element: HTMLElement) => { destroy(): void };
export interface FocusHandlerAPI {
/**
* Prevent the automatic caret restoration, that happens upon field focus
*/
flushCaret(): void;
/**
* Executed upon focus event of editable.
*/
focus: HandlerList<{ event: FocusEvent }>;
}
export function useFocusHandler(): [FocusHandlerAPI, SetupFocusHandlerAction] {
let latestLocation: SelectionLocation | null = null;
let offFocus: Callback | null;
let offPointerDown: Callback | null;
let flush = false;
function flushCaret(): void {
flush = true;
}
const focus = new HandlerList<{ event: FocusEvent }>();
function prepareFocusHandling(
editable: HTMLElement,
latestLocation: SelectionLocation | null = null,
location: SelectionLocation | null = null,
): void {
const off = on(editable, "focus", onFocus(latestLocation), { once: true });
latestLocation = location;
focusHandlingEvents.push(off, on(editable, "pointerdown", off, { once: true }));
offFocus?.();
offFocus = on(
editable,
"focus",
(event: FocusEvent): void => {
if (flush) {
flush = false;
} else {
restoreCaret(event.currentTarget as HTMLElement, latestLocation);
}
focus.dispatch({ event });
},
{ once: true },
);
offPointerDown?.();
offPointerDown = on(
editable,
"pointerdown",
() => {
offFocus?.();
offFocus = null;
},
{ once: true },
);
}
/**
@ -66,22 +98,26 @@ export function customFocusHandling(): CustomFocusHandlingAPI {
prepareFocusHandling(this, saveSelection(this));
}
function setupFocusHandling(editable: HTMLElement): { destroy(): void } {
function setupFocusHandler(editable: HTMLElement): { destroy(): void } {
prepareFocusHandling(editable);
const off = on(editable, "blur", onBlur);
return {
destroy() {
flushEvents();
off();
offFocus?.();
offPointerDown?.();
},
};
}
return {
setupFocusHandling,
flushCaret: flushEvents,
};
return [
{
flushCaret,
focus,
},
setupFocusHandler,
];
}
if (isApplePlatform()) {
@ -102,5 +138,5 @@ export interface ContentEditableAPI {
* the ContentEditable. Can be used when you want to set the caret
* yourself.
*/
flushCaret(): void;
focusHandler: FocusHandlerAPI;
}

View file

@ -326,12 +326,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<svelte:fragment slot="editing-inputs">
<RichTextInput
hidden={richTextsHidden[index]}
on:focusin={() => {
$focusedInput = richTextInputs[index].api;
}}
on:focusout={() => {
$focusedInput = null;
saveFieldNow();
$focusedInput = null;
}}
bind:this={richTextInputs[index]}
>
@ -341,12 +338,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<PlainTextInput
hidden={plainTextsHidden[index]}
on:focusin={() => {
$focusedInput = plainTextInputs[index].api;
}}
on:focusout={() => {
$focusedInput = null;
saveFieldNow();
$focusedInput = null;
}}
bind:this={plainTextInputs[index]}
/>

View file

@ -3,15 +3,18 @@ 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, onMount } from "svelte";
import Shortcut from "../../components/Shortcut.svelte";
export let keyCombination: string | null = null;
let inputRef: HTMLInputElement;
const dispatch = createEventDispatcher();
onMount(() => dispatch("mount", { input: inputRef }));
</script>
<input tabindex="-1" bind:this={inputRef} type="color" on:change />
<input bind:this={inputRef} tabindex="-1" type="color" on:input on:change />
{#if keyCombination}
<Shortcut {keyCombination} on:action={() => inputRef.click()} />
{/if}
<style lang="scss">
input {

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="ts">
import ColorPicker from "../../components/ColorPicker.svelte";
import IconButton from "../../components/IconButton.svelte";
import type {
FormattingNode,
@ -15,6 +14,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { context as noteEditorContext } from "../NoteEditor.svelte";
import { editingInputIsRichText } from "../rich-text-input";
import { removeEmptyStyle, Surrounder } from "../surround";
import ColorPicker from "./ColorPicker.svelte";
import type { RemoveFormat } from "./EditorToolbar.svelte";
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
import { arrowIcon, highlightColorIcon } from "./icons";
@ -128,11 +128,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
>
{@html arrowIcon}
<ColorPicker
on:change={(event) => {
on:input={(event) => {
color = setColor(event);
bridgeCommand(`lastHighlightColor:${color}`);
setTextColor();
bridgeCommand(`lastTextColor:${color}`);
}}
on:change={() => setTextColor()}
/>
</IconButton>
</WithColorHelper>

View file

@ -14,8 +14,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import Shortcut from "../../components/Shortcut.svelte";
import { bridgeCommand } from "../../lib/bridgecommand";
import * as tr from "../../lib/ftl";
import { promiseWithResolver } from "../../lib/promise";
import { registerPackage } from "../../lib/runtime-require";
import { getPlatformString } from "../../lib/shortcuts";
import { context } from "../NoteEditor.svelte";
import { setFormat } from "../old-editor-adapter";
import { editingInputIsRichText } from "../rich-text-input";
import ClozeButton from "./ClozeButton.svelte";
import { micIcon, paperclipIcon } from "./icons";
@ -23,13 +26,44 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const { focusedInput } = context.get();
const attachmentKeyCombination = "F3";
const attachmentCombination = "F3";
let mediaPromise: Promise<string>;
let resolve: (media: string) => void;
function mediaResolve(media: string): void {
resolve(media);
}
registerPackage("anki/TemplateButtons", { mediaResolve });
function onAttachment(): void {
if (!editingInputIsRichText($focusedInput)) {
return;
}
[mediaPromise, resolve] = promiseWithResolver<string>();
$focusedInput.focusHandler.focus.on(
async () => setFormat("inserthtml", await mediaPromise),
{ once: true },
);
bridgeCommand("attach");
}
const recordKeyCombination = "F5";
const recordCombination = "F5";
function onRecord(): void {
if (!editingInputIsRichText($focusedInput)) {
return;
}
[mediaPromise, resolve] = promiseWithResolver<string>();
$focusedInput.focusHandler.focus.on(
async () => setFormat("inserthtml", await mediaPromise),
{ once: true },
);
bridgeCommand("record");
}
@ -49,7 +83,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonGroupItem>
<IconButton
tooltip="{tr.editingAttachPicturesaudiovideo()} ({getPlatformString(
attachmentKeyCombination,
attachmentCombination,
)})"
iconSize={70}
{disabled}
@ -57,16 +91,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
>
{@html paperclipIcon}
</IconButton>
<Shortcut
keyCombination={attachmentKeyCombination}
on:action={onAttachment}
/>
<Shortcut keyCombination={attachmentCombination} on:action={onAttachment} />
</ButtonGroupItem>
<ButtonGroupItem>
<IconButton
tooltip="{tr.editingRecordAudio()} ({getPlatformString(
recordKeyCombination,
recordCombination,
)})"
iconSize={70}
{disabled}
@ -74,7 +105,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
>
{@html micIcon}
</IconButton>
<Shortcut keyCombination={recordKeyCombination} on:action={onRecord} />
<Shortcut keyCombination={recordCombination} on:action={onRecord} />
</ButtonGroupItem>
<ButtonGroupItem id="cloze">

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="ts">
import ColorPicker from "../../components/ColorPicker.svelte";
import IconButton from "../../components/IconButton.svelte";
import Shortcut from "../../components/Shortcut.svelte";
import type {
@ -18,6 +17,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { context as noteEditorContext } from "../NoteEditor.svelte";
import { editingInputIsRichText } from "../rich-text-input";
import { removeEmptyStyle, Surrounder } from "../surround";
import ColorPicker from "./ColorPicker.svelte";
import type { RemoveFormat } from "./EditorToolbar.svelte";
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
import { arrowIcon, textColorIcon } from "./icons";
@ -146,19 +146,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
>
{@html arrowIcon}
<ColorPicker
on:change={(event) => {
keyCombination={pickCombination}
on:input={(event) => {
color = setColor(event);
bridgeCommand(`lastTextColor:${color}`);
setTextColor();
}}
on:change={() => setTextColor()}
/>
</IconButton>
<Shortcut
keyCombination={pickCombination}
on:action={(event) => {
color = setColor(event);
bridgeCommand(`lastTextColor:${color}`);
setTextColor();
}}
/>
</WithColorHelper>

View file

@ -8,7 +8,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let color: string;
function setColor({ currentTarget }: Event): string {
return (color = (currentTarget! as HTMLInputElement).value);
color = (currentTarget! as HTMLInputElement).value;
return color;
}
</script>

View file

@ -17,7 +17,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import MathjaxMenu from "./MathjaxMenu.svelte";
const { container, api } = context.get();
const { flushCaret, preventResubscription } = api;
const { focusHandler, preventResubscription } = api;
const code = writable("");
@ -41,7 +41,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let selectAll = false;
function placeHandle(after: boolean): void {
flushCaret();
focusHandler.flushCaret();
if (after) {
(mathjaxElement as any).placeCaretAfter();

View file

@ -26,6 +26,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import CodeMirror from "../CodeMirror.svelte";
import { context as decoratedElementsContext } from "../DecoratedElements.svelte";
import { context as editingAreaContext } from "../EditingArea.svelte";
import { context as noteEditorContext } from "../NoteEditor.svelte";
export let hidden = false;
@ -35,6 +36,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
...gutterOptions,
};
const { focusedInput } = noteEditorContext.get();
const { editingInputs, content } = editingAreaContext.get();
const decoratedElements = decoratedElementsContext.get();
const code = writable($content);
@ -158,8 +161,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
class="plain-text-input"
class:light-theme={!$pageTheme.isDark}
class:hidden
on:focusin
on:focusout
on:focusin={() => ($focusedInput = api)}
>
<CodeMirror
{configuration}

View file

@ -3,13 +3,12 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script context="module" lang="ts">
import type { FocusHandlerAPI } from "../../editable/content-editable";
import type { ContentEditableAPI } from "../../editable/ContentEditable.svelte";
import contextProperty from "../../sveltelib/context-property";
import type {
OnInputCallback,
OnInsertCallback,
Trigger,
} from "../../sveltelib/input-manager";
import useContextProperty from "../../sveltelib/context-property";
import useDOMMirror from "../../sveltelib/dom-mirror";
import type { InputHandlerAPI } from "../../sveltelib/input-handler";
import useInputHandler from "../../sveltelib/input-handler";
import { pageTheme } from "../../sveltelib/theme";
import type { EditingInputAPI } from "../EditingArea.svelte";
import type CustomStyles from "./CustomStyles.svelte";
@ -21,9 +20,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
moveCaretToEnd(): void;
toggle(): boolean;
preventResubscription(): () => void;
getTriggerOnNextInsert(): Trigger<OnInsertCallback>;
getTriggerOnInput(): Trigger<OnInputCallback>;
getTriggerAfterInput(): Trigger<OnInputCallback>;
inputHandler: InputHandlerAPI;
focusHandler: FocusHandlerAPI;
}
export function editingInputIsRichText(
@ -39,19 +37,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
const key = Symbol("richText");
const [context, setContextProperty] = contextProperty<RichTextInputContextAPI>(key);
const [context, setContextProperty] =
useContextProperty<RichTextInputContextAPI>(key);
const [globalInputHandler, setupGlobalInputHandler] = useInputHandler();
import getInputManager from "../../sveltelib/input-manager";
import getDOMMirror from "../../sveltelib/mirror-dom";
const {
manager: globalInputManager,
getTriggerAfterInput,
getTriggerOnInput,
getTriggerOnNextInsert,
} = getInputManager();
export { context, getTriggerAfterInput, getTriggerOnInput, getTriggerOnNextInsert };
export { context, globalInputHandler as inputHandler };
</script>
<script lang="ts">
@ -71,11 +61,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { nodeStore } from "../../sveltelib/node-store";
import { context as decoratedElementsContext } from "../DecoratedElements.svelte";
import { context as editingAreaContext } from "../EditingArea.svelte";
import { context as noteEditorContext } from "../NoteEditor.svelte";
import RichTextStyles from "./RichTextStyles.svelte";
import SetContext from "./SetContext.svelte";
export let hidden: boolean;
const { focusedInput } = noteEditorContext.get();
const { content, editingInputs } = editingAreaContext.get();
const decoratedElements = decoratedElementsContext.get();
@ -175,8 +168,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
};
}
const { mirror, preventResubscription } = getDOMMirror();
const localInputManager = getInputManager();
const { mirror, preventResubscription } = useDOMMirror();
const [inputHandler, setupInputHandler] = useInputHandler();
function moveCaretToEnd() {
richTextPromise.then(placeCaretAfterContent);
@ -205,9 +198,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
},
moveCaretToEnd,
preventResubscription,
getTriggerOnNextInsert: localInputManager.getTriggerOnNextInsert,
getTriggerOnInput: localInputManager.getTriggerOnInput,
getTriggerAfterInput: localInputManager.getTriggerAfterInput,
inputHandler,
} as RichTextInputAPI;
const allContexts = getAllContexts();
@ -216,12 +207,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
stylesDidLoad.then(
() =>
new ContentEditable({
target: element.shadowRoot!,
target: element.shadowRoot,
props: {
nodes,
resolve,
mirrors: [mirror],
managers: [globalInputManager, localInputManager.manager],
inputHandlers: [setupInputHandler, setupGlobalInputHandler],
api,
},
context: allContexts,
@ -253,7 +244,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
});
</script>
<div class="rich-text-input">
<div class="rich-text-input" on:focusin={() => ($focusedInput = api)}>
<RichTextStyles
color={$pageTheme.isDark ? "white" : "black"}
let:attachToShadow={attachStyles}

View file

@ -9,7 +9,7 @@ import type { SurroundFormat } from "../domlib/surround";
import { boolMatcher, reformat, surround, unsurround } from "../domlib/surround";
import { getRange, getSelection } from "../lib/cross-browser";
import { registerPackage } from "../lib/runtime-require";
import type { OnInsertCallback, Trigger } from "../sveltelib/input-manager";
import type { TriggerItem } from "../sveltelib/handler-list";
import type { RichTextInputAPI } from "./rich-text-input";
function isSurroundedInner(
@ -63,11 +63,11 @@ export class Surrounder {
}
private api: RichTextInputAPI | null = null;
private trigger: Trigger<OnInsertCallback> | null = null;
private trigger: TriggerItem<{ event: InputEvent; text: Text }> | null = null;
set richText(api: RichTextInputAPI) {
this.api = api;
this.trigger = api.getTriggerOnNextInsert();
this.trigger = api.inputHandler.insertText.trigger({ once: true });
}
/**
@ -76,6 +76,7 @@ export class Surrounder {
*/
disable(): void {
this.api = null;
this.trigger?.off();
this.trigger = null;
}
@ -95,13 +96,13 @@ export class Surrounder {
exclusive: SurroundFormat<T>[] = [],
): void {
if (get(this.trigger!.active)) {
this.trigger!.remove();
this.trigger!.off();
} else {
this.trigger!.add(async ({ node }: { node: Node }) => {
this.trigger!.on(async ({ text }) => {
const range = new Range();
range.selectNode(node);
range.selectNode(text);
const matches = Boolean(findClosest(node, base, matcher));
const matches = Boolean(findClosest(text, base, matcher));
const clearedRange = removeFormats(range, base, exclusive);
surroundAndSelect(matches, clearedRange, base, format, selection);
selection.collapseToEnd();
@ -109,6 +110,41 @@ export class Surrounder {
}
}
private _toggleTriggerOverwrite<T>(
base: HTMLElement,
selection: Selection,
format: SurroundFormat<T>,
exclusive: SurroundFormat<T>[] = [],
): void {
this.trigger!.on(async ({ text }) => {
const range = new Range();
range.selectNode(text);
const clearedRange = removeFormats(range, base, exclusive);
const surroundedRange = surround(clearedRange, base, format);
selection.removeAllRanges();
selection.addRange(surroundedRange);
selection.collapseToEnd();
});
}
private _toggleTriggerRemove<T>(
base: HTMLElement,
selection: Selection,
remove: SurroundFormat<T>[],
reformat: SurroundFormat<T>[] = [],
): void {
this.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();
});
}
/**
* Use the surround command on the current range of the RichTextInput.
* If the range is already surrounded, it will unsurround instead.
@ -148,14 +184,13 @@ export class Surrounder {
const base = await this._assert_base();
const selection = getSelection(base)!;
const range = getRange(selection);
const matcher = boolMatcher(format);
if (!range) {
return;
}
if (range.collapsed) {
return this._toggleTrigger(base, selection, matcher, format, exclusive);
return this._toggleTriggerOverwrite(base, selection, format, exclusive);
}
const clearedRange = removeFormats(range, base, exclusive);
@ -194,10 +229,14 @@ export class Surrounder {
const selection = getSelection(base)!;
const range = getRange(selection);
if (!range || range.collapsed) {
if (!range) {
return;
}
if (range.collapsed) {
return this._toggleTriggerRemove(base, selection, formats, reformats);
}
const surroundedRange = removeFormats(range, base, formats, reformats);
selection.removeAllRanges();
selection.addRange(surroundedRange);

View file

@ -16,6 +16,7 @@
*/
type AnkiPackages =
| "anki/NoteEditor"
| "anki/TemplateButtons"
| "anki/packages"
| "anki/bridgecommand"
| "anki/shortcuts"

View file

@ -4,3 +4,5 @@
export function assertUnreachable(x: never): never {
throw new Error(`unreachable: ${x}`);
}
export type Callback = () => void;

View file

@ -41,7 +41,7 @@ function cloneNode(node: Node): DocumentFragment {
* While the element has focus, this connection is tethered.
* In practice, this will sync changes from PlainTextInput to RichTextInput.
*/
function getDOMMirror(): DOMMirrorAPI {
function useDOMMirror(): DOMMirrorAPI {
const allowResubscription = writable(true);
function preventResubscription() {
@ -128,4 +128,4 @@ function getDOMMirror(): DOMMirrorAPI {
};
}
export default getDOMMirror;
export default useDOMMirror;

View file

@ -6,7 +6,9 @@
// would not work.
import * as svelteRuntime from "svelte/internal";
import * as svelteStore from "svelte/store";
import { registerPackageRaw } from "../lib/runtime-require";
registerPackageRaw("svelte/internal", svelteRuntime);
registerPackageRaw("svelte/store", svelteStore);

View file

@ -0,0 +1,165 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { Readable, Writable } from "svelte/store";
import { writable } from "svelte/store";
import type { Callback } from "../lib/typing";
type Handler<T> = (args: T) => Promise<void>;
interface HandlerAccess<T> {
callback: Handler<T>;
clear(): void;
}
class TriggerItem<T> {
#active: Writable<boolean>;
constructor(
private setter: (handler: Handler<T>, clear: Callback) => void,
private clear: Callback,
) {
this.#active = writable(false);
}
/**
* A store which indicates whether the trigger is currently turned on.
*/
get active(): Readable<boolean> {
return this.#active;
}
/**
* Deactivate the trigger. Can be safely called multiple times.
*/
off(): void {
this.#active.set(false);
this.clear();
}
on(handler: Handler<T>): void {
this.setter(handler, () => this.off());
this.#active.set(true);
}
}
interface HandlerOptions {
once: boolean;
}
export class HandlerList<T> {
#list: HandlerAccess<T>[] = [];
trigger(options?: Partial<HandlerOptions>): TriggerItem<T> {
const once = options?.once ?? false;
let handler: Handler<T> | null = null;
return new TriggerItem(
(callback: Handler<T>, doClear: Callback): void => {
const handlerAccess = {
callback(args: T): Promise<void> {
const result = callback(args);
if (once) {
doClear();
}
return result;
},
clear(): void {
if (once) {
doClear();
}
},
};
this.#list.push(handlerAccess);
handler = handlerAccess.callback;
},
() => {
if (handler) {
this.off(handler);
handler = null;
}
},
);
}
on(handler: Handler<T>, options?: Partial<HandlerOptions>): Callback {
const once = options?.once ?? false;
let offHandler: Handler<T> | null = null;
const off = (): void => {
if (offHandler) {
this.off(offHandler);
offHandler = null;
}
};
const handlerAccess = {
callback: (args: T): Promise<void> => {
const result = handler(args);
if (once) {
off();
}
return result;
},
clear(): void {
if (once) {
off();
}
},
};
offHandler = handlerAccess.callback;
this.#list.push(handlerAccess);
return off;
}
private off(handler: Handler<T>): void {
const index = this.#list.findIndex(
(value: HandlerAccess<T>): boolean => value.callback === handler,
);
if (index >= 0) {
this.#list.splice(index, 1);
}
}
get length(): number {
return this.#list.length;
}
dispatch(args: T): Promise<void> {
const promises: Promise<void>[] = [];
for (const { callback } of [...this]) {
promises.push(callback(args));
}
return Promise.all(promises) as unknown as Promise<void>;
}
clear(): void {
for (const { clear } of [...this]) {
clear();
}
}
[Symbol.iterator](): Iterator<HandlerAccess<T>, null, unknown> {
const list = this.#list;
let step = 0;
return {
next(): IteratorResult<HandlerAccess<T>, null> {
if (step >= list.length) {
return { value: null, done: true };
}
return { value: list[step++], done: false };
},
};
}
}
export type { TriggerItem };

View file

@ -0,0 +1,112 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { getRange, getSelection } from "../lib/cross-browser";
import { on } from "../lib/events";
import { keyboardEventIsPrintableKey } from "../lib/keys";
import { HandlerList } from "./handler-list";
const nbsp = "\xa0";
export type SetupInputHandlerAction = (element: HTMLElement) => { destroy(): void };
export interface InputEventParams {
event: InputEvent;
}
export interface InsertTextParams {
event: InputEvent;
text: Text;
}
export interface InputHandlerAPI {
readonly beforeInput: HandlerList<InputEventParams>;
readonly insertText: HandlerList<InsertTextParams>;
}
/**
* An interface that allows Svelte components to attach event listeners via triggers.
* They will be attached to the component(s) that install the manager.
* Prevents that too many event listeners are attached and allows for some
* coordination between them.
*/
function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] {
const beforeInput = new HandlerList<InputEventParams>();
const insertText = new HandlerList<InsertTextParams>();
async function onBeforeInput(this: Element, event: InputEvent): Promise<void> {
const selection = getSelection(this)!;
const range = getRange(selection);
await beforeInput.dispatch({ event });
if (!range || event.inputType !== "insertText" || insertText.length === 0) {
return;
}
event.preventDefault();
const content = !event.data || event.data === " " ? nbsp : event.data;
const text = new Text(content);
range.deleteContents();
range.insertNode(text);
range.selectNode(text);
range.collapse(false);
await insertText.dispatch({ event, text });
range.commonAncestorContainer.normalize();
}
function clearInsertText(): void {
insertText.clear();
}
function onKeydown(event: KeyboardEvent): void {
/* using arrow keys should cancel */
if (!keyboardEventIsPrintableKey(event)) {
clearInsertText();
}
}
function onInput(this: HTMLElement, event: InputEvent): void {
// prevent unwanted <div> from being left behind when clearing field contents
if (
!event.data &&
this.children.length === 1 &&
this.children.item(0) instanceof HTMLDivElement &&
/^\n?$/.test(this.innerText)
) {
this.innerHTML = "";
}
}
function setupHandler(element: HTMLElement): { destroy(): void } {
const beforeInputOff = on(element, "beforeinput", onBeforeInput);
const inputOff = on(element, "input" as "beforeinput", onInput);
const blurOff = on(element, "blur", clearInsertText);
const pointerDownOff = on(element, "pointerdown", clearInsertText);
const keyDownOff = on(element, "keydown", onKeydown);
return {
destroy() {
beforeInputOff();
inputOff();
blurOff();
pointerDownOff();
keyDownOff();
},
};
}
return [
{
beforeInput,
insertText,
},
setupHandler,
];
}
export default useInputHandler;

View file

@ -1,176 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// 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 { getSelection } from "../lib/cross-browser";
import { on } from "../lib/events";
import { id } from "../lib/functional";
import { keyboardEventIsPrintableKey } from "../lib/keys";
export type OnInsertCallback = ({
node,
event,
}: {
node: Node;
event: InputEvent;
}) => Promise<void>;
export type OnInputCallback = ({ event }: { event: InputEvent }) => Promise<void>;
export interface Trigger<C> {
add(callback: C): void;
remove(): void;
active: Writable<boolean>;
}
export type Managed<C> = Pick<Trigger<C>, "remove"> & { callback: C };
export type InputManagerAction = (element: HTMLElement) => { destroy(): void };
interface InputManager {
manager: InputManagerAction;
getTriggerOnNextInsert(): Trigger<OnInsertCallback>;
getTriggerOnInput(): Trigger<OnInputCallback>;
getTriggerAfterInput(): Trigger<OnInputCallback>;
}
function trigger<C>(list: Managed<C>[]) {
return function getTrigger(): Trigger<C> {
const index = list.length++;
const active = writable(false);
function remove() {
delete list[index];
active.set(false);
}
function add(callback: C): void {
list[index] = { callback, remove };
active.set(true);
}
return {
add,
remove,
active,
};
};
}
const nbsp = "\xa0";
/**
* An interface that allows Svelte components to attach event listeners via triggers.
* They will be attached to the component(s) that install the manager.
* Prevents that too many event listeners are attached and allows for some
* coordination between them.
*/
function getInputManager(): InputManager {
const beforeInput: Managed<OnInputCallback>[] = [];
const beforeInsertText: Managed<OnInsertCallback>[] = [];
async function onBeforeInput(event: InputEvent): Promise<void> {
const selection = getSelection(event.target! as Node)!;
const range = selection.getRangeAt(0);
for (const { callback } of beforeInput.filter(id)) {
await callback({ event });
}
const filteredBeforeInsertText = beforeInsertText.filter(id);
if (event.inputType === "insertText" && filteredBeforeInsertText.length > 0) {
event.preventDefault();
const textContent = event.data === " " ? nbsp : event.data ?? nbsp;
const node = new Text(textContent);
range.deleteContents();
range.insertNode(node);
range.selectNode(node);
range.collapse(false);
for (const { callback, remove } of filteredBeforeInsertText) {
await callback({ node, event });
remove();
}
/* we call explicitly because we prevented default */
callAfterInputHooks(event);
}
}
const afterInput: Managed<OnInputCallback>[] = [];
async function callAfterInputHooks(event: InputEvent): Promise<void> {
for (const { callback } of afterInput.filter(id)) {
await callback({ event });
}
}
function clearInsertText(): void {
for (const { remove } of beforeInsertText.filter(id)) {
remove();
}
}
function clearInsertTextIfUnprintableKey(event: KeyboardEvent): void {
/* using arrow keys should cancel */
if (!keyboardEventIsPrintableKey(event)) {
clearInsertText();
}
}
function onInput(event: Event): void {
if (
!(event instanceof InputEvent) ||
!(event.currentTarget instanceof HTMLElement)
) {
return;
}
// prevent unwanted <div> from being left behind when clearing field contents
if (
(event.data === null || event.data === "") &&
event.currentTarget.children.length === 1 &&
event.currentTarget.children.item(0) instanceof HTMLDivElement &&
/^\n?$/.test(event.currentTarget.innerText)
) {
event.currentTarget.innerHTML = "";
}
}
function manager(element: HTMLElement): { destroy(): void } {
const removeBeforeInput = on(element, "beforeinput", onBeforeInput);
const removeInput = on(
element,
"input",
onInput as unknown as (event: Event) => void,
);
const removeBlur = on(element, "blur", clearInsertText);
const removePointerDown = on(element, "pointerdown", clearInsertText);
const removeKeyDown = on(element, "keydown", clearInsertTextIfUnprintableKey);
return {
destroy() {
removeInput();
removeBeforeInput();
removeBlur();
removePointerDown();
removeKeyDown();
removeInput();
},
};
}
return {
manager,
getTriggerOnNextInsert: trigger(beforeInsertText),
getTriggerOnInput: trigger(beforeInput),
getTriggerAfterInput: trigger(afterInput),
};
}
export default getInputManager;