@mp-lb/mdkit 0.3.2 → 0.3.4

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 (160) hide show
  1. package/README.md +8 -2
  2. package/dist/collaboration/useMdKitCollaboration.d.ts +5 -0
  3. package/dist/collaboration/useMdKitCollaboration.d.ts.map +1 -0
  4. package/dist/collaboration/useMdKitCollaboration.js +4 -0
  5. package/dist/core/checkpointPolicy.d.ts +10 -0
  6. package/dist/core/checkpointPolicy.d.ts.map +1 -0
  7. package/dist/core/checkpointPolicy.js +9 -0
  8. package/dist/core/documentEngine.d.ts +1 -0
  9. package/dist/core/documentEngine.d.ts.map +1 -0
  10. package/dist/core/index.d.ts +1 -0
  11. package/dist/core/index.d.ts.map +1 -0
  12. package/dist/document/MdKitConflictPanel.d.ts +5 -0
  13. package/dist/document/MdKitConflictPanel.d.ts.map +1 -0
  14. package/dist/document/MdKitConflictPanel.js +4 -0
  15. package/dist/document/MdKitDocumentToolbar.d.ts +6 -0
  16. package/dist/document/MdKitDocumentToolbar.d.ts.map +1 -0
  17. package/dist/document/MdKitDocumentToolbar.js +5 -0
  18. package/dist/document/documentTypes.d.ts +6 -0
  19. package/dist/document/documentTypes.d.ts.map +1 -0
  20. package/dist/document/useMdKitDocument.d.ts +5 -0
  21. package/dist/document/useMdKitDocument.d.ts.map +1 -0
  22. package/dist/document/useMdKitDocument.js +4 -0
  23. package/dist/fastify.d.ts +1 -0
  24. package/dist/fastify.d.ts.map +1 -0
  25. package/dist/index.d.ts +4 -1
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/markdown/MarkdownBubbleMenu.d.ts +1 -0
  28. package/dist/markdown/MarkdownBubbleMenu.d.ts.map +1 -0
  29. package/dist/markdown/MarkdownPasteExtension.d.ts +1 -0
  30. package/dist/markdown/MarkdownPasteExtension.d.ts.map +1 -0
  31. package/dist/markdown/MarkdownSearchExtension.d.ts +1 -0
  32. package/dist/markdown/MarkdownSearchExtension.d.ts.map +1 -0
  33. package/dist/markdown/MarkdownSearchPanel.d.ts +1 -0
  34. package/dist/markdown/MarkdownSearchPanel.d.ts.map +1 -0
  35. package/dist/markdown/MdKitEditor.d.ts +11 -0
  36. package/dist/markdown/MdKitEditor.d.ts.map +1 -0
  37. package/dist/markdown/MdKitEditor.js +10 -2
  38. package/dist/markdown/MdKitView.d.ts +9 -1
  39. package/dist/markdown/MdKitView.d.ts.map +1 -0
  40. package/dist/markdown/MdKitView.js +7 -2
  41. package/dist/markdown/TiptapMarkdownSurface.d.ts +1 -0
  42. package/dist/markdown/TiptapMarkdownSurface.d.ts.map +1 -0
  43. package/dist/markdown/TiptapMarkdownSurface.js +3 -22
  44. package/dist/markdown/createMdKitTiptapExtensions.d.ts +1 -0
  45. package/dist/markdown/createMdKitTiptapExtensions.d.ts.map +1 -0
  46. package/dist/markdown/editorDebug.d.ts +1 -0
  47. package/dist/markdown/editorDebug.d.ts.map +1 -0
  48. package/dist/markdown/markdownFenceRanges.d.ts +1 -0
  49. package/dist/markdown/markdownFenceRanges.d.ts.map +1 -0
  50. package/dist/markdown/normalizeMarkdownSerialization.d.ts +1 -0
  51. package/dist/markdown/normalizeMarkdownSerialization.d.ts.map +1 -0
  52. package/dist/markdown/prepareMarkdownForEditorHydration.d.ts +1 -0
  53. package/dist/markdown/prepareMarkdownForEditorHydration.d.ts.map +1 -0
  54. package/dist/markdown/preserveMarkdownWhitespace.d.ts +1 -0
  55. package/dist/markdown/preserveMarkdownWhitespace.d.ts.map +1 -0
  56. package/dist/markdown/yamlFrontMatter.d.ts +1 -0
  57. package/dist/markdown/yamlFrontMatter.d.ts.map +1 -0
  58. package/dist/server.d.ts +1 -0
  59. package/dist/server.d.ts.map +1 -0
  60. package/dist/theme/MdKitThemeEditor.d.ts +5 -0
  61. package/dist/theme/MdKitThemeEditor.d.ts.map +1 -0
  62. package/dist/theme/MdKitThemeEditor.js +4 -0
  63. package/dist/theme/editorTheme.d.ts +1 -0
  64. package/dist/theme/editorTheme.d.ts.map +1 -0
  65. package/dist/theme/editorTheme.js +8 -8
  66. package/dist/transport/backend.d.ts +13 -0
  67. package/dist/transport/backend.d.ts.map +1 -0
  68. package/dist/transport/backend.js +6 -0
  69. package/dist/transport/fastify.d.ts +5 -0
  70. package/dist/transport/fastify.d.ts.map +1 -0
  71. package/dist/transport/fastify.js +4 -0
  72. package/dist/transport/http.d.ts +1 -0
  73. package/dist/transport/http.d.ts.map +1 -0
  74. package/dist/transport/index.d.ts +1 -0
  75. package/dist/transport/index.d.ts.map +1 -0
  76. package/dist/transport/rest.d.ts +6 -0
  77. package/dist/transport/rest.d.ts.map +1 -0
  78. package/dist/transport/rest.js +5 -0
  79. package/dist/transport/store.d.ts +1 -0
  80. package/dist/transport/store.d.ts.map +1 -0
  81. package/dist/transport/trpcClient.d.ts +8 -0
  82. package/dist/transport/trpcClient.d.ts.map +1 -0
  83. package/dist/transport/trpcClient.js +7 -0
  84. package/dist/transport/trpcServer.d.ts +6 -0
  85. package/dist/transport/trpcServer.d.ts.map +1 -0
  86. package/dist/transport/trpcServer.js +5 -0
  87. package/dist/trpc/client.d.ts +1 -0
  88. package/dist/trpc/client.d.ts.map +1 -0
  89. package/dist/trpc/server.d.ts +1 -0
  90. package/dist/trpc/server.d.ts.map +1 -0
  91. package/dist/trpc.d.ts +1 -0
  92. package/dist/trpc.d.ts.map +1 -0
  93. package/dist/ui/joinClassNames.d.ts +1 -0
  94. package/dist/ui/joinClassNames.d.ts.map +1 -0
  95. package/dist/versioning/VersionHistoryPanel.d.ts +5 -0
  96. package/dist/versioning/VersionHistoryPanel.d.ts.map +1 -0
  97. package/dist/versioning/VersionHistoryPanel.js +4 -0
  98. package/dist/versioning/useMdKitDocumentVersions.d.ts +5 -0
  99. package/dist/versioning/useMdKitDocumentVersions.d.ts.map +1 -0
  100. package/dist/versioning/useMdKitDocumentVersions.js +4 -0
  101. package/dist/yjs/MdKitMarkdownYjs.d.ts +1 -0
  102. package/dist/yjs/MdKitMarkdownYjs.d.ts.map +1 -0
  103. package/dist/yjs/index.d.ts +1 -0
  104. package/dist/yjs/index.d.ts.map +1 -0
  105. package/package.json +10 -12
  106. package/src/collaboration/useMdKitCollaboration.ts +528 -0
  107. package/src/core/checkpointPolicy.ts +107 -0
  108. package/src/core/documentEngine.ts +175 -0
  109. package/src/core/index.ts +33 -0
  110. package/src/document/MdKitConflictPanel.tsx +129 -0
  111. package/src/document/MdKitDocumentToolbar.tsx +141 -0
  112. package/src/document/documentTypes.ts +89 -0
  113. package/src/document/useMdKitDocument.ts +543 -0
  114. package/src/fastify.ts +6 -0
  115. package/src/index.ts +89 -0
  116. package/src/markdown/MarkdownBubbleMenu.tsx +271 -0
  117. package/src/markdown/MarkdownPasteExtension.ts +81 -0
  118. package/src/markdown/MarkdownSearchExtension.ts +77 -0
  119. package/src/markdown/MarkdownSearchPanel.tsx +98 -0
  120. package/src/markdown/MdKitEditor.tsx +75 -0
  121. package/src/markdown/MdKitView.tsx +80 -0
  122. package/src/markdown/TiptapMarkdownSurface.tsx +923 -0
  123. package/src/markdown/createMdKitTiptapExtensions.ts +42 -0
  124. package/src/markdown/editorDebug.ts +5 -0
  125. package/src/markdown/markdownFenceRanges.ts +68 -0
  126. package/src/markdown/normalizeMarkdownSerialization.ts +55 -0
  127. package/src/markdown/prepareMarkdownForEditorHydration.ts +23 -0
  128. package/src/markdown/preserveMarkdownWhitespace.ts +143 -0
  129. package/src/markdown/yamlFrontMatter.ts +135 -0
  130. package/src/server.ts +6 -0
  131. package/src/styles.css +132 -53
  132. package/src/theme/MdKitThemeEditor.tsx +134 -0
  133. package/src/theme/editorTheme.ts +72 -0
  134. package/src/transport/backend.ts +220 -0
  135. package/src/transport/fastify.ts +57 -0
  136. package/src/transport/http.ts +126 -0
  137. package/src/transport/index.ts +12 -0
  138. package/src/transport/rest.ts +80 -0
  139. package/src/transport/store.ts +45 -0
  140. package/src/transport/trpcClient.ts +90 -0
  141. package/src/transport/trpcServer.ts +66 -0
  142. package/src/trpc/client.ts +11 -0
  143. package/src/trpc/server.ts +12 -0
  144. package/src/trpc.ts +11 -0
  145. package/src/ui/joinClassNames.ts +3 -0
  146. package/src/versioning/VersionHistoryPanel.tsx +146 -0
  147. package/src/versioning/useMdKitDocumentVersions.ts +146 -0
  148. package/src/yjs/MdKitMarkdownYjs.ts +111 -0
  149. package/src/yjs/index.ts +8 -0
  150. package/docs/.vitepress/config.ts +0 -47
  151. package/docs/api.md +0 -512
  152. package/docs/architecture.md +0 -96
  153. package/docs/collaboration-persistence.md +0 -147
  154. package/docs/index.md +0 -341
  155. package/docs/permissions.md +0 -139
  156. package/docs/plain-text.md +0 -131
  157. package/docs/rest.md +0 -98
  158. package/docs/shadcn.md +0 -125
  159. package/docs/styling.md +0 -373
  160. package/docs/use-cases.md +0 -148
@@ -0,0 +1,923 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type PointerEvent,
8
+ } from "react";
9
+ import Collaboration from "@tiptap/extension-collaboration";
10
+ import CollaborationCaret from "@tiptap/extension-collaboration-caret";
11
+ import { EditorContent, useEditor } from "@tiptap/react";
12
+ import type { MdKitCollaborationSession } from "../document/documentTypes";
13
+ import { createMdKitTiptapExtensions } from "./createMdKitTiptapExtensions";
14
+ import type { MdKitEditorDebugEvent } from "./editorDebug";
15
+ import {
16
+ extractYamlFrontMatter,
17
+ prependYamlFrontMatter,
18
+ type MdKitYamlFrontMatter,
19
+ } from "./yamlFrontMatter";
20
+ import { MarkdownBubbleMenu } from "./MarkdownBubbleMenu";
21
+ import { MarkdownSearchPanel } from "./MarkdownSearchPanel";
22
+ import {
23
+ markdownSearchPluginKey,
24
+ type MarkdownSearchMatch,
25
+ } from "./MarkdownSearchExtension";
26
+ import { normalizeMarkdownSerialization } from "./normalizeMarkdownSerialization";
27
+ import { prepareMarkdownForEditorHydration } from "./prepareMarkdownForEditorHydration";
28
+
29
+ type LocalTiptapMarkdownSurfaceProps = {
30
+ collaboration?: null;
31
+ onChange?: (markdown: string) => void;
32
+ onDebugEvent?: (event: MdKitEditorDebugEvent) => void;
33
+ onFocusChange?: (focused: boolean) => void;
34
+ ignoreYamlFrontMatter?: boolean;
35
+ placeholder?: string;
36
+ readOnly?: boolean;
37
+ search?: boolean;
38
+ value: string;
39
+ };
40
+
41
+ type CollaborativeTiptapMarkdownSurfaceProps = {
42
+ collaboration: MdKitCollaborationSession;
43
+ onChange?: (markdown: string) => void;
44
+ onDebugEvent?: (event: MdKitEditorDebugEvent) => void;
45
+ onFocusChange?: (focused: boolean) => void;
46
+ ignoreYamlFrontMatter?: boolean;
47
+ placeholder?: string;
48
+ readOnly?: boolean;
49
+ search?: boolean;
50
+ value?: string;
51
+ };
52
+
53
+ type TiptapMarkdownSurfaceProps =
54
+ | CollaborativeTiptapMarkdownSurfaceProps
55
+ | LocalTiptapMarkdownSurfaceProps;
56
+
57
+ type TiptapEditor = NonNullable<ReturnType<typeof useEditor>>;
58
+
59
+ const describeElement = (element: Element) => {
60
+ const classes =
61
+ element instanceof HTMLElement && element.className
62
+ ? `.${String(element.className).trim().replace(/\s+/g, ".")}`
63
+ : "";
64
+
65
+ return `${element.tagName.toLowerCase()}${classes}`;
66
+ };
67
+
68
+ const describeEventTarget = (target: EventTarget | null) =>
69
+ target instanceof Element ? describeElement(target) : String(target);
70
+
71
+ const isInteractiveElement = (target: Element) =>
72
+ !!target.closest("a,button,input,select,textarea,[contenteditable='false']");
73
+
74
+ const isNativeFocusTarget = (target: Element) =>
75
+ !!target.closest(
76
+ "a[href],button,input,select,textarea,[contenteditable='true'],[tabindex]:not([tabindex='-1'])",
77
+ );
78
+
79
+ const createEditorDebugSnapshot = (editor: TiptapEditor, phase: string) => {
80
+ const activeElement =
81
+ typeof document === "undefined" || !document.activeElement
82
+ ? null
83
+ : describeElement(document.activeElement);
84
+
85
+ const browserSelection =
86
+ typeof window === "undefined" ? null : window.getSelection();
87
+
88
+ let editorIsFocused: boolean | null = null;
89
+ let selectionAnchor: number | null = null;
90
+ let selectionEmpty: boolean | null = null;
91
+ let selectionFrom: number | null = null;
92
+ let selectionHead: number | null = null;
93
+ let selectionTo: number | null = null;
94
+ let viewHasFocus: boolean | null = null;
95
+ let viewUnavailable = false;
96
+
97
+ try {
98
+ editorIsFocused = editor.isFocused;
99
+ selectionAnchor = editor.state.selection.anchor;
100
+ selectionEmpty = editor.state.selection.empty;
101
+ selectionFrom = editor.state.selection.from;
102
+ selectionHead = editor.state.selection.head;
103
+ selectionTo = editor.state.selection.to;
104
+ viewHasFocus = editor.view.hasFocus();
105
+ } catch {
106
+ viewUnavailable = true;
107
+ }
108
+
109
+ return {
110
+ activeElement,
111
+ browserSelectionAnchorNode:
112
+ browserSelection?.anchorNode instanceof Element
113
+ ? describeElement(browserSelection.anchorNode)
114
+ : (browserSelection?.anchorNode?.nodeName ?? null),
115
+ browserSelectionAnchorOffset: browserSelection?.anchorOffset ?? null,
116
+ editorIsFocused,
117
+ phase,
118
+ selectionAnchor,
119
+ selectionEmpty,
120
+ selectionFrom,
121
+ selectionHead,
122
+ selectionTo,
123
+ viewHasFocus,
124
+ viewUnavailable,
125
+ };
126
+ };
127
+
128
+ export const TiptapMarkdownSurface = (props: TiptapMarkdownSurfaceProps) => {
129
+ const {
130
+ collaboration = null,
131
+ ignoreYamlFrontMatter = false,
132
+ onDebugEvent,
133
+ onFocusChange,
134
+ placeholder = "Start writing...",
135
+ readOnly = false,
136
+ search = false,
137
+ } = props;
138
+
139
+ const markdownValue =
140
+ "value" in props && typeof props.value === "string" ? props.value : "";
141
+
142
+ const editorSurfaceRef = useRef<HTMLDivElement>(null);
143
+ const searchInputRef = useRef<HTMLInputElement>(null);
144
+ const onDebugEventRef = useRef(onDebugEvent);
145
+ const onFocusChangeRef = useRef(onFocusChange);
146
+ const onChangeRef = useRef(props.onChange);
147
+ const currentMarkdownRef = useRef(markdownValue);
148
+ const yamlFrontMatterRef = useRef<MdKitYamlFrontMatter | null>(
149
+ ignoreYamlFrontMatter
150
+ ? extractYamlFrontMatter(markdownValue).frontMatter
151
+ : null,
152
+ );
153
+ const isApplyingExternalValueRef = useRef(false);
154
+ const pendingControlledEchoesRef = useRef<Set<string>>(new Set());
155
+
156
+ const pendingContentFocusRef = useRef<{
157
+ pointerId: number;
158
+ x: number;
159
+ y: number;
160
+ } | null>(null);
161
+
162
+ const [searchOpen, setSearchOpen] = useState(false);
163
+ const [searchQuery, setSearchQuery] = useState("");
164
+ const [activeSearchMatchIndex, setActiveSearchMatchIndex] = useState(0);
165
+ const collaborationDocument = collaboration?.document ?? null;
166
+ const collaborationProvider = collaboration?.provider ?? null;
167
+ const collaborationUserColor = collaboration?.collaborator.color ?? "";
168
+ const collaborationUserId = collaboration?.collaborator.id ?? "";
169
+ const collaborationUserImageUrl = collaboration?.collaborator.imageUrl ?? "";
170
+ const collaborationUserName = collaboration?.collaborator.name ?? "";
171
+ const hasCollaboration = !!collaborationDocument;
172
+
173
+ useEffect(() => {
174
+ onDebugEventRef.current = onDebugEvent;
175
+ }, [onDebugEvent]);
176
+
177
+ useEffect(() => {
178
+ onFocusChangeRef.current = onFocusChange;
179
+ }, [onFocusChange]);
180
+
181
+ useEffect(() => {
182
+ onChangeRef.current = props.onChange;
183
+ }, [props.onChange]);
184
+
185
+ const collaborationCaretExtensions = useMemo(
186
+ () =>
187
+ collaborationProvider
188
+ ? [
189
+ CollaborationCaret.configure({
190
+ provider: collaborationProvider,
191
+ render: (user) => {
192
+ const cursor = document.createElement("span");
193
+ cursor.classList.add("mp-lb-mdkit-collaboration-caret");
194
+ cursor.style.borderColor = user.color;
195
+
196
+ const label = document.createElement("div");
197
+ label.classList.add("mp-lb-mdkit-collaboration-caret-label");
198
+ label.style.backgroundColor = user.color;
199
+ label.textContent = user.name;
200
+ cursor.appendChild(label);
201
+
202
+ return cursor;
203
+ },
204
+ selectionRender: (user) => ({
205
+ style: `background-color: ${user.color}20`,
206
+ }),
207
+ user: {
208
+ color: collaborationUserColor,
209
+ id: collaborationUserId,
210
+ imageUrl: collaborationUserImageUrl || undefined,
211
+ name: collaborationUserName,
212
+ },
213
+ }),
214
+ ]
215
+ : [],
216
+ [
217
+ collaborationProvider,
218
+ collaborationUserColor,
219
+ collaborationUserId,
220
+ collaborationUserImageUrl,
221
+ collaborationUserName,
222
+ ],
223
+ );
224
+
225
+ const editor = useEditor(
226
+ {
227
+ content: hasCollaboration
228
+ ? undefined
229
+ : prepareMarkdownForEditorHydration(
230
+ ignoreYamlFrontMatter
231
+ ? extractYamlFrontMatter(markdownValue).body
232
+ : markdownValue,
233
+ ),
234
+ contentType: "markdown",
235
+ editable: !readOnly,
236
+ editorProps: {
237
+ attributes: {
238
+ class: "mp-lb-mdkit-tiptap",
239
+ spellcheck: "false",
240
+ },
241
+ },
242
+ extensions: [
243
+ ...createMdKitTiptapExtensions({
244
+ placeholder,
245
+ undoRedo: !hasCollaboration,
246
+ }),
247
+ ...(collaborationDocument
248
+ ? [
249
+ Collaboration.configure({
250
+ document: collaborationDocument,
251
+ }),
252
+ ...collaborationCaretExtensions,
253
+ ]
254
+ : []),
255
+ ],
256
+ onBlur: ({ editor: blurredEditor }) => {
257
+ onDebugEventRef.current?.({
258
+ detail: createEditorDebugSnapshot(blurredEditor, "blur"),
259
+ timestamp: Date.now(),
260
+ type: "editor-blur",
261
+ });
262
+
263
+ onFocusChangeRef.current?.(false);
264
+ },
265
+ onFocus: ({ editor: focusedEditor }) => {
266
+ onDebugEventRef.current?.({
267
+ detail: createEditorDebugSnapshot(focusedEditor, "focus"),
268
+ timestamp: Date.now(),
269
+ type: "editor-focus",
270
+ });
271
+
272
+ onFocusChangeRef.current?.(true);
273
+ },
274
+ onUpdate: ({ editor: updatedEditor }) => {
275
+ if (isApplyingExternalValueRef.current) {
276
+ return;
277
+ }
278
+
279
+ const nextSerializedMarkdown = normalizeMarkdownSerialization(
280
+ updatedEditor.getMarkdown(),
281
+ );
282
+
283
+ const previousMarkdown = currentMarkdownRef.current;
284
+ const nextMarkdown = prependYamlFrontMatter(
285
+ yamlFrontMatterRef.current,
286
+ nextSerializedMarkdown,
287
+ );
288
+
289
+ currentMarkdownRef.current = nextMarkdown;
290
+
291
+ if (nextMarkdown !== previousMarkdown) {
292
+ pendingControlledEchoesRef.current.add(nextMarkdown);
293
+ onChangeRef.current?.(nextMarkdown);
294
+ }
295
+ },
296
+ },
297
+ [
298
+ collaborationCaretExtensions,
299
+ collaborationDocument,
300
+ hasCollaboration,
301
+ ignoreYamlFrontMatter,
302
+ placeholder,
303
+ ],
304
+ );
305
+
306
+ const searchMatches = useMemo<MarkdownSearchMatch[]>(() => {
307
+ const query = searchQuery.trim().toLocaleLowerCase();
308
+
309
+ if (!editor || query.length === 0) {
310
+ return [];
311
+ }
312
+
313
+ const matches: MarkdownSearchMatch[] = [];
314
+
315
+ editor.state.doc.descendants((node, position) => {
316
+ if (!node.isText || typeof node.text !== "string") {
317
+ return;
318
+ }
319
+
320
+ const text = node.text.toLocaleLowerCase();
321
+ let fromIndex = text.indexOf(query);
322
+
323
+ while (fromIndex >= 0) {
324
+ matches.push({
325
+ from: position + fromIndex,
326
+ to: position + fromIndex + query.length,
327
+ });
328
+
329
+ fromIndex = text.indexOf(query, fromIndex + query.length);
330
+ }
331
+ });
332
+
333
+ return matches;
334
+ }, [editor, searchQuery, markdownValue]);
335
+
336
+ const activeSearchMatchNumber =
337
+ searchMatches.length === 0 ? 0 : activeSearchMatchIndex + 1;
338
+
339
+ const scrollActiveSearchMatchIntoView = useCallback(() => {
340
+ window.requestAnimationFrame(() => {
341
+ const activeMatch = editorSurfaceRef.current?.querySelector(
342
+ ".mp-lb-mdkit-search-match-active",
343
+ );
344
+
345
+ if (!activeMatch || !("scrollIntoView" in activeMatch)) {
346
+ return;
347
+ }
348
+
349
+ activeMatch.scrollIntoView({
350
+ block: "center",
351
+ inline: "nearest",
352
+ });
353
+ });
354
+ }, []);
355
+
356
+ const selectSearchMatch = useCallback(
357
+ (matchIndex: number) => {
358
+ if (!editor || searchMatches.length === 0) {
359
+ return;
360
+ }
361
+
362
+ const nextIndex =
363
+ ((matchIndex % searchMatches.length) + searchMatches.length) %
364
+ searchMatches.length;
365
+
366
+ setActiveSearchMatchIndex(nextIndex);
367
+ editor.view.dispatch(
368
+ editor.state.tr
369
+ .setMeta(markdownSearchPluginKey, {
370
+ activeIndex: nextIndex,
371
+ matches: searchMatches,
372
+ })
373
+ .setMeta("addToHistory", false),
374
+ );
375
+ scrollActiveSearchMatchIntoView();
376
+ },
377
+ [editor, scrollActiveSearchMatchIntoView, searchMatches],
378
+ );
379
+
380
+ const openSearch = useCallback(() => {
381
+ if (!search) {
382
+ return;
383
+ }
384
+
385
+ setSearchOpen(true);
386
+ window.requestAnimationFrame(() => {
387
+ searchInputRef.current?.focus();
388
+ searchInputRef.current?.select();
389
+ });
390
+ }, [search]);
391
+
392
+ const closeSearch = useCallback(() => {
393
+ setSearchOpen(false);
394
+
395
+ if (!editor) {
396
+ return;
397
+ }
398
+
399
+ const activeMatch = searchMatches[activeSearchMatchIndex];
400
+
401
+ if (!activeMatch) {
402
+ editor.commands.focus();
403
+ return;
404
+ }
405
+
406
+ editor
407
+ .chain()
408
+ .focus()
409
+ .setTextSelection({ from: activeMatch.from, to: activeMatch.to })
410
+ .scrollIntoView()
411
+ .run();
412
+ }, [activeSearchMatchIndex, editor, searchMatches]);
413
+
414
+ const selectNextSearchMatch = useCallback(() => {
415
+ selectSearchMatch(activeSearchMatchIndex + 1);
416
+ }, [activeSearchMatchIndex, selectSearchMatch]);
417
+
418
+ const selectPreviousSearchMatch = useCallback(() => {
419
+ selectSearchMatch(activeSearchMatchIndex - 1);
420
+ }, [activeSearchMatchIndex, selectSearchMatch]);
421
+
422
+ useEffect(() => {
423
+ if (!search) {
424
+ setSearchOpen(false);
425
+ setSearchQuery("");
426
+ }
427
+ }, [search]);
428
+
429
+ useEffect(() => {
430
+ if (!search || !editor) {
431
+ return;
432
+ }
433
+
434
+ const handleSearchShortcut = (event: globalThis.KeyboardEvent) => {
435
+ const isFindShortcut =
436
+ (event.metaKey || event.ctrlKey) &&
437
+ !event.altKey &&
438
+ event.key.toLocaleLowerCase() === "f";
439
+
440
+ if (!isFindShortcut) {
441
+ return;
442
+ }
443
+
444
+ if (
445
+ document.activeElement instanceof Element &&
446
+ !editorSurfaceRef.current?.contains(document.activeElement)
447
+ ) {
448
+ return;
449
+ }
450
+
451
+ event.preventDefault();
452
+ openSearch();
453
+ };
454
+
455
+ document.addEventListener("keydown", handleSearchShortcut);
456
+
457
+ return () => {
458
+ document.removeEventListener("keydown", handleSearchShortcut);
459
+ };
460
+ }, [editor, openSearch, search]);
461
+
462
+ useEffect(() => {
463
+ setActiveSearchMatchIndex(0);
464
+ }, [searchQuery]);
465
+
466
+ useEffect(() => {
467
+ if (!searchOpen || searchQuery.trim().length === 0) {
468
+ editor?.view.dispatch(
469
+ editor.state.tr
470
+ .setMeta(markdownSearchPluginKey, {
471
+ activeIndex: 0,
472
+ matches: [],
473
+ })
474
+ .setMeta("addToHistory", false),
475
+ );
476
+ return;
477
+ }
478
+
479
+ const nextActiveSearchMatchIndex = Math.min(
480
+ activeSearchMatchIndex,
481
+ Math.max(0, searchMatches.length - 1),
482
+ );
483
+
484
+ editor?.view.dispatch(
485
+ editor.state.tr
486
+ .setMeta(markdownSearchPluginKey, {
487
+ activeIndex: nextActiveSearchMatchIndex,
488
+ matches: searchMatches,
489
+ })
490
+ .setMeta("addToHistory", false),
491
+ );
492
+ scrollActiveSearchMatchIntoView();
493
+ }, [
494
+ activeSearchMatchIndex,
495
+ editor,
496
+ scrollActiveSearchMatchIntoView,
497
+ searchMatches,
498
+ searchOpen,
499
+ searchQuery,
500
+ ]);
501
+
502
+ useEffect(() => {
503
+ editor?.setEditable(!readOnly);
504
+ }, [editor, readOnly]);
505
+
506
+ useEffect(() => {
507
+ if (!editor) {
508
+ return;
509
+ }
510
+
511
+ const blurEditorOnExternalClick = (event: globalThis.MouseEvent) => {
512
+ if (editor.isDestroyed) {
513
+ return;
514
+ }
515
+
516
+ if (!editor.isFocused && !editor.view.hasFocus()) {
517
+ return;
518
+ }
519
+
520
+ const target = event.target;
521
+
522
+ if (!(target instanceof Element)) {
523
+ return;
524
+ }
525
+
526
+ if (
527
+ editorSurfaceRef.current?.contains(target) ||
528
+ target.closest(".mp-lb-mdkit-toolbar")
529
+ ) {
530
+ return;
531
+ }
532
+
533
+ if (isNativeFocusTarget(target)) {
534
+ return;
535
+ }
536
+
537
+ editor.commands.blur();
538
+ };
539
+
540
+ document.addEventListener("click", blurEditorOnExternalClick);
541
+
542
+ return () => {
543
+ document.removeEventListener("click", blurEditorOnExternalClick);
544
+ };
545
+ }, [editor]);
546
+
547
+ useEffect(() => {
548
+ if (!editor) {
549
+ return;
550
+ }
551
+
552
+ if (hasCollaboration) {
553
+ currentMarkdownRef.current = markdownValue;
554
+ yamlFrontMatterRef.current = ignoreYamlFrontMatter
555
+ ? extractYamlFrontMatter(markdownValue).frontMatter
556
+ : null;
557
+ pendingControlledEchoesRef.current.clear();
558
+ return;
559
+ }
560
+
561
+ if (markdownValue === currentMarkdownRef.current) {
562
+ pendingControlledEchoesRef.current.clear();
563
+ return;
564
+ }
565
+
566
+ if (pendingControlledEchoesRef.current.has(markdownValue)) {
567
+ pendingControlledEchoesRef.current.delete(markdownValue);
568
+ return;
569
+ }
570
+
571
+ pendingControlledEchoesRef.current.clear();
572
+ isApplyingExternalValueRef.current = true;
573
+ const frontMatter = ignoreYamlFrontMatter
574
+ ? extractYamlFrontMatter(markdownValue)
575
+ : null;
576
+
577
+ editor.commands.setContent(
578
+ prepareMarkdownForEditorHydration(frontMatter?.body ?? markdownValue),
579
+ {
580
+ contentType: "markdown",
581
+ emitUpdate: false,
582
+ },
583
+ );
584
+
585
+ currentMarkdownRef.current = markdownValue;
586
+ yamlFrontMatterRef.current = frontMatter?.frontMatter ?? null;
587
+
588
+ window.queueMicrotask(() => {
589
+ isApplyingExternalValueRef.current = false;
590
+ });
591
+ }, [editor, hasCollaboration, ignoreYamlFrontMatter, markdownValue]);
592
+
593
+ if (!editor) {
594
+ return (
595
+ <div className="mp-lb-mdkit-editor-shell">
596
+ <div className="mp-lb-mdkit-editor-empty">
597
+ {collaboration
598
+ ? "Connecting collaboration session..."
599
+ : "Loading editor..."}
600
+ </div>
601
+ </div>
602
+ );
603
+ }
604
+
605
+ const getProseMirrorElement = () =>
606
+ editorSurfaceRef.current?.querySelector(
607
+ ".ProseMirror",
608
+ ) as HTMLElement | null;
609
+
610
+ const clampEditorPosition = (position: number) =>
611
+ Math.max(0, Math.min(position, editor.state.doc.content.size));
612
+
613
+ const getEditorBackgroundPositionAtClientPoint = (
614
+ proseMirror: HTMLElement,
615
+ clientY: number,
616
+ ) => {
617
+ const blockElements = Array.from(proseMirror.children).filter(
618
+ (child): child is HTMLElement => child instanceof HTMLElement,
619
+ );
620
+
621
+ if (blockElements.length === 0) {
622
+ return null;
623
+ }
624
+
625
+ for (let index = 0; index < blockElements.length; index += 1) {
626
+ const block = blockElements[index];
627
+ const rect = block.getBoundingClientRect();
628
+
629
+ if (clientY <= rect.bottom) {
630
+ if (clientY >= rect.top) {
631
+ return null;
632
+ }
633
+
634
+ const previousBlock = blockElements[index - 1];
635
+
636
+ if (previousBlock) {
637
+ const previousRect = previousBlock.getBoundingClientRect();
638
+ const distanceFromPrevious = Math.abs(clientY - previousRect.bottom);
639
+ const distanceFromNext = Math.abs(rect.top - clientY);
640
+
641
+ if (distanceFromPrevious <= distanceFromNext) {
642
+ return editor.view.posAtDOM(
643
+ previousBlock,
644
+ previousBlock.childNodes.length,
645
+ );
646
+ }
647
+ }
648
+
649
+ return editor.view.posAtDOM(block, 0);
650
+ }
651
+ }
652
+
653
+ const lastBlock = blockElements[blockElements.length - 1];
654
+
655
+ return editor.view.posAtDOM(lastBlock, lastBlock.childNodes.length);
656
+ };
657
+
658
+ const getEditorPositionAtClientPoint = (
659
+ clientX: number,
660
+ clientY: number,
661
+ target: EventTarget | null,
662
+ ) => {
663
+ const proseMirror = getProseMirrorElement();
664
+
665
+ const targetIsEditorBackground =
666
+ proseMirror &&
667
+ target instanceof Element &&
668
+ (target === proseMirror || !proseMirror.contains(target));
669
+
670
+ if (proseMirror && targetIsEditorBackground) {
671
+ const backgroundPosition = getEditorBackgroundPositionAtClientPoint(
672
+ proseMirror,
673
+ clientY,
674
+ );
675
+
676
+ if (typeof backgroundPosition === "number") {
677
+ return clampEditorPosition(backgroundPosition);
678
+ }
679
+ }
680
+
681
+ let coordinatePosition: number | undefined;
682
+
683
+ try {
684
+ coordinatePosition = editor.view.posAtCoords({
685
+ left: clientX,
686
+ top: clientY,
687
+ })?.pos;
688
+ } catch {
689
+ coordinatePosition = undefined;
690
+ }
691
+
692
+ if (typeof coordinatePosition !== "number") {
693
+ return editor.state.doc.content.size;
694
+ }
695
+
696
+ return clampEditorPosition(coordinatePosition);
697
+ };
698
+
699
+ const focusEditorAtPosition = (position: number) => {
700
+ if (editor.isDestroyed || readOnly) {
701
+ return;
702
+ }
703
+
704
+ const { state, view } = editor;
705
+
706
+ const nextPosition = Math.max(
707
+ 0,
708
+ Math.min(position, state.doc.content.size),
709
+ );
710
+
711
+ try {
712
+ emitDebugEvent("focus-at-position-before", {
713
+ requestedPosition: nextPosition,
714
+ });
715
+
716
+ view.focus();
717
+ editor.commands.setTextSelection(nextPosition);
718
+ view.focus();
719
+ emitDebugEvent("focus-at-position-after", {
720
+ requestedPosition: nextPosition,
721
+ });
722
+ } catch {
723
+ emitDebugEvent("focus-at-position-aborted", {
724
+ requestedPosition: nextPosition,
725
+ });
726
+ }
727
+ };
728
+
729
+ const queueEditorFocusAtPosition = (position: number) => {
730
+ emitDebugEvent("focus-queued", {
731
+ requestedPosition: position,
732
+ });
733
+
734
+ window.setTimeout(() => {
735
+ focusEditorAtPosition(position);
736
+
737
+ window.requestAnimationFrame(() => {
738
+ emitDebugEvent("focus-raf-run", {
739
+ requestedPosition: position,
740
+ });
741
+
742
+ focusEditorAtPosition(position);
743
+ });
744
+ }, 0);
745
+ };
746
+
747
+ const emitDebugEvent = (type: string, detail: Record<string, unknown>) => {
748
+ const event = {
749
+ detail: {
750
+ ...detail,
751
+ ...createEditorDebugSnapshot(editor, type),
752
+ },
753
+ timestamp: Date.now(),
754
+ type,
755
+ };
756
+
757
+ onDebugEventRef.current?.(event);
758
+ };
759
+
760
+ const shouldFocusEditorBackground = (target: EventTarget | null) => {
761
+ if (!(target instanceof Element)) {
762
+ emitDebugEvent("hitbox-target-not-element", {
763
+ targetType: typeof target,
764
+ });
765
+
766
+ return false;
767
+ }
768
+
769
+ const proseMirror = getProseMirrorElement();
770
+
771
+ if (!proseMirror) {
772
+ emitDebugEvent("hitbox-missing-prosemirror", {
773
+ target: describeElement(target),
774
+ });
775
+
776
+ return false;
777
+ }
778
+
779
+ const targetIsInsideEditor = proseMirror.contains(target);
780
+
781
+ const targetIsEmptyPlaceholder = !!target.closest(
782
+ ".ProseMirror p.is-editor-empty",
783
+ );
784
+
785
+ const editorIsEmpty = editor.isEmpty;
786
+
787
+ const shouldFocus =
788
+ !isInteractiveElement(target) &&
789
+ (target === proseMirror ||
790
+ !targetIsInsideEditor ||
791
+ targetIsEmptyPlaceholder ||
792
+ (editorIsEmpty && targetIsInsideEditor));
793
+
794
+ emitDebugEvent("hitbox-check", {
795
+ editorIsEmpty,
796
+ proseMirrorContainsTarget: targetIsInsideEditor,
797
+ shouldFocus,
798
+ target: describeElement(target),
799
+ targetIsEmptyPlaceholder,
800
+ targetIsProseMirror: target === proseMirror,
801
+ });
802
+
803
+ return shouldFocus;
804
+ };
805
+
806
+ const focusEditorBackgroundOnPointerDown = (
807
+ event: PointerEvent<HTMLDivElement>,
808
+ ) => {
809
+ const proseMirror = editorSurfaceRef.current?.querySelector(
810
+ ".ProseMirror",
811
+ ) as HTMLElement | null;
812
+
813
+ const target = event.target;
814
+
815
+ if (readOnly) {
816
+ return;
817
+ }
818
+
819
+ if (
820
+ proseMirror &&
821
+ target instanceof Element &&
822
+ proseMirror.contains(target) &&
823
+ target !== proseMirror &&
824
+ !editor.isFocused &&
825
+ !editor.isEmpty &&
826
+ !target.closest(".ProseMirror p.is-editor-empty") &&
827
+ !isInteractiveElement(target)
828
+ ) {
829
+ pendingContentFocusRef.current = {
830
+ pointerId: event.pointerId,
831
+ x: event.clientX,
832
+ y: event.clientY,
833
+ };
834
+
835
+ emitDebugEvent("content-pointer-focus-pending", {
836
+ pointerType: event.pointerType,
837
+ target: describeElement(target),
838
+ });
839
+
840
+ return;
841
+ }
842
+
843
+ if (!shouldFocusEditorBackground(event.target)) {
844
+ return;
845
+ }
846
+
847
+ const requestedPosition = getEditorPositionAtClientPoint(
848
+ event.clientX,
849
+ event.clientY,
850
+ event.target,
851
+ );
852
+
853
+ event.preventDefault();
854
+ emitDebugEvent("hitbox-pointer-down", {
855
+ defaultPrevented: event.defaultPrevented,
856
+ pointerType: event.pointerType,
857
+ requestedPosition,
858
+ target: describeEventTarget(event.target),
859
+ });
860
+
861
+ queueEditorFocusAtPosition(requestedPosition);
862
+ };
863
+
864
+ const focusEditorBackgroundOnPointerUp = (
865
+ event: PointerEvent<HTMLDivElement>,
866
+ ) => {
867
+ const pendingContentFocus = pendingContentFocusRef.current;
868
+
869
+ if (readOnly) {
870
+ pendingContentFocusRef.current = null;
871
+ return;
872
+ }
873
+
874
+ if (
875
+ pendingContentFocus &&
876
+ pendingContentFocus.pointerId === event.pointerId
877
+ ) {
878
+ pendingContentFocusRef.current = null;
879
+
880
+ const moved = Math.hypot(
881
+ event.clientX - pendingContentFocus.x,
882
+ event.clientY - pendingContentFocus.y,
883
+ );
884
+
885
+ emitDebugEvent("content-pointer-focus-resolve", {
886
+ moved,
887
+ pointerType: event.pointerType,
888
+ });
889
+
890
+ if (moved < 4 && !editor.isFocused) {
891
+ editor.view.focus();
892
+ }
893
+
894
+ return;
895
+ }
896
+ };
897
+
898
+ return (
899
+ <div className="mp-lb-mdkit-editor-shell">
900
+ <div
901
+ ref={editorSurfaceRef}
902
+ className="mp-lb-mdkit-editor-surface"
903
+ onPointerDownCapture={focusEditorBackgroundOnPointerDown}
904
+ onPointerUpCapture={focusEditorBackgroundOnPointerUp}
905
+ >
906
+ {search && searchOpen ? (
907
+ <MarkdownSearchPanel
908
+ activeMatchNumber={activeSearchMatchNumber}
909
+ inputRef={searchInputRef}
910
+ matchCount={searchMatches.length}
911
+ onClose={closeSearch}
912
+ onNext={selectNextSearchMatch}
913
+ onPrevious={selectPreviousSearchMatch}
914
+ onQueryChange={setSearchQuery}
915
+ query={searchQuery}
916
+ />
917
+ ) : null}
918
+ <MarkdownBubbleMenu editor={editor} />
919
+ <EditorContent editor={editor} />
920
+ </div>
921
+ </div>
922
+ );
923
+ };