You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
❌ This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.
Overview
We need to place custom inline interactions inside rich text content: an author puts an interaction in the middle of a passage, from a toolbar action that inserts it at the cursor or over the selected text, and it has to survive copy, paste and save with all of its data intact. The editor that owns an interaction should be able to define it — its node, its toolbar action, and its clipboard behaviour — without TipTapEditor learning anything about it.
Open four additive seams so a consumer can do that from the outside: an extensions prop, an @ready event, an insertActions prop, and the selection facts a contributed action needs to decide its own availability. This task adds the seams only; the first consumer follows in a separate issue.
Complexity: Medium Target branch: unstable
Context
Extensions and the editor instance are both closed.useEditor.js hardcodes the extension list, and nothing exposes the editor instance to a parent.
Toolbar actions are internal.useToolbarActions.js owns descriptor arrays which the three toolbars render differently: EditorToolbar feeds them to KListWithOverflow, MobileTopBar renders insert tools as KDropdownMenu options, and MobileFormattingBar iterates them directly. There is no way for a consumer to contribute a control.
Toolbar availability state is not reliably reactive.isActive/isAvailable read plain ProseMirror state, which is not reactive by contract; it currently updates only because useEditor.js holds the editor in a ref() and Vue 2.7 deep-observes objects placed in one. useDropdowns.js does not rely on that — it hedges with both a deep: true watch on state.selection and an explicit editor.on('transaction') listener.
Inserting across a multi-line selection silently merges lines. Verified against prosemirror-model / prosemirror-state directly: replacing a selection that spans two paragraphs with an inline node joins the two paragraphs and inserts the node at the join. The document stays schema-valid; the author loses a paragraph break they did not ask to remove. Both tr.replaceSelectionWith() and the tr.replaceWith(from, to, node) path used by tiptap's insertContent behave identically.
The same happens across a hard break inside a single paragraph — Shift-Enter, which StarterKit's HardBreak still provides. The <br> falls inside the replaced range and is deleted, welding the two visual lines together. This case is not caught by comparing the selection's parents, since both ends share one paragraph:
BEFORE (one paragraph, two lines):
<p>Now is the winter of our discontent<br>Made glorious spring by this sun of York;</p>
selection spans the <br>; !$from.sameParent($to) === false
AFTER inserting an inline node:
<p>Now is the [node] by this sun of York;</p>
hard breaks: 1 -> 0
This is a fact about the selection, not about the node — an inline node is schema-valid there. Math is also inline: true and does not want to be blocked, so whether to act on the fact belongs to the consumer, while producing the fact belongs to the editor.
The Change
TipTapEditor should gain four additive seams, plus the documentation to go with them. Existing consumers should see no behaviour change when none of the seams are used.
1. extensions prop. Tiptap extensions supplied by the consumer should be registered on the editor alongside the built-in ones.
2. @ready event. The editor instance should be emitted once it is created and safe to issue commands against, so a consumer can subscribe to editor events and dispatch commands from its own components.
3. insertActions prop. A consumer should be able to contribute an action to the editor's insert tools by passing a descriptor of the same shape the built-in ones already use (name, title, icon, handler, isActive, isAvailable). Contributed actions should appear alongside the built-ins on all three toolbars: as a control in EditorToolbar including its overflow menu, as an entry in MobileTopBar's insert dropdown, and as a control in MobileFormattingBar.
On desktop, we should always see an insert button before the close button:
On mobile, we should show this insert button within the insert dropdown options.
4. Selection and insertion facts for contributed actions. A descriptor's isActive and isAvailable should accept a predicate that the editor evaluates whenever it recomputes toolbar state, passing enough for the consumer to decide availability without reaching into ProseMirror internals:
editor — for command dispatch and isActive reads
selection — at minimum empty and spansLines, where spansLines is true when the selection crosses a block boundary or contains a hardBreak. Both cases destroy a line break on insert, so a consumer asking "would inserting here weld two lines together?" needs one fact, not two.
canInsertNode(typeName) — whether the schema permits that node type at the current position
hasCursor — whether the author has placed a cursor in this editor at all. Sticky: false until the editor is first focused, true from then on, and not cleared when focus moves to the toolbar. An action that inserts at the cursor when there is one and appends to the end when there is not cannot derive this itself — editor.isFocused reads false whenever the action is reached from EditorToolbar's overflow menu or MobileTopBar's dropdown, since neither of those controls suppresses mousedown the way ToolbarButton does, and an editor that has never been focused is otherwise indistinguishable from one whose cursor sits at the start of the document.
These must be re-evaluated on selection change. Given that toolbar state reactivity is currently incidental (see Context), this should be driven by an explicit signal updated on transaction, not left to deep-observation. Keeping evaluation inside the editor keeps reactivity in one place; the rule itself stays with the consumer.
canInsertNode is schema-derived and generic — it correctly reports false inside a code block, whose content spec is text* with marks: "". Making it available also gives the existing alignAction's hand-written isAvailable: !isMarkActive('codeBlock') a general replacement, and would close a latent gap where math can currently be inserted into a code block.
The editor should expose these facts and not decide what they mean. Whether a given action is disabled across a multi-line selection is a product judgement about that action, and belongs to the consumer.
5. Documentation.docs/rich_text_editor.md should cover how a consumer uses these seams, since its current "How to add a custom plugin?" guide assumes the only way to add an extension is to edit useEditor.js directly. It should describe the extensions prop, the @ready event, and the insertActions descriptor shape including the isActive/isAvailable predicates.
It should also call out that an extension's paste handling belongs in transformPasted, not transformPastedHTML. ProseMirror resolves each of these hooks by taking the first provider it finds, checking the options passed at editor construction before any plugin. useEditor.js already supplies transformPastedHTML through editorProps, so an extension providing that same hook is silently never called — no error, no warning. transformPasted is unclaimed, and is the better hook regardless: it runs after parsing, so it receives nodes to work with rather than a string.
It should also explain how a node view reads state that lives in the consumer rather than in the document — whether a widget is currently selected, which panel is open, and so on. ProseMirror mounts node views imperatively, outside the Vue render tree, so slots and $emit are unavailable and this looks impossible at first glance. It is not: tiptap mounts them with parent: editor.contentComponent, which keeps them in the Vue parent chain, so provide/inject from an ancestor of TipTapEditor reaches a node view normally. Nothing in Studio does this today, so an implementer has no example to copy and is likely to reach for something worse.
Out of Scope
Any consumer of these seams. Follow-up issues will introduce the first one.
Any QTI vocabulary in shared/views/TipTapEditor. If a seam can only be used by writing QTI concepts into the shared editor, the seam is wrong.
Contributing actions to toolbar groups other than insert (history, text formatting, lists, scripts). The insert group is the one with a consumer; generalising to the others can follow if a need appears.
Correcting the parts of docs/rich_text_editor.md that are already out of date for other reasons — the links extension it still lists, and the markdown conversion sections. This task only adds guidance for the new seams.
Inverting editor ownership so the consumer calls useEditor() and passes editor in as a prop. This is the cleaner long-term architecture and @ready is compatible with it, but it requires relocating content sync, mode switching and autofocus, which is a refactor of the component rather than an addition to it.
Acceptance Criteria
General
An extension passed via the extensions prop is registered on the editor, and its node types, commands and keyboard shortcuts work
Omitting the extensions prop leaves the editor's behaviour unchanged for all existing consumers
@ready fires once with the editor instance, at a point where commands can safely be issued
An action passed via insertActions appears alongside the built-in insert tools in EditorToolbar, MobileTopBar's insert dropdown, and MobileFormattingBar
A contributed action can declare a prominent desktop treatment and is then rendered labelled and does not collapse into the "More" overflow menu at narrow widths
A contributed action's handler fires from every surface it appears on, including MobileTopBar's dropdown and EditorToolbar's overflow menu
A contributed action's isActive/isAvailable predicates receive editor, selection (with empty, spansLines and hasCursor), and canInsertNode(typeName)
Those predicates are re-evaluated when the selection moves, without the consumer adding its own transaction listener
canInsertNode returns false for an inline node type when the cursor is inside a code block
spansLines is true for a selection crossing two paragraphs, true for a selection crossing a hardBreak within one paragraph, and false for a selection within a single line
hasCursor is false before the editor has been focused and true afterwards, and stays true when the action is triggered from EditorToolbar's overflow menu or MobileTopBar's dropdown
docs/rich_text_editor.md documents the extensions prop, the @ready event, and the insertActions descriptor shape, and its "How to add a custom plugin?" guide no longer implies editing useEditor.js is the only route
docs/rich_text_editor.md warns that transformPastedHTML is already claimed by editorProps and that extension paste handling belongs in transformPasted
docs/rich_text_editor.md shows how a node view reads consumer state via provide/inject, and why slots and $emit are not available to it
Accessibility and i18n
A contributed action participates in the toolbar's roving tabindex: the toolbar remains a single tab stop, and arrow keys move focus onto and off it
A contributed action that is unavailable keeps its tab stop and is marked aria-disabled, consistent with ToolbarButton
No user-visible strings are added to TipTapEditor; a contributed action's title is supplied by the consumer
Testing
A test covers that an extension passed via the prop is registered and usable
A test covers that @ready emits the editor instance
Tests cover spansLines, hasCursor and canInsertNode for the cases named above, including the hardBreak case, the code-block case, and hasCursor surviving a toolbar interaction that blurs the editor
A roving-tabindex test covers a contributed action, extending the existing coverage in __tests__/EditorToolbar.spec.js
__mocks__/TipTapEditor.vue reflects the new props and event, so consumer tests that mock the editor exercise them rather than silently skipping them
References
contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js — editor construction and the fixed extension list
docs/rich_text_editor.md — the editor's developer guide, including the "How to add a custom plugin?" steps this task makes out of date
contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useRovingTabIndex.js — the [data-toolbar-item] contract
contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue — KListWithOverflow grouping and overflow menu
contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/MobileTopBar.vue — insert tools rendered as KDropdownMenu options
I designed this API in a working session with Claude Code, going back and forth over the alternatives. I had it read the existing editor, toolbar and serializer code rather than describe them from memory, and the ProseMirror paragraph-merging behaviour in Context was verified by actually running it against prosemirror-model, not asserted. Claude drafted the issue text from that session; I reviewed and edited it.
❌ This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.
Overview
We need to place custom inline interactions inside rich text content: an author puts an interaction in the middle of a passage, from a toolbar action that inserts it at the cursor or over the selected text, and it has to survive copy, paste and save with all of its data intact. The editor that owns an interaction should be able to define it — its node, its toolbar action, and its clipboard behaviour — without
TipTapEditorlearning anything about it.Open four additive seams so a consumer can do that from the outside: an
extensionsprop, an@readyevent, aninsertActionsprop, and the selection facts a contributed action needs to decide its own availability. This task adds the seams only; the first consumer follows in a separate issue.Complexity: Medium
Target branch: unstable
Context
Extensions and the editor instance are both closed.
useEditor.jshardcodes the extension list, and nothing exposes the editor instance to a parent.Toolbar actions are internal.
useToolbarActions.jsowns descriptor arrays which the three toolbars render differently:EditorToolbarfeeds them toKListWithOverflow,MobileTopBarrenders insert tools asKDropdownMenuoptions, andMobileFormattingBariterates them directly. There is no way for a consumer to contribute a control.Toolbar availability state is not reliably reactive.
isActive/isAvailableread plain ProseMirror state, which is not reactive by contract; it currently updates only becauseuseEditor.jsholds the editor in aref()and Vue 2.7 deep-observes objects placed in one.useDropdowns.jsdoes not rely on that — it hedges with both adeep: truewatch onstate.selectionand an expliciteditor.on('transaction')listener.Inserting across a multi-line selection silently merges lines. Verified against
prosemirror-model/prosemirror-statedirectly: replacing a selection that spans two paragraphs with an inline node joins the two paragraphs and inserts the node at the join. The document stays schema-valid; the author loses a paragraph break they did not ask to remove. Bothtr.replaceSelectionWith()and thetr.replaceWith(from, to, node)path used by tiptap'sinsertContentbehave identically.The same happens across a hard break inside a single paragraph —
Shift-Enter, which StarterKit'sHardBreakstill provides. The<br>falls inside the replaced range and is deleted, welding the two visual lines together. This case is not caught by comparing the selection's parents, since both ends share one paragraph:This is a fact about the selection, not about the node — an inline node is schema-valid there.
Mathis alsoinline: trueand does not want to be blocked, so whether to act on the fact belongs to the consumer, while producing the fact belongs to the editor.The Change
TipTapEditorshould gain four additive seams, plus the documentation to go with them. Existing consumers should see no behaviour change when none of the seams are used.1.
extensionsprop. Tiptap extensions supplied by the consumer should be registered on the editor alongside the built-in ones.2.
@readyevent. The editor instance should be emitted once it is created and safe to issue commands against, so a consumer can subscribe to editor events and dispatch commands from its own components.3.
insertActionsprop. A consumer should be able to contribute an action to the editor's insert tools by passing a descriptor of the same shape the built-in ones already use (name,title,icon,handler,isActive,isAvailable). Contributed actions should appear alongside the built-ins on all three toolbars: as a control inEditorToolbarincluding its overflow menu, as an entry inMobileTopBar's insert dropdown, and as a control inMobileFormattingBar.On desktop, we should always see an
insertbutton before the close button:On mobile, we should show this insert button within the insert dropdown options.
4. Selection and insertion facts for contributed actions. A descriptor's
isActiveandisAvailableshould accept a predicate that the editor evaluates whenever it recomputes toolbar state, passing enough for the consumer to decide availability without reaching into ProseMirror internals:editor— for command dispatch andisActivereadsselection— at minimumemptyandspansLines, wherespansLinesis true when the selection crosses a block boundary or contains ahardBreak. Both cases destroy a line break on insert, so a consumer asking "would inserting here weld two lines together?" needs one fact, not two.canInsertNode(typeName)— whether the schema permits that node type at the current positionhasCursor— whether the author has placed a cursor in this editor at all. Sticky: false until the editor is first focused, true from then on, and not cleared when focus moves to the toolbar. An action that inserts at the cursor when there is one and appends to the end when there is not cannot derive this itself —editor.isFocusedreadsfalsewhenever the action is reached fromEditorToolbar's overflow menu orMobileTopBar's dropdown, since neither of those controls suppressesmousedownthe wayToolbarButtondoes, and an editor that has never been focused is otherwise indistinguishable from one whose cursor sits at the start of the document.These must be re-evaluated on selection change. Given that toolbar state reactivity is currently incidental (see Context), this should be driven by an explicit signal updated on
transaction, not left to deep-observation. Keeping evaluation inside the editor keeps reactivity in one place; the rule itself stays with the consumer.canInsertNodeis schema-derived and generic — it correctly reportsfalseinside a code block, whose content spec istext*withmarks: "". Making it available also gives the existingalignAction's hand-writtenisAvailable: !isMarkActive('codeBlock')a general replacement, and would close a latent gap where math can currently be inserted into a code block.The editor should expose these facts and not decide what they mean. Whether a given action is disabled across a multi-line selection is a product judgement about that action, and belongs to the consumer.
5. Documentation.
docs/rich_text_editor.mdshould cover how a consumer uses these seams, since its current "How to add a custom plugin?" guide assumes the only way to add an extension is to edituseEditor.jsdirectly. It should describe theextensionsprop, the@readyevent, and theinsertActionsdescriptor shape including theisActive/isAvailablepredicates.It should also call out that an extension's paste handling belongs in
transformPasted, nottransformPastedHTML. ProseMirror resolves each of these hooks by taking the first provider it finds, checking the options passed at editor construction before any plugin.useEditor.jsalready suppliestransformPastedHTMLthrougheditorProps, so an extension providing that same hook is silently never called — no error, no warning.transformPastedis unclaimed, and is the better hook regardless: it runs after parsing, so it receives nodes to work with rather than a string.It should also explain how a node view reads state that lives in the consumer rather than in the document — whether a widget is currently selected, which panel is open, and so on. ProseMirror mounts node views imperatively, outside the Vue render tree, so slots and
$emitare unavailable and this looks impossible at first glance. It is not: tiptap mounts them withparent: editor.contentComponent, which keeps them in the Vue parent chain, soprovide/injectfrom an ancestor ofTipTapEditorreaches a node view normally. Nothing in Studio does this today, so an implementer has no example to copy and is likely to reach for something worse.Out of Scope
shared/views/TipTapEditor. If a seam can only be used by writing QTI concepts into the shared editor, the seam is wrong.docs/rich_text_editor.mdthat are already out of date for other reasons — the links extension it still lists, and the markdown conversion sections. This task only adds guidance for the new seams.useEditor()and passeseditorin as a prop. This is the cleaner long-term architecture and@readyis compatible with it, but it requires relocating content sync, mode switching and autofocus, which is a refactor of the component rather than an addition to it.Acceptance Criteria
General
extensionsprop is registered on the editor, and its node types, commands and keyboard shortcuts workextensionsprop leaves the editor's behaviour unchanged for all existing consumers@readyfires once with the editor instance, at a point where commands can safely be issuedinsertActionsappears alongside the built-in insert tools inEditorToolbar,MobileTopBar's insert dropdown, andMobileFormattingBarhandlerfires from every surface it appears on, includingMobileTopBar's dropdown andEditorToolbar's overflow menuisActive/isAvailablepredicates receiveeditor,selection(withempty,spansLinesandhasCursor), andcanInsertNode(typeName)transactionlistenercanInsertNodereturnsfalsefor an inline node type when the cursor is inside a code blockspansLinesistruefor a selection crossing two paragraphs,truefor a selection crossing ahardBreakwithin one paragraph, andfalsefor a selection within a single linehasCursorisfalsebefore the editor has been focused andtrueafterwards, and staystruewhen the action is triggered fromEditorToolbar's overflow menu orMobileTopBar's dropdowndocs/rich_text_editor.mddocuments theextensionsprop, the@readyevent, and theinsertActionsdescriptor shape, and its "How to add a custom plugin?" guide no longer implies editinguseEditor.jsis the only routedocs/rich_text_editor.mdwarns thattransformPastedHTMLis already claimed byeditorPropsand that extension paste handling belongs intransformPasteddocs/rich_text_editor.mdshows how a node view reads consumer state viaprovide/inject, and why slots and$emitare not available to itAccessibility and i18n
aria-disabled, consistent withToolbarButtonTipTapEditor; a contributed action'stitleis supplied by the consumerTesting
@readyemits the editor instancespansLines,hasCursorandcanInsertNodefor the cases named above, including thehardBreakcase, the code-block case, andhasCursorsurviving a toolbar interaction that blurs the editor__tests__/EditorToolbar.spec.js__mocks__/TipTapEditor.vuereflects the new props and event, so consumer tests that mock the editor exercise them rather than silently skipping themReferences
contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js— editor construction and the fixed extension listdocs/rich_text_editor.md— the editor's developer guide, including the "How to add a custom plugin?" steps this task makes out of datecontentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useToolbarActions.js— toolbar action descriptorscontentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useRovingTabIndex.js— the[data-toolbar-item]contractcontentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue—KListWithOverflowgrouping and overflow menucontentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/MobileTopBar.vue— insert tools rendered asKDropdownMenuoptionscanReplaceWith: https://prosemirror.net/docs/ref/#model.NodeTypeAI usage
I designed this API in a working session with Claude Code, going back and forth over the alternatives. I had it read the existing editor, toolbar and serializer code rather than describe them from memory, and the ProseMirror paragraph-merging behaviour in Context was verified by actually running it against
prosemirror-model, not asserted. Claude drafted the issue text from that session; I reviewed and edited it.