@multiplatform.one/rich-text 6.3.0 → 6.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/dist/cjs/RichEditor.cjs +5 -0
  2. package/dist/cjs/RichEditor.native.js +22 -0
  3. package/dist/cjs/RichEditor.native.js.map +1 -1
  4. package/dist/cjs/RichTextEditor/index.cjs +2 -0
  5. package/dist/cjs/RichTextEditor/index.native.js +2 -0
  6. package/dist/cjs/RichTextEditor/index.native.js.map +1 -1
  7. package/dist/cjs/editor/SlashCommands.cjs +8 -1
  8. package/dist/cjs/editor/SlashCommands.native.js +14 -1
  9. package/dist/cjs/editor/SlashCommands.native.js.map +1 -1
  10. package/dist/cjs/editor/TentapEditor.native.js +15 -0
  11. package/dist/cjs/editor/TentapEditor.native.js.map +1 -1
  12. package/dist/cjs/editor/TiptapEditor.cjs +26 -230
  13. package/dist/cjs/editor/TiptapEditor.native.js +34 -280
  14. package/dist/cjs/editor/TiptapEditor.native.js.map +1 -1
  15. package/dist/cjs/editor/capabilities.cjs +37 -0
  16. package/dist/cjs/editor/capabilities.native.js +41 -0
  17. package/dist/cjs/editor/capabilities.native.js.map +1 -0
  18. package/dist/cjs/editor/extensions.cjs +275 -0
  19. package/dist/cjs/editor/extensions.native.js +328 -0
  20. package/dist/cjs/editor/extensions.native.js.map +1 -0
  21. package/dist/cjs/index.cjs +5 -2
  22. package/dist/cjs/index.native.js +4 -1
  23. package/dist/cjs/index.native.js.map +1 -1
  24. package/dist/cjs/toolbar/BubbleToolbar.cjs +9 -6
  25. package/dist/cjs/toolbar/BubbleToolbar.native.js +12 -7
  26. package/dist/cjs/toolbar/BubbleToolbar.native.js.map +1 -1
  27. package/dist/cjs/toolbar/FixedToolbar.cjs +6 -3
  28. package/dist/cjs/toolbar/MoreDropdown.cjs +17 -9
  29. package/dist/cjs/toolbar/MoreDropdown.native.js +21 -9
  30. package/dist/cjs/toolbar/MoreDropdown.native.js.map +1 -1
  31. package/dist/cjs/toolbar/TableBubble.cjs +15 -2
  32. package/dist/cjs/toolbar/TableBubble.native.js +17 -2
  33. package/dist/cjs/toolbar/TableBubble.native.js.map +1 -1
  34. package/dist/cjs/types.native.js.map +1 -1
  35. package/dist/esm/RichEditor.mjs +5 -0
  36. package/dist/esm/RichEditor.mjs.map +1 -1
  37. package/dist/esm/RichEditor.native.js +22 -0
  38. package/dist/esm/RichEditor.native.js.map +1 -1
  39. package/dist/esm/RichTextEditor/index.mjs +2 -0
  40. package/dist/esm/RichTextEditor/index.mjs.map +1 -1
  41. package/dist/esm/RichTextEditor/index.native.js +2 -0
  42. package/dist/esm/RichTextEditor/index.native.js.map +1 -1
  43. package/dist/esm/editor/SlashCommands.mjs +8 -1
  44. package/dist/esm/editor/SlashCommands.mjs.map +1 -1
  45. package/dist/esm/editor/SlashCommands.native.js +14 -1
  46. package/dist/esm/editor/SlashCommands.native.js.map +1 -1
  47. package/dist/esm/editor/TentapEditor.native.js +15 -0
  48. package/dist/esm/editor/TentapEditor.native.js.map +1 -1
  49. package/dist/esm/editor/TiptapEditor.mjs +28 -221
  50. package/dist/esm/editor/TiptapEditor.mjs.map +1 -1
  51. package/dist/esm/editor/TiptapEditor.native.js +36 -271
  52. package/dist/esm/editor/TiptapEditor.native.js.map +1 -1
  53. package/dist/esm/editor/capabilities.mjs +10 -0
  54. package/dist/esm/editor/capabilities.mjs.map +1 -0
  55. package/dist/esm/editor/capabilities.native.js +11 -0
  56. package/dist/esm/editor/capabilities.native.js.map +1 -0
  57. package/dist/esm/editor/extensions.mjs +236 -0
  58. package/dist/esm/editor/extensions.mjs.map +1 -0
  59. package/dist/esm/editor/extensions.native.js +286 -0
  60. package/dist/esm/editor/extensions.native.js.map +1 -0
  61. package/dist/esm/index.mjs +2 -1
  62. package/dist/esm/index.mjs.map +1 -1
  63. package/dist/esm/index.native.js +2 -1
  64. package/dist/esm/index.native.js.map +1 -1
  65. package/dist/esm/toolbar/BubbleToolbar.mjs +9 -6
  66. package/dist/esm/toolbar/BubbleToolbar.mjs.map +1 -1
  67. package/dist/esm/toolbar/BubbleToolbar.native.js +12 -7
  68. package/dist/esm/toolbar/BubbleToolbar.native.js.map +1 -1
  69. package/dist/esm/toolbar/FixedToolbar.mjs +6 -3
  70. package/dist/esm/toolbar/FixedToolbar.mjs.map +1 -1
  71. package/dist/esm/toolbar/MoreDropdown.mjs +17 -9
  72. package/dist/esm/toolbar/MoreDropdown.mjs.map +1 -1
  73. package/dist/esm/toolbar/MoreDropdown.native.js +21 -9
  74. package/dist/esm/toolbar/MoreDropdown.native.js.map +1 -1
  75. package/dist/esm/toolbar/TableBubble.mjs +15 -2
  76. package/dist/esm/toolbar/TableBubble.mjs.map +1 -1
  77. package/dist/esm/toolbar/TableBubble.native.js +17 -2
  78. package/dist/esm/toolbar/TableBubble.native.js.map +1 -1
  79. package/dist/esm/types.mjs.map +1 -1
  80. package/dist/esm/types.native.js.map +1 -1
  81. package/dist/jsx/RichEditor.mjs +5 -0
  82. package/dist/jsx/RichEditor.mjs.map +1 -1
  83. package/dist/jsx/RichEditor.native.js +22 -0
  84. package/dist/jsx/RichEditor.native.js.map +1 -1
  85. package/dist/jsx/RichTextEditor/index.mjs +2 -0
  86. package/dist/jsx/RichTextEditor/index.mjs.map +1 -1
  87. package/dist/jsx/RichTextEditor/index.native.js +2 -0
  88. package/dist/jsx/RichTextEditor/index.native.js.map +1 -1
  89. package/dist/jsx/editor/SlashCommands.mjs +8 -1
  90. package/dist/jsx/editor/SlashCommands.mjs.map +1 -1
  91. package/dist/jsx/editor/SlashCommands.native.js +14 -1
  92. package/dist/jsx/editor/SlashCommands.native.js.map +1 -1
  93. package/dist/jsx/editor/TentapEditor.native.js +15 -0
  94. package/dist/jsx/editor/TentapEditor.native.js.map +1 -1
  95. package/dist/jsx/editor/TiptapEditor.mjs +28 -221
  96. package/dist/jsx/editor/TiptapEditor.mjs.map +1 -1
  97. package/dist/jsx/editor/TiptapEditor.native.js +34 -280
  98. package/dist/jsx/editor/TiptapEditor.native.js.map +1 -1
  99. package/dist/jsx/editor/capabilities.mjs +10 -0
  100. package/dist/jsx/editor/capabilities.mjs.map +1 -0
  101. package/dist/jsx/editor/capabilities.native.js +41 -0
  102. package/dist/jsx/editor/capabilities.native.js.map +1 -0
  103. package/dist/jsx/editor/extensions.mjs +236 -0
  104. package/dist/jsx/editor/extensions.mjs.map +1 -0
  105. package/dist/jsx/editor/extensions.native.js +328 -0
  106. package/dist/jsx/editor/extensions.native.js.map +1 -0
  107. package/dist/jsx/index.js +2 -1
  108. package/dist/jsx/index.js.map +1 -1
  109. package/dist/jsx/index.mjs +2 -1
  110. package/dist/jsx/index.mjs.map +1 -1
  111. package/dist/jsx/index.native.js +4 -1
  112. package/dist/jsx/index.native.js.map +1 -1
  113. package/dist/jsx/toolbar/BubbleToolbar.mjs +9 -6
  114. package/dist/jsx/toolbar/BubbleToolbar.mjs.map +1 -1
  115. package/dist/jsx/toolbar/BubbleToolbar.native.js +12 -7
  116. package/dist/jsx/toolbar/BubbleToolbar.native.js.map +1 -1
  117. package/dist/jsx/toolbar/FixedToolbar.mjs +6 -3
  118. package/dist/jsx/toolbar/FixedToolbar.mjs.map +1 -1
  119. package/dist/jsx/toolbar/MoreDropdown.mjs +17 -9
  120. package/dist/jsx/toolbar/MoreDropdown.mjs.map +1 -1
  121. package/dist/jsx/toolbar/MoreDropdown.native.js +21 -9
  122. package/dist/jsx/toolbar/MoreDropdown.native.js.map +1 -1
  123. package/dist/jsx/toolbar/TableBubble.mjs +15 -2
  124. package/dist/jsx/toolbar/TableBubble.mjs.map +1 -1
  125. package/dist/jsx/toolbar/TableBubble.native.js +17 -2
  126. package/dist/jsx/toolbar/TableBubble.native.js.map +1 -1
  127. package/dist/jsx/types.mjs.map +1 -1
  128. package/dist/jsx/types.native.js.map +1 -1
  129. package/package.json +8 -8
  130. package/src/RichEditor.native.tsx +20 -0
  131. package/src/RichEditor.stories.tsx +32 -0
  132. package/src/RichEditor.tsx +5 -0
  133. package/src/RichTextEditor/index.tsx +14 -1
  134. package/src/editor/SlashCommands.tsx +10 -0
  135. package/src/editor/TentapEditor.native.tsx +13 -0
  136. package/src/editor/TiptapEditor.tsx +51 -279
  137. package/src/editor/capabilities.spec.ts +190 -0
  138. package/src/editor/capabilities.ts +76 -0
  139. package/src/editor/extensions.ts +315 -0
  140. package/src/index.ts +3 -0
  141. package/src/toolbar/BubbleToolbar.tsx +40 -28
  142. package/src/toolbar/FixedToolbar.tsx +20 -12
  143. package/src/toolbar/MoreDropdown.tsx +30 -6
  144. package/src/toolbar/TableBubble.tsx +65 -12
  145. package/src/types.ts +28 -1
  146. package/types/RichEditor.d.ts.map +1 -1
  147. package/types/RichEditor.native.d.ts.map +1 -1
  148. package/types/RichTextEditor/index.d.ts +7 -1
  149. package/types/RichTextEditor/index.d.ts.map +1 -1
  150. package/types/editor/SlashCommands.d.ts.map +1 -1
  151. package/types/editor/TentapEditor.native.d.ts.map +1 -1
  152. package/types/editor/TiptapEditor.d.ts.map +1 -1
  153. package/types/editor/capabilities.d.ts +29 -0
  154. package/types/editor/capabilities.d.ts.map +1 -0
  155. package/types/editor/extensions.d.ts +41 -0
  156. package/types/editor/extensions.d.ts.map +1 -0
  157. package/types/index.d.ts +2 -1
  158. package/types/index.d.ts.map +1 -1
  159. package/types/toolbar/BubbleToolbar.d.ts +4 -2
  160. package/types/toolbar/BubbleToolbar.d.ts.map +1 -1
  161. package/types/toolbar/FixedToolbar.d.ts +4 -2
  162. package/types/toolbar/FixedToolbar.d.ts.map +1 -1
  163. package/types/toolbar/MoreDropdown.d.ts +4 -2
  164. package/types/toolbar/MoreDropdown.d.ts.map +1 -1
  165. package/types/toolbar/TableBubble.d.ts +4 -1
  166. package/types/toolbar/TableBubble.d.ts.map +1 -1
  167. package/types/types.d.ts +23 -1
  168. package/types/types.d.ts.map +1 -1
@@ -20,27 +20,6 @@ import {
20
20
  useState,
21
21
  } from "react";
22
22
  import { useEditor, EditorContent } from "@tiptap/react";
23
- import { Extension } from "@tiptap/core";
24
- import StarterKit from "@tiptap/starter-kit";
25
- import Placeholder from "@tiptap/extension-placeholder";
26
- import TextAlign from "@tiptap/extension-text-align";
27
- import { Color } from "@tiptap/extension-color";
28
- import { TextStyle } from "@tiptap/extension-text-style";
29
- import { Highlight } from "@tiptap/extension-highlight";
30
- import { Subscript } from "@tiptap/extension-subscript";
31
- import { Superscript } from "@tiptap/extension-superscript";
32
- import TaskList from "@tiptap/extension-task-list";
33
- import TaskItem from "@tiptap/extension-task-item";
34
- import Image from "@tiptap/extension-image";
35
- import Typography from "@tiptap/extension-typography";
36
- import CharacterCount from "@tiptap/extension-character-count";
37
- import { Details, DetailsContent, DetailsSummary } from "@tiptap/extension-details";
38
-
39
- import { Table } from "@tiptap/extension-table";
40
- import { TableRow } from "@tiptap/extension-table-row";
41
- import { TableHeader } from "@tiptap/extension-table-header";
42
- import { TableCell } from "@tiptap/extension-table-cell";
43
- import { common, createLowlight } from "lowlight";
44
23
  import { View, getTokens, styled, useTheme, useThemeName } from "tamagui";
45
24
  import { isWeb } from "@tamagui/constants";
46
25
  import { menuRowFrame, useResolvedKnobs } from "@multiplatform.one/theme";
@@ -50,21 +29,13 @@ import { defaultEditorState } from "../types";
50
29
  import { RichEditorContext } from "../context";
51
30
  import { t } from "../shared/t";
52
31
  import { getEditorCSS } from "./editorStyles";
53
- import { SlashCommands } from "./SlashCommands";
54
- import { MarkdownPaste } from "./MarkdownPaste";
55
- import { BlockDragHandle } from "./BlockDragHandle";
56
- import { MediaUpload } from "./media";
32
+ import { buildEditorExtensions } from "./extensions";
33
+ import { isCommandInCapabilities } from "./capabilities";
57
34
  import { createMentionExtension } from "./mentions";
58
- import { EmojiSuggestion } from "./emoji";
59
- import { InlineMath } from "./mathematics";
60
- import { CodeBlockWithPreview, PreviewChromeStore, setPreviewChrome } from "./codeBlockPreview";
35
+ import { setPreviewChrome } from "./codeBlockPreview";
61
36
  import { htmlToMarkdown, markdownToHtml } from "./markdown";
62
37
  import { cleanPastedHtml } from "./pasteCleanup";
63
- import {
64
- SuggestionChromeStore,
65
- fallbackSuggestionChrome,
66
- type SuggestionChrome,
67
- } from "./suggestionPopup";
38
+ import { fallbackSuggestionChrome, type SuggestionChrome } from "./suggestionPopup";
68
39
  import { getEditorStateFromTiptap } from "./useEditorState";
69
40
  import { BubbleToolbar } from "../toolbar/BubbleToolbar";
70
41
  import { LinkBubble } from "../toolbar/LinkBubble";
@@ -73,159 +44,9 @@ import { ImageBubble } from "../toolbar/ImageBubble";
73
44
  import { TableBubble } from "../toolbar/TableBubble";
74
45
  import { MathBubble } from "../toolbar/MathBubble";
75
46
 
76
- const lowlight = createLowlight(common);
77
-
78
47
  /** Enter/exit fade for bubbles and suggestion popups (0 when animation none) */
79
48
  const MOTION_MS = 120;
80
49
 
81
- /**
82
- * Tab / Shift-Tab indentation inside code blocks.
83
- * Inserts or removes 2 spaces at the cursor (or start of each selected line).
84
- */
85
- const CodeBlockTabIndent = Extension.create({
86
- name: "codeBlockTabIndent",
87
- addKeyboardShortcuts() {
88
- return {
89
- Tab: ({ editor }) => {
90
- if (!editor.isActive("codeBlock")) return false;
91
- const { state, dispatch } = editor.view;
92
- const { from, to } = state.selection;
93
-
94
- if (from === to) {
95
- dispatch(state.tr.insertText(" ", from, to));
96
- return true;
97
- }
98
-
99
- const doc = state.doc;
100
- const lines: { start: number }[] = [];
101
- doc.nodesBetween(from, to, (node, pos) => {
102
- if (node.isTextblock) {
103
- lines.push({ start: pos + 1 });
104
- }
105
- });
106
- if (lines.length === 0) {
107
- dispatch(state.tr.insertText(" ", from, to));
108
- return true;
109
- }
110
-
111
- let tr = state.tr;
112
- let offset = 0;
113
- for (const line of lines) {
114
- const lineStart = line.start + offset;
115
- tr = tr.insertText(" ", lineStart);
116
- offset += 2;
117
- }
118
- dispatch(tr);
119
- return true;
120
- },
121
- "Shift-Tab": ({ editor }) => {
122
- if (!editor.isActive("codeBlock")) return false;
123
- const { state, dispatch } = editor.view;
124
- const { from, to, $from } = state.selection;
125
-
126
- if (from === to) {
127
- const lineStart = from - $from.parentOffset;
128
- const text = state.doc.textBetween(lineStart, from);
129
- const leadingSpaces = text.match(/^ {1,2}/)?.[0];
130
- if (leadingSpaces) {
131
- dispatch(state.tr.delete(lineStart, lineStart + leadingSpaces.length));
132
- }
133
- return true;
134
- }
135
-
136
- const doc = state.doc;
137
- let tr = state.tr;
138
- let offset = 0;
139
- doc.nodesBetween(from, to, (node, pos) => {
140
- if (node.isTextblock) {
141
- const lineStart = pos + 1 + offset;
142
- const text = tr.doc.textBetween(
143
- lineStart,
144
- Math.min(lineStart + node.content.size, tr.doc.content.size),
145
- );
146
- const leadingSpaces = text.match(/^ {1,2}/)?.[0];
147
- if (leadingSpaces) {
148
- tr = tr.delete(lineStart, lineStart + leadingSpaces.length);
149
- offset -= leadingSpaces.length;
150
- }
151
- }
152
- });
153
- dispatch(tr);
154
- return true;
155
- },
156
- };
157
- },
158
- });
159
-
160
- /**
161
- * Override Shift-Enter and Mod-Enter to create new paragraphs instead of
162
- * hard breaks (<br>). Mirrors GitLab's approach: HardBreak node stays
163
- * registered (so <br> in pasted HTML is preserved) but its keyboard
164
- * shortcuts are replaced with the default enter (split paragraph) behavior.
165
- */
166
- const ParagraphEnterKeys = Extension.create({
167
- name: "paragraphEnterKeys",
168
- priority: 1000,
169
- addKeyboardShortcuts() {
170
- return {
171
- "Shift-Enter": ({ editor }) =>
172
- editor.commands.first([
173
- () => editor.commands.newlineInCode(),
174
- () => editor.commands.splitListItem("listItem"),
175
- () => editor.commands.createParagraphNear(),
176
- () => editor.commands.liftEmptyBlock(),
177
- () => editor.commands.splitBlock(),
178
- ]),
179
- "Mod-Enter": ({ editor }) =>
180
- editor.commands.first([
181
- () => editor.commands.newlineInCode(),
182
- () => editor.commands.createParagraphNear(),
183
- () => editor.commands.liftEmptyBlock(),
184
- () => editor.commands.splitBlock(),
185
- ]),
186
- };
187
- },
188
- });
189
-
190
- /**
191
- * Tab/Shift-Tab indent/dedent and Alt+Arrow move for list items.
192
- * Tab sinks (indents), Shift-Tab lifts (dedents).
193
- * Alt+Up/Down moves the list item up or down among siblings.
194
- */
195
- const ListItemKeys = Extension.create({
196
- name: "listItemKeys",
197
- addKeyboardShortcuts() {
198
- return {
199
- Tab: ({ editor }) => {
200
- if (editor.isActive("codeBlock")) return false;
201
- if (
202
- editor.isActive("bulletList") ||
203
- editor.isActive("orderedList") ||
204
- editor.isActive("taskList")
205
- ) {
206
- return (
207
- editor.commands.sinkListItem("listItem") || editor.commands.sinkListItem("taskItem")
208
- );
209
- }
210
- return false;
211
- },
212
- "Shift-Tab": ({ editor }) => {
213
- if (editor.isActive("codeBlock")) return false;
214
- if (
215
- editor.isActive("bulletList") ||
216
- editor.isActive("orderedList") ||
217
- editor.isActive("taskList")
218
- ) {
219
- return (
220
- editor.commands.liftListItem("listItem") || editor.commands.liftListItem("taskItem")
221
- );
222
- }
223
- return false;
224
- },
225
- };
226
- },
227
- });
228
-
229
50
  // ===========================================================================
230
51
  // Types
231
52
  // ===========================================================================
@@ -262,6 +83,7 @@ export const TiptapEditor = memo(
262
83
  forwardRef<RichEditorRef, TiptapEditorProps>(
263
84
  (
264
85
  {
86
+ capabilities = "full",
265
87
  initialContent = "",
266
88
  initialJSON,
267
89
  placeholder: placeholderProp,
@@ -318,93 +140,19 @@ export const TiptapEditor = memo(
318
140
  onChangeRef.current = onChange;
319
141
  });
320
142
 
143
+ // Capability surface is fixed at mount (extension lists cannot swap
144
+ // live); the canonical list comes from the one factory the capability
145
+ // specs also verify (LC-52).
146
+ const [extensions] = useState(() =>
147
+ buildEditorExtensions({ capabilities, placeholder, mentionExtension }),
148
+ );
149
+
321
150
  const editor = useEditor({
322
- extensions: [
323
- StarterKit.configure({
324
- heading: {
325
- levels: [1, 2, 3, 4, 5, 6],
326
- HTMLAttributes: { dir: "auto" },
327
- },
328
- paragraph: {
329
- HTMLAttributes: { dir: "auto" },
330
- },
331
- link: {
332
- openOnClick: false,
333
- HTMLAttributes: {
334
- rel: "noopener noreferrer",
335
- target: "_blank",
336
- },
337
- },
338
- bulletList: {
339
- keepMarks: true,
340
- },
341
- orderedList: {
342
- keepMarks: true,
343
- },
344
- codeBlock: false,
345
- hardBreak: false,
346
- }),
347
- CodeBlockWithPreview.configure({
348
- lowlight,
349
- exitOnTripleEnter: true,
350
- exitOnArrowDown: false,
351
- }),
352
- ParagraphEnterKeys,
353
- CodeBlockTabIndent,
354
- ListItemKeys,
355
- // superscriptTwo/Three would mangle typed TeX (`$E=mc^2$` became
356
- // `E=mc²` before the math input rule ran) — off, rest stays.
357
- Typography.configure({
358
- superscriptTwo: false,
359
- superscriptThree: false,
360
- }),
361
- CharacterCount,
362
- SlashCommands,
363
- MarkdownPaste,
364
- MediaUpload,
365
- SuggestionChromeStore,
366
- PreviewChromeStore,
367
- EmojiSuggestion,
368
- InlineMath,
369
- // Collapsible sections (GitLab <details> parity). persist: true
370
- // is the extension's supported mode: its node view re-syncs the
371
- // open state from node.attrs on every update, so ephemeral
372
- // (persist: false) toggles get reverted by the next transaction.
373
- // The open state therefore lives in the doc and serializes as
374
- // `<details open>` — valid GitLab markdown.
375
- Details.configure({ persist: true }),
376
- DetailsSummary,
377
- DetailsContent,
378
- ...(mentionExtension ? [mentionExtension] : []),
379
- BlockDragHandle,
380
- Table.configure({ resizable: true }),
381
- TableRow,
382
- TableHeader,
383
- TableCell,
384
- Placeholder.configure({
385
- placeholder,
386
- }),
387
- TextAlign.configure({
388
- types: ["heading", "paragraph"],
389
- defaultAlignment: "left",
390
- }),
391
- TextStyle,
392
- Color,
393
- Highlight.configure({
394
- multicolor: true,
395
- }),
396
- Subscript,
397
- Superscript,
398
- TaskList,
399
- TaskItem.configure({
400
- nested: true,
401
- }),
402
- Image.configure({
403
- inline: true,
404
- allowBase64: true,
405
- }),
406
- ],
407
- content: initialJSON || initialContent,
151
+ extensions,
152
+ // In markdown capabilities the incoming string IS markdown
153
+ content:
154
+ initialJSON ||
155
+ (capabilities === "markdown" ? markdownToHtml(initialContent) : initialContent),
408
156
  editable,
409
157
  autofocus: autoFocus,
410
158
  // No htmlFor target exists for the contenteditable — name it directly
@@ -426,7 +174,12 @@ export const TiptapEditor = memo(
426
174
  changeRafRef.current = requestAnimationFrame(() => {
427
175
  const html = editor.getHTML();
428
176
  const json = editor.getJSON();
429
- onChangeRef.current?.(html, json);
177
+ // Markdown capabilities serialize the content OUT as markdown —
178
+ // the host's value stays a markdown string end to end.
179
+ onChangeRef.current?.(
180
+ capabilities === "markdown" ? htmlToMarkdown(html) : html,
181
+ json,
182
+ );
430
183
  });
431
184
  },
432
185
  onTransaction: ({ editor }) => {
@@ -505,15 +258,23 @@ export const TiptapEditor = memo(
505
258
  }
506
259
  }, [editorMode, editor, markdownSource]);
507
260
 
508
- const handleMarkdownChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
509
- const md = e.target.value;
510
- setMarkdownSource(md);
511
- onChangeRef.current?.(markdownToHtml(md), null);
512
- }, []);
261
+ const handleMarkdownChange = useCallback(
262
+ (e: React.ChangeEvent<HTMLTextAreaElement>) => {
263
+ const md = e.target.value;
264
+ setMarkdownSource(md);
265
+ // Markdown capabilities emit the markdown source itself; the full
266
+ // surface keeps its HTML onChange contract.
267
+ onChangeRef.current?.(capabilities === "markdown" ? md : markdownToHtml(md), null);
268
+ },
269
+ [capabilities],
270
+ );
513
271
 
514
272
  const executeCommand = useCallback(
515
273
  (command: EditorCommand, payload?: any) => {
516
274
  if (!editor) return;
275
+ // Capability guard: outside the surface the command's extension is
276
+ // not registered, so the chained method would not even exist.
277
+ if (!isCommandInCapabilities(command, capabilities)) return;
517
278
 
518
279
  const chain = editor.chain().focus();
519
280
 
@@ -688,8 +449,13 @@ export const TiptapEditor = memo(
688
449
  getState: () => getEditorStateFromTiptap(editor),
689
450
  getHTML: () => editor?.getHTML() || "",
690
451
  getJSON: () => editor?.getJSON() || null,
452
+ getMarkdown: () => htmlToMarkdown(editor?.getHTML() || ""),
691
453
  setHTML: (html: string) => editor?.commands.setContent(html),
692
454
  setJSON: (json: any) => editor?.commands.setContent(json),
455
+ // External value sync (not a user edit): emitUpdate false so the
456
+ // host's own set does not echo back through onChange.
457
+ setMarkdown: (markdown: string) =>
458
+ editor?.commands.setContent(markdownToHtml(markdown), { emitUpdate: false }),
693
459
  focus: () => editor?.commands.focus(),
694
460
  blur: () => editor?.commands.blur(),
695
461
  }),
@@ -757,17 +523,23 @@ export const TiptapEditor = memo(
757
523
  {isWeb && <style>{editorCSS}</style>}
758
524
  {showBubbleToolbar && (
759
525
  <>
760
- <BubbleToolbar editor={editor} editorRef={internalRef} state={defaultEditorState} />
526
+ <BubbleToolbar
527
+ editor={editor}
528
+ editorRef={internalRef}
529
+ state={defaultEditorState}
530
+ capabilities={capabilities}
531
+ />
761
532
  <LinkBubble editor={editor} />
762
533
  <CodeBlockBubble editor={editor} />
763
534
  <ImageBubble editor={editor} />
764
- <TableBubble editor={editor} />
535
+ <TableBubble editor={editor} capabilities={capabilities} />
765
536
  </>
766
537
  )}
767
538
  {/* Always mounted (unlike the optional bubbles): it is the ONLY
768
539
  edit path for inline math atoms — without it a typed `$x$`
769
- node would be a dead end. */}
770
- {editable && <MathBubble editor={editor} />}
540
+ node would be a dead end. Markdown capabilities have no inline
541
+ math node, so the bubble has nothing to edit and stays out. */}
542
+ {editable && capabilities !== "markdown" && <MathBubble editor={editor} />}
771
543
  <EditorContent
772
544
  editor={editor}
773
545
  style={fullscreen ? { flex: "1 1 0%", minHeight: 0, overflowY: "auto" } : undefined}
@@ -0,0 +1,190 @@
1
+ /**
2
+ * VERIFY arm for LC-52 MARKDOWN-EXPRESSIBLE-EDITING.
3
+ *
4
+ * Proves the markdown capability surface is a real constraint, not a
5
+ * visual one: non-markdown extensions are NOT registered on the editor
6
+ * (their commands do not exist), and markdown in → edit → markdown out
7
+ * round-trips for the whole GFM-expressible set.
8
+ */
9
+ import { describe, expect, it } from "vitest";
10
+ import { Editor } from "@tiptap/core";
11
+ import { MARKDOWN_COMMANDS, isCommandInCapabilities } from "./capabilities";
12
+ import { buildEditorExtensions } from "./extensions";
13
+ import { htmlToMarkdown, markdownToHtml } from "./markdown";
14
+ import type { EditorCommand } from "../types";
15
+
16
+ function createEditor(capabilities: "full" | "markdown", content = "<p>hello</p>") {
17
+ return new Editor({
18
+ element: document.createElement("div"),
19
+ extensions: buildEditorExtensions({ capabilities }),
20
+ content,
21
+ });
22
+ }
23
+
24
+ const NON_MARKDOWN_COMMANDS: EditorCommand[] = [
25
+ "underline",
26
+ "highlight",
27
+ "textColor",
28
+ "subscript",
29
+ "superscript",
30
+ "alignLeft",
31
+ "alignCenter",
32
+ "alignRight",
33
+ "alignJustify",
34
+ "mergeCells",
35
+ "splitCell",
36
+ ];
37
+
38
+ describe("isCommandInCapabilities", () => {
39
+ it("allows everything in full capabilities", () => {
40
+ for (const command of [...MARKDOWN_COMMANDS, ...NON_MARKDOWN_COMMANDS]) {
41
+ expect(isCommandInCapabilities(command, "full")).toBe(true);
42
+ }
43
+ });
44
+
45
+ it("rejects non-markdown commands in markdown capabilities", () => {
46
+ for (const command of NON_MARKDOWN_COMMANDS) {
47
+ expect(isCommandInCapabilities(command, "markdown")).toBe(false);
48
+ }
49
+ });
50
+
51
+ it("keeps the GFM-expressible commands in markdown capabilities", () => {
52
+ for (const command of MARKDOWN_COMMANDS) {
53
+ expect(isCommandInCapabilities(command, "markdown")).toBe(true);
54
+ }
55
+ });
56
+ });
57
+
58
+ describe("markdown capabilities: extension registration", () => {
59
+ it("does not register underline/highlight/color/align/sub/sup extensions", () => {
60
+ const editor = createEditor("markdown");
61
+ const names = new Set(editor.extensionManager.extensions.map((e) => e.name));
62
+ expect(names.has("underline")).toBe(false);
63
+ expect(names.has("highlight")).toBe(false);
64
+ expect(names.has("textStyle")).toBe(false);
65
+ expect(names.has("color")).toBe(false);
66
+ expect(names.has("textAlign")).toBe(false);
67
+ expect(names.has("subscript")).toBe(false);
68
+ expect(names.has("superscript")).toBe(false);
69
+ expect(names.has("inlineMath")).toBe(false);
70
+ expect(names.has("details")).toBe(false);
71
+ editor.destroy();
72
+ });
73
+
74
+ it("commands markdown cannot express do not exist on the editor", () => {
75
+ const editor = createEditor("markdown");
76
+ const commands = editor.commands as unknown as Record<string, unknown>;
77
+ expect(commands.toggleUnderline).toBeUndefined();
78
+ expect(commands.toggleHighlight).toBeUndefined();
79
+ expect(commands.setColor).toBeUndefined();
80
+ expect(commands.setTextAlign).toBeUndefined();
81
+ expect(commands.toggleSubscript).toBeUndefined();
82
+ expect(commands.toggleSuperscript).toBeUndefined();
83
+ editor.destroy();
84
+ });
85
+
86
+ it("full capabilities keep the whole surface (control)", () => {
87
+ const editor = createEditor("full");
88
+ const names = new Set(editor.extensionManager.extensions.map((e) => e.name));
89
+ expect(names.has("underline")).toBe(true);
90
+ expect(names.has("highlight")).toBe(true);
91
+ expect(names.has("textAlign")).toBe(true);
92
+ const commands = editor.commands as unknown as Record<string, unknown>;
93
+ expect(typeof commands.toggleUnderline).toBe("function");
94
+ expect(typeof commands.toggleHighlight).toBe("function");
95
+ editor.destroy();
96
+ });
97
+
98
+ it("keeps the markdown-expressible surface registered", () => {
99
+ const editor = createEditor("markdown");
100
+ const commands = editor.commands as unknown as Record<string, unknown>;
101
+ for (const name of [
102
+ "toggleBold",
103
+ "toggleItalic",
104
+ "toggleStrike",
105
+ "toggleCode",
106
+ "toggleHeading",
107
+ "toggleBulletList",
108
+ "toggleOrderedList",
109
+ "toggleTaskList",
110
+ "toggleBlockquote",
111
+ "toggleCodeBlock",
112
+ "setLink",
113
+ "setImage",
114
+ "setHorizontalRule",
115
+ "insertTable",
116
+ ]) {
117
+ expect(typeof commands[name], name).toBe("function");
118
+ }
119
+ editor.destroy();
120
+ });
121
+
122
+ it("parses pasted underline/highlight HTML without those marks", () => {
123
+ const editor = createEditor(
124
+ "markdown",
125
+ "<p><u>under</u> and <mark>marked</mark> and <strong>bold</strong></p>",
126
+ );
127
+ const html = editor.getHTML();
128
+ expect(html).not.toContain("<u>");
129
+ expect(html).not.toContain("<mark>");
130
+ expect(html).toContain("<strong>bold</strong>");
131
+ editor.destroy();
132
+ });
133
+ });
134
+
135
+ describe("markdown round-trip (markdown in → edit → markdown out)", () => {
136
+ const roundTrip = (markdown: string): string => {
137
+ const editor = createEditor("markdown", markdownToHtml(markdown));
138
+ const out = htmlToMarkdown(editor.getHTML());
139
+ editor.destroy();
140
+ return out;
141
+ };
142
+
143
+ it("preserves headings, emphasis, strike, and inline code", () => {
144
+ const out = roundTrip(
145
+ "# Title\n\nSome **bold** and _italic_ and ~~gone~~ and `code` text.\n\n## Sub",
146
+ );
147
+ expect(out).toContain("# Title");
148
+ expect(out).toContain("**bold**");
149
+ expect(out).toContain("_italic_");
150
+ expect(out).toContain("~~gone~~");
151
+ expect(out).toContain("`code`");
152
+ expect(out).toContain("## Sub");
153
+ });
154
+
155
+ it("preserves bullet, ordered, and task lists", () => {
156
+ const out = roundTrip("- one\n- two\n\n1. first\n2. second\n\n- [x] done\n- [ ] todo");
157
+ expect(out).toMatch(/[-*] {1,3}one/);
158
+ expect(out).toMatch(/1\. {1,2}first/);
159
+ expect(out).toContain("[x] done");
160
+ expect(out).toContain("[ ] todo");
161
+ });
162
+
163
+ it("preserves blockquotes, fenced code, hr, links, and images", () => {
164
+ const out = roundTrip(
165
+ "> quoted\n\n```js\nconst a = 1;\n```\n\n---\n\n[site](https://example.com) ![alt](https://example.com/i.png)",
166
+ );
167
+ expect(out).toContain("> quoted");
168
+ expect(out).toContain("```");
169
+ expect(out).toContain("const a = 1;");
170
+ // any valid markdown thematic break (turndown emits "* * *")
171
+ expect(out).toMatch(/^(-{3,}|_{3,}|(\* ?){3,})$/m);
172
+ expect(out).toContain("[site](https://example.com)");
173
+ expect(out).toContain("![alt](https://example.com/i.png)");
174
+ });
175
+
176
+ it("preserves GFM pipe tables", () => {
177
+ const out = roundTrip("| Name | Role |\n| --- | --- |\n| Ada | Analyst |");
178
+ expect(out).toContain("| Name | Role |");
179
+ expect(out).toContain("| --- | --- |");
180
+ expect(out).toContain("| Ada | Analyst |");
181
+ });
182
+
183
+ it("edits made through markdown commands serialize to markdown", () => {
184
+ const editor = createEditor("markdown", markdownToHtml("plain text"));
185
+ editor.commands.selectAll();
186
+ editor.commands.toggleBold();
187
+ expect(htmlToMarkdown(editor.getHTML())).toContain("**plain text**");
188
+ editor.destroy();
189
+ });
190
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Editor capability presets.
3
+ *
4
+ * "markdown" constrains the WYSIWYG editor to exactly what GFM markdown can
5
+ * express (owner ruling, LC-52 MARKDOWN-EXPRESSIBLE-EDITING): bold, italic,
6
+ * strikethrough, inline code, links, headings, bullet/ordered/task lists,
7
+ * blockquote, fenced code blocks, images, horizontal rule, and simple pipe
8
+ * tables. Everything markdown cannot express — underline, highlight, text
9
+ * color, alignment, sub/superscript, collapsible details, inline math,
10
+ * mention chips, table cell merge/split — is ABSENT in this mode: the
11
+ * extensions are not registered (commands do not exist) and the toolbar
12
+ * controls do not render.
13
+ *
14
+ * This module is the single source of truth both the runtime (extension
15
+ * factory, toolbars, executeCommand guard) and the VERIFY specs consume.
16
+ */
17
+
18
+ import type { EditorCapabilities, EditorCommand } from "../types";
19
+
20
+ /** Commands expressible in GFM markdown (the "markdown" capability surface) */
21
+ export const MARKDOWN_COMMANDS: ReadonlySet<EditorCommand> = new Set<EditorCommand>([
22
+ "bold",
23
+ "italic",
24
+ "strike",
25
+ "code",
26
+ "heading1",
27
+ "heading2",
28
+ "heading3",
29
+ "heading4",
30
+ "heading5",
31
+ "heading6",
32
+ "paragraph",
33
+ "bulletList",
34
+ "orderedList",
35
+ "taskList",
36
+ "blockquote",
37
+ "codeBlock",
38
+ "link",
39
+ "image",
40
+ "undo",
41
+ "redo",
42
+ "horizontalRule",
43
+ "clearFormatting",
44
+ // GFM pipe tables (simple grid — mergeCells/splitCell are NOT expressible)
45
+ "insertTable",
46
+ "addColumnBefore",
47
+ "addColumnAfter",
48
+ "deleteColumn",
49
+ "addRowBefore",
50
+ "addRowAfter",
51
+ "deleteRow",
52
+ "deleteTable",
53
+ "toggleHeaderRow",
54
+ ]);
55
+
56
+ /** True when the command exists in the given capability surface */
57
+ export function isCommandInCapabilities(
58
+ command: EditorCommand,
59
+ capabilities: EditorCapabilities = "full",
60
+ ): boolean {
61
+ if (capabilities === "full") return true;
62
+ return MARKDOWN_COMMANDS.has(command);
63
+ }
64
+
65
+ /**
66
+ * Slash-menu items excluded in markdown capabilities. Mermaid stays out of
67
+ * the INSERT surface to keep it strictly GFM (a hand-typed ``` fence still
68
+ * produces a plain code block); math and details have no markdown syntax.
69
+ * Emoji stays — it inserts a plain unicode character (just text).
70
+ */
71
+ export const MARKDOWN_EXCLUDED_SLASH_IDS: readonly string[] = [
72
+ "mermaid",
73
+ "mathBlock",
74
+ "inlineMath",
75
+ "details",
76
+ ];