@ai-matrx/rich-editor 0.1.2 → 0.2.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/CHANGELOG.md +13 -0
- package/dist/core/markdown-format.js +3 -3
- package/dist/core/text-insertion.d.ts +14 -0
- package/dist/core/text-insertion.js +55 -0
- package/dist/editor/RichEditor.d.ts +4 -0
- package/dist/editor/RichEditor.js +14 -0
- package/dist/editor/RichEditorContext.d.ts +16 -0
- package/dist/editor/RichEditorContext.js +17 -0
- package/dist/editor/RichEditorImpl.d.ts +137 -0
- package/dist/editor/RichEditorImpl.js +714 -0
- package/dist/editor/caretInsert.d.ts +11 -0
- package/dist/editor/caretInsert.js +9 -0
- package/dist/editor/rich-editor.css +181 -0
- package/dist/format/format-actions.d.ts +10 -0
- package/dist/format/format-actions.js +78 -0
- package/dist/format/useTextareaFormatting.d.ts +4 -0
- package/dist/format/useTextareaFormatting.js +37 -0
- package/dist/in-place/EditInPlace.d.ts +38 -0
- package/dist/in-place/EditInPlace.js +72 -0
- package/dist/in-place/EditInPlaceText.d.ts +18 -0
- package/dist/in-place/EditInPlaceText.js +159 -0
- package/dist/in-place/InPlaceEditor.d.ts +41 -0
- package/dist/in-place/InPlaceEditor.js +169 -0
- package/dist/islands/IslandCodeEditor.d.ts +12 -0
- package/dist/islands/IslandCodeEditor.js +82 -0
- package/dist/islands/IslandPreview.d.ts +6 -0
- package/dist/islands/IslandPreview.js +31 -0
- package/dist/islands/island-meta.d.ts +28 -0
- package/dist/islands/island-meta.js +176 -0
- package/dist/panels/FindReplacePanel.d.ts +10 -0
- package/dist/panels/FindReplacePanel.js +118 -0
- package/dist/panels/KindPicker.d.ts +4 -0
- package/dist/panels/KindPicker.js +95 -0
- package/dist/panels/OutlinePanel.d.ts +6 -0
- package/dist/panels/OutlinePanel.js +60 -0
- package/dist/panels/ShortcutsDialog.d.ts +4 -0
- package/dist/panels/ShortcutsDialog.js +24 -0
- package/dist/source/SourceEditor.d.ts +28 -0
- package/dist/source/SourceEditor.js +429 -0
- package/dist/source/cm-theme.d.ts +4 -0
- package/dist/source/cm-theme.js +71 -0
- package/dist/source/live-preview.d.ts +30 -0
- package/dist/source/live-preview.js +304 -0
- package/dist/source/markdown-language.d.ts +2 -0
- package/dist/source/markdown-language.js +7 -0
- package/dist/visual/BlockHandle.d.ts +6 -0
- package/dist/visual/BlockHandle.js +173 -0
- package/dist/visual/VisualEditor.d.ts +56 -0
- package/dist/visual/VisualEditor.js +312 -0
- package/dist/visual/auto-edit.d.ts +3 -0
- package/dist/visual/auto-edit.js +17 -0
- package/dist/visual/context-menu-caret.d.ts +10 -0
- package/dist/visual/context-menu-caret.js +42 -0
- package/dist/visual/decorations.d.ts +9 -0
- package/dist/visual/decorations.js +131 -0
- package/dist/visual/format-actions.d.ts +20 -0
- package/dist/visual/format-actions.js +128 -0
- package/dist/visual/menus/SuggestionMenu.d.ts +37 -0
- package/dist/visual/menus/SuggestionMenu.js +121 -0
- package/dist/visual/nodes/CalloutView.d.ts +2 -0
- package/dist/visual/nodes/CalloutView.js +45 -0
- package/dist/visual/nodes/InlineIslandView.d.ts +2 -0
- package/dist/visual/nodes/InlineIslandView.js +174 -0
- package/dist/visual/nodes/IslandBlockView.d.ts +2 -0
- package/dist/visual/nodes/IslandBlockView.js +206 -0
- package/dist/visual/nodes/SourceLockedView.d.ts +2 -0
- package/dist/visual/nodes/SourceLockedView.js +62 -0
- package/dist/visual/shortcut-handlers.d.ts +27 -0
- package/dist/visual/shortcut-handlers.js +43 -0
- package/dist/visual/slash-items.d.ts +18 -0
- package/dist/visual/slash-items.js +152 -0
- package/dist/visual/visual-extensions.d.ts +10 -0
- package/dist/visual/visual-extensions.js +246 -0
- package/dist/visual/visual-format-target.d.ts +9 -0
- package/dist/visual/visual-format-target.js +43 -0
- package/package.json +42 -4
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
Command,
|
|
6
|
+
CommandEmpty,
|
|
7
|
+
CommandGroup,
|
|
8
|
+
CommandInput,
|
|
9
|
+
CommandItem,
|
|
10
|
+
CommandList,
|
|
11
|
+
Dialog,
|
|
12
|
+
DialogContent,
|
|
13
|
+
DialogDescription,
|
|
14
|
+
DialogHeader,
|
|
15
|
+
DialogTitle
|
|
16
|
+
} from "@ai-matrx/design-system";
|
|
17
|
+
import { Shapes } from "lucide-react";
|
|
18
|
+
import { kindCanonicalExample, searchKindDefinitions } from "@ai-matrx/rich-content/host/app-bindings";
|
|
19
|
+
import { kindMarkdown } from "../core/commands.js";
|
|
20
|
+
import { ErrorAlchemyMenu } from "@ai-matrx/rich-content/host/ErrorActions";
|
|
21
|
+
import { asClause } from "@ai-matrx/kit/text";
|
|
22
|
+
const PICKER_LIMIT = 60;
|
|
23
|
+
function asRecord(value) {
|
|
24
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
25
|
+
}
|
|
26
|
+
async function searchKinds(query) {
|
|
27
|
+
return await searchKindDefinitions(query);
|
|
28
|
+
}
|
|
29
|
+
async function exampleFor(row) {
|
|
30
|
+
return asRecord(await kindCanonicalExample(row));
|
|
31
|
+
}
|
|
32
|
+
function KindPicker({
|
|
33
|
+
open,
|
|
34
|
+
onResolve
|
|
35
|
+
}) {
|
|
36
|
+
const [query, setQuery] = useState("");
|
|
37
|
+
const [rows, setRows] = useState([]);
|
|
38
|
+
const [state, setState] = useState("idle");
|
|
39
|
+
const [message, setMessage] = useState(null);
|
|
40
|
+
const [highlighted, setHighlighted] = useState("");
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!open) return;
|
|
43
|
+
let cancelled = false;
|
|
44
|
+
setState("loading");
|
|
45
|
+
const handle = window.setTimeout(() => {
|
|
46
|
+
searchKinds(query).then((found) => {
|
|
47
|
+
if (cancelled) return;
|
|
48
|
+
setRows(found);
|
|
49
|
+
setHighlighted(found[0]?.id ?? "");
|
|
50
|
+
setState("idle");
|
|
51
|
+
}).catch((error) => {
|
|
52
|
+
if (cancelled) return;
|
|
53
|
+
setState("error");
|
|
54
|
+
setMessage(error instanceof Error ? error.message : String(error));
|
|
55
|
+
});
|
|
56
|
+
}, 150);
|
|
57
|
+
return () => {
|
|
58
|
+
cancelled = true;
|
|
59
|
+
window.clearTimeout(handle);
|
|
60
|
+
};
|
|
61
|
+
}, [open, query]);
|
|
62
|
+
const choose = async (row) => {
|
|
63
|
+
try {
|
|
64
|
+
onResolve(kindMarkdown(row.kind, await exampleFor(row)));
|
|
65
|
+
} catch (error) {
|
|
66
|
+
setState("error");
|
|
67
|
+
setMessage(error instanceof Error ? error.message : String(error));
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange: (next) => !next && onResolve(null), children: /* @__PURE__ */ jsxs(DialogContent, { className: "max-w-lg p-0", children: [
|
|
71
|
+
/* @__PURE__ */ jsxs(DialogHeader, { className: "px-4 pt-4", children: [
|
|
72
|
+
/* @__PURE__ */ jsxs(DialogTitle, { className: "flex items-center gap-2", children: [
|
|
73
|
+
/* @__PURE__ */ jsx(Shapes, { className: "h-4 w-4" }),
|
|
74
|
+
" Insert a structured block"
|
|
75
|
+
] }),
|
|
76
|
+
/* @__PURE__ */ jsx(DialogDescription, { children: "Pick a kind from the shape registry. It is inserted with its example data as a protected block you edit in its own editor." })
|
|
77
|
+
] }),
|
|
78
|
+
/* @__PURE__ */ jsxs(Command, { shouldFilter: false, value: highlighted, onValueChange: setHighlighted, className: "border-t border-border", children: [
|
|
79
|
+
/* @__PURE__ */ jsx(CommandInput, { value: query, onValueChange: setQuery, placeholder: "Search kinds \u2014 flashcards, checklist, timeline\u2026" }),
|
|
80
|
+
/* @__PURE__ */ jsxs(CommandList, { className: "max-h-[50dvh]", children: [
|
|
81
|
+
state === "error" && /* @__PURE__ */ jsxs("div", { className: "px-4 py-3 text-sm text-destructive", children: [
|
|
82
|
+
"The kind list could not load: ",
|
|
83
|
+
asClause(message),
|
|
84
|
+
". Close this and try again.",
|
|
85
|
+
/* @__PURE__ */ jsx(ErrorAlchemyMenu, {})
|
|
86
|
+
] }),
|
|
87
|
+
state !== "error" && /* @__PURE__ */ jsx(CommandEmpty, { children: state === "loading" ? "Searching the shape registry\u2026" : "No kind matches that search." }),
|
|
88
|
+
/* @__PURE__ */ jsx(CommandGroup, { children: rows.map((row) => /* @__PURE__ */ jsx(CommandItem, { value: row.id, onSelect: () => void choose(row), children: /* @__PURE__ */ jsx("span", { className: "flex-1 truncate", children: row.label }) }, row.id)) })
|
|
89
|
+
] })
|
|
90
|
+
] })
|
|
91
|
+
] }) });
|
|
92
|
+
}
|
|
93
|
+
export {
|
|
94
|
+
KindPicker
|
|
95
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useClipboard } from "@ai-matrx/kit/clipboard";
|
|
4
|
+
import { Link2, ListTree } from "lucide-react";
|
|
5
|
+
import { toast } from "@ai-matrx/rich-content/host/toast";
|
|
6
|
+
import { cn } from "@ai-matrx/design-system";
|
|
7
|
+
function OutlinePanel({
|
|
8
|
+
entries,
|
|
9
|
+
onJump,
|
|
10
|
+
className
|
|
11
|
+
}) {
|
|
12
|
+
const { copyText } = useClipboard({
|
|
13
|
+
notify: (message, kind) => kind === "error" ? toast.error(message) : toast.success(message)
|
|
14
|
+
});
|
|
15
|
+
const minLevel = Math.min(...entries.map((entry) => entry.level), 6);
|
|
16
|
+
const copyLink = async (entry) => {
|
|
17
|
+
const url = `${window.location.origin}${window.location.pathname}${window.location.search}#${entry.slug}`;
|
|
18
|
+
try {
|
|
19
|
+
if (!await copyText(url)) return;
|
|
20
|
+
toast.success(`Link to \u201C${entry.text}\u201D copied.`);
|
|
21
|
+
} catch {
|
|
22
|
+
toast.error("Copy failed \u2014 your browser blocked clipboard access.");
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
return /* @__PURE__ */ jsxs("nav", { "aria-label": "Outline", className: cn("flex h-full flex-col overflow-hidden", className), children: [
|
|
26
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5 px-3 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground", children: [
|
|
27
|
+
/* @__PURE__ */ jsx(ListTree, { className: "h-3.5 w-3.5" }),
|
|
28
|
+
" Outline"
|
|
29
|
+
] }),
|
|
30
|
+
/* @__PURE__ */ jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto px-1 pb-4", children: [
|
|
31
|
+
entries.length === 0 && /* @__PURE__ */ jsx("p", { className: "px-2 text-xs text-muted-foreground", children: "Headings you add appear here. Type \u201C# \u201D at the start of a line, or use / \u2192 Heading." }),
|
|
32
|
+
entries.map((entry, index) => /* @__PURE__ */ jsxs("div", { className: "group/outline flex items-center", children: [
|
|
33
|
+
/* @__PURE__ */ jsx(
|
|
34
|
+
"button",
|
|
35
|
+
{
|
|
36
|
+
type: "button",
|
|
37
|
+
onClick: () => onJump(entry),
|
|
38
|
+
className: "min-w-0 flex-1 truncate rounded px-2 py-1 text-left text-sm text-foreground/80 hover:bg-muted hover:text-foreground",
|
|
39
|
+
style: { paddingLeft: `${0.5 + (entry.level - minLevel) * 0.75}rem` },
|
|
40
|
+
title: entry.text,
|
|
41
|
+
children: entry.text || "(empty heading)"
|
|
42
|
+
}
|
|
43
|
+
),
|
|
44
|
+
/* @__PURE__ */ jsx(
|
|
45
|
+
"button",
|
|
46
|
+
{
|
|
47
|
+
type: "button",
|
|
48
|
+
"aria-label": `Copy link to ${entry.text}`,
|
|
49
|
+
onClick: () => void copyLink(entry),
|
|
50
|
+
className: "rounded p-1 text-muted-foreground opacity-0 hover:bg-muted hover:text-foreground group-hover/outline:opacity-100 focus:opacity-100",
|
|
51
|
+
children: /* @__PURE__ */ jsx(Link2, { className: "h-3.5 w-3.5" })
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
] }, `${entry.slug}-${index}`))
|
|
55
|
+
] })
|
|
56
|
+
] });
|
|
57
|
+
}
|
|
58
|
+
export {
|
|
59
|
+
OutlinePanel
|
|
60
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@ai-matrx/design-system";
|
|
4
|
+
import { RICH_EDITOR_SHORTCUTS, formatKeys } from "../core/shortcuts.js";
|
|
5
|
+
const GROUPS = ["Text", "Blocks", "Insert", "Document", "Views"];
|
|
6
|
+
function ShortcutsDialog({ open, onOpenChange }) {
|
|
7
|
+
const apple = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
8
|
+
return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsxs(DialogContent, { className: "max-h-[85dvh] max-w-2xl overflow-y-auto", children: [
|
|
9
|
+
/* @__PURE__ */ jsxs(DialogHeader, { children: [
|
|
10
|
+
/* @__PURE__ */ jsx(DialogTitle, { children: "Keyboard shortcuts" }),
|
|
11
|
+
/* @__PURE__ */ jsx(DialogDescription, { children: "The same keys work in the visual and source views. Where Google Docs and Notion differ, both work." })
|
|
12
|
+
] }),
|
|
13
|
+
/* @__PURE__ */ jsx("div", { className: "grid gap-4 sm:grid-cols-2", children: GROUPS.map((group) => /* @__PURE__ */ jsxs("section", { children: [
|
|
14
|
+
/* @__PURE__ */ jsx("h3", { className: "mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground", children: group }),
|
|
15
|
+
/* @__PURE__ */ jsx("ul", { className: "space-y-1", children: RICH_EDITOR_SHORTCUTS.filter((spec) => spec.group === group).map((spec) => /* @__PURE__ */ jsxs("li", { className: "flex items-center justify-between gap-2 text-sm", children: [
|
|
16
|
+
/* @__PURE__ */ jsx("span", { children: spec.label }),
|
|
17
|
+
/* @__PURE__ */ jsx("span", { className: "flex shrink-0 gap-1", children: spec.keys.map((key) => /* @__PURE__ */ jsx("kbd", { className: "rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[11px]", children: formatKeys(key, apple) }, key)) })
|
|
18
|
+
] }, spec.id)) })
|
|
19
|
+
] }, group)) })
|
|
20
|
+
] }) });
|
|
21
|
+
}
|
|
22
|
+
export {
|
|
23
|
+
ShortcutsDialog
|
|
24
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type Ref } from "react";
|
|
2
|
+
import type { EditorViewHandle } from "../visual/VisualEditor.js";
|
|
3
|
+
import type { RichShellActions } from "../visual/visual-extensions.js";
|
|
4
|
+
export interface SourceEditorProps {
|
|
5
|
+
initialText: string;
|
|
6
|
+
onChange: (text: string) => void;
|
|
7
|
+
shell: RichShellActions;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
focusMode: boolean;
|
|
10
|
+
renderIslands: boolean;
|
|
11
|
+
handleRef: Ref<EditorViewHandle>;
|
|
12
|
+
/**
|
|
13
|
+
* CONTROLLED text. When the host replaces the whole document (a template or
|
|
14
|
+
* sample loaded, an agent write, Clear), the editor follows. The editor's own
|
|
15
|
+
* keystrokes echo back as the same string and are ignored cheaply.
|
|
16
|
+
*/
|
|
17
|
+
value?: string;
|
|
18
|
+
/** "document" (default): the centred reading column. "pane": fills a tool pane edge to edge. */
|
|
19
|
+
layout?: "document" | "pane";
|
|
20
|
+
/** The editor's scroll container scrolled (a host that syncs another pane). */
|
|
21
|
+
onScroll?: () => void;
|
|
22
|
+
/**
|
|
23
|
+
* RAW: the bytes as plain monospace text — no live-preview decorations, no
|
|
24
|
+
* markdown styling. The same editor (history, keymap, paste), just undressed.
|
|
25
|
+
*/
|
|
26
|
+
raw?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export declare function SourceEditor({ initialText, onChange, shell, placeholder, focusMode, renderIslands, handleRef, value, layout, onScroll, raw, }: SourceEditorProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useEffectEvent, useImperativeHandle, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { EditorSelection, EditorState, Prec } from "@codemirror/state";
|
|
6
|
+
import { EditorView, keymap, placeholder as cmPlaceholder } from "@codemirror/view";
|
|
7
|
+
import {
|
|
8
|
+
defaultKeymap,
|
|
9
|
+
history,
|
|
10
|
+
historyKeymap,
|
|
11
|
+
redoDepth,
|
|
12
|
+
undoDepth,
|
|
13
|
+
indentWithTab,
|
|
14
|
+
moveLineDown,
|
|
15
|
+
moveLineUp,
|
|
16
|
+
redo,
|
|
17
|
+
undo
|
|
18
|
+
} from "@codemirror/commands";
|
|
19
|
+
import { autocompletion } from "@codemirror/autocomplete";
|
|
20
|
+
import { getSchema } from "@tiptap/core";
|
|
21
|
+
import { cn } from "@ai-matrx/design-system";
|
|
22
|
+
import { toast } from "@ai-matrx/rich-content/host/toast";
|
|
23
|
+
import { createRichEditorExtensions } from "../core/extensions.js";
|
|
24
|
+
import { locateCaret } from "../core/caret-context.js";
|
|
25
|
+
import { htmlToMarkdown } from "../core/html-to-markdown.js";
|
|
26
|
+
import { mergedCellsNotice, normalizePastedHtml } from "../core/paste-html.js";
|
|
27
|
+
import { findMatches, replaceMatches } from "../core/find-replace.js";
|
|
28
|
+
import { continueMarkupOnEnter, makeLink, setLinePrefix, toggleWrap } from "../core/source-format.js";
|
|
29
|
+
import { markdownSourceLanguage } from "./markdown-language.js";
|
|
30
|
+
import { formatMarkdown } from "../core/markdown-format.js";
|
|
31
|
+
import { markdownFormatHost, textFormatTarget } from "../format/format-target.js";
|
|
32
|
+
import { useSelectionZone } from "@ai-matrx/rich-content/selection-toolbar/selection-zones";
|
|
33
|
+
import { RICH_EDITOR_SHORTCUTS, TYPED_TRIGGERS } from "../core/shortcuts.js";
|
|
34
|
+
import { variableNamesInText, variableSuggestions } from "../core/variables.js";
|
|
35
|
+
import { islandsReleasedBetweenTexts } from "../core/history-approval.js";
|
|
36
|
+
import { IslandPreview } from "../islands/IslandPreview.js";
|
|
37
|
+
import { useRichEditorContext } from "../editor/RichEditorContext.js";
|
|
38
|
+
import { richEditorTheme, richHighlight } from "./cm-theme.js";
|
|
39
|
+
import {
|
|
40
|
+
IslandPortalRegistry,
|
|
41
|
+
livePreviewExtensions,
|
|
42
|
+
findField,
|
|
43
|
+
setFindHighlights,
|
|
44
|
+
setIslandRendering
|
|
45
|
+
} from "./live-preview.js";
|
|
46
|
+
import { blockBoundary } from "../core/text-insertion.js";
|
|
47
|
+
const RAW_THEME = EditorView.theme({
|
|
48
|
+
".cm-content, .cm-line": {
|
|
49
|
+
fontFamily: "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)",
|
|
50
|
+
fontSize: "13px"
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
const PASTE_SCHEMA = getSchema(createRichEditorExtensions());
|
|
54
|
+
function applyResult(view, result) {
|
|
55
|
+
view.dispatch({
|
|
56
|
+
changes: result.changes,
|
|
57
|
+
selection: EditorSelection.single(result.anchor, result.head),
|
|
58
|
+
scrollIntoView: true,
|
|
59
|
+
userEvent: "input.format"
|
|
60
|
+
});
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
function wrap(marker) {
|
|
64
|
+
return (view) => {
|
|
65
|
+
const { from, to } = view.state.selection.main;
|
|
66
|
+
return applyResult(view, toggleWrap(view.state.doc.toString(), from, to, marker));
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function command(id) {
|
|
70
|
+
return (view) => {
|
|
71
|
+
if (view.state.readOnly) return false;
|
|
72
|
+
const { from, to } = view.state.selection.main;
|
|
73
|
+
const result = formatMarkdown(view.state.doc.toString(), from, to, id);
|
|
74
|
+
return result.changes.length ? applyResult(view, result) : true;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function sourceFormatTarget(getView) {
|
|
78
|
+
return textFormatTarget(
|
|
79
|
+
"source",
|
|
80
|
+
() => {
|
|
81
|
+
const v = getView();
|
|
82
|
+
if (!v) return null;
|
|
83
|
+
const { from, to } = v.state.selection.main;
|
|
84
|
+
return { text: v.state.doc.toString(), from, to, editable: !v.state.readOnly };
|
|
85
|
+
},
|
|
86
|
+
(result) => {
|
|
87
|
+
const v = getView();
|
|
88
|
+
if (v) applyResult(v, result);
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
function prefix(value) {
|
|
93
|
+
return (view) => {
|
|
94
|
+
const { from, to } = view.state.selection.main;
|
|
95
|
+
return applyResult(view, setLinePrefix(view.state.doc.toString(), from, to, value));
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function SourceEditor({
|
|
99
|
+
initialText,
|
|
100
|
+
onChange,
|
|
101
|
+
shell,
|
|
102
|
+
placeholder,
|
|
103
|
+
focusMode,
|
|
104
|
+
renderIslands,
|
|
105
|
+
handleRef,
|
|
106
|
+
value,
|
|
107
|
+
layout = "document",
|
|
108
|
+
onScroll,
|
|
109
|
+
raw = false
|
|
110
|
+
}) {
|
|
111
|
+
const context = useRichEditorContext();
|
|
112
|
+
const host = useRef(null);
|
|
113
|
+
const scroller = useRef(null);
|
|
114
|
+
const lastEmitted = useRef(initialText);
|
|
115
|
+
const view = useRef(null);
|
|
116
|
+
const [registry] = useState(() => new IslandPortalRegistry());
|
|
117
|
+
const portals = useSyncExternalStore(registry.subscribe, registry.getSnapshot, registry.getSnapshot);
|
|
118
|
+
const findState = useRef({ matches: [], index: -1 });
|
|
119
|
+
const emitChange = useEffectEvent((text) => onChange(text));
|
|
120
|
+
const getShell = useEffectEvent(() => shell);
|
|
121
|
+
const getVariables = useEffectEvent(() => context.variables);
|
|
122
|
+
const approveIsland = useEffectEvent((raw2) => context.approveIsland(raw2));
|
|
123
|
+
const initial = useEffectEvent(() => initialText);
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (!host.current) return;
|
|
126
|
+
const verbs = {
|
|
127
|
+
bold: command("bold"),
|
|
128
|
+
italic: command("italic"),
|
|
129
|
+
strike: command("strike"),
|
|
130
|
+
code: command("code"),
|
|
131
|
+
link: command("link"),
|
|
132
|
+
paragraph: prefix(null),
|
|
133
|
+
heading1: command("heading1"),
|
|
134
|
+
heading2: command("heading2"),
|
|
135
|
+
heading3: command("heading3"),
|
|
136
|
+
heading4: prefix("#### "),
|
|
137
|
+
heading5: prefix("##### "),
|
|
138
|
+
heading6: prefix("###### "),
|
|
139
|
+
orderedList: command("orderedList"),
|
|
140
|
+
bulletList: command("bulletList"),
|
|
141
|
+
taskList: command("taskList"),
|
|
142
|
+
quote: command("quote"),
|
|
143
|
+
moveUp: moveLineUp,
|
|
144
|
+
moveDown: moveLineDown,
|
|
145
|
+
codeBlock: command("codeBlock"),
|
|
146
|
+
inlineMath: wrap("$"),
|
|
147
|
+
undo,
|
|
148
|
+
redo,
|
|
149
|
+
find: () => (getShell().find(), true),
|
|
150
|
+
replace: () => (getShell().replace(), true),
|
|
151
|
+
save: () => (getShell().save(), true),
|
|
152
|
+
wordCount: () => (getShell().showWordCount(), true),
|
|
153
|
+
outline: () => (getShell().toggleOutline(), true),
|
|
154
|
+
focus: () => (getShell().toggleFocus(), true),
|
|
155
|
+
cycleView: () => (getShell().cycleView(), true),
|
|
156
|
+
help: () => (getShell().showHelp(), true)
|
|
157
|
+
};
|
|
158
|
+
const bindings = RICH_EDITOR_SHORTCUTS.flatMap(
|
|
159
|
+
(spec) => spec.keys.filter((key) => !TYPED_TRIGGERS.has(key) && verbs[spec.id]).map((key) => ({ key, run: verbs[spec.id], preventDefault: true }))
|
|
160
|
+
);
|
|
161
|
+
const variableCompletion = (completion) => {
|
|
162
|
+
const match = completion.matchBefore(/\{\{[^{}\n]*/);
|
|
163
|
+
if (!match) return null;
|
|
164
|
+
const typed = match.text.slice(2);
|
|
165
|
+
const inDocument = variableNamesInText(completion.state.doc.toString());
|
|
166
|
+
const options = variableSuggestions(getVariables() ?? [], inDocument, typed).map((suggestion) => ({
|
|
167
|
+
label: `{{${suggestion.name}}}`,
|
|
168
|
+
detail: suggestion.source === "new" ? "new variable" : suggestion.source === "document" ? "in this document" : suggestion.type,
|
|
169
|
+
info: suggestion.description,
|
|
170
|
+
apply: `{{${suggestion.name}}}`
|
|
171
|
+
}));
|
|
172
|
+
return { from: match.from, options, validFor: /^\{\{[^{}\n]*$/ };
|
|
173
|
+
};
|
|
174
|
+
const instance = new EditorView({
|
|
175
|
+
parent: host.current,
|
|
176
|
+
state: EditorState.create({
|
|
177
|
+
doc: initial(),
|
|
178
|
+
extensions: [
|
|
179
|
+
history(),
|
|
180
|
+
Prec.high(keymap.of(bindings)),
|
|
181
|
+
keymap.of([
|
|
182
|
+
{
|
|
183
|
+
key: "Enter",
|
|
184
|
+
run: (v) => {
|
|
185
|
+
const { from, to } = v.state.selection.main;
|
|
186
|
+
if (from !== to) return false;
|
|
187
|
+
const result = continueMarkupOnEnter(v.state.doc.toString(), from);
|
|
188
|
+
return result ? applyResult(v, result) : false;
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
indentWithTab,
|
|
192
|
+
...defaultKeymap,
|
|
193
|
+
...historyKeymap
|
|
194
|
+
]),
|
|
195
|
+
...raw ? [] : [markdownSourceLanguage, richHighlight],
|
|
196
|
+
richEditorTheme,
|
|
197
|
+
...raw ? [RAW_THEME] : [],
|
|
198
|
+
EditorView.lineWrapping,
|
|
199
|
+
cmPlaceholder(placeholder ?? "Write\u2026"),
|
|
200
|
+
EditorView.editable.of(!context.readOnly),
|
|
201
|
+
EditorView.contentAttributes.of({ "aria-label": "Document source", spellcheck: "true" }),
|
|
202
|
+
autocompletion({ override: [variableCompletion], activateOnTyping: true }),
|
|
203
|
+
...raw ? [findField] : [livePreviewExtensions({ registry, getVariables: () => getVariables() })],
|
|
204
|
+
EditorView.domEventHandlers({
|
|
205
|
+
paste: (event, v) => {
|
|
206
|
+
const html = event.clipboardData?.getData("text/html");
|
|
207
|
+
const images = Array.from(event.clipboardData?.files ?? []).filter((file) => file.type.startsWith("image/"));
|
|
208
|
+
if (images.length) {
|
|
209
|
+
event.preventDefault();
|
|
210
|
+
for (const image of images) {
|
|
211
|
+
void getShell().uploadImage(image).then((markdownImage) => {
|
|
212
|
+
if (!markdownImage) return;
|
|
213
|
+
const { from: from2, to: to2 } = v.state.selection.main;
|
|
214
|
+
v.dispatch({ changes: { from: from2, to: to2, insert: markdownImage } });
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
if (!html || !html.trim()) return false;
|
|
220
|
+
event.preventDefault();
|
|
221
|
+
const notice = mergedCellsNotice(normalizePastedHtml(html).mergedCellsSplit);
|
|
222
|
+
if (notice) toast.info(notice);
|
|
223
|
+
const converted = htmlToMarkdown(html, PASTE_SCHEMA);
|
|
224
|
+
const { from, to } = v.state.selection.main;
|
|
225
|
+
v.dispatch({
|
|
226
|
+
changes: { from, to, insert: converted },
|
|
227
|
+
selection: { anchor: from + converted.length },
|
|
228
|
+
userEvent: "input.paste"
|
|
229
|
+
});
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
}),
|
|
233
|
+
EditorView.updateListener.of((update) => {
|
|
234
|
+
if (!update.docChanged) return;
|
|
235
|
+
const text = update.state.doc.toString();
|
|
236
|
+
if (update.transactions.some((tr) => tr.isUserEvent("undo") || tr.isUserEvent("redo"))) {
|
|
237
|
+
for (const raw2 of islandsReleasedBetweenTexts(update.startState.doc.toString(), text)) approveIsland(raw2);
|
|
238
|
+
}
|
|
239
|
+
lastEmitted.current = text;
|
|
240
|
+
emitChange(text);
|
|
241
|
+
})
|
|
242
|
+
]
|
|
243
|
+
})
|
|
244
|
+
});
|
|
245
|
+
view.current = instance;
|
|
246
|
+
return () => {
|
|
247
|
+
instance.destroy();
|
|
248
|
+
view.current = null;
|
|
249
|
+
};
|
|
250
|
+
}, [registry, context.readOnly, placeholder, raw]);
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
view.current?.dispatch({ effects: setIslandRendering.of(renderIslands) });
|
|
253
|
+
}, [renderIslands]);
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
const v = view.current;
|
|
256
|
+
if (value === void 0 || !v || value === lastEmitted.current) return;
|
|
257
|
+
if (v.state.doc.toString() === value) {
|
|
258
|
+
lastEmitted.current = value;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
lastEmitted.current = value;
|
|
262
|
+
v.dispatch({
|
|
263
|
+
changes: { from: 0, to: v.state.doc.length, insert: value },
|
|
264
|
+
selection: { anchor: Math.min(v.state.selection.main.head, value.length) },
|
|
265
|
+
userEvent: "input.replace"
|
|
266
|
+
});
|
|
267
|
+
}, [value]);
|
|
268
|
+
const highlight = (current) => {
|
|
269
|
+
view.current?.dispatch({
|
|
270
|
+
effects: setFindHighlights.of(
|
|
271
|
+
findState.current.matches.map((match, index) => ({ from: match.start, to: match.end, current: index === current }))
|
|
272
|
+
)
|
|
273
|
+
});
|
|
274
|
+
};
|
|
275
|
+
const [zoneElement, setZoneElement] = useState(null);
|
|
276
|
+
useSelectionZone(zoneElement, { editable: !context.readOnly, host: markdownFormatHost(sourceFormatTarget(() => view.current)) });
|
|
277
|
+
useImperativeHandle(handleRef, () => ({
|
|
278
|
+
focus: () => view.current?.focus(),
|
|
279
|
+
selectedText: () => {
|
|
280
|
+
const v = view.current;
|
|
281
|
+
if (!v) return "";
|
|
282
|
+
const { from, to } = v.state.selection.main;
|
|
283
|
+
return v.state.sliceDoc(from, to);
|
|
284
|
+
},
|
|
285
|
+
replaceSelection: (text) => {
|
|
286
|
+
const v = view.current;
|
|
287
|
+
if (!v) return;
|
|
288
|
+
const { from, to } = v.state.selection.main;
|
|
289
|
+
v.dispatch({ changes: { from, to, insert: text }, selection: { anchor: from, head: from + text.length } });
|
|
290
|
+
},
|
|
291
|
+
insertText: (text, where) => {
|
|
292
|
+
const v = view.current;
|
|
293
|
+
if (!v) return false;
|
|
294
|
+
const { from, to } = v.state.selection.main;
|
|
295
|
+
const at = blockBoundary(v.state.doc.toString(), from, to, where);
|
|
296
|
+
const insert = where === "before" ? `${text}
|
|
297
|
+
|
|
298
|
+
` : `
|
|
299
|
+
|
|
300
|
+
${text}`;
|
|
301
|
+
v.dispatch({ changes: { from: at, insert } });
|
|
302
|
+
return true;
|
|
303
|
+
},
|
|
304
|
+
flush: () => view.current?.state.doc.toString() ?? initialText,
|
|
305
|
+
historyDepth: () => {
|
|
306
|
+
const v = view.current;
|
|
307
|
+
return v ? { undo: undoDepth(v.state), redo: redoDepth(v.state) } : { undo: 0, redo: 0 };
|
|
308
|
+
},
|
|
309
|
+
placeCaret: (context2) => {
|
|
310
|
+
const v = view.current;
|
|
311
|
+
if (!v) return false;
|
|
312
|
+
const index = locateCaret(v.state.doc.toString(), context2);
|
|
313
|
+
if (index === null) return false;
|
|
314
|
+
v.dispatch({ selection: { anchor: index }, effects: EditorView.scrollIntoView(index, { y: "center" }) });
|
|
315
|
+
v.focus();
|
|
316
|
+
return true;
|
|
317
|
+
},
|
|
318
|
+
scrollToHeading: (_slug, offset) => {
|
|
319
|
+
const v = view.current;
|
|
320
|
+
if (!v) return;
|
|
321
|
+
v.dispatch({ selection: { anchor: offset }, effects: EditorView.scrollIntoView(offset, { y: "start" }) });
|
|
322
|
+
v.focus();
|
|
323
|
+
},
|
|
324
|
+
find: (query, options, step) => {
|
|
325
|
+
const v = view.current;
|
|
326
|
+
if (!v) return { count: 0, current: -1, skippedProtected: 0, error: null };
|
|
327
|
+
const result = findMatches(v.state.doc.toString(), query, options);
|
|
328
|
+
const cursor = v.state.selection.main.from;
|
|
329
|
+
let index = result.matches.findIndex((match2) => match2.start >= cursor);
|
|
330
|
+
if (index === -1) index = result.matches.length ? 0 : -1;
|
|
331
|
+
const previous = findState.current;
|
|
332
|
+
if (step && previous.index >= 0 && result.matches.length) {
|
|
333
|
+
index = (previous.index + step + result.matches.length) % result.matches.length;
|
|
334
|
+
}
|
|
335
|
+
findState.current = { matches: result.matches, index };
|
|
336
|
+
highlight(index);
|
|
337
|
+
const match = result.matches[index];
|
|
338
|
+
if (match && step) {
|
|
339
|
+
v.dispatch({ selection: { anchor: match.start, head: match.end }, scrollIntoView: true });
|
|
340
|
+
}
|
|
341
|
+
return { count: result.matches.length, current: index, skippedProtected: result.skippedProtected, error: result.error };
|
|
342
|
+
},
|
|
343
|
+
replaceCurrent: (query, replacement, options) => {
|
|
344
|
+
const v = view.current;
|
|
345
|
+
const match = findState.current.matches[findState.current.index];
|
|
346
|
+
if (!v || !match) return [];
|
|
347
|
+
const text = v.state.doc.toString();
|
|
348
|
+
const found = findMatches(text, query, options).matches.find((candidate) => candidate.start === match.start);
|
|
349
|
+
if (!found) return [];
|
|
350
|
+
const next = replaceMatches(text, [found], query, replacement, options);
|
|
351
|
+
const insert = next.slice(found.start, next.length - (text.length - found.end));
|
|
352
|
+
v.dispatch({ changes: { from: found.start, to: found.end, insert } });
|
|
353
|
+
return found.islandRaw ? [found.islandRaw] : [];
|
|
354
|
+
},
|
|
355
|
+
replaceAll: (query, replacement, options) => {
|
|
356
|
+
const v = view.current;
|
|
357
|
+
if (!v) return [];
|
|
358
|
+
const text = v.state.doc.toString();
|
|
359
|
+
const { matches } = findMatches(text, query, options);
|
|
360
|
+
const changes = matches.map((match) => {
|
|
361
|
+
const next = replaceMatches(text, [match], query, replacement, options);
|
|
362
|
+
return { from: match.start, to: match.end, insert: next.slice(match.start, next.length - (text.length - match.end)) };
|
|
363
|
+
});
|
|
364
|
+
v.dispatch({ changes });
|
|
365
|
+
return matches.flatMap((match) => match.islandRaw ? [match.islandRaw] : []);
|
|
366
|
+
},
|
|
367
|
+
clearFind: () => {
|
|
368
|
+
findState.current = { matches: [], index: -1 };
|
|
369
|
+
highlight(-1);
|
|
370
|
+
},
|
|
371
|
+
editLink: (href) => {
|
|
372
|
+
const v = view.current;
|
|
373
|
+
if (!v || !href) return;
|
|
374
|
+
const { from, to } = v.state.selection.main;
|
|
375
|
+
applyResult(v, makeLink(v.state.doc.toString(), from, to, href));
|
|
376
|
+
},
|
|
377
|
+
currentLink: () => null,
|
|
378
|
+
scroller: () => scroller.current,
|
|
379
|
+
topLine: () => {
|
|
380
|
+
const v = view.current;
|
|
381
|
+
const el = scroller.current;
|
|
382
|
+
if (!v || !el) return 0;
|
|
383
|
+
const y = el.getBoundingClientRect().top - v.documentTop;
|
|
384
|
+
if (y <= 0) return 0;
|
|
385
|
+
const block = v.lineBlockAtHeight(y);
|
|
386
|
+
const index = v.state.doc.lineAt(block.from).number - 1;
|
|
387
|
+
return index + (block.height > 0 ? Math.min(1, Math.max(0, (y - block.top) / block.height)) : 0);
|
|
388
|
+
},
|
|
389
|
+
scrollToLine: (lineIndex) => {
|
|
390
|
+
const v = view.current;
|
|
391
|
+
if (!v) return;
|
|
392
|
+
const index = Math.min(Math.max(0, Math.floor(lineIndex)), v.state.doc.lines - 1);
|
|
393
|
+
const line = v.state.doc.line(index + 1);
|
|
394
|
+
const at = line.from + Math.round((lineIndex - index) * line.length);
|
|
395
|
+
v.dispatch({ effects: EditorView.scrollIntoView(at, { y: "start" }) });
|
|
396
|
+
}
|
|
397
|
+
}));
|
|
398
|
+
return /* @__PURE__ */ jsxs(
|
|
399
|
+
"div",
|
|
400
|
+
{
|
|
401
|
+
ref: scroller,
|
|
402
|
+
onScroll,
|
|
403
|
+
className: cn("rich-editor-source relative h-full overflow-y-auto", focusMode && "rich-editor-focus"),
|
|
404
|
+
children: [
|
|
405
|
+
/* @__PURE__ */ jsx(
|
|
406
|
+
"div",
|
|
407
|
+
{
|
|
408
|
+
ref: (node) => {
|
|
409
|
+
host.current = node;
|
|
410
|
+
setZoneElement(node);
|
|
411
|
+
},
|
|
412
|
+
className: layout === "pane" ? "min-h-full" : "mx-auto min-h-full max-w-3xl pb-[40dvh]",
|
|
413
|
+
"data-testid": "rich-editor-source"
|
|
414
|
+
}
|
|
415
|
+
),
|
|
416
|
+
portals.map(
|
|
417
|
+
(portal, index) => createPortal(
|
|
418
|
+
/* @__PURE__ */ jsx("div", { className: "cm-rich-island-body pointer-events-none", children: /* @__PURE__ */ jsx(IslandPreview, { raw: portal.raw, islandType: portal.islandType }) }),
|
|
419
|
+
portal.element,
|
|
420
|
+
`island-${index}`
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
]
|
|
424
|
+
}
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
export {
|
|
428
|
+
SourceEditor
|
|
429
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const richEditorTheme: import("@codemirror/state").Extension;
|
|
2
|
+
/** Code-shaped editors (islands) use the mono face. */
|
|
3
|
+
export declare const monoTheme: import("@codemirror/state").Extension;
|
|
4
|
+
export declare const richHighlight: import("@codemirror/state").Extension;
|