Repository navigation
feat: block inserter — sidebar panel + slash command - #287
ViewFromTheBox merged 2 commits into
Conversation
Adds the first two ways to insert new blocks into the canvas: a sidebar panel with a searchable block list, and an in-editor slash command that replaces the current paragraph with a chosen block type. - New `editor/inserter/` module: cached-snapshot registry, built-in seeds for paragraph/heading, block factories, API loader with silent fallback (for #274), `insertBlockAtSelection`, and an atomic `replaceBlockWithInserterBlock` that commits replacement in a single undo step. - `InserterPanel` renders a searchable, click-to-insert list wired into the editor shell next to the canvas. - `SlashCommandPopover` renders the slash menu; `ParagraphEdit` detects `/query` in the block's plain text, filters the list, handles ArrowUp/ArrowDown/Escape via a window listener, routes Enter through Tiptap's keymap to commit the replacement, and suppresses the backspace-merge path while the slash popover is open. - History snapshots now capture `{ blocks, selection }` so undo/redo restore the caret to the block that was being edited at the time of the change. This fixes the focus-loss after undoing a slash command replacement — without it, the replaced block's clientId was gone and nothing was selected, leaving the editor unfocused. - Boot sequence calls `loadInserterBlocks({ apiBase })`. Closes #273
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (8)
✅ Files skipped from review due to trivial changes (3)
🚧 Files skipped from review as they are similar to previous changes (5)
📝 WalkthroughWalkthroughAdds a block inserter system: a searchable sidebar InserterPanel, an in-editor SlashCommandPopover, a lightweight inserter registry (with builtin paragraph/heading and optional API loading), insertion/replace actions, paragraph block slash integration, and history snapshots updated to include selection for undo/redo. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant ParagraphEditor as Paragraph<br/>Editor
participant Popover as SlashCommand<br/>Popover
participant Store as Editor<br/>Store
participant Factory as Block<br/>Factory
User->>ParagraphEditor: Type "/"
ParagraphEditor->>Store: Read inserter blocks (subscribe/get)
Store-->>ParagraphEditor: Provide filtered blocks
ParagraphEditor->>Popover: Render popover with blocks
Popover-->>User: Display menu
User->>Popover: ArrowDown / ArrowUp
Popover->>Popover: Update selectedIndex
User->>Popover: Press Enter
Popover->>Factory: Request block factory for selection
Factory-->>Store: New block instance created
Store->>ParagraphEditor: Replace paragraph with new block
ParagraphEditor-->>User: Popover closed, block inserted
sequenceDiagram
actor User
participant UI as InserterPanel
participant Registry as Inserter<br/>Registry
participant Store as Editor<br/>Store
participant Factory as Block<br/>Factory
UI->>Registry: Subscribe / get inserter blocks
Registry-->>UI: Block list
User->>UI: Type query in search
UI->>Registry: Filter locally (filterInserterBlocks)
UI-->>User: Show filtered list
User->>UI: Click block
UI->>Factory: Create block via factory
Factory-->>Store: Insert new block at selection
Store-->>User: Editor updated with new block
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (1)
resources/js/visual-editor/editor/inserter/__tests__/inserterRegistry.test.ts (1)
39-48: Strengthen unsubscribe behavior assertion.You unsubscribe on Line 47, but the test does not verify listener silence afterward. Add a second mutation and assert call count is unchanged.
Suggested test tightening
it('notifies subscribers when the registry changes', () => { const listener = vi.fn(); const unsubscribe = subscribeInserterBlocks(listener); registerBuiltinInserterBlocks(); - expect(listener).toHaveBeenCalled(); + expect(listener).toHaveBeenCalledTimes(1); unsubscribe(); + registerBuiltinInserterBlocks(); + expect(listener).toHaveBeenCalledTimes(1); });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@resources/js/visual-editor/editor/inserter/__tests__/inserterRegistry.test.ts` around lines 39 - 48, After unsubscribing, the test should assert the listener is no longer called: keep the existing subscribeInserterBlocks(listener) and registerBuiltinInserterBlocks() call to trigger the first notification, then call unsubscribe() and perform another registry mutation (e.g., call registerBuiltinInserterBlocks() again or any other registry-changing function) and assert the listener's call count did not increase (use toHaveBeenCalledTimes or equivalent) to confirm unsubscribe removed the listener.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@resources/js/visual-editor/editor/blocks/paragraph/edit.tsx`:
- Around line 63-76: The slash menu is being activated from persisted content
because slashQuery (seeded via detectSlashQuery) stays set even when the editor
is not focused, and global key handlers (the window listener referenced
alongside filteredSlashBlocks) remain active; update the logic so the menu only
opens and global keys are handled when the editor has active focus/selection or
clear slashQuery on blur. Concretely, in the paragraph edit component ensure you
track editor focus/selection (or use the existing selection/focus API) and gate
usage of slashQuery/filteredSlashBlocks and registration of the global keydown
listener on that focused state, or add a blur handler that calls
setSlashQuery(null) and unregisters the window listener; reference the
detectSlashQuery, slashQuery, setSlashQuery, filteredSlashBlocks, and the code
that subscribes/registers the window key listener to implement this change.
In `@resources/js/visual-editor/editor/components/editor.css`:
- Around line 211-219: The inserter lists can grow beyond the viewport because
.ve-inserter-panel uses max-height: 100% which is ineffective without a sized
parent and the slash popover has no vertical bounds; update .ve-inserter-panel
and the slash popover selector (the component that renders the “/” menu) to
include a viewport-aware max-height (e.g. max-height: calc(100vh - 120px) or a
sensible vh value) and overflow-y: auto (and box-sizing: border-box if needed)
so both menus get a real scrollable vertical bound and items remain reachable by
mouse and keyboard.
In `@resources/js/visual-editor/editor/components/EditorShell.tsx`:
- Around line 28-33: Global undo/redo keydown handler in EditorShell is
capturing Ctrl/Cmd+Z/Y even when focus is inside InserterPanel's search input;
update the window-level keydown handler (the global shortcut handler in
EditorShell) to early-return if the event target is an editable element or
inside the inserter (check event.target via
HTMLInputElement/HTMLTextAreaElement, element.isContentEditable, or
element.closest('.ve-inserter-panel' or the InserterPanel root/class)), so
native undo/redo works in the search field and only fall back to editor history
shortcuts when focus is outside editable inputs.
In `@resources/js/visual-editor/editor/components/SlashCommandPopover.tsx`:
- Around line 17-23: SlashCommandPopover currently renders role="listbox" and
role="option" but never receives focus nor exposes an aria-activedescendant
linked to the focused editor; either wire it properly or drop listbox semantics.
To fix: in the SlashCommandPopover component make the popover container
focusable (tabIndex={0}) and focus it when the popover opens, maintain an
activeOptionId state and set aria-activedescendant={activeOptionId} on the
container, ensure each option (role="option") has a stable id (e.g.
`option-${item.id}`), and update activeOptionId on ArrowUp/ArrowDown keyboard
handlers so screen readers see changes; alternatively, if you cannot manage
focus/active id, remove role="listbox" and role="option" and use
role="presentation" or no role.
In `@resources/js/visual-editor/editor/inserter/insertBlockActions.ts`:
- Around line 29-35: The helper always inserts after the selected block (using
state.selection.clientId → currentIndex → insertIndex = currentIndex + 1) and
ignores the caret edge; update the logic to read state.selection.edge and
compute insertIndex accordingly: if edge === 'start' insertIndex should be
currentIndex (i.e., before the block), otherwise insertIndex should be
currentIndex + 1 (after); also handle a missing/undefined edge by defaulting to
'end' so existing behavior is preserved.
In `@resources/js/visual-editor/editor/inserter/loadInserterBlocks.ts`:
- Around line 39-45: The code currently calls registerInserterBlock(block) for
every API-returned block, which exposes blocks that lack a runtime factory and
thus are non-insertable by insertBlockAtSelection and
replaceBlockWithInserterBlock; update loadInserterBlocks (the loop handling
data.blocks) to only register blocks that have an associated factory (use
getBlockFactory(block.name) to check) or alternatively derive and register a
minimal factory from the block payload before calling registerInserterBlock;
ensure you reference registerInserterBlock, getBlockFactory,
insertBlockAtSelection and replaceBlockWithInserterBlock when implementing the
conditional registration or factory derivation so API-only entries are not
surfaced until insertable.
- Around line 39-45: filterInserterBlocks()/loadInserterBlocks currently assumes
optional fields are strings/arrays and lowercases them blindly, which throws on
malformed payloads; before calling registerInserterBlock (or inside
filterInserterBlocks) validate and sanitize InserterBlock fields: ensure
description is a string before calling .toLowerCase() (otherwise set it to
undefined or ''), ensure keywords is an array and replace it with
keywords.filter(k => typeof k === 'string').map(k => k.toLowerCase()), and
ignore/omit any non-string keyword entries; apply these checks to the block
objects created from data.blocks so registerInserterBlock receives only safe,
normalized fields.
---
Nitpick comments:
In
`@resources/js/visual-editor/editor/inserter/__tests__/inserterRegistry.test.ts`:
- Around line 39-48: After unsubscribing, the test should assert the listener is
no longer called: keep the existing subscribeInserterBlocks(listener) and
registerBuiltinInserterBlocks() call to trigger the first notification, then
call unsubscribe() and perform another registry mutation (e.g., call
registerBuiltinInserterBlocks() again or any other registry-changing function)
and assert the listener's call count did not increase (use toHaveBeenCalledTimes
or equivalent) to confirm unsubscribe removed the listener.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: c4f2d5a2-9bee-40c0-8c02-a07fce0f593a
📒 Files selected for processing (20)
resources/js/visual-editor/editor/blocks/__tests__/slashCommand.test.tsxresources/js/visual-editor/editor/blocks/paragraph/edit.tsxresources/js/visual-editor/editor/components/EditorShell.tsxresources/js/visual-editor/editor/components/InserterPanel.tsxresources/js/visual-editor/editor/components/SlashCommandPopover.tsxresources/js/visual-editor/editor/components/__tests__/InserterPanel.test.tsxresources/js/visual-editor/editor/components/editor.cssresources/js/visual-editor/editor/components/index.tsresources/js/visual-editor/editor/inserter/__tests__/insertBlockActions.test.tsresources/js/visual-editor/editor/inserter/__tests__/inserterRegistry.test.tsresources/js/visual-editor/editor/inserter/builtinInserterBlocks.tsresources/js/visual-editor/editor/inserter/filterInserterBlocks.tsresources/js/visual-editor/editor/inserter/index.tsresources/js/visual-editor/editor/inserter/insertBlockActions.tsresources/js/visual-editor/editor/inserter/inserterRegistry.tsresources/js/visual-editor/editor/inserter/loadInserterBlocks.tsresources/js/visual-editor/editor/main.tsxresources/js/visual-editor/editor/store/__tests__/editorStoreHistory.test.tsresources/js/visual-editor/editor/store/editorStore.tsresources/js/visual-editor/editor/store/types.ts
| <div className="ve-editor-shell__body"> | ||
| <InserterPanel /> | ||
| <Canvas> | ||
| <BlockList /> | ||
| </Canvas> | ||
| </div> |
There was a problem hiding this comment.
Global undo/redo shortcuts will hijack InserterPanel search input editing.
Line 29 introduces a text input path, but the window-level handler still captures Ctrl/Cmd+Z/Y unconditionally. This prevents native undo/redo in the search field and triggers editor history operations instead.
Proposed fix
function KeyboardBindings() {
const store = useEditorStore();
useEffect(() => {
+ function isFormFieldTarget(target: EventTarget | null): boolean {
+ if (!(target instanceof HTMLElement)) {
+ return false;
+ }
+ const tag = target.tagName;
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
+ }
+
function handleKeyDown(event: KeyboardEvent) {
+ if (isFormFieldTarget(event.target)) {
+ return;
+ }
if (!isUndoRedoKey(event)) {
return;
}
const isRedo = event.shiftKey || event.key.toLowerCase() === 'y';🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@resources/js/visual-editor/editor/components/EditorShell.tsx` around lines 28
- 33, Global undo/redo keydown handler in EditorShell is capturing Ctrl/Cmd+Z/Y
even when focus is inside InserterPanel's search input; update the window-level
keydown handler (the global shortcut handler in EditorShell) to early-return if
the event target is an editable element or inside the inserter (check
event.target via HTMLInputElement/HTMLTextAreaElement,
element.isContentEditable, or element.closest('.ve-inserter-panel' or the
InserterPanel root/class)), so native undo/redo works in the search field and
only fall back to editor history shortcuts when focus is outside editable
inputs.
- Paragraph edit: stop seeding slash query from persisted content; clear on editor blur so the popover never activates outside an active edit. - Inserter panel + slash popover: add viewport-aware max-height and overflow-y so long lists stay reachable via mouse and keyboard. - Editor shell: defer Cmd/Ctrl+Z and Cmd/Ctrl+Y to native browser history when focus is inside an <input> or <textarea> so the sidebar search field keeps its own undo stack. - SlashCommandPopover: drop the listbox/option ARIA roles — focus stays in the Tiptap contenteditable, so the roles were unanchored. - insertBlockAtSelection: honor `selection.edge`; insert before the selected block when the caret is at its start, after when at end. - loadInserterBlocks: skip API-returned blocks that have no client factory (otherwise they render as dead list items) and sanitize the payload so malformed description/keywords fields don't throw.
Description
Implements Phase 1.11: gives users two ways to add blocks to the canvas — a searchable sidebar inserter panel and an in-editor slash command. Both consume a new inserter registry that seeds with the built-in text blocks from #272 and will merge in API-supplied definitions once #274 lands.
Closes: #273
Type of Change
Related Issue
Issue: #273
Motivation and Context
Phase 1 needs a discovery surface for inserting blocks. The sidebar covers the "I don't know what's available" flow, and the slash command covers the keyboard-first "I know what I want" flow. Both go through the same registry so new block types only need to be registered once to show up in both.
Changes Made
editor/inserter/moduleinserterRegistry.ts— cached-snapshot registry withregisterInserterBlock,getInserterBlocks,subscribeInserterBlocks, and a parallel block-factory map. The snapshot is frozen and only rebuilt on mutation souseSyncExternalStorestays stable.builtinInserterBlocks.ts— seeds paragraph + heading metadata and factories.loadInserterBlocks.ts— async loader that seeds built-ins and optionallyGET {apiBase}/blocks. Network errors and non-OK responses are swallowed so the editor always boots with a usable set.insertBlockActions.ts—insertBlockAtSelectioninserts after the current selection (or appends if nothing is selected) and selects the new block.replaceBlockWithInserterBlockswaps a block for a brand-new one viareplaceBlocksso undo reverses the replacement in a single step.filterInserterBlocks.ts— case-insensitive search over title, name, description, and keywords.InserterPanel— sidebar component with a search input and a click-to-insert list. Subscribes to the registry viauseSyncExternalStoreand dispatchesinsertBlockAtSelectionon click.SlashCommandPopover— listbox popover with mouse + keyboard selection state.ParagraphEditintegration — detects `/query` in the block's plain text fromonUpdate, filters the inserter list, handlesArrowUp/ArrowDown/Escapevia a capture-phase window keydown listener, routesEnterthrough Tiptap's keymap to commit the replacement, and suppresses the Backspace-merge path while the slash popover is open.EditorShelllayout — adds ave-editor-shell__bodyflex container so theInserterPanelsits beside the canvas.main.tsxboot — now callsloadInserterBlocks({ apiBase }).HistorySnapshotis now{ blocks, selection }. Undo and redo restore both, guarded byselectionExistsIn. This fixes the focus-loss after undoing a slash-command replacement: the restored snapshot's selection correctly points at the block the user was editing when the change happened, so the paragraph block re-mounts withisSelected=trueand the focus effect places the caret back in the editor.How Has This Been Tested?
Testing Environment:
npm run dev:editorTests Performed:
Accessibility Tests Run
Details:
The search input has both a visually-hidden `<span class="ve-sr-only">` label and an explicit `aria-label`. Popover items expose their selected state via `aria-selected` and a data attribute used for tests. Mouse-down on the popover preventDefaults to avoid stealing focus from the contenteditable mid-interaction.
Tests Added
Test details:
34 new Vitest specs across five files, plus updates to the history test:
Full suite: 221 tests passing.
Documentation
Documentation details:
JSDoc on the public helpers (`loadInserterBlocks`, `insertBlockAtSelection`, `replaceBlockWithInserterBlock`, `filterInserterBlocks`, `registerBuiltinInserterBlocks`). Wiki / README updates land with the Phase 1 closeout.
Pre-Submission Checklist
Summary by CodeRabbit
New Features
Bug Fixes
Style
Tests