mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
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:
parent
a0d0f2f8fd
commit
0d83581ab0
22 changed files with 574 additions and 373 deletions
|
@ -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."""
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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]}
|
||||
/>
|
||||
|
|
|
@ -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 {
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
*/
|
||||
type AnkiPackages =
|
||||
| "anki/NoteEditor"
|
||||
| "anki/TemplateButtons"
|
||||
| "anki/packages"
|
||||
| "anki/bridgecommand"
|
||||
| "anki/shortcuts"
|
||||
|
|
|
@ -4,3 +4,5 @@
|
|||
export function assertUnreachable(x: never): never {
|
||||
throw new Error(`unreachable: ${x}`);
|
||||
}
|
||||
|
||||
export type Callback = () => void;
|
||||
|
|
|
@ -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;
|
|
@ -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);
|
||||
|
|
165
ts/sveltelib/handler-list.ts
Normal file
165
ts/sveltelib/handler-list.ts
Normal 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 };
|
112
ts/sveltelib/input-handler.ts
Normal file
112
ts/sveltelib/input-handler.ts
Normal 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;
|
|
@ -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;
|
Loading…
Reference in a new issue