canvas-recorder-lib 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BREAKING-CHANGES.md +18 -0
- package/README.md +112 -20
- package/dev/audio-builder.ts +4 -2
- package/dev/canvas-recorder.css +59 -0
- package/dev/canvas-recorder.ts +679 -51
- package/dev/delay-files.ts +1 -1
- package/dev/font-utils.ts +8 -1
- package/dev/format-editor.ts +734 -0
- package/dev/format-string.ts +110 -0
- package/dev/local-file-row.ts +107 -0
- package/dev/multi-text-dialog.ts +360 -0
- package/dev/multi-text-panel.ts +11 -0
- package/dev/player-body.ts +86 -6
- package/dev/rect-snap.ts +9 -9
- package/dev/slide-panel.ts +1 -1
- package/dev/split-dialog.ts +13 -2
- package/dev/static-image-panel.ts +35 -5
- package/dev/timeline-display.ts +50 -5
- package/dev/transcripts.ts +5 -3
- package/dev/video-clip-panel.ts +201 -15
- package/dist/dev/audio-builder.d.ts +1 -1
- package/dist/dev/audio-builder.d.ts.map +1 -1
- package/dist/dev/audio-builder.js +3 -2
- package/dist/dev/audio-builder.js.map +1 -1
- package/dist/dev/canvas-recorder.css +59 -0
- package/dist/dev/canvas-recorder.js +612 -50
- package/dist/dev/canvas-recorder.js.map +1 -1
- package/dist/dev/delay-files.js +1 -1
- package/dist/dev/delay-files.js.map +1 -1
- package/dist/dev/font-utils.d.ts.map +1 -1
- package/dist/dev/font-utils.js +9 -1
- package/dist/dev/font-utils.js.map +1 -1
- package/dist/dev/format-editor.d.ts +75 -0
- package/dist/dev/format-editor.d.ts.map +1 -0
- package/dist/dev/format-editor.js +642 -0
- package/dist/dev/format-editor.js.map +1 -0
- package/dist/dev/format-string.d.ts +62 -0
- package/dist/dev/format-string.d.ts.map +1 -0
- package/dist/dev/format-string.js +86 -0
- package/dist/dev/format-string.js.map +1 -0
- package/dist/dev/local-file-row.d.ts +22 -0
- package/dist/dev/local-file-row.d.ts.map +1 -0
- package/dist/dev/local-file-row.js +84 -0
- package/dist/dev/local-file-row.js.map +1 -0
- package/dist/dev/multi-text-dialog.d.ts +30 -0
- package/dist/dev/multi-text-dialog.d.ts.map +1 -0
- package/dist/dev/multi-text-dialog.js +297 -0
- package/dist/dev/multi-text-dialog.js.map +1 -0
- package/dist/dev/multi-text-panel.d.ts +2 -0
- package/dist/dev/multi-text-panel.d.ts.map +1 -1
- package/dist/dev/multi-text-panel.js +4 -0
- package/dist/dev/multi-text-panel.js.map +1 -1
- package/dist/dev/player-body.d.ts +1 -1
- package/dist/dev/player-body.d.ts.map +1 -1
- package/dist/dev/player-body.js +86 -6
- package/dist/dev/player-body.js.map +1 -1
- package/dist/dev/rect-snap.d.ts +1 -1
- package/dist/dev/rect-snap.js +7 -7
- package/dist/dev/rect-snap.js.map +1 -1
- package/dist/dev/slide-panel.d.ts +1 -1
- package/dist/dev/slide-panel.d.ts.map +1 -1
- package/dist/dev/slide-panel.js +1 -1
- package/dist/dev/slide-panel.js.map +1 -1
- package/dist/dev/split-dialog.d.ts.map +1 -1
- package/dist/dev/split-dialog.js +14 -2
- package/dist/dev/split-dialog.js.map +1 -1
- package/dist/dev/static-image-panel.d.ts +2 -0
- package/dist/dev/static-image-panel.d.ts.map +1 -1
- package/dist/dev/static-image-panel.js +29 -5
- package/dist/dev/static-image-panel.js.map +1 -1
- package/dist/dev/timeline-display.d.ts +23 -2
- package/dist/dev/timeline-display.d.ts.map +1 -1
- package/dist/dev/timeline-display.js +47 -5
- package/dist/dev/timeline-display.js.map +1 -1
- package/dist/dev/transcripts.d.ts.map +1 -1
- package/dist/dev/transcripts.js +5 -3
- package/dist/dev/transcripts.js.map +1 -1
- package/dist/dev/video-clip-panel.d.ts +8 -0
- package/dist/dev/video-clip-panel.d.ts.map +1 -1
- package/dist/dev/video-clip-panel.js +175 -9
- package/dist/dev/video-clip-panel.js.map +1 -1
- package/dist/lib/index.d.ts +3 -0
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +3 -0
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/main-control.d.ts +50 -4
- package/dist/lib/main-control.d.ts.map +1 -1
- package/dist/lib/main-control.js +94 -12
- package/dist/lib/main-control.js.map +1 -1
- package/dist/src/binary-search.js +1 -1
- package/dist/src/binary-search.js.map +1 -1
- package/dist/src/corner-rounder.d.ts +17 -0
- package/dist/src/corner-rounder.d.ts.map +1 -0
- package/dist/src/corner-rounder.js +89 -0
- package/dist/src/corner-rounder.js.map +1 -0
- package/dist/src/cross-fade.d.ts +1 -1
- package/dist/src/cross-fade.d.ts.map +1 -1
- package/dist/src/cross-fade.js +3 -3
- package/dist/src/cross-fade.js.map +1 -1
- package/dist/src/fancy-text.d.ts +116 -0
- package/dist/src/fancy-text.d.ts.map +1 -0
- package/dist/src/fancy-text.js +211 -0
- package/dist/src/fancy-text.js.map +1 -0
- package/dist/src/glib/letters-base.d.ts +1 -1
- package/dist/src/glib/letters-base.d.ts.map +1 -1
- package/dist/src/glib/letters-base.js +4 -3
- package/dist/src/glib/letters-base.js.map +1 -1
- package/dist/src/glib/line-font.d.ts +2 -2
- package/dist/src/glib/line-font.d.ts.map +1 -1
- package/dist/src/glib/line-font.js +2 -2
- package/dist/src/glib/line-font.js.map +1 -1
- package/dist/src/glib/paragraph-layout.d.ts +2 -2
- package/dist/src/glib/paragraph-layout.d.ts.map +1 -1
- package/dist/src/glib/paragraph-layout.js +1 -1
- package/dist/src/glib/paragraph-layout.js.map +1 -1
- package/dist/src/glib/path-shape-splitter.d.ts +1 -1
- package/dist/src/glib/path-shape-splitter.d.ts.map +1 -1
- package/dist/src/glib/path-shape-splitter.js +1 -1
- package/dist/src/glib/path-shape-splitter.js.map +1 -1
- package/dist/src/glib/path-shape.js +2 -2
- package/dist/src/glib/path-shape.js.map +1 -1
- package/dist/src/interpolate.d.ts +2 -2
- package/dist/src/interpolate.d.ts.map +1 -1
- package/dist/src/interpolate.js +1 -1
- package/dist/src/interpolate.js.map +1 -1
- package/dist/src/lattice.d.ts +1 -1
- package/dist/src/lattice.d.ts.map +1 -1
- package/dist/src/local-files.d.ts +129 -0
- package/dist/src/local-files.d.ts.map +1 -0
- package/dist/src/local-files.js +327 -0
- package/dist/src/local-files.js.map +1 -0
- package/dist/src/morph-animation.d.ts +1 -1
- package/dist/src/morph-animation.d.ts.map +1 -1
- package/dist/src/morph-animation.js +2 -2
- package/dist/src/morph-animation.js.map +1 -1
- package/dist/src/peano-fourier/fourier-shared.d.ts +1 -1
- package/dist/src/peano-fourier/fourier-shared.d.ts.map +1 -1
- package/dist/src/peano-fourier/fourier-shared.js +17 -4
- package/dist/src/peano-fourier/fourier-shared.js.map +1 -1
- package/dist/src/schedule-helper.d.ts +4 -4
- package/dist/src/schedule-helper.d.ts.map +1 -1
- package/dist/src/schedule-helper.js +2 -2
- package/dist/src/schedule-helper.js.map +1 -1
- package/dist/src/showable.d.ts +36 -4
- package/dist/src/showable.d.ts.map +1 -1
- package/dist/src/showable.js +37 -1
- package/dist/src/showable.js.map +1 -1
- package/dist/src/slide-components/arrow.d.ts +4 -4
- package/dist/src/slide-components/arrow.d.ts.map +1 -1
- package/dist/src/slide-components/arrow.js +2 -2
- package/dist/src/slide-components/arrow.js.map +1 -1
- package/dist/src/slide-components/cross-fade-transition.d.ts +3 -3
- package/dist/src/slide-components/cross-fade-transition.d.ts.map +1 -1
- package/dist/src/slide-components/cross-fade-transition.js +3 -3
- package/dist/src/slide-components/cross-fade-transition.js.map +1 -1
- package/dist/src/slide-components/duration-agnostic.d.ts +3 -3
- package/dist/src/slide-components/duration-agnostic.d.ts.map +1 -1
- package/dist/src/slide-components/duration-agnostic.js +1 -1
- package/dist/src/slide-components/duration-agnostic.js.map +1 -1
- package/dist/src/slide-components/fixed-duration.d.ts +1 -1
- package/dist/src/slide-components/fixed-duration.d.ts.map +1 -1
- package/dist/src/slide-components/fixed-duration.js +1 -1
- package/dist/src/slide-components/fixed-duration.js.map +1 -1
- package/dist/src/slide-components/fourier.d.ts +6 -6
- package/dist/src/slide-components/fourier.d.ts.map +1 -1
- package/dist/src/slide-components/fourier.js +6 -6
- package/dist/src/slide-components/fourier.js.map +1 -1
- package/dist/src/slide-components/frame-counter.d.ts +2 -2
- package/dist/src/slide-components/frame-counter.d.ts.map +1 -1
- package/dist/src/slide-components/frame-counter.js +1 -1
- package/dist/src/slide-components/frame-counter.js.map +1 -1
- package/dist/src/slide-components/function-graph.d.ts +2 -2
- package/dist/src/slide-components/function-graph.d.ts.map +1 -1
- package/dist/src/slide-components/function-graph.js +3 -3
- package/dist/src/slide-components/function-graph.js.map +1 -1
- package/dist/src/slide-components/halftone-shadow.d.ts +4 -4
- package/dist/src/slide-components/halftone-shadow.d.ts.map +1 -1
- package/dist/src/slide-components/halftone-shadow.js +2 -2
- package/dist/src/slide-components/halftone-shadow.js.map +1 -1
- package/dist/src/slide-components/handwriting-effect.d.ts +5 -5
- package/dist/src/slide-components/handwriting-effect.d.ts.map +1 -1
- package/dist/src/slide-components/handwriting-effect.js +5 -5
- package/dist/src/slide-components/handwriting-effect.js.map +1 -1
- package/dist/src/slide-components/hold.d.ts +3 -3
- package/dist/src/slide-components/hold.d.ts.map +1 -1
- package/dist/src/slide-components/hold.js +3 -3
- package/dist/src/slide-components/hold.js.map +1 -1
- package/dist/src/slide-components/image.d.ts +3 -3
- package/dist/src/slide-components/image.d.ts.map +1 -1
- package/dist/src/slide-components/image.js +3 -3
- package/dist/src/slide-components/image.js.map +1 -1
- package/dist/src/slide-components/in-parallel.d.ts +3 -3
- package/dist/src/slide-components/in-parallel.d.ts.map +1 -1
- package/dist/src/slide-components/in-parallel.js +2 -2
- package/dist/src/slide-components/in-parallel.js.map +1 -1
- package/dist/src/slide-components/in-series.d.ts +4 -4
- package/dist/src/slide-components/in-series.d.ts.map +1 -1
- package/dist/src/slide-components/in-series.js +5 -1
- package/dist/src/slide-components/in-series.js.map +1 -1
- package/dist/src/slide-components/live-duration.d.ts +1 -1
- package/dist/src/slide-components/live-duration.d.ts.map +1 -1
- package/dist/src/slide-components/live-duration.js +1 -1
- package/dist/src/slide-components/live-duration.js.map +1 -1
- package/dist/src/slide-components/multi-text.d.ts +76 -15
- package/dist/src/slide-components/multi-text.d.ts.map +1 -1
- package/dist/src/slide-components/multi-text.js +170 -25
- package/dist/src/slide-components/multi-text.js.map +1 -1
- package/dist/src/slide-components/rectangle.d.ts +3 -3
- package/dist/src/slide-components/rectangle.d.ts.map +1 -1
- package/dist/src/slide-components/rectangle.js +1 -1
- package/dist/src/slide-components/rectangle.js.map +1 -1
- package/dist/src/slide-components/registry.d.ts +1 -1
- package/dist/src/slide-components/registry.d.ts.map +1 -1
- package/dist/src/slide-components/registry.js +18 -18
- package/dist/src/slide-components/registry.js.map +1 -1
- package/dist/src/slide-components/serialize.d.ts +1 -1
- package/dist/src/slide-components/serialize.d.ts.map +1 -1
- package/dist/src/slide-components/serialize.js +2 -2
- package/dist/src/slide-components/serialize.js.map +1 -1
- package/dist/src/slide-components/show-error.js +2 -2
- package/dist/src/slide-components/show-error.js.map +1 -1
- package/dist/src/slide-components/simple-text.d.ts +4 -4
- package/dist/src/slide-components/simple-text.d.ts.map +1 -1
- package/dist/src/slide-components/simple-text.js +4 -4
- package/dist/src/slide-components/simple-text.js.map +1 -1
- package/dist/src/slide-components/slide-component.d.ts +5 -5
- package/dist/src/slide-components/slide-component.d.ts.map +1 -1
- package/dist/src/slide-components/slide-component.js +4 -4
- package/dist/src/slide-components/slide-component.js.map +1 -1
- package/dist/src/slide-components/slide-left.d.ts +3 -3
- package/dist/src/slide-components/slide-left.d.ts.map +1 -1
- package/dist/src/slide-components/slide-left.js +3 -3
- package/dist/src/slide-components/slide-left.js.map +1 -1
- package/dist/src/slide-components/split-video-clip.d.ts +2 -2
- package/dist/src/slide-components/split-video-clip.d.ts.map +1 -1
- package/dist/src/slide-components/split-video-clip.js +3 -3
- package/dist/src/slide-components/split-video-clip.js.map +1 -1
- package/dist/src/slide-components/text-effect.d.ts +2 -2
- package/dist/src/slide-components/text-effect.d.ts.map +1 -1
- package/dist/src/slide-components/traditional-text.d.ts +4 -4
- package/dist/src/slide-components/traditional-text.d.ts.map +1 -1
- package/dist/src/slide-components/traditional-text.js +3 -3
- package/dist/src/slide-components/traditional-text.js.map +1 -1
- package/dist/src/slide-components/video-clip.d.ts +17 -5
- package/dist/src/slide-components/video-clip.d.ts.map +1 -1
- package/dist/src/slide-components/video-clip.js +52 -11
- package/dist/src/slide-components/video-clip.js.map +1 -1
- package/dist/src/slide-components/video-info.d.ts +11 -11
- package/dist/src/slide-components/video-info.d.ts.map +1 -1
- package/dist/src/slide-components/video-info.js +32 -21
- package/dist/src/slide-components/video-info.js.map +1 -1
- package/dist/src/slow-image-sources.d.ts +7 -1
- package/dist/src/slow-image-sources.d.ts.map +1 -1
- package/dist/src/slow-image-sources.js +36 -5
- package/dist/src/slow-image-sources.js.map +1 -1
- package/dist/src/snapshot.d.ts +2 -2
- package/dist/src/snapshot.d.ts.map +1 -1
- package/dist/src/snapshot.js +4 -4
- package/dist/src/snapshot.js.map +1 -1
- package/dist/src/stroke-colors.d.ts +1 -1
- package/dist/src/stroke-colors.d.ts.map +1 -1
- package/dist/src/stroke-colors.js +4 -4
- package/dist/src/stroke-colors.js.map +1 -1
- package/dist/src/utility.d.ts +3 -4
- package/dist/src/utility.d.ts.map +1 -1
- package/dist/src/utility.js +4 -4
- package/dist/src/utility.js.map +1 -1
- package/lib/index.ts +3 -0
- package/lib/main-control.ts +119 -12
- package/package.json +7 -5
- package/src/binary-search.ts +2 -2
- package/src/corner-rounder.ts +128 -0
- package/src/cross-fade.ts +3 -3
- package/src/fancy-text.ts +255 -0
- package/src/glib/letters-base.ts +4 -3
- package/src/glib/line-font.ts +2 -2
- package/src/glib/paragraph-layout.ts +2 -2
- package/src/glib/path-shape-splitter.ts +1 -1
- package/src/glib/path-shape.ts +2 -2
- package/src/interpolate.ts +3 -3
- package/src/lattice.ts +1 -1
- package/src/local-files.ts +389 -0
- package/src/morph-animation.ts +2 -2
- package/src/peano-fourier/fourier-shared.ts +17 -4
- package/src/schedule-helper.ts +4 -4
- package/src/showable.ts +54 -4
- package/src/slide-components/arrow.ts +4 -4
- package/src/slide-components/cross-fade-transition.ts +4 -4
- package/src/slide-components/duration-agnostic.ts +3 -3
- package/src/slide-components/fixed-duration.ts +1 -1
- package/src/slide-components/fourier.ts +9 -9
- package/src/slide-components/frame-counter.ts +2 -2
- package/src/slide-components/function-graph.ts +4 -4
- package/src/slide-components/halftone-shadow.ts +4 -4
- package/src/slide-components/handwriting-effect.ts +7 -7
- package/src/slide-components/hold.ts +4 -4
- package/src/slide-components/image.ts +5 -5
- package/src/slide-components/in-parallel.ts +4 -4
- package/src/slide-components/in-series.ts +7 -4
- package/src/slide-components/live-duration.ts +1 -1
- package/src/slide-components/multi-text.ts +195 -30
- package/src/slide-components/rectangle.ts +3 -3
- package/src/slide-components/registry.ts +19 -19
- package/src/slide-components/serialize.ts +2 -2
- package/src/slide-components/show-error.ts +2 -2
- package/src/slide-components/simple-text.ts +7 -7
- package/src/slide-components/slide-component.ts +7 -7
- package/src/slide-components/slide-left.ts +4 -4
- package/src/slide-components/split-video-clip.ts +4 -4
- package/src/slide-components/text-effect.ts +2 -2
- package/src/slide-components/traditional-text.ts +5 -5
- package/src/slide-components/video-clip.ts +64 -13
- package/src/slide-components/video-info.ts +33 -21
- package/src/slow-image-sources.ts +38 -6
- package/src/snapshot.ts +4 -4
- package/src/stroke-colors.ts +4 -4
- package/src/utility.ts +5 -5
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
// MARK: Format Editor
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
formatInEffect,
|
|
5
|
+
isFormat,
|
|
6
|
+
parse,
|
|
7
|
+
pushContent,
|
|
8
|
+
serialize,
|
|
9
|
+
Unit,
|
|
10
|
+
} from "./format-string.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A `contenteditable` element containing a mix of plain text and "chips".
|
|
14
|
+
* Each chip is a format marker, displayed as a `<select>`.
|
|
15
|
+
*
|
|
16
|
+
* **Who does what:**
|
|
17
|
+
* * Ordinary typing and deleting are left to the browser.
|
|
18
|
+
* That keeps spell check, IME, autocorrect, etc. working.
|
|
19
|
+
* After each `input` event we inspect the DOM and repair anything we don't like.
|
|
20
|
+
* * Everything else (paste, cut, copy, Enter, undo, insert-a-format) is done by us.
|
|
21
|
+
* We read the DOM into an array of {@link Unit}s, edit the array, and rebuild the DOM.
|
|
22
|
+
* Selections are saved and restored as *logical offsets*:
|
|
23
|
+
* each character counts as 1 and each chip counts as 1.
|
|
24
|
+
*
|
|
25
|
+
* **Invariants, restored after every change:**
|
|
26
|
+
* * The top level of {@link element} only contains text nodes, chips, and possibly a trailing {@link SENTINEL_CLASS} `<br>`.
|
|
27
|
+
* * The first unit is always a format.
|
|
28
|
+
* * Content never contains `⸨` or `⸩`.
|
|
29
|
+
*
|
|
30
|
+
* Because we rebuild the DOM ourselves, the browser's built in undo would be confused.
|
|
31
|
+
* So we keep our own undo history and intercept the undo and redo commands.
|
|
32
|
+
*
|
|
33
|
+
* Copied from https://github.com/TradeIdeasPhilip/format-editor-prototype.
|
|
34
|
+
* Added here: **New…** at the end of each menu (see {@link FormatEditorOptions.onNewFormat})
|
|
35
|
+
* and {@link destroy}.
|
|
36
|
+
*/
|
|
37
|
+
export class FormatEditor {
|
|
38
|
+
/**
|
|
39
|
+
* The editable area. Put this wherever you like.
|
|
40
|
+
*/
|
|
41
|
+
readonly element = document.createElement("div");
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Called after every change to {@link value}, whatever caused it.
|
|
45
|
+
*/
|
|
46
|
+
onChange: ((value: string) => void) | undefined;
|
|
47
|
+
|
|
48
|
+
#knownNames: readonly string[] = [];
|
|
49
|
+
/**
|
|
50
|
+
* If the user deletes the leading format, we put this one back.
|
|
51
|
+
* Usually this is the name of the format the user just deleted.
|
|
52
|
+
*/
|
|
53
|
+
#leadingName: string;
|
|
54
|
+
/**
|
|
55
|
+
* Chips that we created and still trust.
|
|
56
|
+
* Anything else that looks like a chip came from somewhere unexpected, so we rebuild the DOM.
|
|
57
|
+
*/
|
|
58
|
+
readonly #chips = new WeakSet<HTMLElement>();
|
|
59
|
+
/**
|
|
60
|
+
* Where the selection was the last time it was inside the editor.
|
|
61
|
+
* Clicking a button elsewhere on the page might clear the real selection.
|
|
62
|
+
*/
|
|
63
|
+
#lastSelection: Offsets | undefined;
|
|
64
|
+
readonly #history: Snapshot[] = [];
|
|
65
|
+
#historyIndex = 0;
|
|
66
|
+
readonly #onNewFormat: FormatEditorOptions["onNewFormat"];
|
|
67
|
+
/** Removes the listeners that aren't on {@link element}. See {@link destroy}. */
|
|
68
|
+
readonly #abort = new AbortController();
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param initialValue A string in the `⸨format⸩content` format.
|
|
72
|
+
* @param knownNames The format names that will *not* be drawn in red.
|
|
73
|
+
*/
|
|
74
|
+
constructor(
|
|
75
|
+
initialValue: string,
|
|
76
|
+
knownNames: readonly string[],
|
|
77
|
+
options: FormatEditorOptions = {},
|
|
78
|
+
) {
|
|
79
|
+
this.#onNewFormat = options.onNewFormat;
|
|
80
|
+
this.#knownNames = knownNames;
|
|
81
|
+
this.#leadingName = knownNames[0] ?? "";
|
|
82
|
+
const element = this.element;
|
|
83
|
+
element.className = "format-editor";
|
|
84
|
+
element.contentEditable = "true";
|
|
85
|
+
element.spellcheck = false;
|
|
86
|
+
element.addEventListener("beforeinput", (event) =>
|
|
87
|
+
this.#onBeforeInput(event),
|
|
88
|
+
);
|
|
89
|
+
element.addEventListener("input", (event) => {
|
|
90
|
+
// A <select> inside a chip also sends input events, and they bubble up to here.
|
|
91
|
+
if (event.target === element) {
|
|
92
|
+
this.#repairDom();
|
|
93
|
+
this.#commit("typing");
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
element.addEventListener("keydown", (event) => this.#onKeyDown(event));
|
|
97
|
+
element.addEventListener("copy", (event) => this.#onCopy(event));
|
|
98
|
+
element.addEventListener("cut", (event) => {
|
|
99
|
+
const selection = this.#getSelection();
|
|
100
|
+
if (this.#onCopy(event) && selection) {
|
|
101
|
+
this.#replace(selection, [], "cut");
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
element.addEventListener("paste", (event) => {
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
const text = event.clipboardData?.getData("text/plain") ?? "";
|
|
107
|
+
this.#replace(this.#getSelection(), parse(text), "paste");
|
|
108
|
+
});
|
|
109
|
+
// Drag and drop would need its own version of all the cut and paste logic,
|
|
110
|
+
// so it's turned off.
|
|
111
|
+
for (const type of ["dragstart", "dragover", "drop"]) {
|
|
112
|
+
element.addEventListener(type, (event) => event.preventDefault());
|
|
113
|
+
}
|
|
114
|
+
document.addEventListener(
|
|
115
|
+
"selectionchange",
|
|
116
|
+
() => {
|
|
117
|
+
const selection = this.#getSelection();
|
|
118
|
+
if (selection) {
|
|
119
|
+
this.#lastSelection = selection;
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
{ signal: this.#abort.signal },
|
|
123
|
+
);
|
|
124
|
+
const units = parse(initialValue);
|
|
125
|
+
this.#ensureLeadingFormat(units);
|
|
126
|
+
this.#render(units);
|
|
127
|
+
this.#history.push({
|
|
128
|
+
value: this.value,
|
|
129
|
+
selection: { start: 1, end: 1 },
|
|
130
|
+
kind: "initial",
|
|
131
|
+
time: 0,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The current contents, in the format expected by `MultiTextComponent.addText1()`.
|
|
137
|
+
*/
|
|
138
|
+
get value(): string {
|
|
139
|
+
return serialize(this.#units());
|
|
140
|
+
}
|
|
141
|
+
set value(newValue: string) {
|
|
142
|
+
this.#replace({ start: 0, end: this.#units().length }, parse(newValue), "set");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
get knownNames(): readonly string[] {
|
|
146
|
+
return this.#knownNames;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Changing the list of known names immediately updates which chips are red.
|
|
150
|
+
* It does not change {@link value} and it is not recorded in the undo history.
|
|
151
|
+
*/
|
|
152
|
+
set knownNames(newValue: readonly string[]) {
|
|
153
|
+
this.#knownNames = [...newValue];
|
|
154
|
+
for (const chip of this.#chipElements()) {
|
|
155
|
+
this.#refreshChip(chip);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Insert a new format at the cursor and open its menu.
|
|
161
|
+
*
|
|
162
|
+
* The new format starts with the same name as the format that was already
|
|
163
|
+
* in effect at the cursor, so the text doesn't change appearance until the user picks something.
|
|
164
|
+
* @param name Use this name instead of the default.
|
|
165
|
+
*/
|
|
166
|
+
insertFormat(name?: string) {
|
|
167
|
+
const selection = this.#getSelection() ??
|
|
168
|
+
this.#lastSelection ?? {
|
|
169
|
+
start: this.#units().length,
|
|
170
|
+
end: this.#units().length,
|
|
171
|
+
};
|
|
172
|
+
name ??=
|
|
173
|
+
formatInEffect(this.#units(), selection.start) ?? this.#leadingName;
|
|
174
|
+
this.#replace(selection, [{ format: name }], "insert format");
|
|
175
|
+
// The new chip is immediately before the cursor.
|
|
176
|
+
const cursor = this.#getSelection();
|
|
177
|
+
const chip = cursor && this.#chipElements()[this.#chipIndexBefore(cursor.start)];
|
|
178
|
+
const select = chip?.querySelector("select");
|
|
179
|
+
if (select) {
|
|
180
|
+
select.focus();
|
|
181
|
+
try {
|
|
182
|
+
// Chrome & Firefox allow this during a click.
|
|
183
|
+
select.showPicker();
|
|
184
|
+
} catch {
|
|
185
|
+
// Not supported. The select still has focus, so the arrow keys work.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
undo() {
|
|
191
|
+
if (this.#historyIndex > 0) {
|
|
192
|
+
this.#historyIndex--;
|
|
193
|
+
this.#restore(this.#history[this.#historyIndex]);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
redo() {
|
|
198
|
+
if (this.#historyIndex < this.#history.length - 1) {
|
|
199
|
+
this.#historyIndex++;
|
|
200
|
+
this.#restore(this.#history[this.#historyIndex]);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Call when {@link element} is gone for good. It stops listening to the document. */
|
|
205
|
+
destroy() {
|
|
206
|
+
this.#abort.abort();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// MARK: Events
|
|
210
|
+
|
|
211
|
+
#onBeforeInput(event: InputEvent) {
|
|
212
|
+
const type = event.inputType;
|
|
213
|
+
if (type == "insertParagraph" || type == "insertLineBreak") {
|
|
214
|
+
// Left alone, the browser would add <div> or <br> elements.
|
|
215
|
+
event.preventDefault();
|
|
216
|
+
this.#replace(this.#getSelection(), ["\n"], "typing");
|
|
217
|
+
} else if (type == "historyUndo") {
|
|
218
|
+
event.preventDefault();
|
|
219
|
+
this.undo();
|
|
220
|
+
} else if (type == "historyRedo") {
|
|
221
|
+
event.preventDefault();
|
|
222
|
+
this.redo();
|
|
223
|
+
} else if (type.startsWith("format")) {
|
|
224
|
+
// Ctrl-B, Ctrl-I, etc. This is not that kind of editor.
|
|
225
|
+
event.preventDefault();
|
|
226
|
+
} else if (type == "insertText" && event.data != null) {
|
|
227
|
+
const selection = this.#getSelection();
|
|
228
|
+
if (selection && selection.start == 0) {
|
|
229
|
+
// The cursor is *before* the leading format.
|
|
230
|
+
// If we let the browser insert the text there, the repair code would have to
|
|
231
|
+
// add another format in front of it. Let's put the text where the user meant it.
|
|
232
|
+
event.preventDefault();
|
|
233
|
+
this.#replace(selection, pushContent([], event.data), "typing");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
#onKeyDown(event: KeyboardEvent) {
|
|
239
|
+
// Most browsers would report these as historyUndo or historyRedo in beforeinput,
|
|
240
|
+
// but not all of them, and not when the focus is in a chip.
|
|
241
|
+
if (!(event.ctrlKey || event.metaKey) || event.altKey) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const key = event.key.toLowerCase();
|
|
245
|
+
if (key == "z" && !event.shiftKey) {
|
|
246
|
+
event.preventDefault();
|
|
247
|
+
this.undo();
|
|
248
|
+
} else if ((key == "z" && event.shiftKey) || key == "y") {
|
|
249
|
+
event.preventDefault();
|
|
250
|
+
this.redo();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Put the selected units on the clipboard, in the `⸨format⸩content` format.
|
|
256
|
+
* @returns true if there was something to copy.
|
|
257
|
+
*/
|
|
258
|
+
#onCopy(event: ClipboardEvent): boolean {
|
|
259
|
+
const range = this.#getRange();
|
|
260
|
+
if (!range || range.collapsed) {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
event.preventDefault();
|
|
264
|
+
const units = unitsFromNodes(range.cloneContents());
|
|
265
|
+
event.clipboardData?.setData("text/plain", serialize(units));
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// MARK: Model ↔ DOM
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Read the current state of the DOM.
|
|
273
|
+
* This works even if the browser has added unexpected elements.
|
|
274
|
+
*/
|
|
275
|
+
#units(): Unit[] {
|
|
276
|
+
return unitsFromNodes(this.element);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Replace the entire DOM to match `units`.
|
|
281
|
+
* This does not touch the selection.
|
|
282
|
+
*/
|
|
283
|
+
#render(units: readonly Unit[]) {
|
|
284
|
+
const nodes: Node[] = [];
|
|
285
|
+
let pendingText = "";
|
|
286
|
+
const flushText = () => {
|
|
287
|
+
if (pendingText != "") {
|
|
288
|
+
nodes.push(document.createTextNode(pendingText));
|
|
289
|
+
pendingText = "";
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
for (const unit of units) {
|
|
293
|
+
if (isFormat(unit)) {
|
|
294
|
+
flushText();
|
|
295
|
+
nodes.push(this.#createChip(unit.format));
|
|
296
|
+
} else {
|
|
297
|
+
pendingText += unit;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
flushText();
|
|
301
|
+
if (units.at(-1) == "\n") {
|
|
302
|
+
nodes.push(createSentinel());
|
|
303
|
+
}
|
|
304
|
+
this.element.replaceChildren(...nodes);
|
|
305
|
+
const first = units[0];
|
|
306
|
+
if (isFormat(first)) {
|
|
307
|
+
this.#leadingName = first.format;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The browser has finished editing the DOM.
|
|
313
|
+
* Fix anything that breaks our invariants, while keeping the cursor in the same logical place.
|
|
314
|
+
*/
|
|
315
|
+
#repairDom() {
|
|
316
|
+
const units = this.#units();
|
|
317
|
+
const clean = this.#domIsClean(units);
|
|
318
|
+
const addedLeadingFormat = this.#ensureLeadingFormat(units);
|
|
319
|
+
if (addedLeadingFormat == 0 && clean) {
|
|
320
|
+
const first = units[0];
|
|
321
|
+
if (isFormat(first)) {
|
|
322
|
+
this.#leadingName = first.format;
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const selection = this.#getSelection();
|
|
327
|
+
this.#render(units);
|
|
328
|
+
if (selection) {
|
|
329
|
+
this.#setSelection({
|
|
330
|
+
start: selection.start + addedLeadingFormat,
|
|
331
|
+
end: selection.end + addedLeadingFormat,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The workhorse for every edit that we do ourselves.
|
|
338
|
+
* Replace part of the contents, put the cursor after the new stuff, and record the change.
|
|
339
|
+
* @param selection What to replace. If this is undefined, nothing happens.
|
|
340
|
+
* This happens, e.g., if the user pastes while the focus is in a `<select>`.
|
|
341
|
+
* @param inserted What to put in its place.
|
|
342
|
+
* @param kind For the undo history.
|
|
343
|
+
*/
|
|
344
|
+
#replace(
|
|
345
|
+
selection: Offsets | undefined,
|
|
346
|
+
inserted: readonly Unit[],
|
|
347
|
+
kind: string,
|
|
348
|
+
) {
|
|
349
|
+
if (!selection) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
let { start, end } = selection;
|
|
353
|
+
const units = this.#units();
|
|
354
|
+
if (start == 0 && end == 0 && units.length > 0 && !isFormat(inserted[0])) {
|
|
355
|
+
// Don't put text in front of the leading format.
|
|
356
|
+
start = end = 1;
|
|
357
|
+
}
|
|
358
|
+
units.splice(start, end - start, ...inserted);
|
|
359
|
+
const cursor = start + inserted.length + this.#ensureLeadingFormat(units);
|
|
360
|
+
this.#render(units);
|
|
361
|
+
this.#setSelection({ start: cursor, end: cursor });
|
|
362
|
+
this.#commit(kind);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Add a format to the front of `units` if it doesn't already start with one.
|
|
367
|
+
* @returns The number of units added, 0 or 1.
|
|
368
|
+
*/
|
|
369
|
+
#ensureLeadingFormat(units: Unit[]): number {
|
|
370
|
+
if (isFormat(units[0])) {
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
units.unshift({ format: this.#leadingName });
|
|
374
|
+
return 1;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Is the top level DOM exactly what {@link #render} would have created?
|
|
379
|
+
* @param units The current contents, already read from the DOM.
|
|
380
|
+
*/
|
|
381
|
+
#domIsClean(units: readonly Unit[]): boolean {
|
|
382
|
+
const children = this.element.childNodes;
|
|
383
|
+
let previousWasText = false;
|
|
384
|
+
for (let i = 0; i < children.length; i++) {
|
|
385
|
+
const child = children[i];
|
|
386
|
+
const isText = child instanceof Text;
|
|
387
|
+
if (isText) {
|
|
388
|
+
if (previousWasText || /[⸨⸩\r]/.test(child.data)) {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
} else if (child instanceof HTMLElement && this.#chips.has(child)) {
|
|
392
|
+
if (!(child.firstElementChild instanceof HTMLSelectElement)) {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
} else if (
|
|
396
|
+
!(child instanceof HTMLElement) ||
|
|
397
|
+
!child.classList.contains(SENTINEL_CLASS) ||
|
|
398
|
+
i != children.length - 1
|
|
399
|
+
) {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
previousWasText = isText;
|
|
403
|
+
}
|
|
404
|
+
const hasSentinel =
|
|
405
|
+
this.element.lastElementChild?.classList.contains(SENTINEL_CLASS) ??
|
|
406
|
+
false;
|
|
407
|
+
return hasSentinel == (units.at(-1) == "\n");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// MARK: Chips
|
|
411
|
+
|
|
412
|
+
#createChip(name: string): HTMLElement {
|
|
413
|
+
const chip = document.createElement("span");
|
|
414
|
+
chip.className = CHIP_CLASS;
|
|
415
|
+
chip.contentEditable = "false";
|
|
416
|
+
chip.dataset.name = name;
|
|
417
|
+
const select = document.createElement("select");
|
|
418
|
+
select.title = "Format";
|
|
419
|
+
// A menu sends "change" only when the user picks something, which is exactly when to act.
|
|
420
|
+
select.addEventListener("change", () => {
|
|
421
|
+
if (select.value !== NEW_FORMAT_VALUE) {
|
|
422
|
+
this.#setChipName(chip, select.value);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
// Show the current name until the user has picked a new one.
|
|
426
|
+
const currentName = chip.dataset.name ?? "";
|
|
427
|
+
select.value = currentName;
|
|
428
|
+
void this.#onNewFormat?.(currentName).then((name) => {
|
|
429
|
+
// The chip might have been deleted while the user was typing the name.
|
|
430
|
+
if (name !== undefined && chip.isConnected) {
|
|
431
|
+
this.#setChipName(chip, name);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
chip.append(select);
|
|
436
|
+
this.#chips.add(chip);
|
|
437
|
+
this.#refreshChip(chip);
|
|
438
|
+
return chip;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
#setChipName(chip: HTMLElement, name: string) {
|
|
442
|
+
chip.dataset.name = name;
|
|
443
|
+
this.#refreshChip(chip);
|
|
444
|
+
if (chip === this.element.firstChild) {
|
|
445
|
+
this.#leadingName = name;
|
|
446
|
+
}
|
|
447
|
+
// Send the user back to typing, just after this chip.
|
|
448
|
+
this.element.focus();
|
|
449
|
+
const index = this.#chipElements().indexOf(chip);
|
|
450
|
+
this.#setSelection(this.#selectionAfterChip(index));
|
|
451
|
+
this.#commit("change format");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Update the options and the color of a chip to match its name and the list of known names.
|
|
456
|
+
*
|
|
457
|
+
* An unknown name is drawn in red, and it is added at the top of the menu,
|
|
458
|
+
* so the menu can still show the current value.
|
|
459
|
+
* As soon as the user picks a known name, the unknown one disappears.
|
|
460
|
+
*
|
|
461
|
+
* With {@link FormatEditorOptions.onNewFormat}, the last item is **New…**.
|
|
462
|
+
*/
|
|
463
|
+
#refreshChip(chip: HTMLElement) {
|
|
464
|
+
const select = chip.querySelector("select")!;
|
|
465
|
+
const name = chip.dataset.name ?? "";
|
|
466
|
+
const valid = this.#knownNames.includes(name);
|
|
467
|
+
const options = this.#knownNames.map((known) => createOption(known));
|
|
468
|
+
if (!valid) {
|
|
469
|
+
const option = createOption(name);
|
|
470
|
+
option.classList.add("invalid");
|
|
471
|
+
options.unshift(option);
|
|
472
|
+
}
|
|
473
|
+
if (this.#onNewFormat) {
|
|
474
|
+
options.push(new Option("New…", NEW_FORMAT_VALUE));
|
|
475
|
+
}
|
|
476
|
+
select.replaceChildren(...options);
|
|
477
|
+
select.value = name;
|
|
478
|
+
chip.classList.toggle("invalid", !valid);
|
|
479
|
+
chip.title = valid ? "" : `Unknown format: “${name}”`;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
#chipElements(): HTMLElement[] {
|
|
483
|
+
return Array.from(this.element.children).filter(
|
|
484
|
+
(child): child is HTMLElement =>
|
|
485
|
+
child instanceof HTMLElement && this.#chips.has(child),
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* @returns The index (among chips only) of the last chip before `offset`.
|
|
491
|
+
*/
|
|
492
|
+
#chipIndexBefore(offset: number): number {
|
|
493
|
+
return (
|
|
494
|
+
this.#units()
|
|
495
|
+
.slice(0, offset)
|
|
496
|
+
.filter((unit) => isFormat(unit)).length - 1
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
#selectionAfterChip(chipIndex: number): Offsets {
|
|
501
|
+
const units = this.#units();
|
|
502
|
+
let seen = -1;
|
|
503
|
+
for (let i = 0; i < units.length; i++) {
|
|
504
|
+
if (isFormat(units[i])) {
|
|
505
|
+
seen++;
|
|
506
|
+
if (seen == chipIndex) {
|
|
507
|
+
return { start: i + 1, end: i + 1 };
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return { start: units.length, end: units.length };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// MARK: Selection
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* @returns The DOM selection, but only if it's entirely within the editor.
|
|
518
|
+
*/
|
|
519
|
+
#getRange(): Range | undefined {
|
|
520
|
+
const selection = document.getSelection();
|
|
521
|
+
if (!selection || selection.rangeCount == 0) {
|
|
522
|
+
return undefined;
|
|
523
|
+
}
|
|
524
|
+
const range = selection.getRangeAt(0);
|
|
525
|
+
if (
|
|
526
|
+
!this.element.contains(range.startContainer) ||
|
|
527
|
+
!this.element.contains(range.endContainer)
|
|
528
|
+
) {
|
|
529
|
+
return undefined;
|
|
530
|
+
}
|
|
531
|
+
return range;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* @returns The current selection as logical offsets, or undefined if the selection is not in the editor.
|
|
536
|
+
*/
|
|
537
|
+
#getSelection(): Offsets | undefined {
|
|
538
|
+
const range = this.#getRange();
|
|
539
|
+
if (!range) {
|
|
540
|
+
return undefined;
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
start: this.#offsetOf(range.startContainer, range.startOffset),
|
|
544
|
+
end: this.#offsetOf(range.endContainer, range.endOffset),
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Convert a DOM position into a logical offset.
|
|
550
|
+
*
|
|
551
|
+
* This copies everything from the start of the editor up to the given point, then counts the units.
|
|
552
|
+
* That's not the fastest approach, but it gives exactly the same answer as {@link #units},
|
|
553
|
+
* no matter how messy the DOM is. A point inside a chip counts as being after that chip.
|
|
554
|
+
*/
|
|
555
|
+
#offsetOf(node: Node, offset: number): number {
|
|
556
|
+
const range = document.createRange();
|
|
557
|
+
range.setStart(this.element, 0);
|
|
558
|
+
range.setEnd(node, offset);
|
|
559
|
+
return unitsFromNodes(range.cloneContents()).length;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Convert a logical offset into a DOM position.
|
|
564
|
+
* This assumes that the DOM was just created by {@link #render}.
|
|
565
|
+
*/
|
|
566
|
+
#pointAt(offset: number): [Node, number] {
|
|
567
|
+
const children = this.element.childNodes;
|
|
568
|
+
let remaining = offset;
|
|
569
|
+
for (let i = 0; i < children.length; i++) {
|
|
570
|
+
const child = children[i];
|
|
571
|
+
if (child instanceof Text) {
|
|
572
|
+
if (remaining <= child.length) {
|
|
573
|
+
return [child, remaining];
|
|
574
|
+
}
|
|
575
|
+
remaining -= child.length;
|
|
576
|
+
} else if (this.#chips.has(child as HTMLElement)) {
|
|
577
|
+
if (remaining == 0) {
|
|
578
|
+
return [this.element, i];
|
|
579
|
+
}
|
|
580
|
+
remaining--;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const last = this.element.lastElementChild;
|
|
584
|
+
const end =
|
|
585
|
+
children.length - (last?.classList.contains(SENTINEL_CLASS) ? 1 : 0);
|
|
586
|
+
return [this.element, end];
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
#setSelection({ start, end }: Offsets) {
|
|
590
|
+
const selection = document.getSelection();
|
|
591
|
+
if (!selection) {
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const range = document.createRange();
|
|
595
|
+
range.setStart(...this.#pointAt(start));
|
|
596
|
+
range.setEnd(...this.#pointAt(end));
|
|
597
|
+
selection.removeAllRanges();
|
|
598
|
+
selection.addRange(range);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// MARK: History
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Record the current state for undo, and tell the listener.
|
|
605
|
+
*
|
|
606
|
+
* Consecutive typing is merged into a single undo step,
|
|
607
|
+
* unless the user pauses for a while.
|
|
608
|
+
*/
|
|
609
|
+
#commit(kind: string) {
|
|
610
|
+
const value = this.value;
|
|
611
|
+
const selection = this.#getSelection() ?? { start: 0, end: 0 };
|
|
612
|
+
const time = performance.now();
|
|
613
|
+
const top = this.#history[this.#historyIndex];
|
|
614
|
+
if (top.value == value) {
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
this.#history.length = this.#historyIndex + 1;
|
|
618
|
+
const snapshot = { value, selection, kind, time };
|
|
619
|
+
if (
|
|
620
|
+
kind == "typing" &&
|
|
621
|
+
top.kind == "typing" &&
|
|
622
|
+
time - top.time < TYPING_MERGE_MS &&
|
|
623
|
+
this.#historyIndex > 0
|
|
624
|
+
) {
|
|
625
|
+
this.#history[this.#historyIndex] = snapshot;
|
|
626
|
+
} else {
|
|
627
|
+
this.#history.push(snapshot);
|
|
628
|
+
this.#historyIndex++;
|
|
629
|
+
}
|
|
630
|
+
this.onChange?.(value);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
#restore(snapshot: Snapshot) {
|
|
634
|
+
const units = parse(snapshot.value);
|
|
635
|
+
this.#ensureLeadingFormat(units);
|
|
636
|
+
this.#render(units);
|
|
637
|
+
this.element.focus();
|
|
638
|
+
this.#setSelection(snapshot.selection);
|
|
639
|
+
this.onChange?.(snapshot.value);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export type FormatEditorOptions = {
|
|
644
|
+
/**
|
|
645
|
+
* The user picked **New…** in the menu of a chip whose format is `currentName`.
|
|
646
|
+
* Make the new format, add it to {@link FormatEditor.knownNames}, and resolve to its name,
|
|
647
|
+
* which the chip then uses. Resolve to undefined if the user cancelled.
|
|
648
|
+
*
|
|
649
|
+
* Without this, the menus have no New….
|
|
650
|
+
*/
|
|
651
|
+
readonly onNewFormat?: (currentName: string) => Promise<string | undefined>;
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
/** The value of the New… menu item. It contains ⸨, so it can never be a format's name. */
|
|
655
|
+
const NEW_FORMAT_VALUE = "⸨new⸩";
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Logical offsets. See {@link FormatEditor}.
|
|
659
|
+
*/
|
|
660
|
+
type Offsets = { readonly start: number; readonly end: number };
|
|
661
|
+
|
|
662
|
+
type Snapshot = {
|
|
663
|
+
readonly value: string;
|
|
664
|
+
/**
|
|
665
|
+
* Where to put the cursor if we go back to this state.
|
|
666
|
+
*/
|
|
667
|
+
readonly selection: Offsets;
|
|
668
|
+
readonly kind: string;
|
|
669
|
+
readonly time: number;
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
const TYPING_MERGE_MS = 1000;
|
|
673
|
+
|
|
674
|
+
const CHIP_CLASS = "format-chip";
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* A `<br>` at the very end of the editor.
|
|
678
|
+
* Without this, a trailing newline would not be visible and the cursor could not get past it.
|
|
679
|
+
* This is not part of the value.
|
|
680
|
+
*/
|
|
681
|
+
const SENTINEL_CLASS = "trailing-newline";
|
|
682
|
+
|
|
683
|
+
function createSentinel() {
|
|
684
|
+
const br = document.createElement("br");
|
|
685
|
+
br.className = SENTINEL_CLASS;
|
|
686
|
+
return br;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function createOption(name: string) {
|
|
690
|
+
// An empty name is legal but would make an invisible menu item.
|
|
691
|
+
return new Option(name == "" ? "(empty)" : name, name);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const BLOCK_ELEMENTS = new Set(["DIV", "P", "LI", "PRE", "BLOCKQUOTE"]);
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Convert DOM nodes into units.
|
|
698
|
+
*
|
|
699
|
+
* This understands our chips, but it also does something reasonable with
|
|
700
|
+
* whatever else the browser might have added, like `<br>` or `<div>`.
|
|
701
|
+
* It is used on the editor itself and on copies of the current selection.
|
|
702
|
+
* @param parent Read all of the children of this node.
|
|
703
|
+
* @param result Append to this.
|
|
704
|
+
* @returns `result`.
|
|
705
|
+
*/
|
|
706
|
+
function unitsFromNodes(parent: Node, result: Unit[] = []): Unit[] {
|
|
707
|
+
for (const node of parent.childNodes) {
|
|
708
|
+
if (node instanceof Text) {
|
|
709
|
+
pushContent(result, node.data);
|
|
710
|
+
} else if (node instanceof HTMLElement) {
|
|
711
|
+
if (node.classList.contains(CHIP_CLASS)) {
|
|
712
|
+
result.push({ format: node.dataset.name ?? "" });
|
|
713
|
+
} else if (node.classList.contains(SENTINEL_CLASS)) {
|
|
714
|
+
// Display only.
|
|
715
|
+
} else if (node.tagName == "BR") {
|
|
716
|
+
// A <br> at the end of a block doesn't make a visible line.
|
|
717
|
+
// Chrome adds one as a placeholder when you delete everything.
|
|
718
|
+
if (node.nextSibling) {
|
|
719
|
+
result.push("\n");
|
|
720
|
+
}
|
|
721
|
+
} else {
|
|
722
|
+
if (
|
|
723
|
+
BLOCK_ELEMENTS.has(node.tagName) &&
|
|
724
|
+
result.length > 0 &&
|
|
725
|
+
result.at(-1) != "\n"
|
|
726
|
+
) {
|
|
727
|
+
result.push("\n");
|
|
728
|
+
}
|
|
729
|
+
unitsFromNodes(node, result);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return result;
|
|
734
|
+
}
|