@gogitcms/editor 0.23.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/app/index.html +38 -0
- package/app/src/App.tsx +2665 -0
- package/app/src/apollo.ts +119 -0
- package/app/src/auth.ts +248 -0
- package/app/src/config.ts +88 -0
- package/app/src/main.tsx +18 -0
- package/app/src/media.ts +151 -0
- package/app/src/navigation.tsx +321 -0
- package/app/src/plugins.ts +11 -0
- package/app/src/previewHost.tsx +263 -0
- package/app/src/previewToken.ts +68 -0
- package/app/src/queries.ts +591 -0
- package/app/src/virtual-cms-plugins.d.ts +6 -0
- package/app/vendor/analytics/src/__tests__/config.test.ts +91 -0
- package/app/vendor/analytics/src/config.ts +94 -0
- package/app/vendor/analytics/src/events.ts +56 -0
- package/app/vendor/analytics/src/index.ts +23 -0
- package/app/vendor/analytics/src/provider.tsx +196 -0
- package/app/vendor/design-system/src/ThemeProvider.tsx +86 -0
- package/app/vendor/design-system/src/__tests__/ApplyChangesModal.test.tsx +148 -0
- package/app/vendor/design-system/src/__tests__/BranchImport.test.tsx +46 -0
- package/app/vendor/design-system/src/__tests__/Button.test.tsx +45 -0
- package/app/vendor/design-system/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.media.test.tsx +140 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.search.test.tsx +135 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
- package/app/vendor/design-system/src/__tests__/MediaBrowser.test.tsx +353 -0
- package/app/vendor/design-system/src/__tests__/MediaField.test.tsx +185 -0
- package/app/vendor/design-system/src/__tests__/Notifications.test.tsx +69 -0
- package/app/vendor/design-system/src/__tests__/Onboarding.test.tsx +287 -0
- package/app/vendor/design-system/src/__tests__/cssTokens.test.ts +201 -0
- package/app/vendor/design-system/src/__tests__/fieldComponents.test.ts +43 -0
- package/app/vendor/design-system/src/__tests__/reorder.test.ts +58 -0
- package/app/vendor/design-system/src/components/ApplyChangesModal.tsx +348 -0
- package/app/vendor/design-system/src/components/BranchImport.tsx +157 -0
- package/app/vendor/design-system/src/components/BranchMenu.tsx +192 -0
- package/app/vendor/design-system/src/components/Button.tsx +131 -0
- package/app/vendor/design-system/src/components/ChangeDetail.tsx +472 -0
- package/app/vendor/design-system/src/components/ChangeRequestSummary.tsx +173 -0
- package/app/vendor/design-system/src/components/CollabField.tsx +388 -0
- package/app/vendor/design-system/src/components/ContentBrowser.tsx +5073 -0
- package/app/vendor/design-system/src/components/Icon.tsx +28 -0
- package/app/vendor/design-system/src/components/Icon.web.tsx +31 -0
- package/app/vendor/design-system/src/components/Input.tsx +106 -0
- package/app/vendor/design-system/src/components/MediaBrowser.tsx +766 -0
- package/app/vendor/design-system/src/components/MediaField.tsx +670 -0
- package/app/vendor/design-system/src/components/MediaPreview.tsx +91 -0
- package/app/vendor/design-system/src/components/MediaPreview.web.tsx +169 -0
- package/app/vendor/design-system/src/components/NavRow.tsx +105 -0
- package/app/vendor/design-system/src/components/Notifications.tsx +301 -0
- package/app/vendor/design-system/src/components/Onboarding.tsx +751 -0
- package/app/vendor/design-system/src/components/ProjectMenu.tsx +124 -0
- package/app/vendor/design-system/src/components/Segment.tsx +87 -0
- package/app/vendor/design-system/src/components/Skeleton.tsx +216 -0
- package/app/vendor/design-system/src/components/Spinner.tsx +44 -0
- package/app/vendor/design-system/src/components/Text.tsx +85 -0
- package/app/vendor/design-system/src/components/documentDrafts.ts +213 -0
- package/app/vendor/design-system/src/components/layout.tsx +284 -0
- package/app/vendor/design-system/src/components/primitives.tsx +143 -0
- package/app/vendor/design-system/src/components/reorder.ts +40 -0
- package/app/vendor/design-system/src/fieldComponents.ts +63 -0
- package/app/vendor/design-system/src/icons.ts +102 -0
- package/app/vendor/design-system/src/index.ts +198 -0
- package/app/vendor/design-system/src/media.ts +229 -0
- package/app/vendor/design-system/src/theme.ts +116 -0
- package/app/vendor/design-system/src/web/Button.tsx +110 -0
- package/app/vendor/design-system/src/web/Icon.tsx +52 -0
- package/app/vendor/design-system/src/web/Input.tsx +39 -0
- package/app/vendor/design-system/src/web/index.ts +43 -0
- package/app/vendor/design-system/src/web/primitives.tsx +119 -0
- package/app/vendor/markdown-editor/src/MarkdownEditor.tsx +248 -0
- package/app/vendor/markdown-editor/src/Toolbar.tsx +157 -0
- package/app/vendor/markdown-editor/src/field.tsx +67 -0
- package/app/vendor/markdown-editor/src/flavors/commonmark/index.ts +27 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/index.ts +30 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/parser.ts +73 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/schema.ts +70 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/serializer.ts +82 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/taskList.ts +39 -0
- package/app/vendor/markdown-editor/src/flavors/registry.ts +13 -0
- package/app/vendor/markdown-editor/src/flavors/shared/inputrules.ts +79 -0
- package/app/vendor/markdown-editor/src/flavors/shared/keymap.ts +89 -0
- package/app/vendor/markdown-editor/src/flavors/shared/placeholder.ts +24 -0
- package/app/vendor/markdown-editor/src/flavors/shared/plugins.ts +58 -0
- package/app/vendor/markdown-editor/src/flavors/types.ts +22 -0
- package/app/vendor/markdown-editor/src/formats/registry.ts +45 -0
- package/app/vendor/markdown-editor/src/icons.tsx +120 -0
- package/app/vendor/markdown-editor/src/index.ts +23 -0
- package/app/vendor/markdown-editor/src/theme.ts +249 -0
- package/app/vendor/markdown-editor/src/types.ts +58 -0
- package/app/vendor/plugin-sdk/src/__tests__/documentActions.test.ts +108 -0
- package/app/vendor/plugin-sdk/src/__tests__/loader.test.ts +75 -0
- package/app/vendor/plugin-sdk/src/__tests__/registry.test.ts +148 -0
- package/app/vendor/plugin-sdk/src/index.ts +33 -0
- package/app/vendor/plugin-sdk/src/loader.ts +45 -0
- package/app/vendor/plugin-sdk/src/navkeys.ts +17 -0
- package/app/vendor/plugin-sdk/src/react.tsx +155 -0
- package/app/vendor/plugin-sdk/src/registry.ts +337 -0
- package/app/vendor/plugin-sdk/src/types.ts +230 -0
- package/app/vendor/realtime/src/__tests__/pure.test.ts +58 -0
- package/app/vendor/realtime/src/__tests__/seeded-room.base64 +1 -0
- package/app/vendor/realtime/src/__tests__/seeding.test.ts +70 -0
- package/app/vendor/realtime/src/collab.ts +308 -0
- package/app/vendor/realtime/src/hooks.ts +57 -0
- package/app/vendor/realtime/src/index.ts +12 -0
- package/app/vendor/realtime/src/pure.ts +43 -0
- package/app/vite.config.mjs +59 -0
- package/bin/gogitcms-editor.mjs +168 -0
- package/npm-shrinkwrap.json +5696 -0
- package/package.json +74 -0
- package/src/commands/build.mjs +100 -0
- package/src/commands/dev.mjs +186 -0
- package/src/commands/init.mjs +144 -0
- package/src/commands/login.mjs +116 -0
- package/src/commands/status.mjs +68 -0
- package/src/lib/api.mjs +122 -0
- package/src/lib/appRoot.mjs +51 -0
- package/src/lib/baked.json +3 -0
- package/src/lib/config.mjs +108 -0
- package/src/lib/credentials.mjs +91 -0
- package/src/lib/defaults.mjs +26 -0
- package/src/lib/graphql.mjs +28 -0
- package/src/lib/localServer.mjs +206 -0
- package/src/lib/open.mjs +23 -0
- package/src/lib/plugins.mjs +164 -0
- package/src/lib/tokenBroker.mjs +85 -0
- package/src/tui/prompts.mjs +92 -0
- package/src/tui/screen.mjs +282 -0
- package/src/tui/theme.mjs +40 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use dom";
|
|
2
|
+
|
|
3
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { EditorState, type Transaction } from "prosemirror-state";
|
|
5
|
+
import {
|
|
6
|
+
ProseMirror,
|
|
7
|
+
ProseMirrorDoc,
|
|
8
|
+
reactKeys,
|
|
9
|
+
} from "@handlewithcare/react-prosemirror";
|
|
10
|
+
import * as Y from "yjs";
|
|
11
|
+
import { prosemirrorToYXmlFragment } from "y-prosemirror";
|
|
12
|
+
import type { Awareness } from "y-protocols/awareness";
|
|
13
|
+
import { resolveFlavor } from "./flavors/registry";
|
|
14
|
+
import type { EditorCollabBinding } from "./flavors/shared/plugins";
|
|
15
|
+
import { placeholderPlugin } from "./flavors/shared/placeholder";
|
|
16
|
+
import { ensureEditorStyles } from "./theme";
|
|
17
|
+
import { Toolbar } from "./Toolbar";
|
|
18
|
+
import type { MarkdownEditorProps } from "./types";
|
|
19
|
+
|
|
20
|
+
// A markdown editor authored as web React and marked as an Expo DOM component.
|
|
21
|
+
// Under a web bundler (Vite) the `"use dom"` directive is inert and this renders
|
|
22
|
+
// inline; under Expo's Metro it runs inside a WebView on native. Every prop is
|
|
23
|
+
// serializable and `onChange` is treated as an async native action.
|
|
24
|
+
export default function MarkdownEditor({
|
|
25
|
+
value,
|
|
26
|
+
onChange,
|
|
27
|
+
flavor: flavorId,
|
|
28
|
+
readOnly = false,
|
|
29
|
+
placeholder = "",
|
|
30
|
+
theme = "light",
|
|
31
|
+
debounceMs = 200,
|
|
32
|
+
expandable = true,
|
|
33
|
+
toolbar = true,
|
|
34
|
+
collab,
|
|
35
|
+
}: MarkdownEditorProps) {
|
|
36
|
+
const flavor = useMemo(() => resolveFlavor(flavorId), [flavorId]);
|
|
37
|
+
const [expanded, setExpanded] = useState(false);
|
|
38
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
39
|
+
|
|
40
|
+
// Focus mode covers the whole viewport. It used to measure the nearest
|
|
41
|
+
// host-designated container and cover only that, which left the editor
|
|
42
|
+
// stretched full-height beside the field list — the very layout focus mode
|
|
43
|
+
// exists to get out of.
|
|
44
|
+
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
|
|
45
|
+
|
|
46
|
+
// Resolve the collaborative binding once per doc/flavor: the shared fragment
|
|
47
|
+
// and awareness.
|
|
48
|
+
const collabBinding = useMemo<EditorCollabBinding | undefined>(() => {
|
|
49
|
+
if (!collab) return undefined;
|
|
50
|
+
return {
|
|
51
|
+
fragment: (collab.ydoc as Y.Doc).getXmlFragment(collab.fragmentName),
|
|
52
|
+
awareness: collab.awareness as Awareness,
|
|
53
|
+
};
|
|
54
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
55
|
+
}, [collab, flavor]);
|
|
56
|
+
|
|
57
|
+
// Seed the shared fragment from the current markdown when the room turns out
|
|
58
|
+
// to be empty (server-side seeding of the body supersedes this in a later
|
|
59
|
+
// phase). ySyncPlugin observes the fragment, so seeded content flows into the
|
|
60
|
+
// mounted editor.
|
|
61
|
+
//
|
|
62
|
+
// The decision waits for the provider's initial sync when the host exposes it
|
|
63
|
+
// (collab.synced/subscribeSynced): before sync the local doc is ALWAYS empty
|
|
64
|
+
// and awareness holds only ourselves, so an eager check both seeds over a
|
|
65
|
+
// room that already has content (duplicating the body after the merge) and
|
|
66
|
+
// reads its "single client" guard from a list that cannot contain anyone
|
|
67
|
+
// else yet. After sync the fragment and peer set are real: an empty fragment
|
|
68
|
+
// means the room is genuinely fresh, and if several clients arrive at one
|
|
69
|
+
// together, the lowest client id seeds — deterministic, exactly one writer,
|
|
70
|
+
// and re-evaluated on awareness/fragment changes so a lingering awareness
|
|
71
|
+
// entry from a crashed tab only defers seeding until it times out instead of
|
|
72
|
+
// leaving the body empty for good.
|
|
73
|
+
const seededRef = useRef(false);
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!collabBinding || seededRef.current) return;
|
|
76
|
+
const { fragment, awareness } = collabBinding;
|
|
77
|
+
const trySeed = () => {
|
|
78
|
+
if (seededRef.current) return;
|
|
79
|
+
if (collab?.synced && !collab.synced()) return;
|
|
80
|
+
if (fragment.length > 0 || !value) {
|
|
81
|
+
// Non-empty room, or nothing to seed: either way the decision is made.
|
|
82
|
+
seededRef.current = true;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const states = awareness.getStates();
|
|
86
|
+
if (states.size > 1 && awareness.clientID !== Math.min(...states.keys())) return;
|
|
87
|
+
prosemirrorToYXmlFragment(flavor.parse(value), fragment);
|
|
88
|
+
seededRef.current = true;
|
|
89
|
+
};
|
|
90
|
+
trySeed();
|
|
91
|
+
if (seededRef.current) return;
|
|
92
|
+
const unsubSync = collab?.subscribeSynced?.(trySeed);
|
|
93
|
+
const onFragment = () => trySeed();
|
|
94
|
+
fragment.observe(onFragment);
|
|
95
|
+
const onAwareness = () => trySeed();
|
|
96
|
+
awareness.on("change", onAwareness);
|
|
97
|
+
return () => {
|
|
98
|
+
unsubSync?.();
|
|
99
|
+
fragment.unobserve(onFragment);
|
|
100
|
+
awareness.off("change", onAwareness);
|
|
101
|
+
};
|
|
102
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
103
|
+
}, [collabBinding]);
|
|
104
|
+
|
|
105
|
+
const makeState = useCallback(
|
|
106
|
+
(markdown: string) =>
|
|
107
|
+
EditorState.create(
|
|
108
|
+
collabBinding
|
|
109
|
+
? {
|
|
110
|
+
// The shared fragment is the source of truth; ySyncPlugin fills
|
|
111
|
+
// the (initially empty) doc from it.
|
|
112
|
+
schema: flavor.schema,
|
|
113
|
+
plugins: [...flavor.plugins(collabBinding), placeholderPlugin(placeholder), reactKeys()],
|
|
114
|
+
}
|
|
115
|
+
: {
|
|
116
|
+
doc: flavor.parse(markdown),
|
|
117
|
+
plugins: [...flavor.plugins(), placeholderPlugin(placeholder), reactKeys()],
|
|
118
|
+
},
|
|
119
|
+
),
|
|
120
|
+
[flavor, placeholder, collabBinding],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const [state, setState] = useState<EditorState>(() => makeState(value));
|
|
124
|
+
const stateRef = useRef(state);
|
|
125
|
+
stateRef.current = state;
|
|
126
|
+
|
|
127
|
+
// Inject the stylesheet once the component is mounted in a real document.
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
ensureEditorStyles(typeof document !== "undefined" ? document : undefined);
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
// Rebuild the document when the flavor changes.
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
setState(makeState(value));
|
|
135
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
136
|
+
}, [flavor]);
|
|
137
|
+
|
|
138
|
+
// Adopt external value changes without clobbering in-progress edits: only
|
|
139
|
+
// reparse when the incoming markdown differs from what the current doc holds.
|
|
140
|
+
// In collab mode the shared fragment is the source of truth, so `value` is
|
|
141
|
+
// ignored (the CRDT drives the content).
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (collabBinding) return;
|
|
144
|
+
if (value !== flavor.serialize(stateRef.current.doc)) {
|
|
145
|
+
setState(makeState(value));
|
|
146
|
+
}
|
|
147
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
148
|
+
}, [value]);
|
|
149
|
+
|
|
150
|
+
// Debounced serialize -> onChange.
|
|
151
|
+
//
|
|
152
|
+
// Suppressed when the serialized doc is merely the canonical form of the
|
|
153
|
+
// value this editor was given: serialize(parse(value)) normalizes formatting
|
|
154
|
+
// the author never touched (a leading blank line, bullet or emphasis
|
|
155
|
+
// markers), and reporting that as a change marks an untouched document
|
|
156
|
+
// unsaved. The collab fill is the case that matters — ySyncPlugin's initial
|
|
157
|
+
// sync is a doc-changing transaction, so without this guard every document
|
|
158
|
+
// whose markdown is not already in canonical form opens dirty. A fill that
|
|
159
|
+
// really does differ from `value` (a peer edited before we joined) still
|
|
160
|
+
// emits, which is what keeps the host's map — and the next save — current.
|
|
161
|
+
const valueRef = useRef(value);
|
|
162
|
+
valueRef.current = value;
|
|
163
|
+
const canonicalCache = useRef<{ flavor: typeof flavor; raw: string; canonical: string } | null>(null);
|
|
164
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
165
|
+
const emit = useCallback(
|
|
166
|
+
(doc: EditorState["doc"]) => {
|
|
167
|
+
if (!onChange) return;
|
|
168
|
+
if (timer.current) clearTimeout(timer.current);
|
|
169
|
+
timer.current = setTimeout(() => {
|
|
170
|
+
const markdown = flavor.serialize(doc);
|
|
171
|
+
const raw = valueRef.current;
|
|
172
|
+
if (markdown === raw) return;
|
|
173
|
+
if (canonicalCache.current?.flavor !== flavor || canonicalCache.current.raw !== raw) {
|
|
174
|
+
let canonical = raw;
|
|
175
|
+
try {
|
|
176
|
+
canonical = flavor.serialize(flavor.parse(raw));
|
|
177
|
+
} catch {
|
|
178
|
+
/* unparseable value: the raw comparison above already ran */
|
|
179
|
+
}
|
|
180
|
+
canonicalCache.current = { flavor, raw, canonical };
|
|
181
|
+
}
|
|
182
|
+
if (markdown === canonicalCache.current.canonical) return;
|
|
183
|
+
void onChange(markdown);
|
|
184
|
+
}, debounceMs);
|
|
185
|
+
},
|
|
186
|
+
[onChange, flavor, debounceMs],
|
|
187
|
+
);
|
|
188
|
+
useEffect(() => () => {
|
|
189
|
+
if (timer.current) clearTimeout(timer.current);
|
|
190
|
+
}, []);
|
|
191
|
+
|
|
192
|
+
const dispatchTransaction = useCallback(
|
|
193
|
+
(tr: Transaction) => {
|
|
194
|
+
setState((prev) => {
|
|
195
|
+
const next = prev.apply(tr);
|
|
196
|
+
if (tr.docChanged) emit(next.doc);
|
|
197
|
+
return next;
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
[emit],
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const editable = useCallback(() => !readOnly, [readOnly]);
|
|
204
|
+
|
|
205
|
+
// Focus mode: close on Escape and lock background scroll while expanded.
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (!expanded || typeof document === "undefined") return;
|
|
208
|
+
const onKey = (e: KeyboardEvent) => {
|
|
209
|
+
if (e.key === "Escape") setExpanded(false);
|
|
210
|
+
};
|
|
211
|
+
document.addEventListener("keydown", onKey);
|
|
212
|
+
const prevOverflow = document.body.style.overflow;
|
|
213
|
+
document.body.style.overflow = "hidden";
|
|
214
|
+
return () => {
|
|
215
|
+
document.removeEventListener("keydown", onKey);
|
|
216
|
+
document.body.style.overflow = prevOverflow;
|
|
217
|
+
};
|
|
218
|
+
}, [expanded]);
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div
|
|
222
|
+
ref={wrapperRef}
|
|
223
|
+
className={expanded ? "cms-editor cms-editor--expanded" : "cms-editor"}
|
|
224
|
+
data-theme={theme}
|
|
225
|
+
// Publish presence (which field this user is editing) as the contenteditable
|
|
226
|
+
// gains/loses focus. Capture-phase so it fires for the inner editor node.
|
|
227
|
+
onFocusCapture={collab?.onFocusChange ? () => collab.onFocusChange!(true) : undefined}
|
|
228
|
+
onBlurCapture={collab?.onFocusChange ? () => collab.onFocusChange!(false) : undefined}
|
|
229
|
+
>
|
|
230
|
+
<ProseMirror
|
|
231
|
+
state={state}
|
|
232
|
+
dispatchTransaction={dispatchTransaction}
|
|
233
|
+
editable={editable}
|
|
234
|
+
>
|
|
235
|
+
{toolbar || expandable ? (
|
|
236
|
+
<Toolbar
|
|
237
|
+
schema={flavor.schema}
|
|
238
|
+
readOnly={readOnly || !toolbar}
|
|
239
|
+
expandable={expandable}
|
|
240
|
+
expanded={expanded}
|
|
241
|
+
onToggleExpand={toggleExpanded}
|
|
242
|
+
/>
|
|
243
|
+
) : null}
|
|
244
|
+
<ProseMirrorDoc />
|
|
245
|
+
</ProseMirror>
|
|
246
|
+
</div>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import React, { useMemo } from "react";
|
|
2
|
+
import { setBlockType, toggleMark, wrapIn } from "prosemirror-commands";
|
|
3
|
+
import { wrapInList } from "prosemirror-schema-list";
|
|
4
|
+
import {
|
|
5
|
+
useEditorEventCallback,
|
|
6
|
+
useEditorState,
|
|
7
|
+
} from "@handlewithcare/react-prosemirror";
|
|
8
|
+
import type { MarkType, NodeType, Schema } from "prosemirror-model";
|
|
9
|
+
import type { Command, EditorState } from "prosemirror-state";
|
|
10
|
+
import type { EditorView } from "prosemirror-view";
|
|
11
|
+
import {
|
|
12
|
+
BoldIcon,
|
|
13
|
+
BulletListIcon,
|
|
14
|
+
CodeIcon,
|
|
15
|
+
ExpandIcon,
|
|
16
|
+
H1Icon,
|
|
17
|
+
H2Icon,
|
|
18
|
+
ItalicIcon,
|
|
19
|
+
OrderedListIcon,
|
|
20
|
+
QuoteIcon,
|
|
21
|
+
StrikethroughIcon,
|
|
22
|
+
} from "./icons";
|
|
23
|
+
|
|
24
|
+
function markActive(state: EditorState, type: MarkType): boolean {
|
|
25
|
+
const { from, $from, to, empty } = state.selection;
|
|
26
|
+
if (empty) return !!type.isInSet(state.storedMarks || $from.marks());
|
|
27
|
+
return state.doc.rangeHasMark(from, to, type);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function blockActive(state: EditorState, type: NodeType, attrs: Record<string, unknown> = {}): boolean {
|
|
31
|
+
const { $from, to } = state.selection;
|
|
32
|
+
return to <= $from.end() && $from.parent.hasMarkup(type, attrs);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// True when any ancestor of the selection is a node of the given type (for
|
|
36
|
+
// list/blockquote wrappers, which aren't the direct textblock parent).
|
|
37
|
+
function ancestorActive(state: EditorState, type: NodeType): boolean {
|
|
38
|
+
const { $from } = state.selection;
|
|
39
|
+
for (let d = $from.depth; d > 0; d--) {
|
|
40
|
+
if ($from.node(d).type === type) return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type ToolItem = {
|
|
46
|
+
key: string;
|
|
47
|
+
title: string;
|
|
48
|
+
Icon: React.ComponentType<{ size?: number }>;
|
|
49
|
+
command: Command;
|
|
50
|
+
isActive?: (state: EditorState) => boolean;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function buildGroups(schema: Schema): ToolItem[][] {
|
|
54
|
+
const groups: ToolItem[][] = [];
|
|
55
|
+
|
|
56
|
+
const marks: ToolItem[] = [];
|
|
57
|
+
const { strong, em, strikethrough, code } = schema.marks;
|
|
58
|
+
if (strong) marks.push({ key: "bold", title: "Bold (⌘B)", Icon: BoldIcon, command: toggleMark(strong), isActive: (s) => markActive(s, strong) });
|
|
59
|
+
if (em) marks.push({ key: "italic", title: "Italic (⌘I)", Icon: ItalicIcon, command: toggleMark(em), isActive: (s) => markActive(s, em) });
|
|
60
|
+
if (strikethrough) marks.push({ key: "strike", title: "Strikethrough (⌘⇧X)", Icon: StrikethroughIcon, command: toggleMark(strikethrough), isActive: (s) => markActive(s, strikethrough) });
|
|
61
|
+
if (code) marks.push({ key: "code", title: "Inline code", Icon: CodeIcon, command: toggleMark(code), isActive: (s) => markActive(s, code) });
|
|
62
|
+
if (marks.length) groups.push(marks);
|
|
63
|
+
|
|
64
|
+
const blocks: ToolItem[] = [];
|
|
65
|
+
const heading = schema.nodes.heading;
|
|
66
|
+
const paragraph = schema.nodes.paragraph;
|
|
67
|
+
const toggleHeading = (level: number): Command => (state, dispatch, view) => {
|
|
68
|
+
const cmd = blockActive(state, heading, { level })
|
|
69
|
+
? setBlockType(paragraph)
|
|
70
|
+
: setBlockType(heading, { level });
|
|
71
|
+
return cmd(state, dispatch, view);
|
|
72
|
+
};
|
|
73
|
+
if (heading) {
|
|
74
|
+
blocks.push({ key: "h1", title: "Heading 1", Icon: H1Icon, command: toggleHeading(1), isActive: (s) => blockActive(s, heading, { level: 1 }) });
|
|
75
|
+
blocks.push({ key: "h2", title: "Heading 2", Icon: H2Icon, command: toggleHeading(2), isActive: (s) => blockActive(s, heading, { level: 2 }) });
|
|
76
|
+
}
|
|
77
|
+
if (schema.nodes.bullet_list) {
|
|
78
|
+
const t = schema.nodes.bullet_list;
|
|
79
|
+
blocks.push({ key: "ul", title: "Bullet list", Icon: BulletListIcon, command: wrapInList(t), isActive: (s) => ancestorActive(s, t) });
|
|
80
|
+
}
|
|
81
|
+
if (schema.nodes.ordered_list) {
|
|
82
|
+
const t = schema.nodes.ordered_list;
|
|
83
|
+
blocks.push({ key: "ol", title: "Numbered list", Icon: OrderedListIcon, command: wrapInList(t), isActive: (s) => ancestorActive(s, t) });
|
|
84
|
+
}
|
|
85
|
+
if (schema.nodes.blockquote) {
|
|
86
|
+
const t = schema.nodes.blockquote;
|
|
87
|
+
blocks.push({ key: "quote", title: "Blockquote", Icon: QuoteIcon, command: wrapIn(t), isActive: (s) => ancestorActive(s, t) });
|
|
88
|
+
}
|
|
89
|
+
if (blocks.length) groups.push(blocks);
|
|
90
|
+
|
|
91
|
+
return groups;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function ToolButton({ item }: { item: ToolItem }) {
|
|
95
|
+
const state = useEditorState();
|
|
96
|
+
const run = useEditorEventCallback((view: EditorView, command: Command) => {
|
|
97
|
+
command(view.state, view.dispatch, view);
|
|
98
|
+
view.focus();
|
|
99
|
+
});
|
|
100
|
+
const active = item.isActive?.(state) ?? false;
|
|
101
|
+
const enabled = item.command(state); // dry run: whether the command applies
|
|
102
|
+
return (
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
className="cms-editor__tool"
|
|
106
|
+
aria-pressed={active}
|
|
107
|
+
aria-label={item.title}
|
|
108
|
+
title={item.title}
|
|
109
|
+
disabled={!enabled}
|
|
110
|
+
// Keep the editor selection while clicking the toolbar.
|
|
111
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
112
|
+
onClick={() => run(item.command)}
|
|
113
|
+
>
|
|
114
|
+
<item.Icon />
|
|
115
|
+
</button>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function Toolbar({
|
|
120
|
+
schema,
|
|
121
|
+
readOnly,
|
|
122
|
+
expandable,
|
|
123
|
+
expanded,
|
|
124
|
+
onToggleExpand,
|
|
125
|
+
}: {
|
|
126
|
+
schema: Schema;
|
|
127
|
+
readOnly: boolean;
|
|
128
|
+
expandable: boolean;
|
|
129
|
+
expanded: boolean;
|
|
130
|
+
onToggleExpand: () => void;
|
|
131
|
+
}) {
|
|
132
|
+
const groups = useMemo(() => buildGroups(schema), [schema]);
|
|
133
|
+
return (
|
|
134
|
+
<div className="cms-editor__toolbar" role="toolbar" aria-label="Formatting">
|
|
135
|
+
{!readOnly &&
|
|
136
|
+
groups.map((group, gi) => (
|
|
137
|
+
<React.Fragment key={gi}>
|
|
138
|
+
{gi > 0 ? <span className="cms-editor__toolbar-sep" /> : null}
|
|
139
|
+
{group.map((item) => (
|
|
140
|
+
<ToolButton key={item.key} item={item} />
|
|
141
|
+
))}
|
|
142
|
+
</React.Fragment>
|
|
143
|
+
))}
|
|
144
|
+
{expandable ? (
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
className="cms-editor__tool cms-editor__tool--expand"
|
|
148
|
+
onClick={onToggleExpand}
|
|
149
|
+
aria-label={expanded ? "Exit focus mode" : "Expand to focus mode"}
|
|
150
|
+
title={expanded ? "Exit focus mode (Esc)" : "Focus mode"}
|
|
151
|
+
>
|
|
152
|
+
<ExpandIcon expanded={expanded} />
|
|
153
|
+
</button>
|
|
154
|
+
) : null}
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import MarkdownEditor from "./MarkdownEditor";
|
|
3
|
+
import type { EditorTheme, MarkdownFlavor } from "./types";
|
|
4
|
+
|
|
5
|
+
// Minimal shape of a field-slot argument — intentionally structural so this
|
|
6
|
+
// package stays decoupled from @gogitcms/design-system. It is assignable from the
|
|
7
|
+
// design-system's richer FieldSlotArgs.
|
|
8
|
+
export type MarkdownFieldArgs = {
|
|
9
|
+
field: { component?: string; format?: string; flavor?: string };
|
|
10
|
+
value: unknown;
|
|
11
|
+
onChange: (value: string) => void;
|
|
12
|
+
// When true, render the editor non-editable (host is in read-only mode).
|
|
13
|
+
readOnly?: boolean;
|
|
14
|
+
// Live collaboration handle (Yjs Doc/Awareness are opaque here) + the field's
|
|
15
|
+
// dotted path. When present, the body binds to the shared CRDT fragment
|
|
16
|
+
// "body:<path>" and publishes presence via setFocus.
|
|
17
|
+
collab?: {
|
|
18
|
+
ydoc: unknown;
|
|
19
|
+
awareness: unknown;
|
|
20
|
+
setFocus(path: string | null): void;
|
|
21
|
+
synced?(): boolean;
|
|
22
|
+
subscribeSynced?(cb: () => void): () => void;
|
|
23
|
+
};
|
|
24
|
+
path?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type MarkdownFieldOptions = {
|
|
28
|
+
theme?: EditorTheme;
|
|
29
|
+
defaultFlavor?: MarkdownFlavor;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Builds a `renderField`-compatible function that renders the markdown editor
|
|
33
|
+
// for `component: "body"` (markdown) fields and defers everything else back to
|
|
34
|
+
// the host's default control. Wire it into <ContentBrowser renderField={…}> in
|
|
35
|
+
// the web and native apps.
|
|
36
|
+
export function createMarkdownRenderField(options: MarkdownFieldOptions = {}) {
|
|
37
|
+
const { theme = "light", defaultFlavor = "gfm" } = options;
|
|
38
|
+
return ({ field, value, onChange, readOnly, collab, path }: MarkdownFieldArgs): React.ReactNode => {
|
|
39
|
+
if (field.component !== "body") return undefined;
|
|
40
|
+
if (field.format && field.format !== "markdown") return undefined;
|
|
41
|
+
const flavor = (field.flavor as MarkdownFlavor) || defaultFlavor;
|
|
42
|
+
// In a live session the body binds to a shared Y.XmlFragment keyed by the
|
|
43
|
+
// field path and reports focus for presence.
|
|
44
|
+
const collabProp =
|
|
45
|
+
collab && path
|
|
46
|
+
? {
|
|
47
|
+
ydoc: collab.ydoc,
|
|
48
|
+
awareness: collab.awareness,
|
|
49
|
+
fragmentName: `body:${path}`,
|
|
50
|
+
onFocusChange: (focused: boolean) => collab.setFocus(focused ? path : null),
|
|
51
|
+
synced: collab.synced?.bind(collab),
|
|
52
|
+
subscribeSynced: collab.subscribeSynced?.bind(collab),
|
|
53
|
+
}
|
|
54
|
+
: undefined;
|
|
55
|
+
return (
|
|
56
|
+
<MarkdownEditor
|
|
57
|
+
value={value == null ? "" : String(value)}
|
|
58
|
+
onChange={onChange}
|
|
59
|
+
flavor={flavor}
|
|
60
|
+
theme={theme}
|
|
61
|
+
readOnly={readOnly}
|
|
62
|
+
dom={{ matchContents: true }}
|
|
63
|
+
collab={collabProp}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defaultMarkdownParser,
|
|
3
|
+
defaultMarkdownSerializer,
|
|
4
|
+
schema as markdownSchema,
|
|
5
|
+
} from "prosemirror-markdown";
|
|
6
|
+
import type { Node as PMNode } from "prosemirror-model";
|
|
7
|
+
import type { Plugin } from "prosemirror-state";
|
|
8
|
+
import type { Flavor } from "../types";
|
|
9
|
+
import { basePlugins, type EditorCollabBinding } from "../shared/plugins";
|
|
10
|
+
|
|
11
|
+
// CommonMark: the stock prosemirror-markdown schema, parser, and serializer.
|
|
12
|
+
export const commonmark: Flavor = {
|
|
13
|
+
id: "commonmark",
|
|
14
|
+
label: "CommonMark",
|
|
15
|
+
schema: markdownSchema,
|
|
16
|
+
parse(markdown: string): PMNode {
|
|
17
|
+
// The parser returns null for input it cannot handle; fall back to an empty
|
|
18
|
+
// document so the editor always has a valid doc to render.
|
|
19
|
+
return defaultMarkdownParser.parse(markdown) ?? markdownSchema.topNodeType.createAndFill()!;
|
|
20
|
+
},
|
|
21
|
+
serialize(doc: PMNode): string {
|
|
22
|
+
return defaultMarkdownSerializer.serialize(doc);
|
|
23
|
+
},
|
|
24
|
+
plugins(collab?: EditorCollabBinding): Plugin[] {
|
|
25
|
+
return basePlugins(markdownSchema, [], collab);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { columnResizing, tableEditing } from "prosemirror-tables";
|
|
2
|
+
import type { Node as PMNode } from "prosemirror-model";
|
|
3
|
+
import type { Plugin } from "prosemirror-state";
|
|
4
|
+
import type { Flavor } from "../types";
|
|
5
|
+
import { basePlugins, type EditorCollabBinding } from "../shared/plugins";
|
|
6
|
+
import { gfmSchema } from "./schema";
|
|
7
|
+
import { parseGfm } from "./parser";
|
|
8
|
+
import { serializeGfm } from "./serializer";
|
|
9
|
+
import { taskListPlugin } from "./taskList";
|
|
10
|
+
|
|
11
|
+
// GitHub Flavored Markdown: CommonMark plus strikethrough, tables, autolinks,
|
|
12
|
+
// and task lists.
|
|
13
|
+
export const gfm: Flavor = {
|
|
14
|
+
id: "gfm",
|
|
15
|
+
label: "GitHub Flavored Markdown",
|
|
16
|
+
schema: gfmSchema,
|
|
17
|
+
parse(markdown: string): PMNode {
|
|
18
|
+
return parseGfm(markdown);
|
|
19
|
+
},
|
|
20
|
+
serialize(doc: PMNode): string {
|
|
21
|
+
return serializeGfm(doc);
|
|
22
|
+
},
|
|
23
|
+
plugins(collab?: EditorCollabBinding): Plugin[] {
|
|
24
|
+
return basePlugins(gfmSchema, [
|
|
25
|
+
columnResizing(),
|
|
26
|
+
tableEditing(),
|
|
27
|
+
taskListPlugin(),
|
|
28
|
+
], collab);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import MarkdownIt from "markdown-it";
|
|
2
|
+
import type Token from "markdown-it/lib/token.mjs";
|
|
3
|
+
import { MarkdownParser, defaultMarkdownParser } from "prosemirror-markdown";
|
|
4
|
+
import type { Node as PMNode } from "prosemirror-model";
|
|
5
|
+
import { gfmSchema } from "./schema";
|
|
6
|
+
|
|
7
|
+
// A markdown-it configured for GFM: tables and strikethrough are on by default;
|
|
8
|
+
// linkify turns bare URLs into autolinks. HTML is disabled (rendered as text).
|
|
9
|
+
const md = MarkdownIt({ html: false, linkify: true });
|
|
10
|
+
|
|
11
|
+
// Detect GFM task-list items (`- [ ] …` / `- [x] …`). markdown-it has no native
|
|
12
|
+
// support, so a small core rule stamps `checked` onto the list_item_open token
|
|
13
|
+
// and strips the `[ ] ` marker from the item's first inline run.
|
|
14
|
+
md.core.ruler.after("inline", "gfm-task-lists", (state) => {
|
|
15
|
+
const tokens = state.tokens;
|
|
16
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
17
|
+
if (tokens[i].type !== "list_item_open") continue;
|
|
18
|
+
for (let j = i + 1; j < tokens.length; j++) {
|
|
19
|
+
const t = tokens[j];
|
|
20
|
+
if (t.type === "list_item_close") break;
|
|
21
|
+
if (t.type === "paragraph_open" || t.type === "paragraph_close") continue;
|
|
22
|
+
if (t.type !== "inline") break;
|
|
23
|
+
const m = /^\[([ xX])\]\s+/.exec(t.content);
|
|
24
|
+
if (m) {
|
|
25
|
+
tokens[i].meta = { ...(tokens[i].meta || {}), task: true, checked: m[1] !== " " };
|
|
26
|
+
t.content = t.content.slice(m[0].length);
|
|
27
|
+
const child = t.children && t.children[0];
|
|
28
|
+
if (child && child.type === "text") {
|
|
29
|
+
child.content = child.content.replace(/^\[([ xX])\]\s+/, "");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// prosemirror-tables models `table > table_row > cell`, with no thead/tbody
|
|
39
|
+
// wrappers. Drop those structural tokens so the rows become direct children.
|
|
40
|
+
const tokenizer = {
|
|
41
|
+
parse(src: string, env: unknown): Token[] {
|
|
42
|
+
return md
|
|
43
|
+
.parse(src, env)
|
|
44
|
+
.filter(
|
|
45
|
+
(t) =>
|
|
46
|
+
t.type !== "thead_open" &&
|
|
47
|
+
t.type !== "thead_close" &&
|
|
48
|
+
t.type !== "tbody_open" &&
|
|
49
|
+
t.type !== "tbody_close",
|
|
50
|
+
);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const tokens = {
|
|
55
|
+
...defaultMarkdownParser.tokens,
|
|
56
|
+
s: { mark: "strikethrough" },
|
|
57
|
+
table: { block: "table" },
|
|
58
|
+
tr: { block: "table_row" },
|
|
59
|
+
th: { block: "table_header" },
|
|
60
|
+
td: { block: "table_cell" },
|
|
61
|
+
list_item: {
|
|
62
|
+
block: "list_item",
|
|
63
|
+
getAttrs: (tok: Token) => ({
|
|
64
|
+
checked: tok.meta && tok.meta.task ? Boolean(tok.meta.checked) : null,
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const parser = new MarkdownParser(gfmSchema, tokenizer as unknown as MarkdownIt, tokens);
|
|
70
|
+
|
|
71
|
+
export function parseGfm(markdown: string): PMNode {
|
|
72
|
+
return parser.parse(markdown) ?? gfmSchema.topNodeType.createAndFill()!;
|
|
73
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { schema as base } from "prosemirror-markdown";
|
|
2
|
+
import { Schema, type DOMOutputSpec, type Node as PMNode } from "prosemirror-model";
|
|
3
|
+
import { tableNodes } from "prosemirror-tables";
|
|
4
|
+
|
|
5
|
+
// GFM schema = the CommonMark (prosemirror-markdown) schema extended with:
|
|
6
|
+
// - a `strikethrough` mark (`~~text~~`)
|
|
7
|
+
// - GitHub pipe tables (via prosemirror-tables)
|
|
8
|
+
// - task-list items: `list_item` gains a `checked` attr (null = plain item,
|
|
9
|
+
// true/false = a `[x]`/`[ ]` task) rendered with a real checkbox.
|
|
10
|
+
|
|
11
|
+
const tNodes = tableNodes({
|
|
12
|
+
tableGroup: "block",
|
|
13
|
+
// GFM table cells only hold inline content, so cells are simple textblocks.
|
|
14
|
+
cellContent: "inline*",
|
|
15
|
+
cellAttributes: {},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Extend list_item with the task `checked` attribute + checkbox rendering.
|
|
19
|
+
const baseListItem = base.spec.nodes.get("list_item")!;
|
|
20
|
+
const listItem = {
|
|
21
|
+
...baseListItem,
|
|
22
|
+
attrs: { checked: { default: null as boolean | null } },
|
|
23
|
+
parseDOM: [
|
|
24
|
+
{
|
|
25
|
+
tag: "li",
|
|
26
|
+
getAttrs: (dom: HTMLElement) => {
|
|
27
|
+
const raw = dom.getAttribute("data-checked");
|
|
28
|
+
return { checked: raw == null ? null : raw === "true" };
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
toDOM(node: PMNode): DOMOutputSpec {
|
|
33
|
+
const checked = node.attrs.checked as boolean | null;
|
|
34
|
+
if (checked === null) return ["li", 0];
|
|
35
|
+
// A span (not a real <input>) styled as a checkbox: react-prosemirror
|
|
36
|
+
// renders toDOM output through React, and a controlled <input checked> with
|
|
37
|
+
// no onChange trips React's form-field warning. The taskListPlugin toggles
|
|
38
|
+
// the node's `checked` attr on click.
|
|
39
|
+
return [
|
|
40
|
+
"li",
|
|
41
|
+
{ class: "task-list-item", "data-checked": String(checked) },
|
|
42
|
+
[
|
|
43
|
+
"span",
|
|
44
|
+
{
|
|
45
|
+
class: "task-list-item-checkbox",
|
|
46
|
+
contenteditable: "false",
|
|
47
|
+
role: "checkbox",
|
|
48
|
+
"aria-checked": String(checked),
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
["div", { class: "task-list-item-content" }, 0],
|
|
52
|
+
];
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const nodes = base.spec.nodes.update("list_item", listItem).append(tNodes);
|
|
57
|
+
|
|
58
|
+
const marks = base.spec.marks.addToEnd("strikethrough", {
|
|
59
|
+
parseDOM: [
|
|
60
|
+
{ tag: "s" },
|
|
61
|
+
{ tag: "del" },
|
|
62
|
+
{ tag: "strike" },
|
|
63
|
+
{ style: "text-decoration=line-through" },
|
|
64
|
+
],
|
|
65
|
+
toDOM(): DOMOutputSpec {
|
|
66
|
+
return ["s", 0];
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export const gfmSchema = new Schema({ nodes, marks });
|