bookish-press 0.9.4 → 0.10.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.
Files changed (154) hide show
  1. package/dist/components/Auth.svelte +37 -21
  2. package/dist/components/BookPreview.svelte +4 -4
  3. package/dist/components/BookPreview.svelte.d.ts +1 -1
  4. package/dist/components/Link.svelte +19 -12
  5. package/dist/components/app/Button.svelte +1 -8
  6. package/dist/components/app/Link.svelte +1 -7
  7. package/dist/components/app/Options.svelte +1 -6
  8. package/dist/components/app/TextInput.svelte +2 -8
  9. package/dist/components/chapter/Atom.svelte +2 -1
  10. package/dist/components/chapter/Atom.svelte.d.ts +1 -1
  11. package/dist/components/chapter/Block.svelte +9 -9
  12. package/dist/components/chapter/Block.svelte.d.ts +1 -1
  13. package/dist/components/chapter/Callout.svelte.d.ts +1 -1
  14. package/dist/components/chapter/CaptionedCode.svelte +1 -0
  15. package/dist/components/chapter/CaptionedCode.svelte.d.ts +1 -1
  16. package/dist/components/chapter/ChapterBody.svelte +5 -2
  17. package/dist/components/chapter/ChapterBody.svelte.d.ts +1 -1
  18. package/dist/components/chapter/ChapterBody.test.js +3 -3
  19. package/dist/components/chapter/Citations.svelte +17 -16
  20. package/dist/components/chapter/Citations.svelte.d.ts +1 -1
  21. package/dist/components/chapter/Code.svelte +8 -14
  22. package/dist/components/chapter/Comment.svelte +7 -12
  23. package/dist/components/chapter/Comment.svelte.d.ts +1 -1
  24. package/dist/components/chapter/Definition.svelte +8 -9
  25. package/dist/components/chapter/Definition.svelte.d.ts +1 -1
  26. package/dist/components/chapter/Embed.svelte +41 -61
  27. package/dist/components/chapter/Embed.svelte.d.ts +1 -1
  28. package/dist/components/chapter/ErrorMessage.svelte.d.ts +1 -1
  29. package/dist/components/chapter/Figure.svelte +14 -14
  30. package/dist/components/chapter/Figure.svelte.d.ts +4 -4
  31. package/dist/components/chapter/Footnote.svelte +11 -17
  32. package/dist/components/chapter/Footnote.svelte.d.ts +1 -1
  33. package/dist/components/chapter/Format.svelte +11 -11
  34. package/dist/components/chapter/Format.svelte.d.ts +1 -1
  35. package/dist/components/chapter/InlineCode.svelte.d.ts +1 -1
  36. package/dist/components/chapter/Label.svelte +1 -1
  37. package/dist/components/chapter/Label.svelte.d.ts +1 -1
  38. package/dist/components/chapter/LineBreak.svelte +1 -0
  39. package/dist/components/chapter/LineBreak.svelte.d.ts +1 -1
  40. package/dist/components/chapter/Link.svelte +14 -12
  41. package/dist/components/chapter/Link.svelte.d.ts +1 -1
  42. package/dist/components/chapter/List.svelte +1 -1
  43. package/dist/components/chapter/List.svelte.d.ts +1 -1
  44. package/dist/components/chapter/Marginal.svelte +36 -42
  45. package/dist/components/chapter/Paragraph.svelte +4 -4
  46. package/dist/components/chapter/Paragraph.svelte.d.ts +1 -1
  47. package/dist/components/chapter/Positioned.svelte +1 -0
  48. package/dist/components/chapter/Positioned.svelte.d.ts +1 -1
  49. package/dist/components/chapter/Python.svelte +42 -31
  50. package/dist/components/chapter/Python.svelte.d.ts +1 -1
  51. package/dist/components/chapter/Quote.svelte.d.ts +1 -1
  52. package/dist/components/chapter/Rule.svelte +1 -0
  53. package/dist/components/chapter/Rule.svelte.d.ts +1 -1
  54. package/dist/components/chapter/Table.svelte.d.ts +1 -1
  55. package/dist/components/chapter/Text.svelte +41 -50
  56. package/dist/components/chapter/Text.svelte.d.ts +1 -1
  57. package/dist/components/controls/DarkToggle.svelte +4 -4
  58. package/dist/components/editor/BookishEditor.svelte +605 -569
  59. package/dist/components/editor/BookishEditor.svelte.d.ts +1 -1
  60. package/dist/components/editor/CalloutEditor.svelte +1 -1
  61. package/dist/components/editor/CalloutEditor.svelte.d.ts +1 -1
  62. package/dist/components/editor/CaptionedCodeEditor.svelte +1 -1
  63. package/dist/components/editor/CaptionedCodeEditor.svelte.d.ts +1 -1
  64. package/dist/components/editor/CaretContext.d.ts +14 -14
  65. package/dist/components/editor/CaretState.d.ts +3 -3
  66. package/dist/components/editor/CaretView.svelte +8 -19
  67. package/dist/components/editor/CitationsEditor.svelte +10 -18
  68. package/dist/components/editor/CitationsEditor.svelte.d.ts +1 -1
  69. package/dist/components/editor/Clipboard.d.ts +1 -1
  70. package/dist/components/editor/Command.d.ts +1 -1
  71. package/dist/components/editor/Commands.js +19 -19
  72. package/dist/components/editor/ConfirmButton.svelte +1 -6
  73. package/dist/components/editor/DefinitionEditor.svelte +9 -7
  74. package/dist/components/editor/DefinitionEditor.svelte.d.ts +1 -1
  75. package/dist/components/editor/EmbedEditor.svelte +25 -34
  76. package/dist/components/editor/EmbedEditor.svelte.d.ts +1 -1
  77. package/dist/components/editor/ImageChooser.svelte +8 -7
  78. package/dist/components/editor/ImageChooser.svelte.d.ts +1 -1
  79. package/dist/components/editor/ImageThumbnail.svelte +21 -18
  80. package/dist/components/editor/InlineCodeEditor.svelte +1 -1
  81. package/dist/components/editor/InlineCodeEditor.svelte.d.ts +1 -1
  82. package/dist/components/editor/LabelEditor.svelte +2 -2
  83. package/dist/components/editor/LabelEditor.svelte.d.ts +1 -1
  84. package/dist/components/editor/LanguageEditor.svelte +8 -1
  85. package/dist/components/editor/LinkEditor.svelte +42 -44
  86. package/dist/components/editor/LinkEditor.svelte.d.ts +1 -1
  87. package/dist/components/editor/Permissions.svelte +23 -29
  88. package/dist/components/editor/PositionEditor.svelte +1 -1
  89. package/dist/components/editor/PositionEditor.svelte.d.ts +1 -1
  90. package/dist/components/editor/QuoteEditor.svelte +1 -1
  91. package/dist/components/editor/QuoteEditor.svelte.d.ts +1 -1
  92. package/dist/components/editor/Switch.svelte +5 -11
  93. package/dist/components/editor/TableEditor.svelte +1 -1
  94. package/dist/components/editor/TableEditor.svelte.d.ts +1 -1
  95. package/dist/components/editor/TextEditor.svelte +61 -58
  96. package/dist/components/editor/Toggle.svelte +16 -14
  97. package/dist/components/editor/Toolbar.svelte +81 -94
  98. package/dist/components/editor/UndoState.d.ts +2 -2
  99. package/dist/components/page/Acknowledgements.svelte +2 -8
  100. package/dist/components/page/Authors.svelte +12 -21
  101. package/dist/components/page/BookLoader.svelte +32 -34
  102. package/dist/components/page/BulkReferenceEditor.svelte +21 -41
  103. package/dist/components/page/Chapter.svelte +139 -114
  104. package/dist/components/page/Chapter.svelte.d.ts +1 -1
  105. package/dist/components/page/ChapterTitle.svelte +1 -1
  106. package/dist/components/page/Contexts.js +3 -1
  107. package/dist/components/page/DefinitionView.svelte +30 -42
  108. package/dist/components/page/DefinitionView.svelte.d.ts +1 -1
  109. package/dist/components/page/Description.svelte +2 -8
  110. package/dist/components/page/DownloadEPUB.svelte +83 -41
  111. package/dist/components/page/EditableHeader.svelte.d.ts +1 -1
  112. package/dist/components/page/Edition.svelte +44 -30
  113. package/dist/components/page/Edition.svelte.d.ts +1 -1
  114. package/dist/components/page/EditionLoader.svelte +101 -100
  115. package/dist/components/page/Editions.svelte +13 -13
  116. package/dist/components/page/Glossary.svelte +9 -10
  117. package/dist/components/page/Header.svelte +15 -28
  118. package/dist/components/page/Index.svelte +17 -18
  119. package/dist/components/page/License.svelte +2 -8
  120. package/dist/components/page/Media.svelte +7 -14
  121. package/dist/components/page/MediaPreview.svelte +14 -17
  122. package/dist/components/page/Outline.svelte +74 -66
  123. package/dist/components/page/Page.svelte +23 -17
  124. package/dist/components/page/PossibleChapter.svelte +2 -2
  125. package/dist/components/page/PossibleReference.svelte +1 -1
  126. package/dist/components/page/PossibleReference.svelte.d.ts +2 -2
  127. package/dist/components/page/Print.svelte +5 -8
  128. package/dist/components/page/Publish.svelte +33 -39
  129. package/dist/components/page/Reference.svelte +9 -11
  130. package/dist/components/page/Reference.svelte.d.ts +1 -1
  131. package/dist/components/page/References.svelte +1 -1
  132. package/dist/components/page/Search.svelte +39 -40
  133. package/dist/components/page/SubdomainEditor.svelte +11 -10
  134. package/dist/components/page/TableOfContents.svelte +52 -48
  135. package/dist/components/page/TableOfContentsImage.svelte +2 -2
  136. package/dist/components/page/TableOfContentsRow.svelte +9 -22
  137. package/dist/components/page/TableOfContentsRow.svelte.d.ts +2 -2
  138. package/dist/components/page/ThemeEditor.svelte +10 -17
  139. package/dist/components/page/ThemeEditorPreview.svelte +3 -6
  140. package/dist/components/page/ThemeEditorPreview.svelte.d.ts +1 -1
  141. package/dist/components/page/ThemeVariableEditor.svelte +6 -6
  142. package/dist/components/page/Unknown.svelte +1 -1
  143. package/dist/epub/build.d.ts +6 -0
  144. package/dist/epub/build.js +16 -3
  145. package/dist/epub/build.test.js +65 -0
  146. package/dist/epub/images.d.ts +7 -5
  147. package/dist/epub/images.js +15 -10
  148. package/dist/epub/images.test.js +8 -6
  149. package/dist/epub/package.js +25 -3
  150. package/dist/models/FirebaseAvailable.d.ts +9 -0
  151. package/dist/models/FirebaseAvailable.js +10 -0
  152. package/dist/models/book/BookMedia.d.ts +2 -1
  153. package/dist/models/book/BookMedia.js +33 -9
  154. package/package.json +18 -14
@@ -1,648 +1,684 @@
1
- <script lang="ts">import BookishNode from "../../models/chapter/Node";
2
- import ChapterNode from "../../models/chapter/ChapterNode";
3
- import LinkNode from "../../models/chapter/LinkNode";
4
- import ParagraphNode from "../../models/chapter/ParagraphNode";
5
- import TextNode from "../../models/chapter/TextNode";
6
- import AtomNode from "../../models/chapter/AtomNode";
7
- import FormatNode from "../../models/chapter/FormatNode";
8
- import BlocksNode from "../../models/chapter/BlocksNode";
9
- import ListNode from "../../models/chapter/ListNode";
10
- import TableNode from "../../models/chapter/TableNode";
11
- import MetadataNode from "../../models/chapter/MetadataNode";
12
- import CodeNode from "../../models/chapter/CodeNode";
13
- import BlockNode from "../../models/chapter/BlockNode";
14
- import EmbedNode from "../../models/chapter/EmbedNode";
15
- import ErrorNode from "../../models/chapter/ErrorNode";
16
- import {
17
- caretRangeToIndexRange,
18
- indexRangeToCaretRange
19
- } from "../../models/chapter/Caret";
1
+ <script lang="ts">import BookishNode from "../../models/chapter/Node.js";
2
+ import ChapterNode from "../../models/chapter/ChapterNode.js";
3
+ import LinkNode from "../../models/chapter/LinkNode.js";
4
+ import ParagraphNode from "../../models/chapter/ParagraphNode.js";
5
+ import TextNode from "../../models/chapter/TextNode.js";
6
+ import AtomNode from "../../models/chapter/AtomNode.js";
7
+ import FormatNode from "../../models/chapter/FormatNode.js";
8
+ import BlocksNode from "../../models/chapter/BlocksNode.js";
9
+ import ListNode from "../../models/chapter/ListNode.js";
10
+ import TableNode from "../../models/chapter/TableNode.js";
11
+ import MetadataNode from "../../models/chapter/MetadataNode.js";
12
+ import CodeNode from "../../models/chapter/CodeNode.js";
13
+ import BlockNode from "../../models/chapter/BlockNode.js";
14
+ import EmbedNode from "../../models/chapter/EmbedNode.js";
15
+ import ErrorNode from "../../models/chapter/ErrorNode.js";
16
+ import { caretRangeToIndexRange, indexRangeToCaretRange } from "../../models/chapter/Caret.js";
20
17
  import commands from "./Commands";
21
18
  import { onMount, setContext, untrack } from "svelte";
22
19
  import { getCaret } from "../page/Contexts";
23
20
  import CaretView from "./CaretView.svelte";
24
- import Parser, { Reserved } from "../../models/chapter/Parser";
21
+ import Parser, { Reserved } from "../../models/chapter/Parser.js";
25
22
  const IDLE_TIME = 500;
26
- let {
27
- text,
28
- parser,
29
- save,
30
- chapter,
31
- autofocus = false,
32
- component,
33
- placeholder,
34
- leasee,
35
- lease,
36
- inline = false
37
- } = $props();
23
+ let { text, parser, save, chapter, autofocus = false, component, placeholder, leasee, lease, inline = false } = $props();
38
24
  let element = $state(null);
39
- let caretRange = $state(void 0);
40
- let caretCoordinate = $state(void 0);
25
+ let caretRange = $state(undefined);
26
+ let caretCoordinate = $state(undefined);
41
27
  let lastInputTime = 0;
42
28
  let keyboardIdle = $state(true);
43
29
  let editorFocused = $state(false);
44
30
  let undoStack = $state([]);
45
31
  let undoPosition = $state(-1);
46
- let clipboard = $state(void 0);
32
+ let clipboard = $state(undefined);
47
33
  let ignoredInput = $state(false);
48
34
  let activeEditor = getCaret();
49
- let accent = $state(void 0);
35
+ let accent = $state(undefined);
36
+ // Note: when the text changes externally, the caret will be lost. We either have
37
+ // to implement fancy operational transform revision control for collaborative editing
38
+ // or a basic chapter locking strategy to avoid data loss. I went with locking for maintenance simplicity,
39
+ // but it would be nice to implement operational transform in the future.
40
+ // Deliberately parses the initial text (untracked); the effect below
41
+ // reparses when the text prop changes externally.
50
42
  let editedNode = $state(untrack(() => parser(text)));
51
43
  $effect.pre(() => {
52
- if (untrack(() => editedNode).toBookdown() !== text) {
53
- editedNode = parser(text);
54
- }
44
+ if (untrack(() => editedNode).toBookdown() !== text) {
45
+ editedNode = parser(text);
46
+ }
55
47
  });
56
48
  let locked = $derived(typeof leasee === "string");
49
+ // The component used to render the edited root node.
57
50
  let RootView = $derived(component);
58
51
  onMount(() => {
59
- const caret = editedNode.getFirstCaret();
60
- if (caret) caretRange = { start: caret, end: caret };
61
- if (element && autofocus === true) {
62
- element.focus();
63
- }
64
- document.addEventListener("selectionchange", handleSelectionChange);
65
- return () => {
66
- document.removeEventListener(
67
- "selectionchange",
68
- handleSelectionChange
69
- );
70
- };
52
+ const caret = editedNode.getFirstCaret();
53
+ if (caret) caretRange = {
54
+ start: caret,
55
+ end: caret
56
+ };
57
+ // Focus the editor on load.
58
+ if (element && autofocus === true) {
59
+ element.focus();
60
+ }
61
+ // Listen to selection changes
62
+ document.addEventListener("selectionchange", handleSelectionChange);
63
+ // Stop listening to selection changes
64
+ return () => {
65
+ document.removeEventListener("selectionchange", handleSelectionChange);
66
+ };
71
67
  });
72
68
  onMount(() => {
73
- const keystrokeTimer = setInterval(() => {
74
- const isIdle = Date.now() - lastInputTime > IDLE_TIME;
75
- if (isIdle) {
76
- keyboardIdle = true;
77
- ignoredInput = false;
78
- }
79
- }, IDLE_TIME);
80
- return () => {
81
- clearTimeout(keystrokeTimer);
82
- };
69
+ // When input happens, set a timer and then see if idle time has elapsed. If it has, track time since last keystroke to control caret blinking behavior.
70
+ const keystrokeTimer = setInterval(() => {
71
+ // Remember that we're idle so we can render things accordingly.
72
+ const isIdle = Date.now() - lastInputTime > IDLE_TIME;
73
+ if (isIdle) {
74
+ keyboardIdle = true;
75
+ ignoredInput = false;
76
+ }
77
+ }, IDLE_TIME);
78
+ return () => {
79
+ // Clear the timer if we're unmounting or re-rendering.
80
+ clearTimeout(keystrokeTimer);
81
+ };
83
82
  });
83
+ // When the selection changes and is rendered, set the browser's selection to correspond. This helps with two things:
84
+ // 1) we can rely on the browser to render selections rather than rendering it ourselves.
85
+ // 2) We can measure the caret position, so we can render our own fancy one rather than relying on inconsistent cross browser behavior.
86
+ // This runs after the DOM updates whenever the selection, content, or focus changes.
84
87
  $effect(() => {
85
- void caretRange;
86
- void editedNode;
87
- if (!editorFocused) return;
88
- let docSelection = document.getSelection();
89
- let newCaretPosition = void 0;
90
- if (docSelection) {
91
- if (caretRange) {
92
- const sortedRange = caretRange;
93
- let startNode = document.querySelector(
94
- `[data-nodeid='${sortedRange.start.node.nodeID}`
95
- );
96
- let endNode = document.querySelector(
97
- `[data-nodeid='${sortedRange.end.node.nodeID}`
98
- );
99
- if (startNode && endNode && element) {
100
- let rangeChanged = true;
101
- let currentRange = void 0;
102
- if (docSelection.rangeCount > 0) {
103
- currentRange = docSelection.getRangeAt(0);
104
- const startIsEqual = startNode.childNodes.length > 0 && currentRange.startContainer === startNode.childNodes[0] && currentRange.startOffset === sortedRange.start.index;
105
- const endIsEqual = endNode.childNodes.length > 0 && currentRange.endContainer === endNode.childNodes[0] && currentRange.endOffset === sortedRange.end.index;
106
- if (startIsEqual && endIsEqual) rangeChanged = false;
107
- }
108
- const emptyStart = startNode.childNodes.length === 0;
109
- const emptyEnd = startNode !== endNode && endNode.childNodes.length === 0;
110
- if (emptyStart)
111
- startNode.appendChild(
112
- document.createTextNode("\uFEFF")
113
- );
114
- if (emptyEnd)
115
- endNode.appendChild(document.createTextNode("\uFEFF"));
116
- const afterNewLine = sortedRange.start.node instanceof TextNode && sortedRange.start.index > 0 && sortedRange.start.node.getText().charAt(sortedRange.start.index - 1) === "\n";
117
- if (rangeChanged) {
118
- try {
119
- docSelection.setBaseAndExtent(
120
- startNode.childNodes[0],
121
- sortedRange.start.index,
122
- endNode.childNodes[0],
123
- sortedRange.end.index
124
- );
125
- } catch (e) {
126
- console.error(e);
127
- console.error(
128
- `Error setting caret range, trying to set to ${startNode.childNodes[0]}:${sortedRange.start.index} - ${endNode.childNodes[0]}:${sortedRange.end.index}`
129
- );
130
- }
131
- currentRange = docSelection.getRangeAt(0);
132
- }
133
- if (currentRange) {
134
- const rangeRect = currentRange.getBoundingClientRect();
135
- const textRect = startNode.getBoundingClientRect();
136
- const lineHeightString = window.getComputedStyle(startNode).getPropertyValue("line-height");
137
- const lineHeight = lineHeightString.endsWith("px") ? parseInt(
138
- lineHeightString.substring(
139
- 0,
140
- lineHeightString.length - 2
141
- )
142
- ) : void 0;
143
- let editorAncestor = element;
144
- let relativeX = 0;
145
- let relativeY = 0;
146
- while (editorAncestor != null) {
147
- const style = window.getComputedStyle(editorAncestor);
148
- if (style.position === "relative") {
149
- relativeX += editorAncestor.offsetLeft;
150
- relativeY += editorAncestor.offsetTop;
151
- }
152
- editorAncestor = editorAncestor.parentElement;
153
- }
154
- const left = afterNewLine ? textRect.left : rangeRect.left;
155
- const top = afterNewLine && lineHeight ? rangeRect.top + lineHeight : rangeRect.top;
156
- const position = {
157
- x: left + window.scrollX - relativeX,
158
- y: top + window.scrollY - relativeY,
159
- height: rangeRect.height
160
- };
161
- newCaretPosition = position;
162
- }
163
- }
164
- } else if (editorFocused) document.getSelection()?.empty();
165
- }
166
- caretCoordinate = newCaretPosition;
88
+ void caretRange;
89
+ void editedNode;
90
+ if (!editorFocused) return;
91
+ // Measure the location of the selection using the browser's selection.
92
+ let docSelection = document.getSelection();
93
+ let newCaretPosition = undefined;
94
+ if (docSelection) {
95
+ if (caretRange) {
96
+ // Browsers don't render backwards ranges, so we sort this before measuring.
97
+ const sortedRange = caretRange;
98
+ let startNode = document.querySelector(`[data-nodeid='${sortedRange.start.node.nodeID}`);
99
+ let endNode = document.querySelector(`[data-nodeid='${sortedRange.end.node.nodeID}`);
100
+ // If we found both selected nodes and we have a reference to the DOM, update the
101
+ // browser's selection if necessary and measure it's position so we can draw our own caret.
102
+ if (startNode && endNode && element) {
103
+ // If the current selection isn't different from the new one, then we don't do any of this.
104
+ // This prevents an infinite loop when, for example, the user clicks, the browser selection changes,
105
+ // and we update the caret range, which updates the browser selection, ad infinitum.
106
+ let rangeChanged = true;
107
+ let currentRange = undefined;
108
+ if (docSelection.rangeCount > 0) {
109
+ currentRange = docSelection.getRangeAt(0);
110
+ // If our caret range is non-empty TextNodes at a particular index and equal to the browser's caret range, then nothing's changed, so we shouldn't update the selection.
111
+ const startIsEqual = startNode.childNodes.length > 0 && currentRange.startContainer === startNode.childNodes[0] && currentRange.startOffset === sortedRange.start.index;
112
+ const endIsEqual = endNode.childNodes.length > 0 && currentRange.endContainer === endNode.childNodes[0] && currentRange.endOffset === sortedRange.end.index;
113
+ if (startIsEqual && endIsEqual) rangeChanged = false;
114
+ }
115
+ // We always aim for the DOM text nodes inside of bookish-text, but when the text nodes are the empty string, React doesn't
116
+ // render a DOM text node, and browsers often render an empty bounding box in this case. To measure the caret position,
117
+ // we insert a zero-width space in the empty span, then remove it after measurement.
118
+ // We only do this to the end node if it's different from the start node.
119
+ // We leave them in there so that the "rangeChanged" guard below can track them.
120
+ const emptyStart = startNode.childNodes.length === 0;
121
+ const emptyEnd = startNode !== endNode && endNode.childNodes.length === 0;
122
+ if (emptyStart) startNode.appendChild(document.createTextNode(""));
123
+ if (emptyEnd) endNode.appendChild(document.createTextNode(""));
124
+ // If we're just after a new line, set the selection to the character after.
125
+ const afterNewLine = sortedRange.start.node instanceof TextNode && sortedRange.start.index > 0 && sortedRange.start.node.getText().charAt(sortedRange.start.index - 1) === "\n";
126
+ // If the range has changed, set the selection so we can measure it's location.
127
+ // We guard this to avoid triggering unnecessary selection change events that don't actually modify the selection.
128
+ if (rangeChanged) {
129
+ try {
130
+ docSelection.setBaseAndExtent(startNode.childNodes[0], sortedRange.start.index, endNode.childNodes[0], sortedRange.end.index);
131
+ } catch (e) {
132
+ console.error(e);
133
+ console.error(`Error setting caret range, trying to set to ${startNode.childNodes[0]}:${sortedRange.start.index} - ${endNode.childNodes[0]}:${sortedRange.end.index}`);
134
+ }
135
+ currentRange = docSelection.getRangeAt(0);
136
+ }
137
+ // Measure and remember caret position
138
+ if (currentRange) {
139
+ const rangeRect = currentRange.getBoundingClientRect();
140
+ const textRect = startNode.getBoundingClientRect();
141
+ const lineHeightString = window.getComputedStyle(startNode).getPropertyValue("line-height");
142
+ const lineHeight = lineHeightString.endsWith("px") ? parseInt(lineHeightString.substring(0, lineHeightString.length - 2)) : undefined;
143
+ // Figure out the relative position of this editor.
144
+ let editorAncestor = element;
145
+ let relativeX = 0;
146
+ let relativeY = 0;
147
+ while (editorAncestor != null) {
148
+ const style = window.getComputedStyle(editorAncestor);
149
+ if (style.position === "relative") {
150
+ relativeX += editorAncestor.offsetLeft;
151
+ relativeY += editorAncestor.offsetTop;
152
+ }
153
+ editorAncestor = editorAncestor.parentElement;
154
+ }
155
+ // If we're after a new line, calculate the correct position, since selections don't actually render to the next line.
156
+ const left = afterNewLine ? textRect.left : rangeRect.left;
157
+ const top = afterNewLine && lineHeight ? rangeRect.top + lineHeight : rangeRect.top;
158
+ // Compute the final position of the caret.
159
+ const position = {
160
+ x: left + window.scrollX - relativeX,
161
+ y: top + window.scrollY - relativeY,
162
+ height: rangeRect.height
163
+ };
164
+ newCaretPosition = position;
165
+ }
166
+ }
167
+ } else if (editorFocused) document.getSelection()?.empty();
168
+ }
169
+ // Save the new caret position so we render it.
170
+ caretCoordinate = newCaretPosition;
167
171
  });
168
172
  function rangeToCaret(domNode, rangeIndex) {
169
- if (domNode.nodeType === Node.TEXT_NODE) {
170
- let parent = domNode.parentNode;
171
- while (parent && !(parent instanceof HTMLElement && parent.classList.contains("bookish-text")))
172
- parent = parent.parentNode;
173
- if (parent && parent.dataset.nodeid) {
174
- const node = editedNode.getNode(
175
- parseInt(parent.dataset.nodeid)
176
- );
177
- if (node instanceof TextNode)
178
- return {
179
- node,
180
- index: Math.min(rangeIndex, node.getLength())
181
- };
182
- }
183
- } else if (domNode.nodeType === Node.ELEMENT_NODE && domNode instanceof HTMLElement && domNode.dataset.nodeid) {
184
- const node = editedNode.getNode(parseInt(domNode.dataset.nodeid));
185
- if (node instanceof ParagraphNode) {
186
- const first = node.getFirstTextNode();
187
- return { node: first, index: 0 };
188
- } else if (node instanceof TextNode) {
189
- return { node, index: 0 };
190
- }
191
- }
192
- return void 0;
173
+ // If it's a text node, find the closest TextNode parent
174
+ if (domNode.nodeType === Node.TEXT_NODE) {
175
+ let parent = domNode.parentNode;
176
+ // Keep looking up until we find the TextNode span with the node ID.
177
+ while (parent && !(parent instanceof HTMLElement && parent.classList.contains("bookish-text"))) parent = parent.parentNode;
178
+ if (parent && parent.dataset.nodeid) {
179
+ const node = editedNode.getNode(parseInt(parent.dataset.nodeid));
180
+ if (node instanceof TextNode)
181
+ // Account for the zero-width spaces that we insert in order to make selections possible on empty text nodes.
182
+ return {
183
+ node,
184
+ index: Math.min(rangeIndex, node.getLength())
185
+ };
186
+ }
187
+ } else if (domNode.nodeType === Node.ELEMENT_NODE && domNode instanceof HTMLElement && domNode.dataset.nodeid) {
188
+ const node = editedNode.getNode(parseInt(domNode.dataset.nodeid));
189
+ // These assume that triple clicks on paragraphs in the browser choose text nodes for the selection start
190
+ // and spans or paragraph nodes for the end.
191
+ if (node instanceof ParagraphNode) {
192
+ const first = node.getFirstTextNode();
193
+ return {
194
+ node: first,
195
+ index: 0
196
+ };
197
+ } else if (node instanceof TextNode) {
198
+ return {
199
+ node,
200
+ index: 0
201
+ };
202
+ }
203
+ }
204
+ // Return nothing by default.
205
+ return undefined;
193
206
  }
194
207
  function handleSelectionChange() {
195
- lastInputTime = Date.now();
196
- keyboardIdle = false;
197
- const selection = document.getSelection();
198
- if (selection && selection.anchorNode && selection.focusNode) {
199
- const start = rangeToCaret(
200
- selection.anchorNode,
201
- selection.anchorOffset
202
- );
203
- const end = rangeToCaret(
204
- selection.focusNode,
205
- selection.focusOffset
206
- );
207
- if (start && end) {
208
- caretRange = { start, end };
209
- }
210
- }
208
+ // Make sure we're showing the caret.
209
+ lastInputTime = Date.now();
210
+ keyboardIdle = false;
211
+ // Map the browser selection to our caret range model.
212
+ const selection = document.getSelection();
213
+ if (selection && selection.anchorNode && selection.focusNode) {
214
+ const start = rangeToCaret(selection.anchorNode, selection.anchorOffset);
215
+ const end = rangeToCaret(selection.focusNode, selection.focusOffset);
216
+ // If we found to text nodes, set the selection
217
+ if (start && end) {
218
+ caretRange = {
219
+ start,
220
+ end
221
+ };
222
+ }
223
+ }
211
224
  }
212
225
  function getCaretCoordinate(caret) {
213
- if (!(caret.node instanceof TextNode || caret.node instanceof AtomNode))
214
- return void 0;
215
- const domNode = document.querySelector(
216
- `[data-nodeid='${caret.node.nodeID}`
217
- );
218
- if (domNode === null || domNode.childNodes.length === 0)
219
- return void 0;
220
- const range = document.createRange();
221
- range.setStart(
222
- domNode.childNodes.length === 0 ? domNode : domNode.childNodes[0],
223
- domNode.childNodes.length === 0 ? 0 : caret.index
224
- );
225
- const characterRect = range.getBoundingClientRect();
226
- if (characterRect.left > 0)
227
- return {
228
- top: characterRect.top,
229
- left: characterRect.left,
230
- height: characterRect.height
231
- };
232
- const elementRect = domNode.getBoundingClientRect();
233
- return {
234
- top: elementRect.top,
235
- left: elementRect.left,
236
- height: elementRect.height
237
- };
226
+ if (!(caret.node instanceof TextNode || caret.node instanceof AtomNode)) return undefined;
227
+ const domNode = document.querySelector(`[data-nodeid='${caret.node.nodeID}`);
228
+ // If we didn't find the node or there's no text node inside it, then there's no caret position.
229
+ // This happens temporarily before the useEffect above has a chance to insert zero-width non-breaking space.
230
+ if (domNode === null || domNode.childNodes.length === 0) return undefined;
231
+ const range = document.createRange();
232
+ range.setStart(domNode.childNodes.length === 0 ? domNode : domNode.childNodes[0], domNode.childNodes.length === 0 ? 0 : caret.index);
233
+ const characterRect = range.getBoundingClientRect();
234
+ if (characterRect.left > 0) return {
235
+ top: characterRect.top,
236
+ left: characterRect.left,
237
+ height: characterRect.height
238
+ };
239
+ const elementRect = domNode.getBoundingClientRect();
240
+ return {
241
+ top: elementRect.top,
242
+ left: elementRect.left,
243
+ height: elementRect.height
244
+ };
238
245
  }
239
246
  function getCaretOnLine(caret, below) {
240
- if (caret.node instanceof TextNode || caret.node instanceof AtomNode) {
241
- const startCoordinate = getCaretCoordinate(caret);
242
- let candidate = editedNode.getAdjacentCaret(caret, below);
243
- if (candidate === void 0) return caret;
244
- let previousCandidate = void 0;
245
- let previousCoordinate = void 0;
246
- if (startCoordinate) {
247
- while (candidate.node instanceof TextNode || candidate.node instanceof AtomNode) {
248
- const candidateCoordinate = getCaretCoordinate(candidate);
249
- if (candidateCoordinate) {
250
- const startTop = startCoordinate.top;
251
- const candidateTop = candidateCoordinate.top;
252
- const closerCandidate = previousCoordinate && previousCandidate && Math.abs(
253
- startCoordinate.left - previousCoordinate.left
254
- ) < Math.abs(
255
- startCoordinate.left - candidateCoordinate.left
256
- ) ? previousCandidate : candidate;
257
- if (below) {
258
- if (previousCandidate && previousCoordinate && previousCoordinate.top > startTop + startCoordinate.height && candidateTop > previousCoordinate.top + previousCoordinate.height)
259
- return previousCandidate;
260
- else if (candidateTop > startTop + startCoordinate.height && candidateCoordinate.left >= startCoordinate.left)
261
- return closerCandidate;
262
- } else {
263
- if (previousCandidate && previousCoordinate && previousCoordinate.top + previousCoordinate.height < startTop && candidateTop + candidateCoordinate.height < previousCoordinate.top)
264
- return previousCandidate;
265
- if (candidateTop + candidateCoordinate.height < startTop && candidateCoordinate.left <= startCoordinate.left)
266
- return closerCandidate;
267
- }
268
- }
269
- const nextCandidate = editedNode.getAdjacentCaret(candidate, below);
270
- if (nextCandidate === void 0) break;
271
- if (nextCandidate.node === candidate.node && nextCandidate.index === candidate.index) {
272
- break;
273
- }
274
- previousCandidate = candidate;
275
- previousCoordinate = candidateCoordinate;
276
- candidate = nextCandidate;
277
- if (candidate === void 0) return caret;
278
- }
279
- return candidate;
280
- }
281
- }
282
- return caret;
247
+ if (caret.node instanceof TextNode || caret.node instanceof AtomNode) {
248
+ // Find the position of the current start node.
249
+ const startCoordinate = getCaretCoordinate(caret);
250
+ let candidate = editedNode.getAdjacentCaret(caret, below);
251
+ if (candidate === undefined) return caret;
252
+ let previousCandidate = undefined;
253
+ let previousCoordinate = undefined;
254
+ if (startCoordinate) {
255
+ while (candidate.node instanceof TextNode || candidate.node instanceof AtomNode) {
256
+ const candidateCoordinate = getCaretCoordinate(candidate);
257
+ // Did we make it to the next line and the character before/after? If we're going down,
258
+ // we're looking for a top that's below the bottom. If we're going up, we're looking for a bottom
259
+ // that's below the top.
260
+ if (candidateCoordinate) {
261
+ const startTop = startCoordinate.top;
262
+ const candidateTop = candidateCoordinate.top;
263
+ const closerCandidate = previousCoordinate && previousCandidate && Math.abs(startCoordinate.left - previousCoordinate.left) < Math.abs(startCoordinate.left - candidateCoordinate.left) ? previousCandidate : candidate;
264
+ if (below) {
265
+ // If this candidate is on the line below the start line, then return the last candidate on the line below the start line.
266
+ if (previousCandidate && previousCoordinate && previousCoordinate.top > startTop + startCoordinate.height && candidateTop > previousCoordinate.top + previousCoordinate.height) return previousCandidate;
267
+ else if (candidateTop > startTop + startCoordinate.height && candidateCoordinate.left >= startCoordinate.left) return closerCandidate;
268
+ } else {
269
+ // If this candidate is on the live above the line above the start line, then return the last candidate on the line above the start line.
270
+ if (previousCandidate && previousCoordinate && previousCoordinate.top + previousCoordinate.height < startTop && candidateTop + candidateCoordinate.height < previousCoordinate.top) return previousCandidate;
271
+ // If this is just above the start position, stop and return the closer of the current and previous candidates.
272
+ if (candidateTop + candidateCoordinate.height < startTop && candidateCoordinate.left <= startCoordinate.left) return closerCandidate;
273
+ }
274
+ }
275
+ // Get the next candidate to consider.
276
+ const nextCandidate = editedNode.getAdjacentCaret(candidate, below);
277
+ if (nextCandidate === undefined) break;
278
+ // If the caret didn't move, we stop searching, something is wrong.
279
+ if (nextCandidate.node === candidate.node && nextCandidate.index === candidate.index) {
280
+ break;
281
+ }
282
+ // Otherwise, we advance to the next candidate.
283
+ previousCandidate = candidate;
284
+ previousCoordinate = candidateCoordinate;
285
+ candidate = nextCandidate;
286
+ if (candidate === undefined) return caret;
287
+ }
288
+ return candidate;
289
+ }
290
+ }
291
+ // If we didn't find one, just return what we were given.
292
+ return caret;
283
293
  }
284
294
  function atParagraphStart() {
285
- if (caretRange === void 0 || caretRange.start.node !== caretRange.end.node || caretRange.start.index !== caretRange.end.index || !(caretRange.start.node instanceof TextNode))
286
- return false;
287
- const firstCaret = caretRange.start.node.getFormatRoot(editedNode)?.getFirstCaret();
288
- return firstCaret !== void 0 && firstCaret.node === caretRange.start.node && firstCaret.index === caretRange.start.index;
295
+ if (caretRange === undefined || caretRange.start.node !== caretRange.end.node || caretRange.start.index !== caretRange.end.index || !(caretRange.start.node instanceof TextNode)) return false;
296
+ const firstCaret = caretRange.start.node.getFormatRoot(editedNode)?.getFirstCaret();
297
+ return firstCaret !== undefined && firstCaret.node === caretRange.start.node && firstCaret.index === caretRange.start.index;
289
298
  }
290
299
  function undo() {
291
- let undoState = undoStack[undoPosition + 1];
292
- let node = parser(undoState.bookdown);
293
- if (node === void 0 || node instanceof ErrorNode) return;
294
- updateRoot(node);
295
- save(node);
296
- if (undoPosition < undoStack.length) undoPosition = undoPosition + 1;
297
- const range = indexRangeToCaretRange(node, undoState.range);
298
- if (range === void 0) return;
299
- return { root: node, range };
300
+ // Grab the next state
301
+ let undoState = undoStack[undoPosition + 1];
302
+ // Restore the content of the chapter.
303
+ let node = parser(undoState.bookdown);
304
+ if (node === undefined || node instanceof ErrorNode) return;
305
+ // Restore the view
306
+ updateRoot(node);
307
+ // Save the undo.
308
+ save(node);
309
+ // Move the undo state down a position.
310
+ if (undoPosition < undoStack.length) undoPosition = undoPosition + 1;
311
+ // Return the original caret.
312
+ const range = indexRangeToCaretRange(node, undoState.range);
313
+ if (range === undefined) return;
314
+ return {
315
+ root: node,
316
+ range
317
+ };
300
318
  }
301
319
  function redo() {
302
- let undoState = undoStack[undoPosition - 1];
303
- let node = parser(undoState.bookdown);
304
- if (node === void 0) return;
305
- updateRoot(node);
306
- if (undoPosition > 0) undoPosition = undoPosition - 1;
307
- const range = indexRangeToCaretRange(node, undoState.range);
308
- if (range === void 0) return;
309
- return { root: node, range };
320
+ // Grab the next state
321
+ let undoState = undoStack[undoPosition - 1];
322
+ // Restore the content of the chapter.
323
+ let node = parser(undoState.bookdown);
324
+ if (node === undefined) return;
325
+ updateRoot(node);
326
+ // Move the undo state down a position.
327
+ if (undoPosition > 0) undoPosition = undoPosition - 1;
328
+ // Return the original caret.
329
+ const range = indexRangeToCaretRange(node, undoState.range);
330
+ if (range === undefined) return;
331
+ return {
332
+ root: node,
333
+ range
334
+ };
310
335
  }
311
336
  function getCaretContext() {
312
- if (caretRange === void 0) return void 0;
313
- const nodes = editedNode.getNodes();
314
- let inside = false;
315
- let includesList = false;
316
- nodes.forEach((n) => {
317
- if (caretRange === void 0) return;
318
- if (n === caretRange.start.node) inside = true;
319
- if (inside && n.getClosestParentMatching(
320
- editedNode,
321
- (p) => p instanceof ListNode
322
- ) !== void 0)
323
- includesList = true;
324
- if (n === caretRange.end.node) inside = false;
325
- });
326
- const parents = editedNode.getParentsOf(caretRange.start.node)?.reverse();
327
- return {
328
- // We make a new range so that setCaretRange always causes a re-render
329
- chapter,
330
- range: { start: caretRange.start, end: caretRange.end },
331
- start: caretRange.start,
332
- end: caretRange.end,
333
- isSelection: caretRange.start.node !== caretRange.end.node || caretRange.start.index !== caretRange.end.index,
334
- root: editedNode,
335
- blocks: parents?.find((n) => n instanceof BlocksNode),
336
- paragraph: parents?.find(
337
- (n) => n instanceof ParagraphNode
338
- ),
339
- block: parents?.find((n) => n instanceof BlockNode),
340
- code: parents?.find((n) => n instanceof CodeNode),
341
- list: parents?.find((n) => n instanceof ListNode),
342
- atom: parents?.find((n) => n instanceof AtomNode),
343
- meta: parents?.find(
344
- (n) => n instanceof MetadataNode
345
- ),
346
- includesList,
347
- table: parents?.find((n) => n instanceof TableNode),
348
- formatRoot: caretRange.end.node instanceof TextNode || caretRange.end.node instanceof AtomNode ? caretRange.end.node.getFormatRoot(editedNode) : void 0,
349
- startIsText: caretRange.start.node instanceof TextNode,
350
- endIsText: caretRange.end.node instanceof TextNode,
351
- startIsTextOrAtom: caretRange.start.node instanceof TextNode || caretRange.start.node instanceof AtomNode || caretRange.start.node instanceof EmbedNode,
352
- endIsTextOrAtom: caretRange.end.node instanceof TextNode || caretRange.end.node instanceof AtomNode || caretRange.start.node instanceof EmbedNode,
353
- atParagraphStart: atParagraphStart(),
354
- undoStack,
355
- undoPosition,
356
- undo,
357
- redo,
358
- clipboard,
359
- handleCopy,
360
- handlePaste,
361
- setAccent,
362
- getAccent
363
- };
337
+ if (caretRange === undefined) return undefined;
338
+ // Determine whether the range contains a list
339
+ const nodes = editedNode.getNodes();
340
+ let inside = false;
341
+ let includesList = false;
342
+ nodes.forEach((n) => {
343
+ if (caretRange === undefined) return;
344
+ if (n === caretRange.start.node) inside = true;
345
+ if (inside && n.getClosestParentMatching(editedNode, (p) => p instanceof ListNode) !== undefined) includesList = true;
346
+ if (n === caretRange.end.node) inside = false;
347
+ });
348
+ const parents = editedNode.getParentsOf(caretRange.start.node)?.reverse();
349
+ return {
350
+ // We make a new range so that setCaretRange always causes a re-render
351
+ chapter,
352
+ range: {
353
+ start: caretRange.start,
354
+ end: caretRange.end
355
+ },
356
+ start: caretRange.start,
357
+ end: caretRange.end,
358
+ isSelection: caretRange.start.node !== caretRange.end.node || caretRange.start.index !== caretRange.end.index,
359
+ root: editedNode,
360
+ blocks: parents?.find((n) => n instanceof BlocksNode),
361
+ paragraph: parents?.find((n) => n instanceof ParagraphNode),
362
+ block: parents?.find((n) => n instanceof BlockNode),
363
+ code: parents?.find((n) => n instanceof CodeNode),
364
+ list: parents?.find((n) => n instanceof ListNode),
365
+ atom: parents?.find((n) => n instanceof AtomNode),
366
+ meta: parents?.find((n) => n instanceof MetadataNode),
367
+ includesList,
368
+ table: parents?.find((n) => n instanceof TableNode),
369
+ formatRoot: caretRange.end.node instanceof TextNode || caretRange.end.node instanceof AtomNode ? caretRange.end.node.getFormatRoot(editedNode) : undefined,
370
+ startIsText: caretRange.start.node instanceof TextNode,
371
+ endIsText: caretRange.end.node instanceof TextNode,
372
+ startIsTextOrAtom: caretRange.start.node instanceof TextNode || caretRange.start.node instanceof AtomNode || caretRange.start.node instanceof EmbedNode,
373
+ endIsTextOrAtom: caretRange.end.node instanceof TextNode || caretRange.end.node instanceof AtomNode || caretRange.start.node instanceof EmbedNode,
374
+ atParagraphStart: atParagraphStart(),
375
+ undoStack,
376
+ undoPosition,
377
+ undo,
378
+ redo,
379
+ clipboard,
380
+ handleCopy,
381
+ handlePaste,
382
+ setAccent,
383
+ getAccent
384
+ };
364
385
  }
365
386
  function setAccent(newAccent) {
366
- accent = newAccent;
387
+ accent = newAccent;
367
388
  }
368
389
  function getAccent() {
369
- return accent;
390
+ return accent;
370
391
  }
371
392
  function getUtilities() {
372
- return {
373
- getCaretOnLine
374
- };
393
+ return { getCaretOnLine };
375
394
  }
376
395
  function handlePress(event) {
377
- handleKey(event, true);
396
+ handleKey(event, true);
378
397
  }
379
398
  function handleKey(event, press = false) {
380
- if (!editorFocused) return;
381
- lastInputTime = Date.now();
382
- keyboardIdle = false;
383
- if (caretRange === void 0) return;
384
- const context = getCaretContext();
385
- if (context === void 0) return;
386
- const command = commands.find((command2) => {
387
- if ((command2.press === void 0 || command2.press === press) && (command2.shift === void 0 || command2.shift === event.shiftKey) && (command2.alt === void 0 || command2.alt === event.altKey) && (command2.control === void 0 || command2.control === (event.ctrlKey || event.metaKey)) && (command2.key === void 0 || Array.isArray(command2.key) && command2.key.includes(event.key) || command2.key instanceof Function && command2.key(event.key) || command2.key === event.key) && (command2.code === void 0 || command2.code === event.code) && (command2.active === true || command2.active instanceof Function && command2.active.call(void 0, context, event.key))) {
388
- if (executeCommand(command2, event.key, event.code)) {
389
- event.preventDefault();
390
- event.stopPropagation();
391
- return true;
392
- } else return false;
393
- }
394
- return false;
395
- });
396
- if (command === void 0) {
397
- if (event.key === "Escape") {
398
- if (caretRange.start.node instanceof AtomNode && caretRange.start.node.getMeta() instanceof FormatNode) {
399
- const firstCaret = caretRange.start.node.getMeta().getFirstCaret();
400
- caretRange = { start: firstCaret, end: firstCaret };
401
- event.preventDefault();
402
- event.stopPropagation();
403
- return true;
404
- } else if (caretRange.start.node.isInside(editedNode, AtomNode)) {
405
- const atom = caretRange.start.node.getClosestParentOfType(editedNode, AtomNode);
406
- if (atom) {
407
- const atomCaret = { node: atom, index: 0 };
408
- caretRange = { start: atomCaret, end: atomCaret };
409
- event.preventDefault();
410
- event.stopPropagation();
411
- return true;
412
- }
413
- }
414
- const controls = [
415
- document.querySelector(
416
- '.bookish-editor-toolbar [tabindex="0"]'
417
- ),
418
- document.querySelector(".bookish-editor-toolbar input"),
419
- document.querySelector(".bookish-editor-toolbar select"),
420
- document.querySelector(".bookish-editor-toolbar button")
421
- ];
422
- const match = controls.find(
423
- (control) => control && control instanceof HTMLElement
424
- );
425
- if (match && match instanceof HTMLElement) {
426
- match.focus();
427
- event.preventDefault();
428
- event.stopPropagation();
429
- return true;
430
- }
431
- }
432
- }
399
+ // Only handle keystrokes when this is focused.
400
+ // Otherwise, we let any focusable elements in this editor handle them.
401
+ if (!editorFocused) return;
402
+ // Remember the time of this keystroke.
403
+ lastInputTime = Date.now();
404
+ keyboardIdle = false;
405
+ if (caretRange === undefined) return;
406
+ // Build some context
407
+ const context = getCaretContext();
408
+ if (context === undefined) return;
409
+ // Find a command that handles the event.
410
+ const command = commands.find((command) => {
411
+ // If the keystroke and caret position matches the command signature, execute the command and update the caret range.
412
+ if ((command.press === undefined || command.press === press) && (command.shift === undefined || command.shift === event.shiftKey) && (command.alt === undefined || command.alt === event.altKey) && (command.control === undefined || command.control === (event.ctrlKey || event.metaKey)) && (command.key === undefined || Array.isArray(command.key) && command.key.includes(event.key) || command.key instanceof Function && command.key(event.key) || command.key === event.key) && (command.code === undefined || command.code === event.code) && (command.active === true || command.active instanceof Function && command.active.call(undefined, context, event.key))) {
413
+ // See if the command is handled
414
+ if (executeCommand(command, event.key, event.code)) {
415
+ event.preventDefault();
416
+ event.stopPropagation();
417
+ return true;
418
+ } else return false;
419
+ }
420
+ // Continue searching for a matching command.
421
+ return false;
422
+ });
423
+ if (command === undefined) {
424
+ // Toolbar navigation
425
+ if (event.key === "Escape") {
426
+ // If we've selected a FootnoteNode or Comment, navigate to the footnote or comment text to edit it.
427
+ if (caretRange.start.node instanceof AtomNode && caretRange.start.node.getMeta() instanceof FormatNode) {
428
+ const firstCaret = caretRange.start.node.getMeta().getFirstCaret();
429
+ caretRange = {
430
+ start: firstCaret,
431
+ end: firstCaret
432
+ };
433
+ event.preventDefault();
434
+ event.stopPropagation();
435
+ return true;
436
+ } else if (caretRange.start.node.isInside(editedNode, AtomNode)) {
437
+ const atom = caretRange.start.node.getClosestParentOfType(editedNode, AtomNode);
438
+ if (atom) {
439
+ const atomCaret = {
440
+ node: atom,
441
+ index: 0
442
+ };
443
+ caretRange = {
444
+ start: atomCaret,
445
+ end: atomCaret
446
+ };
447
+ event.preventDefault();
448
+ event.stopPropagation();
449
+ return true;
450
+ }
451
+ }
452
+ // Otherwise, if we have a reference to this editor's DOM element, find the nearest focusable element in it's toolbar and focus on that.
453
+ const controls = [
454
+ document.querySelector(".bookish-editor-toolbar [tabindex=\"0\"]"),
455
+ document.querySelector(".bookish-editor-toolbar input"),
456
+ document.querySelector(".bookish-editor-toolbar select"),
457
+ document.querySelector(".bookish-editor-toolbar button")
458
+ ];
459
+ const match = controls.find((control) => control && control instanceof HTMLElement);
460
+ if (match && match instanceof HTMLElement) {
461
+ match.focus();
462
+ event.preventDefault();
463
+ event.stopPropagation();
464
+ return true;
465
+ }
466
+ }
467
+ }
433
468
  }
434
469
  function executeCommand(command, key, code) {
435
- ignoredInput = false;
436
- const context = getCaretContext();
437
- if (context && caretRange) {
438
- const results = command.handler.call(
439
- void 0,
440
- context,
441
- getUtilities(),
442
- key,
443
- code
444
- );
445
- if (results === true) {
446
- return true;
447
- } else if (results !== void 0) {
448
- const { root, range } = results;
449
- const newRange = { start: range.start, end: range.end };
450
- if (root === editedNode && command.mutates)
451
- console.error(
452
- `Warning: immutability violation on ${command.description}`
453
- );
454
- caretRange = newRange;
455
- if (command.category !== "navigation" && command.category !== "selection" && command.category !== "history") {
456
- saveEdit(root, newRange, command);
457
- }
458
- return true;
459
- } else {
460
- ignoredInput = true;
461
- return false;
462
- }
463
- }
464
- return false;
470
+ // Assume we process it, then flip it if we don't.
471
+ ignoredInput = false;
472
+ const context = getCaretContext();
473
+ if (context && caretRange) {
474
+ const results = command.handler.call(undefined, context, getUtilities(), key, code);
475
+ // If the command invoked produced a new range
476
+ if (results === true) {
477
+ return true;
478
+ } else if (results !== undefined) {
479
+ const { root, range } = results;
480
+ const newRange = {
481
+ start: range.start,
482
+ end: range.end
483
+ };
484
+ if (root === editedNode && command.mutates) console.error(`Warning: immutability violation on ${command.description}`);
485
+ // Set the range to force a rerender, assuming something in the document changed.
486
+ caretRange = newRange;
487
+ // Save the copy in the undo stack if this isn't a navigation or selection state.
488
+ if (command.category !== "navigation" && command.category !== "selection" && command.category !== "history") {
489
+ saveEdit(root, newRange, command);
490
+ }
491
+ return true;
492
+ } else {
493
+ ignoredInput = true;
494
+ return false;
495
+ }
496
+ }
497
+ return false;
465
498
  }
466
499
  async function saveEdit(newRoot, newRange, command) {
467
- if (locked) return;
468
- save(newRoot);
469
- let newStack = undoStack.length > 0 ? undoStack : [];
470
- if (undoStack.length === 0 && caretRange !== void 0) {
471
- const currentRange = caretRangeToIndexRange(editedNode, caretRange);
472
- if (currentRange !== void 0)
473
- newStack = [
474
- {
475
- bookdown: editedNode.toBookdown(),
476
- command: void 0,
477
- range: currentRange
478
- }
479
- ];
480
- }
481
- updateRoot(newRoot);
482
- const newTextRange = caretRangeToIndexRange(newRoot, newRange);
483
- if (newTextRange !== void 0)
484
- undoStack = [
485
- {
486
- bookdown: newRoot.toBookdown(),
487
- command,
488
- range: newTextRange
489
- // If the undo position is beyond the front, clear everything before it, because we're changing history.
490
- },
491
- ...undoPosition > 0 ? newStack.slice(undoPosition) : newStack
492
- ];
493
- undoPosition = 0;
494
- if (!toolbarIsFocused()) element?.focus();
495
- claimActiveEditor();
500
+ // Don't save edits if locked.
501
+ if (locked) return;
502
+ // Call the save callback.
503
+ save(newRoot);
504
+ // Initialize a new history if we don't have one.
505
+ let newStack = undoStack.length > 0 ? undoStack : [];
506
+ // If there's nothing on the stack, save the initial state before we add something to the stack.
507
+ if (undoStack.length === 0 && caretRange !== undefined) {
508
+ const currentRange = caretRangeToIndexRange(editedNode, caretRange);
509
+ if (currentRange !== undefined) newStack = [{
510
+ bookdown: editedNode.toBookdown(),
511
+ command: undefined,
512
+ range: currentRange
513
+ }];
514
+ }
515
+ // Change the chapter's AST.
516
+ updateRoot(newRoot);
517
+ // Set the new undo stack, pre-pending the new command to the front.
518
+ const newTextRange = caretRangeToIndexRange(newRoot, newRange);
519
+ if (newTextRange !== undefined) undoStack = [{
520
+ bookdown: newRoot.toBookdown(),
521
+ command,
522
+ range: newTextRange
523
+ }, ...undoPosition > 0 ? newStack.slice(undoPosition) : newStack];
524
+ // Set the undo position to the last index.
525
+ undoPosition = 0;
526
+ // Focus the editor on the new caret location, unless the toolbar is focused.
527
+ if (!toolbarIsFocused()) element?.focus();
528
+ // Update the caret
529
+ claimActiveEditor();
496
530
  }
497
531
  function updateRoot(newRoot) {
498
- editedNode = newRoot;
499
- text = editedNode.toBookdown();
532
+ editedNode = newRoot;
533
+ text = editedNode.toBookdown();
500
534
  }
501
535
  function editNode(previous, edited) {
502
- if (previous === edited) return;
503
- if (caretRange) {
504
- const newRoot = editedNode.withNodeReplaced(previous, edited);
505
- if (newRoot === void 0) return;
506
- saveEdit(newRoot, { start: caretRange.start, end: caretRange.end });
507
- if (caretRange.start.node === previous && (edited instanceof AtomNode || edited instanceof TextNode))
508
- caretRange = {
509
- start: { node: edited, index: 0 },
510
- end: { node: edited, index: 0 }
511
- };
512
- }
536
+ if (previous === edited) return;
537
+ if (caretRange) {
538
+ const newRoot = editedNode.withNodeReplaced(previous, edited);
539
+ if (newRoot === undefined) return;
540
+ saveEdit(newRoot, {
541
+ start: caretRange.start,
542
+ end: caretRange.end
543
+ });
544
+ // Update the range if the current range contains the previous node. This will generally
545
+ // be true any time a selected atom node is edited.
546
+ if (caretRange.start.node === previous && (edited instanceof AtomNode || edited instanceof TextNode)) caretRange = {
547
+ start: {
548
+ node: edited,
549
+ index: 0
550
+ },
551
+ end: {
552
+ node: edited,
553
+ index: 0
554
+ }
555
+ };
556
+ }
513
557
  }
514
558
  function handlePointerDown() {
515
- if (!element) return;
516
- element.focus();
517
- if (caretRange)
518
- caretRange = { start: caretRange.start, end: caretRange.end };
519
- if (caretRange && !(caretRange.start.node instanceof TextNode || caretRange.start.node instanceof AtomNode || caretRange.start.node instanceof EmbedNode)) {
520
- caretRange = void 0;
521
- }
559
+ if (!element) return;
560
+ // Grab focus.
561
+ element.focus();
562
+ // Update the caret range to ensure it's positioned correctly, since it depends on relative positioned parents.
563
+ if (caretRange) caretRange = {
564
+ start: caretRange.start,
565
+ end: caretRange.end
566
+ };
567
+ // If we've selected a non-TextNode, release it, so the browser is free to select a text node.
568
+ if (caretRange && !(caretRange.start.node instanceof TextNode || caretRange.start.node instanceof AtomNode || caretRange.start.node instanceof EmbedNode)) {
569
+ caretRange = undefined;
570
+ }
522
571
  }
523
572
  function isFocused() {
524
- return document.activeElement === element || element !== null && element?.contains(document.activeElement) || toolbarIsFocused();
573
+ return document.activeElement === element || element !== null && element?.contains(document.activeElement) || toolbarIsFocused();
525
574
  }
526
575
  function handleFocus() {
527
- editorFocused = isFocused();
528
- lease(true);
576
+ editorFocused = isFocused();
577
+ lease(true);
529
578
  }
530
579
  function handleBlur() {
531
- editorFocused = isFocused();
532
- lease(false);
580
+ editorFocused = isFocused();
581
+ lease(false);
533
582
  }
534
583
  function handleCopy(node) {
535
- clipboard = node;
536
- if (navigator.clipboard) {
537
- navigator.clipboard.write([
538
- new ClipboardItem({
539
- "text/plain": new Blob([node.toText()], {
540
- type: "text/plain"
541
- }),
542
- "text/html": new Blob([node.toHTML()], {
543
- type: "text/html"
544
- })
545
- })
546
- ]);
547
- } else
548
- window.alert(
549
- "Your browser doesn't support copying to the clipboard :("
550
- );
584
+ // Set the editor's clipboard.
585
+ clipboard = node;
586
+ // Set the OS clipboard.
587
+ if (navigator.clipboard) {
588
+ navigator.clipboard.write([new ClipboardItem({
589
+ "text/plain": new Blob([node.toText()], { type: "text/plain" }),
590
+ "text/html": new Blob([node.toHTML()], { type: "text/html" })
591
+ })]);
592
+ } else window.alert("Your browser doesn't support copying to the clipboard :(");
551
593
  }
552
- function handlePaste(context, text2, save2) {
553
- if (text2 instanceof BookishNode)
554
- return handlePasteNode(context, text2, save2);
555
- if (text2.plain === void 0) return void 0;
556
- const escaped = text2.plain.replaceAll(Reserved, "\\$1");
557
- const chapter2 = (
558
- // If we're pasting text into a paragraph node, then we parse it as a series of paragraphs.
559
- context.block instanceof ParagraphNode && context.atom === void 0 ? Parser.parseChapter(void 0, escaped) : (
560
- // Otherwise parse as just a format node, replacing all text newlines with a space.
561
- Parser.parseFormat(void 0, escaped.replaceAll("\n", " "))
562
- )
563
- );
564
- const edit = handlePasteNode(context, chapter2, save2);
565
- if (edit) return edit;
566
- return void 0;
594
+ function handlePaste(context, text, save) {
595
+ // If we're pasting a node, see what will accept it.
596
+ if (text instanceof BookishNode) return handlePasteNode(context, text, save);
597
+ if (text.plain === undefined) return undefined;
598
+ // Escape all reserved Bookdown characters.
599
+ const escaped = text.plain.replaceAll(Reserved, "\\$1");
600
+ const chapter = context.block instanceof ParagraphNode && context.atom === undefined ? Parser.parseChapter(undefined, escaped) : // Otherwise parse as just a format node, replacing all text newlines with a space.
601
+ Parser.parseFormat(undefined, escaped.replaceAll("\n", " "));
602
+ const edit = handlePasteNode(context, chapter, save);
603
+ if (edit) return edit;
604
+ return undefined;
567
605
  }
568
- function handlePasteNode(context, node, save2) {
569
- let newRoot = context.root;
570
- let newCaret = context.start;
571
- if (context.isSelection) {
572
- const edit2 = newRoot.withRangeFormatted(context.range, void 0);
573
- if (edit2 === void 0 || !(edit2.root instanceof ChapterNode))
574
- return;
575
- newRoot = edit2.root;
576
- newCaret = edit2.range.start;
577
- }
578
- const parents = context.root.getParentsOf(newCaret.node);
579
- if (parents === void 0) return;
580
- let edit = void 0;
581
- let parent = void 0;
582
- while (parents.length > 0) {
583
- parent = parents.pop();
584
- if (parent === void 0) break;
585
- edit = parent.withNodeInserted(newCaret, node.copy());
586
- if (edit !== void 0) break;
587
- }
588
- if (parent !== void 0 && edit !== void 0) {
589
- const newChapter = context.root.withNodeReplaced(parent, edit.root);
590
- if (newChapter === void 0) return;
591
- if (save2) {
592
- caretRange = edit.range;
593
- saveEdit(newChapter, edit.range);
594
- } else return { root: newChapter, range: edit.range };
595
- }
606
+ function handlePasteNode(context, node, save) {
607
+ // Track revisions in these two variables.
608
+ let newRoot = context.root;
609
+ let newCaret = context.start;
610
+ // Is there any text to remove? Remove it first.
611
+ if (context.isSelection) {
612
+ const edit = newRoot.withRangeFormatted(context.range, undefined);
613
+ if (edit === undefined || !(edit.root instanceof ChapterNode)) return;
614
+ newRoot = edit.root;
615
+ newCaret = edit.range.start;
616
+ }
617
+ // Find the parents of the caret node, from nearest to furthest
618
+ const parents = context.root.getParentsOf(newCaret.node);
619
+ if (parents === undefined) return;
620
+ // Pop parents, nearest to farthest, searching for one that handles an insertion.
621
+ let edit = undefined;
622
+ let parent = undefined;
623
+ while (parents.length > 0) {
624
+ parent = parents.pop();
625
+ if (parent === undefined) break;
626
+ // It's critical that we insert a copy and not the node in case people copy multiple times. A node can only appear once in a tree.
627
+ edit = parent.withNodeInserted(newCaret, node.copy());
628
+ if (edit !== undefined) break;
629
+ }
630
+ // Insert the node at the caret.
631
+ if (parent !== undefined && edit !== undefined) {
632
+ const newChapter = context.root.withNodeReplaced(parent, edit.root);
633
+ if (newChapter === undefined) return;
634
+ if (save) {
635
+ // Set the range to force a rerender, assuming something in the document changed.
636
+ caretRange = edit.range;
637
+ // Save the copy in the undo stack if this isn't a navigation or selection state.
638
+ saveEdit(newChapter, edit.range);
639
+ } else return {
640
+ root: newChapter,
641
+ range: edit.range
642
+ };
643
+ }
596
644
  }
597
645
  function toolbarIsFocused() {
598
- const toolbar = document.querySelector(".bookish-editor-toolbar");
599
- return toolbar !== null && toolbar.contains(document.activeElement);
646
+ const toolbar = document.querySelector(".bookish-editor-toolbar");
647
+ return toolbar !== null && toolbar.contains(document.activeElement);
600
648
  }
601
- let isAtom = $derived.by(
602
- () => caretRange !== void 0 && caretRange.start.node instanceof AtomNode
603
- );
604
- let inAtom = $derived.by(
605
- () => caretRange !== void 0 && caretRange.start.node.isInside(editedNode, AtomNode)
606
- );
607
- let isSelection = $derived.by(
608
- () => caretRange !== void 0 && (caretRange.start.node !== caretRange.end.node || caretRange.start.index !== caretRange.end.index)
609
- );
610
- let isItalic = $derived.by(
611
- () => caretRange !== void 0 && isSelection === false && caretRange.start.node instanceof TextNode && caretRange.start.node.isItalic(editedNode)
612
- );
613
- let isBold = $derived.by(
614
- () => caretRange !== void 0 && isSelection === false && caretRange.start.node instanceof TextNode && caretRange.start.node.isBold(editedNode)
615
- );
616
- let isLink = $derived.by(
617
- () => caretRange !== void 0 && isSelection === false && caretRange.start.node.getClosestParentMatching(
618
- editedNode,
619
- (p) => p instanceof LinkNode
620
- ) !== void 0
621
- );
649
+ let isAtom = $derived.by(() => caretRange !== undefined && caretRange.start.node instanceof AtomNode);
650
+ let inAtom = $derived.by(() => caretRange !== undefined && caretRange.start.node.isInside(editedNode, AtomNode));
651
+ let isSelection = $derived.by(() => caretRange !== undefined && (caretRange.start.node !== caretRange.end.node || caretRange.start.index !== caretRange.end.index));
652
+ let isItalic = $derived.by(() => caretRange !== undefined && isSelection === false && caretRange.start.node instanceof TextNode && caretRange.start.node.isItalic(editedNode));
653
+ let isBold = $derived.by(() => caretRange !== undefined && isSelection === false && caretRange.start.node instanceof TextNode && caretRange.start.node.isBold(editedNode));
654
+ let isLink = $derived.by(() => caretRange !== undefined && isSelection === false && caretRange.start.node.getClosestParentMatching(editedNode, (p) => p instanceof LinkNode) !== undefined);
655
+ // When the caret range or editor focus changes, update the active editor.
656
+ // Only those two are tracked; the claim itself reads much more state.
622
657
  $effect.pre(() => {
623
- void caretRange;
624
- void editorFocused;
625
- untrack(() => {
626
- if ((caretRange || editorFocused) && isFocused())
627
- claimActiveEditor();
628
- else activeEditor.set(void 0);
629
- });
658
+ void caretRange;
659
+ void editorFocused;
660
+ untrack(() => {
661
+ if ((caretRange || editorFocused) && isFocused()) claimActiveEditor();
662
+ else activeEditor.set(undefined);
663
+ });
630
664
  });
631
665
  function claimActiveEditor() {
632
- activeEditor.set({
633
- range: caretRange,
634
- coordinate: caretCoordinate,
635
- setCaret: (range) => {
636
- caretRange = range;
637
- element?.focus();
638
- },
639
- edit: editNode,
640
- executor: executeCommand,
641
- context: getCaretContext(),
642
- root: editedNode,
643
- focused: editorFocused
644
- });
666
+ activeEditor.set({
667
+ range: caretRange,
668
+ coordinate: caretCoordinate,
669
+ setCaret: (range) => {
670
+ caretRange = range;
671
+ element?.focus();
672
+ },
673
+ edit: editNode,
674
+ executor: executeCommand,
675
+ context: getCaretContext(),
676
+ root: editedNode,
677
+ focused: editorFocused
678
+ });
645
679
  }
680
+ // Pass the function above to all children so they can claim the active editor for non-focus reasons.
681
+ // (Drag and drop, for example.)
646
682
  setContext("claimeditor", claimActiveEditor);
647
683
  </script>
648
684