@mp-lb/mdkit 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -63,6 +63,7 @@ import {
63
63
 
64
64
  const document = useMdKitDocument({
65
65
  adapter,
66
+ debounceMs: 1000,
66
67
  documentId: "docs/brief.md",
67
68
  });
68
69
 
@@ -5,7 +5,7 @@ const emptyDocumentState = {
5
5
  version: null,
6
6
  };
7
7
  export const useMdKitDocument = (options) => {
8
- const { adapter, debounceMs = 350, documentId, pollMs = 2000 } = options;
8
+ const { adapter, debounceMs = 1000, documentId, pollMs = 2000 } = options;
9
9
  const [local, setLocal] = useState("");
10
10
  const [base, setBase] = useState("");
11
11
  const [version, setVersion] = useState(null);
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export { MdKitConflictPanel } from "./document/MdKitConflictPanel.js";
6
6
  export { MdKitDocumentToolbar } from "./document/MdKitDocumentToolbar.js";
7
7
  export { MdKitEditor } from "./markdown/MdKitEditor.js";
8
8
  export { MdKitView } from "./markdown/MdKitView.js";
9
+ export { extractYamlFrontMatter, hasYamlFrontMatter, parseYamlFrontMatter, prependYamlFrontMatter, removeYamlFrontMatter, } from "./markdown/yamlFrontMatter.js";
9
10
  export { MdKitThemeEditor } from "./theme/MdKitThemeEditor.js";
10
11
  export { createMdKitRestAdapter } from "./transport/rest.js";
11
12
  export { createMdKitEditorThemeStyle, darkMdKitEditorTheme, defaultMdKitEditorTheme, } from "./theme/editorTheme.js";
@@ -21,6 +22,7 @@ export type { MdKitDocumentToolbarProps } from "./document/MdKitDocumentToolbar.
21
22
  export type { MdKitEditorProps } from "./markdown/MdKitEditor.js";
22
23
  export type { MdKitEditorDebugEvent } from "./markdown/editorDebug.js";
23
24
  export type { MdKitViewProps } from "./markdown/MdKitView.js";
25
+ export type { MdKitYamlFrontMatter, MdKitYamlFrontMatterExtraction, } from "./markdown/yamlFrontMatter.js";
24
26
  export type { MdKitThemeEditorProps } from "./theme/MdKitThemeEditor.js";
25
27
  export type { CreateMdKitRestAdapterOptions } from "./transport/rest.js";
26
28
  export type { MdKitEditorTheme, MdKitEditorThemeStyle, } from "./theme/editorTheme.js";
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ export { MdKitConflictPanel } from "./document/MdKitConflictPanel.js";
6
6
  export { MdKitDocumentToolbar } from "./document/MdKitDocumentToolbar.js";
7
7
  export { MdKitEditor } from "./markdown/MdKitEditor.js";
8
8
  export { MdKitView } from "./markdown/MdKitView.js";
9
+ export { extractYamlFrontMatter, hasYamlFrontMatter, parseYamlFrontMatter, prependYamlFrontMatter, removeYamlFrontMatter, } from "./markdown/yamlFrontMatter.js";
9
10
  export { MdKitThemeEditor } from "./theme/MdKitThemeEditor.js";
10
11
  export { createMdKitRestAdapter } from "./transport/rest.js";
11
12
  export { createMdKitEditorThemeStyle, darkMdKitEditorTheme, defaultMdKitEditorTheme, } from "./theme/editorTheme.js";
@@ -0,0 +1,3 @@
1
+ import { Extension } from "@tiptap/core";
2
+ export declare const shouldPastePlainTextAsMarkdown: (text: string) => boolean;
3
+ export declare const MarkdownPasteExtension: Extension<any, any>;
@@ -0,0 +1,62 @@
1
+ import { Extension } from "@tiptap/core";
2
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
3
+ const markdownPastePluginKey = new PluginKey("mdkitMarkdownPaste");
4
+ const blockMarkdownPatterns = [
5
+ /^ {0,3}#{1,6}\s+\S/m,
6
+ /^ {0,3}(?:[-+*]|\d+[.)])\s+\S/m,
7
+ /^ {0,3}>\s+\S/m,
8
+ /^ {0,3}(?:```|~~~)/m,
9
+ /^ {0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/m,
10
+ /^\|.+\|\s*\n\|(?:\s*:?-{3,}:?\s*\|)+/m,
11
+ ];
12
+ const inlineMarkdownPatterns = [
13
+ /!\[[^\]]*]\([^)]+\)/,
14
+ /\[[^\]]+]\([^)]+\)/,
15
+ /(^|[^\w])(?:\*\*|__)\S[\s\S]*?\S(?:\*\*|__)($|[^\w])/,
16
+ /(^|[^\w])`[^`\n]+`($|[^\w])/,
17
+ ];
18
+ const getClipboardValue = (event, mimeType) => {
19
+ try {
20
+ return event.clipboardData?.getData(mimeType) ?? "";
21
+ }
22
+ catch {
23
+ return "";
24
+ }
25
+ };
26
+ export const shouldPastePlainTextAsMarkdown = (text) => {
27
+ const trimmedText = text.trim();
28
+ if (trimmedText.length === 0) {
29
+ return false;
30
+ }
31
+ return [...blockMarkdownPatterns, ...inlineMarkdownPatterns].some((pattern) => pattern.test(trimmedText));
32
+ };
33
+ export const MarkdownPasteExtension = Extension.create({
34
+ name: "mdkitMarkdownPaste",
35
+ addProseMirrorPlugins() {
36
+ return [
37
+ new Plugin({
38
+ key: markdownPastePluginKey,
39
+ props: {
40
+ handlePaste: (view, event) => {
41
+ if (view.state.selection.$from.parent.type.spec.code) {
42
+ return false;
43
+ }
44
+ const markdownText = getClipboardValue(event, "text/markdown");
45
+ const plainText = getClipboardValue(event, "text/plain");
46
+ const pastedMarkdown = markdownText || plainText;
47
+ if (!pastedMarkdown.trim()) {
48
+ return false;
49
+ }
50
+ if (!markdownText &&
51
+ !shouldPastePlainTextAsMarkdown(pastedMarkdown)) {
52
+ return false;
53
+ }
54
+ return this.editor.commands.insertContent(pastedMarkdown, {
55
+ contentType: "markdown",
56
+ });
57
+ },
58
+ },
59
+ }),
60
+ ];
61
+ },
62
+ });
@@ -0,0 +1,9 @@
1
+ import { Extension } from "@tiptap/core";
2
+ import { PluginKey } from "@tiptap/pm/state";
3
+ import { DecorationSet } from "@tiptap/pm/view";
4
+ export type MarkdownSearchMatch = {
5
+ from: number;
6
+ to: number;
7
+ };
8
+ export declare const markdownSearchPluginKey: PluginKey<DecorationSet>;
9
+ export declare const MarkdownSearchExtension: Extension<any, any>;
@@ -0,0 +1,42 @@
1
+ import { Extension } from "@tiptap/core";
2
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
3
+ import { Decoration, DecorationSet } from "@tiptap/pm/view";
4
+ export const markdownSearchPluginKey = new PluginKey("mdkitMarkdownSearch");
5
+ const createSearchDecorations = (document, { activeIndex, matches }) => DecorationSet.create(document, matches.map((match, index) => Decoration.inline(match.from, match.to, {
6
+ class: index === activeIndex
7
+ ? "mp-lb-mdkit-search-match mp-lb-mdkit-search-match-active"
8
+ : "mp-lb-mdkit-search-match",
9
+ })));
10
+ export const MarkdownSearchExtension = Extension.create({
11
+ name: "mdkitMarkdownSearch",
12
+ addProseMirrorPlugins() {
13
+ return [
14
+ new Plugin({
15
+ key: markdownSearchPluginKey,
16
+ props: {
17
+ decorations(state) {
18
+ return markdownSearchPluginKey.getState(state);
19
+ },
20
+ },
21
+ state: {
22
+ apply(transaction, previousDecorations) {
23
+ const searchMeta = transaction.getMeta(markdownSearchPluginKey);
24
+ if (searchMeta) {
25
+ return createSearchDecorations(transaction.doc, searchMeta);
26
+ }
27
+ if (transaction.docChanged) {
28
+ return previousDecorations.map(transaction.mapping, transaction.doc);
29
+ }
30
+ return previousDecorations;
31
+ },
32
+ init(_config, instance) {
33
+ return createSearchDecorations(instance.doc, {
34
+ activeIndex: 0,
35
+ matches: [],
36
+ });
37
+ },
38
+ },
39
+ }),
40
+ ];
41
+ },
42
+ });
@@ -0,0 +1,13 @@
1
+ import type { RefObject } from "react";
2
+ type MarkdownSearchPanelProps = {
3
+ activeMatchNumber: number;
4
+ inputRef: RefObject<HTMLInputElement | null>;
5
+ matchCount: number;
6
+ onClose: () => void;
7
+ onPrevious: () => void;
8
+ onNext: () => void;
9
+ onQueryChange: (query: string) => void;
10
+ query: string;
11
+ };
12
+ export declare const MarkdownSearchPanel: ({ activeMatchNumber, inputRef, matchCount, onClose, onPrevious, onNext, onQueryChange, query, }: MarkdownSearchPanelProps) => import("react/jsx-runtime").JSX.Element;
13
+ export {};
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ChevronDown, ChevronUp, Search, X } from "lucide-react";
3
+ export const MarkdownSearchPanel = ({ activeMatchNumber, inputRef, matchCount, onClose, onPrevious, onNext, onQueryChange, query, }) => {
4
+ const submitSearch = (event) => {
5
+ event.preventDefault();
6
+ onNext();
7
+ };
8
+ const handleSearchKeyDown = (event) => {
9
+ if (event.key === "Escape") {
10
+ event.preventDefault();
11
+ onClose();
12
+ return;
13
+ }
14
+ if (event.key === "Enter" && event.shiftKey) {
15
+ event.preventDefault();
16
+ onPrevious();
17
+ }
18
+ };
19
+ const matchStatus = query.trim().length === 0
20
+ ? "No query"
21
+ : matchCount === 0
22
+ ? "No matches"
23
+ : `${activeMatchNumber} of ${matchCount}`;
24
+ return (_jsxs("form", { "aria-label": "Search document", className: "mp-lb-mdkit-search-panel", onSubmit: submitSearch, children: [_jsx(Search, { "aria-hidden": "true", className: "mp-lb-mdkit-search-icon" }), _jsx("input", { ref: inputRef, "aria-label": "Search document", className: "mp-lb-mdkit-search-input", onChange: (event) => onQueryChange(event.target.value), onKeyDown: handleSearchKeyDown, placeholder: "Search", type: "search", value: query }), _jsx("span", { className: "mp-lb-mdkit-search-status", children: matchStatus }), _jsx("button", { "aria-label": "Previous match", className: "mp-lb-mdkit-search-button", disabled: matchCount === 0, onClick: onPrevious, title: "Previous match", type: "button", children: _jsx(ChevronUp, { "aria-hidden": "true" }) }), _jsx("button", { "aria-label": "Next match", className: "mp-lb-mdkit-search-button", disabled: matchCount === 0, title: "Next match", type: "submit", children: _jsx(ChevronDown, { "aria-hidden": "true" }) }), _jsx("button", { "aria-label": "Close search", className: "mp-lb-mdkit-search-button", onClick: onClose, title: "Close search", type: "button", children: _jsx(X, { "aria-hidden": "true" }) })] }));
25
+ };
@@ -4,10 +4,12 @@ import type { MdKitEditorDebugEvent } from "./editorDebug.js";
4
4
  type MdKitEditorBaseProps = {
5
5
  className?: string;
6
6
  fillHeight?: boolean;
7
+ ignoreYamlFrontMatter?: boolean;
7
8
  instanceKey?: string | number;
8
9
  onDebugEvent?: (event: MdKitEditorDebugEvent) => void;
9
10
  onFocusChange?: (focused: boolean) => void;
10
11
  readOnly?: boolean;
12
+ search?: boolean;
11
13
  style?: CSSProperties;
12
14
  };
13
15
  type LocalMdKitEditorProps = MdKitEditorBaseProps & {
@@ -2,8 +2,9 @@ import type { CSSProperties } from "react";
2
2
  export type MdKitViewProps = {
3
3
  className?: string;
4
4
  fillHeight?: boolean;
5
+ ignoreYamlFrontMatter?: boolean;
5
6
  placeholder?: string;
6
7
  style?: CSSProperties;
7
8
  value: string;
8
9
  };
9
- export declare const MdKitView: ({ className, fillHeight, placeholder, style, value, }: MdKitViewProps) => import("react/jsx-runtime").JSX.Element;
10
+ export declare const MdKitView: ({ className, fillHeight, ignoreYamlFrontMatter, placeholder, style, value, }: MdKitViewProps) => import("react/jsx-runtime").JSX.Element;
@@ -2,8 +2,12 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import ReactMarkdown from "react-markdown";
3
3
  import remarkGfm from "remark-gfm";
4
4
  import { joinClassNames } from "../ui/joinClassNames.js";
5
- export const MdKitView = ({ className, fillHeight = false, placeholder, style, value, }) => {
6
- const renderedValue = value.trim().length > 0 ? value : (placeholder ?? "");
5
+ import { removeYamlFrontMatter } from "./yamlFrontMatter.js";
6
+ export const MdKitView = ({ className, fillHeight = false, ignoreYamlFrontMatter = false, placeholder, style, value, }) => {
7
+ const markdownValue = ignoreYamlFrontMatter
8
+ ? removeYamlFrontMatter(value)
9
+ : value;
10
+ const renderedValue = markdownValue.trim().length > 0 ? markdownValue : (placeholder ?? "");
7
11
  return (_jsx("div", { className: joinClassNames("mp-lb-mdkit-markdown-editor", "mp-lb-mdkit-markdown-view", fillHeight && "mp-lb-mdkit-markdown-editor-fill-height", className), "data-read-only": "true", style: style, children: _jsx("div", { className: "mp-lb-mdkit-editor-shell", children: _jsx("div", { className: "mp-lb-mdkit-editor-surface", children: renderedValue.length > 0 ? (_jsx("div", { className: "mp-lb-mdkit-tiptap mp-lb-mdkit-view-content", children: _jsx(ReactMarkdown, { components: {
8
12
  a: ({ children, ...linkProps }) => (_jsx("a", { ...linkProps, rel: "noopener noreferrer", target: "_blank", children: children })),
9
13
  }, remarkPlugins: [remarkGfm], children: renderedValue }) })) : (_jsx("div", { className: "mp-lb-mdkit-editor-empty" })) }) }) }));
@@ -5,8 +5,10 @@ type LocalTiptapMarkdownSurfaceProps = {
5
5
  onChange?: (markdown: string) => void;
6
6
  onDebugEvent?: (event: MdKitEditorDebugEvent) => void;
7
7
  onFocusChange?: (focused: boolean) => void;
8
+ ignoreYamlFrontMatter?: boolean;
8
9
  placeholder?: string;
9
10
  readOnly?: boolean;
11
+ search?: boolean;
10
12
  value: string;
11
13
  };
12
14
  type CollaborativeTiptapMarkdownSurfaceProps = {
@@ -14,8 +16,10 @@ type CollaborativeTiptapMarkdownSurfaceProps = {
14
16
  onChange?: (markdown: string) => void;
15
17
  onDebugEvent?: (event: MdKitEditorDebugEvent) => void;
16
18
  onFocusChange?: (focused: boolean) => void;
19
+ ignoreYamlFrontMatter?: boolean;
17
20
  placeholder?: string;
18
21
  readOnly?: boolean;
22
+ search?: boolean;
19
23
  value?: string;
20
24
  };
21
25
  type TiptapMarkdownSurfaceProps = CollaborativeTiptapMarkdownSurfaceProps | LocalTiptapMarkdownSurfaceProps;
@@ -1,10 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useMemo, useRef } from "react";
2
+ import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
3
3
  import Collaboration from "@tiptap/extension-collaboration";
4
4
  import CollaborationCaret from "@tiptap/extension-collaboration-caret";
5
5
  import { EditorContent, useEditor } from "@tiptap/react";
6
6
  import { createMdKitTiptapExtensions } from "./createMdKitTiptapExtensions.js";
7
+ import { extractYamlFrontMatter, prependYamlFrontMatter, } from "./yamlFrontMatter.js";
7
8
  import { MarkdownBubbleMenu } from "./MarkdownBubbleMenu.js";
9
+ import { MarkdownSearchPanel } from "./MarkdownSearchPanel.js";
10
+ import { markdownSearchPluginKey, } from "./MarkdownSearchExtension.js";
8
11
  import { normalizeMarkdownSerialization } from "./normalizeMarkdownSerialization.js";
9
12
  import { prepareMarkdownForEditorHydration } from "./prepareMarkdownForEditorHydration.js";
10
13
  const describeElement = (element) => {
@@ -58,17 +61,24 @@ const createEditorDebugSnapshot = (editor, phase) => {
58
61
  };
59
62
  };
60
63
  export const TiptapMarkdownSurface = (props) => {
61
- const { collaboration = null, onDebugEvent, onFocusChange, placeholder = "Start writing...", readOnly = false, } = props;
64
+ const { collaboration = null, ignoreYamlFrontMatter = false, onDebugEvent, onFocusChange, placeholder = "Start writing...", readOnly = false, search = false, } = props;
62
65
  const markdownValue = "value" in props && typeof props.value === "string" ? props.value : "";
63
66
  const editorSurfaceRef = useRef(null);
67
+ const searchInputRef = useRef(null);
64
68
  const onDebugEventRef = useRef(onDebugEvent);
65
69
  const onFocusChangeRef = useRef(onFocusChange);
66
70
  const onChangeRef = useRef(props.onChange);
67
71
  const currentMarkdownRef = useRef(markdownValue);
72
+ const yamlFrontMatterRef = useRef(ignoreYamlFrontMatter
73
+ ? extractYamlFrontMatter(markdownValue).frontMatter
74
+ : null);
68
75
  const isApplyingExternalValueRef = useRef(false);
69
76
  const pendingControlledEchoesRef = useRef(new Set());
70
77
  const pendingContentFocusRef = useRef(null);
71
78
  const shouldFocusAfterPointerRef = useRef(false);
79
+ const [searchOpen, setSearchOpen] = useState(false);
80
+ const [searchQuery, setSearchQuery] = useState("");
81
+ const [activeSearchMatchIndex, setActiveSearchMatchIndex] = useState(0);
72
82
  const collaborationDocument = collaboration?.document ?? null;
73
83
  const collaborationProvider = collaboration?.provider ?? null;
74
84
  const collaborationUserColor = collaboration?.collaborator.color ?? "";
@@ -121,7 +131,9 @@ export const TiptapMarkdownSurface = (props) => {
121
131
  const editor = useEditor({
122
132
  content: hasCollaboration
123
133
  ? undefined
124
- : prepareMarkdownForEditorHydration(markdownValue),
134
+ : prepareMarkdownForEditorHydration(ignoreYamlFrontMatter
135
+ ? extractYamlFrontMatter(markdownValue).body
136
+ : markdownValue),
125
137
  contentType: "markdown",
126
138
  editable: !readOnly,
127
139
  editorProps: {
@@ -166,7 +178,7 @@ export const TiptapMarkdownSurface = (props) => {
166
178
  }
167
179
  const nextSerializedMarkdown = normalizeMarkdownSerialization(updatedEditor.getMarkdown());
168
180
  const previousMarkdown = currentMarkdownRef.current;
169
- const nextMarkdown = nextSerializedMarkdown;
181
+ const nextMarkdown = prependYamlFrontMatter(yamlFrontMatterRef.current, nextSerializedMarkdown);
170
182
  currentMarkdownRef.current = nextMarkdown;
171
183
  if (nextMarkdown !== previousMarkdown) {
172
184
  pendingControlledEchoesRef.current.add(nextMarkdown);
@@ -177,8 +189,150 @@ export const TiptapMarkdownSurface = (props) => {
177
189
  collaborationCaretExtensions,
178
190
  collaborationDocument,
179
191
  hasCollaboration,
192
+ ignoreYamlFrontMatter,
180
193
  placeholder,
181
194
  ]);
195
+ const searchMatches = useMemo(() => {
196
+ const query = searchQuery.trim().toLocaleLowerCase();
197
+ if (!editor || query.length === 0) {
198
+ return [];
199
+ }
200
+ const matches = [];
201
+ editor.state.doc.descendants((node, position) => {
202
+ if (!node.isText || typeof node.text !== "string") {
203
+ return;
204
+ }
205
+ const text = node.text.toLocaleLowerCase();
206
+ let fromIndex = text.indexOf(query);
207
+ while (fromIndex >= 0) {
208
+ matches.push({
209
+ from: position + fromIndex,
210
+ to: position + fromIndex + query.length,
211
+ });
212
+ fromIndex = text.indexOf(query, fromIndex + query.length);
213
+ }
214
+ });
215
+ return matches;
216
+ }, [editor, searchQuery, markdownValue]);
217
+ const activeSearchMatchNumber = searchMatches.length === 0 ? 0 : activeSearchMatchIndex + 1;
218
+ const scrollActiveSearchMatchIntoView = useCallback(() => {
219
+ window.requestAnimationFrame(() => {
220
+ const activeMatch = editorSurfaceRef.current?.querySelector(".mp-lb-mdkit-search-match-active");
221
+ if (!activeMatch || !("scrollIntoView" in activeMatch)) {
222
+ return;
223
+ }
224
+ activeMatch.scrollIntoView({
225
+ block: "center",
226
+ inline: "nearest",
227
+ });
228
+ });
229
+ }, []);
230
+ const selectSearchMatch = useCallback((matchIndex) => {
231
+ if (!editor || searchMatches.length === 0) {
232
+ return;
233
+ }
234
+ const nextIndex = ((matchIndex % searchMatches.length) + searchMatches.length) %
235
+ searchMatches.length;
236
+ setActiveSearchMatchIndex(nextIndex);
237
+ editor.view.dispatch(editor.state.tr
238
+ .setMeta(markdownSearchPluginKey, {
239
+ activeIndex: nextIndex,
240
+ matches: searchMatches,
241
+ })
242
+ .setMeta("addToHistory", false));
243
+ scrollActiveSearchMatchIntoView();
244
+ }, [editor, scrollActiveSearchMatchIntoView, searchMatches]);
245
+ const openSearch = useCallback(() => {
246
+ if (!search) {
247
+ return;
248
+ }
249
+ setSearchOpen(true);
250
+ window.requestAnimationFrame(() => {
251
+ searchInputRef.current?.focus();
252
+ searchInputRef.current?.select();
253
+ });
254
+ }, [search]);
255
+ const closeSearch = useCallback(() => {
256
+ setSearchOpen(false);
257
+ if (!editor) {
258
+ return;
259
+ }
260
+ const activeMatch = searchMatches[activeSearchMatchIndex];
261
+ if (!activeMatch) {
262
+ editor.commands.focus();
263
+ return;
264
+ }
265
+ editor
266
+ .chain()
267
+ .focus()
268
+ .setTextSelection({ from: activeMatch.from, to: activeMatch.to })
269
+ .scrollIntoView()
270
+ .run();
271
+ }, [activeSearchMatchIndex, editor, searchMatches]);
272
+ const selectNextSearchMatch = useCallback(() => {
273
+ selectSearchMatch(activeSearchMatchIndex + 1);
274
+ }, [activeSearchMatchIndex, selectSearchMatch]);
275
+ const selectPreviousSearchMatch = useCallback(() => {
276
+ selectSearchMatch(activeSearchMatchIndex - 1);
277
+ }, [activeSearchMatchIndex, selectSearchMatch]);
278
+ useEffect(() => {
279
+ if (!search) {
280
+ setSearchOpen(false);
281
+ setSearchQuery("");
282
+ }
283
+ }, [search]);
284
+ useEffect(() => {
285
+ if (!search || !editor) {
286
+ return;
287
+ }
288
+ const handleSearchShortcut = (event) => {
289
+ const isFindShortcut = (event.metaKey || event.ctrlKey) &&
290
+ !event.altKey &&
291
+ event.key.toLocaleLowerCase() === "f";
292
+ if (!isFindShortcut) {
293
+ return;
294
+ }
295
+ if (document.activeElement instanceof Element &&
296
+ !editorSurfaceRef.current?.contains(document.activeElement)) {
297
+ return;
298
+ }
299
+ event.preventDefault();
300
+ openSearch();
301
+ };
302
+ document.addEventListener("keydown", handleSearchShortcut);
303
+ return () => {
304
+ document.removeEventListener("keydown", handleSearchShortcut);
305
+ };
306
+ }, [editor, openSearch, search]);
307
+ useEffect(() => {
308
+ setActiveSearchMatchIndex(0);
309
+ }, [searchQuery]);
310
+ useEffect(() => {
311
+ if (!searchOpen || searchQuery.trim().length === 0) {
312
+ editor?.view.dispatch(editor.state.tr
313
+ .setMeta(markdownSearchPluginKey, {
314
+ activeIndex: 0,
315
+ matches: [],
316
+ })
317
+ .setMeta("addToHistory", false));
318
+ return;
319
+ }
320
+ const nextActiveSearchMatchIndex = Math.min(activeSearchMatchIndex, Math.max(0, searchMatches.length - 1));
321
+ editor?.view.dispatch(editor.state.tr
322
+ .setMeta(markdownSearchPluginKey, {
323
+ activeIndex: nextActiveSearchMatchIndex,
324
+ matches: searchMatches,
325
+ })
326
+ .setMeta("addToHistory", false));
327
+ scrollActiveSearchMatchIntoView();
328
+ }, [
329
+ activeSearchMatchIndex,
330
+ editor,
331
+ scrollActiveSearchMatchIntoView,
332
+ searchMatches,
333
+ searchOpen,
334
+ searchQuery,
335
+ ]);
182
336
  useEffect(() => {
183
337
  editor?.setEditable(!readOnly);
184
338
  }, [editor, readOnly]);
@@ -215,6 +369,9 @@ export const TiptapMarkdownSurface = (props) => {
215
369
  }
216
370
  if (hasCollaboration) {
217
371
  currentMarkdownRef.current = markdownValue;
372
+ yamlFrontMatterRef.current = ignoreYamlFrontMatter
373
+ ? extractYamlFrontMatter(markdownValue).frontMatter
374
+ : null;
218
375
  pendingControlledEchoesRef.current.clear();
219
376
  return;
220
377
  }
@@ -228,15 +385,19 @@ export const TiptapMarkdownSurface = (props) => {
228
385
  }
229
386
  pendingControlledEchoesRef.current.clear();
230
387
  isApplyingExternalValueRef.current = true;
231
- editor.commands.setContent(prepareMarkdownForEditorHydration(markdownValue), {
388
+ const frontMatter = ignoreYamlFrontMatter
389
+ ? extractYamlFrontMatter(markdownValue)
390
+ : null;
391
+ editor.commands.setContent(prepareMarkdownForEditorHydration(frontMatter?.body ?? markdownValue), {
232
392
  contentType: "markdown",
233
393
  emitUpdate: false,
234
394
  });
235
395
  currentMarkdownRef.current = markdownValue;
396
+ yamlFrontMatterRef.current = frontMatter?.frontMatter ?? null;
236
397
  window.queueMicrotask(() => {
237
398
  isApplyingExternalValueRef.current = false;
238
399
  });
239
- }, [editor, hasCollaboration, markdownValue]);
400
+ }, [editor, hasCollaboration, ignoreYamlFrontMatter, markdownValue]);
240
401
  if (!editor) {
241
402
  return (_jsx("div", { className: "mp-lb-mdkit-editor-shell", children: _jsx("div", { className: "mp-lb-mdkit-editor-empty", children: collaboration
242
403
  ? "Connecting collaboration session..."
@@ -449,5 +610,5 @@ export const TiptapMarkdownSurface = (props) => {
449
610
  });
450
611
  queueEditorFocusAtPosition(getEditorPositionAtClientPoint(event.clientX, event.clientY, event.target));
451
612
  };
452
- return (_jsx("div", { className: "mp-lb-mdkit-editor-shell", children: _jsxs("div", { ref: editorSurfaceRef, className: "mp-lb-mdkit-editor-surface", onPointerDownCapture: focusEditorBackgroundOnPointerDown, onPointerUpCapture: focusEditorBackgroundOnPointerUp, children: [_jsx(MarkdownBubbleMenu, { editor: editor }), _jsx(EditorContent, { editor: editor })] }) }));
613
+ return (_jsx("div", { className: "mp-lb-mdkit-editor-shell", children: _jsxs("div", { ref: editorSurfaceRef, className: "mp-lb-mdkit-editor-surface", onPointerDownCapture: focusEditorBackgroundOnPointerDown, onPointerUpCapture: focusEditorBackgroundOnPointerUp, children: [search && searchOpen ? (_jsx(MarkdownSearchPanel, { activeMatchNumber: activeSearchMatchNumber, inputRef: searchInputRef, matchCount: searchMatches.length, onClose: closeSearch, onNext: selectNextSearchMatch, onPrevious: selectPreviousSearchMatch, onQueryChange: setSearchQuery, query: searchQuery })) : null, _jsx(MarkdownBubbleMenu, { editor: editor }), _jsx(EditorContent, { editor: editor })] }) }));
453
614
  };
@@ -1,6 +1,8 @@
1
1
  import { Markdown } from "@tiptap/markdown";
2
2
  import Placeholder from "@tiptap/extension-placeholder";
3
3
  import StarterKit from "@tiptap/starter-kit";
4
+ import { MarkdownPasteExtension } from "./MarkdownPasteExtension.js";
5
+ import { MarkdownSearchExtension } from "./MarkdownSearchExtension.js";
4
6
  export const defaultMdKitMarkdownPlaceholder = "Start writing...";
5
7
  export const createMdKitTiptapExtensions = ({ placeholder = defaultMdKitMarkdownPlaceholder, undoRedo = true, } = {}) => [
6
8
  StarterKit.configure({
@@ -24,4 +26,6 @@ export const createMdKitTiptapExtensions = ({ placeholder = defaultMdKitMarkdown
24
26
  gfm: true,
25
27
  },
26
28
  }),
29
+ MarkdownPasteExtension,
30
+ MarkdownSearchExtension,
27
31
  ];
@@ -0,0 +1,16 @@
1
+ export type MdKitYamlFrontMatter = {
2
+ data: unknown;
3
+ raw: string;
4
+ trailingWhitespace: string;
5
+ yaml: string;
6
+ };
7
+ export type MdKitYamlFrontMatterExtraction = {
8
+ body: string;
9
+ errors: string[];
10
+ frontMatter: MdKitYamlFrontMatter | null;
11
+ };
12
+ export declare const parseYamlFrontMatter: (yaml: string) => unknown;
13
+ export declare const extractYamlFrontMatter: (markdown: string) => MdKitYamlFrontMatterExtraction;
14
+ export declare const hasYamlFrontMatter: (markdown: string) => boolean;
15
+ export declare const removeYamlFrontMatter: (markdown: string) => string;
16
+ export declare const prependYamlFrontMatter: (frontMatter: MdKitYamlFrontMatter | string | null, body: string) => string;
@@ -0,0 +1,88 @@
1
+ import { parseDocument } from "yaml";
2
+ const delimiter = "---";
3
+ const getLineEnd = (markdown, lineStart) => {
4
+ const newlineIndex = markdown.indexOf("\n", lineStart);
5
+ if (newlineIndex === -1) {
6
+ return {
7
+ contentEnd: markdown.length,
8
+ lineEnd: markdown.length,
9
+ newline: "",
10
+ };
11
+ }
12
+ const contentEnd = newlineIndex > lineStart && markdown[newlineIndex - 1] === "\r"
13
+ ? newlineIndex - 1
14
+ : newlineIndex;
15
+ return {
16
+ contentEnd,
17
+ lineEnd: newlineIndex + 1,
18
+ newline: markdown.slice(contentEnd, newlineIndex + 1),
19
+ };
20
+ };
21
+ const getNextBodyStart = (markdown, lineStart) => {
22
+ let bodyStart = lineStart;
23
+ while (bodyStart < markdown.length) {
24
+ const lineEnd = getLineEnd(markdown, bodyStart);
25
+ const line = markdown.slice(bodyStart, lineEnd.contentEnd);
26
+ if (!/^[ \t]*$/.test(line)) {
27
+ break;
28
+ }
29
+ bodyStart = lineEnd.lineEnd;
30
+ }
31
+ return bodyStart;
32
+ };
33
+ export const parseYamlFrontMatter = (yaml) => {
34
+ const document = parseDocument(yaml, { prettyErrors: false });
35
+ if (document.errors.length > 0) {
36
+ throw new Error(document.errors.map((error) => error.message).join("\n"));
37
+ }
38
+ return document.toJSON();
39
+ };
40
+ export const extractYamlFrontMatter = (markdown) => {
41
+ const openingLine = getLineEnd(markdown, 0);
42
+ if (markdown.slice(0, openingLine.contentEnd) !== delimiter) {
43
+ return { body: markdown, errors: [], frontMatter: null };
44
+ }
45
+ let lineStart = openingLine.lineEnd;
46
+ while (lineStart < markdown.length) {
47
+ const lineEnd = getLineEnd(markdown, lineStart);
48
+ const line = markdown.slice(lineStart, lineEnd.contentEnd);
49
+ if (line !== delimiter) {
50
+ lineStart = lineEnd.lineEnd;
51
+ continue;
52
+ }
53
+ const bodyStart = getNextBodyStart(markdown, lineEnd.lineEnd);
54
+ const raw = markdown.slice(0, bodyStart);
55
+ const yaml = markdown.slice(openingLine.lineEnd, lineStart);
56
+ const trailingWhitespace = markdown.slice(lineEnd.lineEnd, bodyStart);
57
+ try {
58
+ const data = parseYamlFrontMatter(yaml);
59
+ return {
60
+ body: markdown.slice(bodyStart),
61
+ errors: [],
62
+ frontMatter: {
63
+ data,
64
+ raw,
65
+ trailingWhitespace,
66
+ yaml,
67
+ },
68
+ };
69
+ }
70
+ catch (error) {
71
+ return {
72
+ body: markdown,
73
+ errors: [error instanceof Error ? error.message : String(error)],
74
+ frontMatter: null,
75
+ };
76
+ }
77
+ }
78
+ return { body: markdown, errors: [], frontMatter: null };
79
+ };
80
+ export const hasYamlFrontMatter = (markdown) => extractYamlFrontMatter(markdown).frontMatter !== null;
81
+ export const removeYamlFrontMatter = (markdown) => extractYamlFrontMatter(markdown).body;
82
+ export const prependYamlFrontMatter = (frontMatter, body) => {
83
+ if (!frontMatter) {
84
+ return body;
85
+ }
86
+ const raw = typeof frontMatter === "string" ? frontMatter : frontMatter.raw;
87
+ return `${raw}${body}`;
88
+ };
@@ -1,13 +1,13 @@
1
1
  export const defaultMdKitEditorTheme = {
2
2
  background: "#ffffff",
3
- blockGap: "0.75rem",
3
+ blockGap: "0.72em",
4
4
  border: "#d8dee8",
5
5
  codeBackground: "#eef1f4",
6
6
  codeRadius: "0.35rem",
7
7
  fontFamily: "inherit",
8
8
  fontSize: "16px",
9
9
  foreground: "#18212f",
10
- lineHeight: "1.7",
10
+ lineHeight: "1.55",
11
11
  link: "#4f46e5",
12
12
  muted: "#eef1f4",
13
13
  mutedForeground: "#5b6472",
@@ -15,14 +15,14 @@ export const defaultMdKitEditorTheme = {
15
15
  };
16
16
  export const darkMdKitEditorTheme = {
17
17
  background: "#0b1220",
18
- blockGap: "0.75rem",
18
+ blockGap: "0.72em",
19
19
  border: "#314158",
20
20
  codeBackground: "#111827",
21
21
  codeRadius: "0.35rem",
22
22
  fontFamily: "inherit",
23
23
  fontSize: "16px",
24
24
  foreground: "#e5edf7",
25
- lineHeight: "1.7",
25
+ lineHeight: "1.55",
26
26
  link: "#38bdf8",
27
27
  muted: "#172033",
28
28
  mutedForeground: "#94a3b8",
@@ -1,6 +1,7 @@
1
1
  import * as Y from "yjs";
2
2
  export type MdKitMarkdownYjsOptions = {
3
3
  fragmentName?: string;
4
+ ignoreYamlFrontMatter?: boolean;
4
5
  };
5
6
  export declare const replaceMdKitYjsMarkdown: (ydoc: Y.Doc, markdown: string, options?: MdKitMarkdownYjsOptions) => Uint8Array;
6
7
  export declare const markdownToMdKitYjs: (markdown: string, options?: MdKitMarkdownYjsOptions) => Uint8Array;
@@ -3,10 +3,14 @@ import { MarkdownManager } from "@tiptap/markdown";
3
3
  import { prosemirrorJSONToYXmlFragment, yXmlFragmentToProsemirrorJSON, } from "@tiptap/y-tiptap";
4
4
  import * as Y from "yjs";
5
5
  import { createMdKitTiptapExtensions } from "../markdown/createMdKitTiptapExtensions.js";
6
+ import { extractYamlFrontMatter, prependYamlFrontMatter, } from "../markdown/yamlFrontMatter.js";
6
7
  import { normalizeMarkdownSerialization } from "../markdown/normalizeMarkdownSerialization.js";
7
8
  import { prepareMarkdownForEditorHydration } from "../markdown/prepareMarkdownForEditorHydration.js";
8
9
  const defaultMdKitYjsFragmentName = "default";
10
+ const mdKitYjsMetadataMapName = "__mdkit";
11
+ const frontMatterPrefixMetadataKey = "frontMatterPrefix";
9
12
  const getMdKitYjsFragmentName = (options) => options?.fragmentName ?? defaultMdKitYjsFragmentName;
13
+ const getFrontMatterPrefixMetadataKey = (fragmentName) => `${fragmentName}:${frontMatterPrefixMetadataKey}`;
10
14
  const createMdKitMarkdownManager = () => new MarkdownManager({
11
15
  extensions: createMdKitTiptapExtensions(),
12
16
  markedOptions: {
@@ -17,10 +21,22 @@ const createMdKitProseMirrorSchema = () => getSchema(createMdKitTiptapExtensions
17
21
  const markdownToProseMirrorJson = (markdown) => createMdKitMarkdownManager().parse(prepareMarkdownForEditorHydration(markdown));
18
22
  const proseMirrorJsonToMarkdown = (json) => normalizeMarkdownSerialization(createMdKitMarkdownManager().serialize(json));
19
23
  export const replaceMdKitYjsMarkdown = (ydoc, markdown, options) => {
20
- const fragment = ydoc.getXmlFragment(getMdKitYjsFragmentName(options));
24
+ const fragmentName = getMdKitYjsFragmentName(options);
25
+ const fragment = ydoc.getXmlFragment(fragmentName);
26
+ const metadata = ydoc.getMap(mdKitYjsMetadataMapName);
21
27
  const schema = createMdKitProseMirrorSchema();
22
- const json = markdownToProseMirrorJson(markdown);
28
+ const frontMatter = options?.ignoreYamlFrontMatter
29
+ ? extractYamlFrontMatter(markdown)
30
+ : null;
31
+ const json = markdownToProseMirrorJson(frontMatter?.body ?? markdown);
32
+ const metadataKey = getFrontMatterPrefixMetadataKey(fragmentName);
23
33
  prosemirrorJSONToYXmlFragment(schema, json, fragment);
34
+ if (frontMatter?.frontMatter) {
35
+ metadata.set(metadataKey, frontMatter.frontMatter.raw);
36
+ }
37
+ else {
38
+ metadata.delete(metadataKey);
39
+ }
24
40
  return Y.encodeStateAsUpdate(ydoc);
25
41
  };
26
42
  export const markdownToMdKitYjs = (markdown, options) => {
@@ -30,8 +46,11 @@ export const markdownToMdKitYjs = (markdown, options) => {
30
46
  export const mdKitYjsToMarkdown = (yjsState, options) => {
31
47
  const ydoc = new Y.Doc();
32
48
  Y.applyUpdate(ydoc, yjsState);
33
- const json = yXmlFragmentToProsemirrorJSON(ydoc.getXmlFragment(getMdKitYjsFragmentName(options)));
34
- return proseMirrorJsonToMarkdown(json);
49
+ const fragmentName = getMdKitYjsFragmentName(options);
50
+ const json = yXmlFragmentToProsemirrorJSON(ydoc.getXmlFragment(fragmentName));
51
+ const metadata = ydoc.getMap(mdKitYjsMetadataMapName);
52
+ const frontMatterRaw = metadata.get(getFrontMatterPrefixMetadataKey(fragmentName)) ?? "";
53
+ return prependYamlFrontMatter(frontMatterRaw, proseMirrorJsonToMarkdown(json));
35
54
  };
36
55
  export const yjs = {
37
56
  markdownToMdKitYjs,
@@ -7,6 +7,7 @@ export default defineConfig({
7
7
  themeConfig: {
8
8
  nav: [
9
9
  { text: "Quick Start", link: "/" },
10
+ { text: "Plain Text", link: "/plain-text" },
10
11
  { text: "Styling", link: "/styling" },
11
12
  { text: "Shadcn", link: "/shadcn" },
12
13
  { text: "REST", link: "/rest" },
@@ -21,6 +22,7 @@ export default defineConfig({
21
22
  text: "Guide",
22
23
  items: [
23
24
  { text: "Quick Start", link: "/" },
25
+ { text: "Plain Text Editors", link: "/plain-text" },
24
26
  { text: "Styling", link: "/styling" },
25
27
  { text: "Shadcn Plugin", link: "/shadcn" },
26
28
  { text: "REST Backend", link: "/rest" },
package/docs/api.md CHANGED
@@ -39,6 +39,7 @@ Local editing props:
39
39
  - `onChange?: (markdown: string) => void`
40
40
  - `onFocusChange?: (focused: boolean) => void`
41
41
  - `fillHeight?: boolean`
42
+ - `search?: boolean`
42
43
  - `instanceKey?: string | number`
43
44
  - `className?: string`
44
45
  - `style?: CSSProperties`
@@ -50,6 +51,7 @@ Collaborative editing props:
50
51
  - `onChange?: (markdown: string) => void`
51
52
  - `onFocusChange?: (focused: boolean) => void`
52
53
  - `fillHeight?: boolean`
54
+ - `search?: boolean`
53
55
  - `className?: string`
54
56
  - `style?: CSSProperties`
55
57
 
@@ -57,6 +59,10 @@ Collaborative editing props:
57
59
  keep blank space below the last line clickable so it focuses the cursor at the
58
60
  end. Leave it off when the host application owns sizing and scrolling.
59
61
 
62
+ `search` opts the editor into the built-in document search panel. The panel is
63
+ not rendered by default; when enabled, users open it with `Cmd+F` on macOS or
64
+ `Ctrl+F` on Windows/Linux.
65
+
60
66
  The package stylesheet includes reset-resistant markdown rules for headings,
61
67
  lists, code blocks, blockquotes, and links. Styling is controlled with CSS
62
68
  variables on `.mp-lb-mdkit-markdown-editor`. See [Styling](./styling.md) for setup,
package/docs/index.md CHANGED
@@ -113,7 +113,11 @@ export function ConnectedMarkdownEditor({
113
113
  () => createMdKitTrpcAdapter({ client: trpc.mdkit }),
114
114
  [trpc],
115
115
  );
116
- const document = useMdKitDocument({ adapter, documentId });
116
+ const document = useMdKitDocument({
117
+ adapter,
118
+ debounceMs: 1000,
119
+ documentId,
120
+ });
117
121
  const versions = useMdKitDocumentVersions({ adapter, documentId });
118
122
 
119
123
  const collaboration = useMdKitCollaboration({
@@ -0,0 +1,131 @@
1
+ # Plain Text Editors
2
+
3
+ MDKit's connected workflow is not limited to `MdKitEditor`. The document hooks
4
+ and backend adapters work with serialized text, so you can bring a plain text,
5
+ code, JSON, or custom text editor and still use the same storage, autosave,
6
+ checkpoint history, restore, and conflict handling.
7
+
8
+ The one major exception is collaboration. Collaboration is currently a
9
+ markdown/Tiptap capability because it depends on Yjs, ProseMirror, and the
10
+ Tiptap collaboration extensions.
11
+
12
+ ## What Works
13
+
14
+ Any editor can plug into the connected workflow if it behaves like a controlled
15
+ text input:
16
+
17
+ ```tsx
18
+ type TextEditorProps = {
19
+ value: string;
20
+ onChange(value: string): void;
21
+ onFocusChange?(focused: boolean): void;
22
+ readOnly?: boolean;
23
+ };
24
+ ```
25
+
26
+ That is enough for:
27
+
28
+ - loading the current document
29
+ - autosave
30
+ - dirty state
31
+ - conflict detection
32
+ - force save
33
+ - remote resync
34
+ - checkpoint history
35
+ - checkpoint restore
36
+
37
+ The editor does not need to know about MDKit internals. It only needs to receive
38
+ `document.value` and call `document.setContent`.
39
+
40
+ ## Example
41
+
42
+ ```tsx
43
+ import {
44
+ MdKitConflictPanel,
45
+ MdKitDocumentToolbar,
46
+ VersionHistoryPanel,
47
+ useMdKitDocument,
48
+ useMdKitDocumentVersions,
49
+ type MdKitDocumentAdapter,
50
+ } from "@mp-lb/mdkit";
51
+
52
+ function PlainTextDocument({
53
+ adapter,
54
+ documentId,
55
+ }: {
56
+ adapter: MdKitDocumentAdapter;
57
+ documentId: string;
58
+ }) {
59
+ const document = useMdKitDocument({
60
+ adapter,
61
+ debounceMs: 1000,
62
+ documentId,
63
+ });
64
+ const versions = useMdKitDocumentVersions({ adapter, documentId });
65
+
66
+ return (
67
+ <>
68
+ <MdKitDocumentToolbar document={document} versions={versions} />
69
+
70
+ <textarea
71
+ readOnly={document.conflict}
72
+ value={document.value}
73
+ onBlur={() => document.setFocused(false)}
74
+ onChange={(event) => document.setContent(event.currentTarget.value)}
75
+ onFocus={() => document.setFocused(true)}
76
+ />
77
+
78
+ <MdKitConflictPanel document={document} />
79
+ <VersionHistoryPanel controller={versions} />
80
+ </>
81
+ );
82
+ }
83
+ ```
84
+
85
+ Use the same backend adapter you would use for markdown. The document content is
86
+ still just `content: string`.
87
+
88
+ ## Backend Shape
89
+
90
+ You do not need a separate backend for plain text documents. A single MDKit
91
+ backend can expose:
92
+
93
+ - document read/write
94
+ - checkpoint list/read/restore
95
+ - optional collaboration websocket routes
96
+ - optional collaboration state persistence
97
+
98
+ Plain text editors use the document and checkpoint APIs. Markdown collaborative
99
+ editors additionally use the collaboration websocket and Yjs persistence.
100
+
101
+ The underlying database layout is application-owned. It is reasonable to store
102
+ markdown and plain text documents in the same documents table, or in separate
103
+ tables if your product needs that. MDKit only requires a stable `documentId`,
104
+ `content`, and an opaque revision token.
105
+
106
+ ## Collaboration Boundary
107
+
108
+ Do not pass `useMdKitCollaboration` to a plain text editor. The current
109
+ collaboration adapter is for `MdKitEditor` because that editor knows how to bind
110
+ Tiptap to a Yjs document and render remote cursors.
111
+
112
+ For plain text documents:
113
+
114
+ - keep using `useMdKitDocument`
115
+ - omit `useMdKitCollaboration`
116
+ - omit collaboration UI
117
+ - rely on optimistic conflicts and resync for multi-client safety
118
+
119
+ If MDKit later adds a collaboration-capable CodeMirror, Monaco, or textarea
120
+ adapter, that should be a new editor-specific capability. The generic text
121
+ workflow does not need to change.
122
+
123
+ ## Testbench
124
+
125
+ The testbench includes a connected stack named
126
+ `Storage + checkpoints (plain text)`. It reuses the same checkpoints backend as
127
+ the markdown stack, stores content under `docs/plain-text.txt`, and renders a
128
+ controlled textarea instead of `MdKitEditor`.
129
+
130
+ Use it to verify that plain text can autosave, create checkpoints, restore
131
+ history, and avoid collaboration UI.
package/docs/shadcn.md CHANGED
@@ -46,7 +46,11 @@ import { MdKitConnectedWorkflow } from "@/components/mdkit/mdkit-connected-workf
46
46
  export function EditorScreen() {
47
47
  const client = createMdKitTrpcClient({ url: "/trpc" });
48
48
  const adapter = createMdKitTrpcAdapter({ client });
49
- const document = useMdKitDocument({ adapter, documentId });
49
+ const document = useMdKitDocument({
50
+ adapter,
51
+ debounceMs: 1000,
52
+ documentId,
53
+ });
50
54
  const versions = useMdKitDocumentVersions({ adapter, documentId });
51
55
 
52
56
  const collaboration = useMdKitCollaboration({
package/docs/styling.md CHANGED
@@ -120,7 +120,8 @@ you need structural changes, component-specific spacing, or state styling.
120
120
 
121
121
  `MdKitEditor` renders the markdown editing surface and the selection bubble
122
122
  toolbar. The toolbar appears for non-empty text selections while the editor or
123
- toolbar has focus.
123
+ toolbar has focus. The search panel appears only when `search` is enabled and
124
+ the user opens it with the find keyboard shortcut.
124
125
 
125
126
  - `.mp-lb-mdkit-markdown-editor`: root element rendered by `MdKitEditor`
126
127
  - `.mp-lb-mdkit-markdown-editor-fill-height`: added to the root when
@@ -129,6 +130,10 @@ toolbar has focus.
129
130
  - `.mp-lb-mdkit-editor-surface`: scroll and background surface around the
130
131
  ProseMirror editor
131
132
  - `.mp-lb-mdkit-editor-empty`: loading or connecting placeholder
133
+ - `.mp-lb-mdkit-search-panel`: optional document search panel
134
+ - `.mp-lb-mdkit-search-input`: search text input
135
+ - `.mp-lb-mdkit-search-status`: search result count
136
+ - `.mp-lb-mdkit-search-button`: search navigation or close button
132
137
  - `.mp-lb-mdkit-tiptap`: ProseMirror editable element
133
138
  - `.mp-lb-mdkit-toolbar`: selection bubble toolbar
134
139
  - `.mp-lb-mdkit-toolbar-button`: toolbar button
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mp-lb/mdkit",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -94,6 +94,7 @@
94
94
  "lucide-react": "^0.554.0",
95
95
  "react-markdown": "10.1.0",
96
96
  "remark-gfm": "4.0.1",
97
+ "yaml": "2.9.0",
97
98
  "yjs": "^13.6.24",
98
99
  "zod": "^4.1.12"
99
100
  },
package/src/styles.css CHANGED
@@ -19,18 +19,38 @@
19
19
  --mp-lb-mdkit-muted: var(--muted, #f3f4f6);
20
20
  --mp-lb-mdkit-muted-foreground: var(--muted-foreground, #6b7280);
21
21
  --mp-lb-mdkit-border: var(--border, #e5e7eb);
22
+ --mp-lb-mdkit-input: var(--input, var(--mp-lb-mdkit-border));
23
+ --mp-lb-mdkit-input-background: color-mix(
24
+ in srgb,
25
+ var(--mp-lb-mdkit-input) 30%,
26
+ transparent
27
+ );
28
+ --mp-lb-mdkit-input-background-hover: color-mix(
29
+ in srgb,
30
+ var(--mp-lb-mdkit-input) 50%,
31
+ transparent
32
+ );
33
+ --mp-lb-mdkit-popover: var(--popover, var(--mp-lb-mdkit-background));
34
+ --mp-lb-mdkit-popover-foreground: var(
35
+ --popover-foreground,
36
+ var(--mp-lb-mdkit-foreground)
37
+ );
38
+ --mp-lb-mdkit-ring: var(--ring, #94a3b8);
22
39
  --mp-lb-mdkit-accent: var(--primary, #111827);
23
40
  --mp-lb-mdkit-accent-foreground: var(--primary-foreground, #ffffff);
24
41
  --mp-lb-mdkit-link: #4f46e5;
25
42
  --mp-lb-mdkit-font-family: inherit;
26
43
  --mp-lb-mdkit-font-size: 1rem;
27
- --mp-lb-mdkit-line-height: 1.7;
44
+ --mp-lb-mdkit-line-height: 1.55;
28
45
  --mp-lb-mdkit-surface-padding: 1rem;
29
- --mp-lb-mdkit-block-gap: 0.75rem;
46
+ --mp-lb-mdkit-block-gap: 0.72em;
47
+ --mp-lb-mdkit-tight-gap: 0.35em;
48
+ --mp-lb-mdkit-section-gap: 1.25em;
30
49
  --mp-lb-mdkit-list-item-gap: 0.125rem;
31
50
  --mp-lb-mdkit-heading-font-weight: 650;
32
51
  --mp-lb-mdkit-heading-1-size: 1.5rem;
33
52
  --mp-lb-mdkit-heading-2-size: 1.25rem;
53
+ --mp-lb-mdkit-heading-3-size: 1.125rem;
34
54
  --mp-lb-mdkit-code-background: var(--mp-lb-mdkit-muted);
35
55
  --mp-lb-mdkit-code-radius: 0.35rem;
36
56
  --mp-lb-mdkit-code-block-radius: 0.75rem;
@@ -108,6 +128,7 @@
108
128
  }
109
129
 
110
130
  .mp-lb-mdkit-editor-surface {
131
+ position: relative;
111
132
  width: 100%;
112
133
  min-height: 0;
113
134
  overflow: visible;
@@ -115,6 +136,168 @@
115
136
  padding: var(--mp-lb-mdkit-surface-padding);
116
137
  }
117
138
 
139
+ .mp-lb-mdkit-search-panel {
140
+ position: sticky;
141
+ z-index: 5;
142
+ top: 0.5rem;
143
+ margin: calc(var(--mp-lb-mdkit-surface-padding) * -0.5) 0
144
+ var(--mp-lb-mdkit-block-gap) auto;
145
+ width: min(100%, 27rem);
146
+ min-height: 2.75rem;
147
+ display: flex;
148
+ align-items: center;
149
+ gap: 0.375rem;
150
+ border: 0;
151
+ border-radius: var(--radius-xl, 0.875rem);
152
+ background: var(--mp-lb-mdkit-popover);
153
+ box-shadow:
154
+ 0 1px 2px rgb(15 23 42 / 6%),
155
+ 0 0.5rem 1.25rem rgb(15 23 42 / 10%);
156
+ color: var(--mp-lb-mdkit-popover-foreground);
157
+ padding: 0.375rem;
158
+ }
159
+
160
+ .mp-lb-mdkit-search-icon {
161
+ width: 1rem;
162
+ height: 1rem;
163
+ flex: 0 0 auto;
164
+ color: var(--mp-lb-mdkit-muted-foreground);
165
+ margin-left: 0.5rem;
166
+ }
167
+
168
+ .mp-lb-mdkit-search-input {
169
+ height: 2rem;
170
+ min-width: 0;
171
+ flex: 1 1 auto;
172
+ border: 0;
173
+ border-radius: var(--radius-md, 0.5rem);
174
+ background: var(--mp-lb-mdkit-input-background);
175
+ color: inherit;
176
+ font: inherit;
177
+ font-size: 0.875rem;
178
+ line-height: 1.25rem;
179
+ outline: none;
180
+ padding: 0 0.5rem;
181
+ transition:
182
+ background-color 120ms ease,
183
+ box-shadow 120ms ease;
184
+ }
185
+
186
+ .mp-lb-mdkit-markdown-editor .mp-lb-mdkit-search-input {
187
+ border-color: transparent;
188
+ border-style: solid;
189
+ border-width: 0;
190
+ box-shadow: none;
191
+ outline: none;
192
+ }
193
+
194
+ .mp-lb-mdkit-search-input::placeholder {
195
+ color: var(--mp-lb-mdkit-muted-foreground);
196
+ }
197
+
198
+ .mp-lb-mdkit-search-input:focus {
199
+ outline: none;
200
+ background: var(--mp-lb-mdkit-input-background-hover);
201
+ }
202
+
203
+ .mp-lb-mdkit-markdown-editor .mp-lb-mdkit-search-input:focus,
204
+ .mp-lb-mdkit-markdown-editor .mp-lb-mdkit-search-input:focus-visible {
205
+ border-color: transparent;
206
+ border-width: 0;
207
+ box-shadow: none;
208
+ outline: none;
209
+ }
210
+
211
+ .mp-lb-mdkit-search-input::-webkit-search-cancel-button {
212
+ appearance: none;
213
+ width: 1rem;
214
+ height: 1rem;
215
+ background-color: var(--mp-lb-mdkit-muted-foreground);
216
+ cursor: pointer;
217
+ opacity: 0.75;
218
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='black' d='M4.22 3.28a.75.75 0 0 0-.94.94L7.06 8l-3.78 3.78a.75.75 0 1 0 .94.94L8 8.94l3.78 3.78a.75.75 0 1 0 .94-.94L8.94 8l3.78-3.78a.75.75 0 1 0-.94-.94L8 7.06 4.22 3.28Z'/%3E%3C/svg%3E")
219
+ center / 1rem 1rem no-repeat;
220
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='black' d='M4.22 3.28a.75.75 0 0 0-.94.94L7.06 8l-3.78 3.78a.75.75 0 1 0 .94.94L8 8.94l3.78 3.78a.75.75 0 1 0 .94-.94L8.94 8l3.78-3.78a.75.75 0 1 0-.94-.94L8 7.06 4.22 3.28Z'/%3E%3C/svg%3E")
221
+ center / 1rem 1rem no-repeat;
222
+ }
223
+
224
+ .mp-lb-mdkit-search-input::-webkit-search-cancel-button:hover {
225
+ opacity: 1;
226
+ }
227
+
228
+ .mp-lb-mdkit-search-status {
229
+ flex: 0 0 auto;
230
+ color: var(--mp-lb-mdkit-muted-foreground);
231
+ font-size: 0.75rem;
232
+ line-height: 1rem;
233
+ padding: 0 0.375rem;
234
+ white-space: nowrap;
235
+ }
236
+
237
+ .mp-lb-mdkit-search-button {
238
+ width: 2rem;
239
+ height: 2rem;
240
+ display: inline-flex;
241
+ align-items: center;
242
+ justify-content: center;
243
+ flex: 0 0 auto;
244
+ border: 0;
245
+ border-radius: var(--radius-4xl, 999px);
246
+ background: transparent;
247
+ color: var(--mp-lb-mdkit-muted-foreground);
248
+ cursor: pointer;
249
+ padding: 0;
250
+ transition:
251
+ background-color 120ms ease,
252
+ color 120ms ease,
253
+ box-shadow 120ms ease;
254
+ }
255
+
256
+ .mp-lb-mdkit-search-button:hover:not(:disabled),
257
+ .mp-lb-mdkit-search-button:focus-visible {
258
+ background: var(--mp-lb-mdkit-input-background-hover);
259
+ color: var(--mp-lb-mdkit-foreground);
260
+ }
261
+
262
+ .mp-lb-mdkit-search-button:focus-visible {
263
+ outline: none;
264
+ box-shadow: 0 0 0 3px
265
+ color-mix(in srgb, var(--mp-lb-mdkit-ring) 50%, transparent);
266
+ }
267
+
268
+ .mp-lb-mdkit-search-button:disabled {
269
+ cursor: default;
270
+ opacity: 0.45;
271
+ }
272
+
273
+ .mp-lb-mdkit-search-button svg {
274
+ width: 1rem;
275
+ height: 1rem;
276
+ }
277
+
278
+ .mp-lb-mdkit-search-match {
279
+ border-radius: 0;
280
+ background: transparent;
281
+ box-shadow: inset 0 -3px 0 #3b82f6;
282
+ }
283
+
284
+ .mp-lb-mdkit-search-match-active {
285
+ background: transparent;
286
+ box-shadow: inset 0 -3px 0 #2563eb;
287
+ }
288
+
289
+ @media (max-width: 42rem) {
290
+ .mp-lb-mdkit-search-panel {
291
+ width: 100%;
292
+ }
293
+
294
+ .mp-lb-mdkit-search-status {
295
+ max-width: 5.75rem;
296
+ overflow: hidden;
297
+ text-overflow: ellipsis;
298
+ }
299
+ }
300
+
118
301
  .mp-lb-mdkit-markdown-editor-fill-height .mp-lb-mdkit-editor-surface {
119
302
  display: flex;
120
303
  overflow: auto;
@@ -162,34 +345,68 @@
162
345
  flex: 0 0 auto;
163
346
  }
164
347
 
348
+ .mp-lb-mdkit-tiptap
349
+ :is(p, h1, h2, h3, h4, h5, h6, ul, ol, blockquote, pre, table) {
350
+ margin: 0;
351
+ }
352
+
165
353
  .mp-lb-mdkit-tiptap > * + * {
166
354
  margin-top: var(--mp-lb-mdkit-block-gap);
167
355
  }
168
356
 
357
+ .mp-lb-mdkit-tiptap h1,
358
+ .mp-lb-mdkit-tiptap h2,
359
+ .mp-lb-mdkit-tiptap h3,
360
+ .mp-lb-mdkit-tiptap h4,
361
+ .mp-lb-mdkit-tiptap h5,
362
+ .mp-lb-mdkit-tiptap h6 {
363
+ font-weight: var(--mp-lb-mdkit-heading-font-weight);
364
+ }
365
+
169
366
  .mp-lb-mdkit-tiptap h1 {
170
- margin: 0 0 var(--mp-lb-mdkit-block-gap);
171
367
  font-size: var(--mp-lb-mdkit-heading-1-size);
172
- font-weight: var(--mp-lb-mdkit-heading-font-weight);
173
368
  line-height: 1.25;
174
369
  }
175
370
 
176
371
  .mp-lb-mdkit-tiptap h2 {
177
- margin: 0 0 var(--mp-lb-mdkit-block-gap);
178
372
  font-size: var(--mp-lb-mdkit-heading-2-size);
179
- font-weight: var(--mp-lb-mdkit-heading-font-weight);
180
373
  line-height: 1.3;
181
374
  }
182
375
 
183
- .mp-lb-mdkit-tiptap p {
184
- margin: 0 0 var(--mp-lb-mdkit-block-gap);
376
+ .mp-lb-mdkit-tiptap h3,
377
+ .mp-lb-mdkit-tiptap h4,
378
+ .mp-lb-mdkit-tiptap h5,
379
+ .mp-lb-mdkit-tiptap h6 {
380
+ font-size: var(--mp-lb-mdkit-heading-3-size);
381
+ line-height: 1.35;
185
382
  }
186
383
 
187
384
  .mp-lb-mdkit-tiptap ul,
188
385
  .mp-lb-mdkit-tiptap ol {
189
- margin: 0 0 var(--mp-lb-mdkit-block-gap);
386
+ margin: 0;
190
387
  padding-left: 1.5rem;
191
388
  }
192
389
 
390
+ .mp-lb-mdkit-tiptap
391
+ > :is(h1, h2, h3)
392
+ + :is(p, ul, ol, blockquote, pre, table) {
393
+ margin-top: var(--mp-lb-mdkit-tight-gap);
394
+ }
395
+
396
+ .mp-lb-mdkit-tiptap > p + p {
397
+ margin-top: var(--mp-lb-mdkit-block-gap);
398
+ }
399
+
400
+ .mp-lb-mdkit-tiptap > :is(p, ul, ol) + :is(ul, ol, p) {
401
+ margin-top: var(--mp-lb-mdkit-tight-gap);
402
+ }
403
+
404
+ .mp-lb-mdkit-tiptap
405
+ > :is(p, ul, ol, blockquote, pre, table, hr)
406
+ + :is(h1, h2, h3) {
407
+ margin-top: var(--mp-lb-mdkit-section-gap);
408
+ }
409
+
193
410
  .mp-lb-mdkit-tiptap ul {
194
411
  list-style: disc;
195
412
  list-style-position: outside;
@@ -220,7 +437,7 @@
220
437
  }
221
438
 
222
439
  .mp-lb-mdkit-tiptap pre {
223
- margin: 0 0 var(--mp-lb-mdkit-block-gap);
440
+ margin: 0;
224
441
  padding: 0.75rem 0.875rem;
225
442
  overflow-x: auto;
226
443
  border: 1px solid var(--mp-lb-mdkit-border);
@@ -242,7 +459,7 @@
242
459
  }
243
460
 
244
461
  .mp-lb-mdkit-tiptap blockquote {
245
- margin: 0 0 var(--mp-lb-mdkit-block-gap);
462
+ margin: 0;
246
463
  padding-left: 0.875rem;
247
464
  border-left: 3px solid var(--mp-lb-mdkit-quote-border-color);
248
465
  color: var(--mp-lb-mdkit-muted-foreground);
@@ -252,7 +469,7 @@
252
469
  height: 1px;
253
470
  border: 0;
254
471
  background: var(--mp-lb-mdkit-border);
255
- margin: var(--mp-lb-mdkit-block-gap) 0;
472
+ margin: var(--mp-lb-mdkit-section-gap) 0;
256
473
  }
257
474
 
258
475
  .mp-lb-mdkit-tiptap img {
@@ -263,7 +480,7 @@
263
480
  .mp-lb-mdkit-tiptap table {
264
481
  width: 100%;
265
482
  border-collapse: collapse;
266
- margin: 0 0 var(--mp-lb-mdkit-block-gap);
483
+ margin: 0;
267
484
  }
268
485
 
269
486
  .mp-lb-mdkit-tiptap th,