Skip to content

feat: paragraph/heading blocks with shared rich-text toolbar - #286

Merged
ViewFromTheBox merged 2 commits into
add/phase-onefrom
feature/272-paragraph-heading-rich-text-toolbar
Apr 14, 2026
Merged

ViewFromTheBox merged 2 commits into
add/phase-onefrom
feature/272-paragraph-heading-rich-text-toolbar

Conversation

@ViewFromTheBox

@ViewFromTheBox ViewFromTheBox commented Apr 14, 2026 •

Copy link
Copy Markdown
Member

Description

Implements Phase 1.10: ships the first two production block types (paragraph and heading) along with a shared rich-text toolbar that serves both. Builds on the Tiptap strategy decided in #267 (extension picks + paragraph-node approach) and the Zustand store from #265/#266.

Closes: #272

Type of Change

  • New feature (adds new functionality)

Related Issue

Issue: #272

Motivation and Context

Phase 1 needs at least paragraph and heading blocks before the editor can hold real content. The shared Tiptap factory and rich-text toolbar built here are the foundations every subsequent text-oriented block (list, quote, etc.) will extend.

Changes Made

  • Shared Tiptap factory ( useBlockTiptap) that wires document + paragraph/heading + text + hard-break + bold/italic/link extensions with a block-level keydown handler and an external-content sync effect.
  • Paragraph block at editor/blocks/paragraph/ with Enter-splits-into-new-block and Backspace-at-start-merges-into-previous behavior, both committed through a single replaceBlocks call so undo reverses the whole operation in one step.
  • Heading block at editor/blocks/heading/ with levels H1–H6 stored as a level attribute, level-switcher integration, and the same split/merge keymap.
  • RichTextToolbar at editor/components/RichTextToolbar.tsx with Bold / Italic / Link (popover with URL input + apply/remove) plus a heading-level dropdown slot that appears only for selected headings. Subscribes to the active editor via a useSyncExternalStore hook over a blockEditorRegistry.
  • Pending-cursor sidechannel in blockEditorRegistry so the merge handler can tell the previous block's edit component to place the caret at the merge boundary (rather than at the end of the merged content).
  • Font Awesome icons (@fortawesome/react-fontawesome) for all in-editor icons, including the toolbar buttons and the block drag handle.
  • registerCoreBlocks() entry point wired into main.tsx; the editor now boots with a heading + paragraph instead of placeholder blocks.
  • createClientId() helper in editor/store/ids.ts for fresh block IDs.

How Has This Been Tested?

Testing Environment:

  • Operating System: macOS Darwin 25.3.0
  • Browser: manually verified in the dev editor via npm run dev:editor
  • Node: project default
  • Project Version: visual-editor 1.0.0-spike

Tests Performed:

  1. Typed into paragraph and heading blocks; applied bold/italic via the toolbar.
  2. Opened the link popover, applied a link, reopened and removed it.
  3. Switched heading level via the dropdown between H1 and H6.
  4. Enter at end of a paragraph creates a new paragraph below; mid-paragraph Enter moves the trailing text into the new block.
  5. Backspace at start of the second paragraph merges into the first and the caret lands at the merge boundary.
  6. Ctrl/Cmd+Z after a split restores the combined paragraph in one step; Ctrl/Cmd+Z after a merge restores both paragraphs in one step.

Accessibility Tests Run

  • Keyboard navigation tested (tab into toolbar buttons, Enter/Escape in link popover, arrow keys for the heading select)
  • Screen reader tested
  • Color contrast verified (buttons use existing editor palette)
  • ARIA labels checked (toolbar role=\"toolbar\" with aria-label; buttons use aria-label + aria-pressed; link popover uses role=\"dialog\"; Icon wrapper sets aria-hidden unless a title is provided, in which case it exposes role=\"img\")

Details:

Toolbar buttons all have explicit aria-label text. The heading level select is wrapped in a <label> with a screen-reader-only span for context. The Icon component exposes icons to assistive tech only when a title is passed, otherwise it stays decorative.

Tests Added

  • Unit tests added/updated
  • Integration tests added/updated
  • All tests passing

Test details:

21 new Vitest specs across three files:

  • editor/blocks/__tests__/paragraph.test.tsx — registration, initial content, onUpdate → store, Enter-at-end split, mid-cursor split, Backspace-at-start merge, cursor-at-merge-boundary, single-entry undo for split and for merge.
  • editor/blocks/__tests__/heading.test.tsx — registration, normalizeHeadingLevel guard, initial render, level-attribute → node updates, onUpdate → store.
  • editor/components/__tests__/RichTextToolbar.test.tsx — hidden when no selection, bold/italic toggles apply to the active editor, link popover creates + removes links, heading-level switcher dispatches updateBlockAttributes.

Full suite: 187 tests passing.

Documentation

  • Inline code documentation added
  • README updated
  • Wiki updated
  • API documentation updated

Documentation details:

Short JSDoc blocks on the public helpers (useBlockTiptap, handleBlockEnter, handleBlockBackspace, splitAtCursor, Icon). No wiki or README changes yet; those land with the Phase 1 closeout.

Pre-Submission Checklist

  • Followed contributing guidelines
  • Checked for other open PRs for same update
  • Code passes all tests
  • Code has been linted (TypeScript strict, CodeRabbit review applied)
  • Accessibility tests completed (ARIA labels + keyboard paths)
  • Code follows project style guide
  • Self-review completed
  • Comments added for complex code
  • No new warnings generated

Summary by CodeRabbit

  • New Features

    • Added editable paragraph and heading blocks with full rich-text support.
    • Introduced a rich text toolbar with bold, italic, link controls and a heading-level selector.
    • Improved drag handle visuals using iconography.
  • Behavior

    • Robust block split/merge with correct cursor placement and single-step undo/redo.
  • Tests

    • Comprehensive tests covering paragraph/heading editing, splits/merges, toolbar actions.
  • Style

    • New styling for rich-text surface, toolbar, buttons, and link popover.

Adds the first two production block types and a shared Bold/Italic/Link
toolbar. Both blocks use a minimal Tiptap editor (document + paragraph or
heading + text + hard-break + bold/italic/link) with a block-level keymap
that dispatches Enter and Backspace back to the Zustand store.

- Split (Enter) and merge (Backspace at start) are committed via
  replaceBlocks so each operation is a single undo step.
- Backspace-merge places the cursor at the merge boundary via a pending
  cursor sidechannel, not at the end of the combined content.
- Icons are served via @fortawesome/react-fontawesome with an Icon
  wrapper; the toolbar and block drag handle both consume it.
- Adds registerCoreBlocks() and wires it into the editor boot so the
  initial document contains a heading + paragraph.

Closes #272
@coderabbitai

coderabbitai Bot commented Apr 14, 2026 •

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f622c49e-f1ce-4d68-bdc2-6fab8a5ecfc5

📥 Commits

Reviewing files that changed from the base of the PR and between a1d83f2 and 4f095d4.

📒 Files selected for processing (1)
  • resources/js/visual-editor/editor/components/editor.css
✅ Files skipped from review due to trivial changes (1)
  • resources/js/visual-editor/editor/components/editor.css

📝 Walkthrough

Walkthrough

Adds paragraph and heading block implementations with Tiptap editors, split/merge behavior, a shared rich-text toolbar (bold/italic/link and heading level switch), supporting utilities (editor registry, split content, TipTap hook), Font Awesome integration, component wiring, tests, and store initialization updates.

Changes

Cohort / File(s) Summary
Dependencies
package.json
Added Font Awesome packages and discrete Tiptap extension packages (bold, document, hard-break, heading, italic, paragraph, text); existing Tiptap deps retained.
Paragraph Block
resources/js/visual-editor/editor/blocks/paragraph/edit.tsx, resources/js/visual-editor/editor/blocks/paragraph/index.ts, resources/js/visual-editor/editor/blocks/__tests__/paragraph.test.tsx
Implemented ve/paragraph block: TipTap integration via hook, attribute persistence, Enter-to-split and Backspace-to-merge behaviors, registration helper, and comprehensive split/merge/undo tests.
Heading Block
resources/js/visual-editor/editor/blocks/heading/edit.tsx, resources/js/visual-editor/editor/blocks/heading/index.ts, resources/js/visual-editor/editor/blocks/__tests__/heading.test.tsx
Added ve/heading block with H1–H6 support, normalizeHeadingLevel, TipTap sync, registration helper, and tests for level normalization and content syncing.
Shared Block Utilities
resources/js/visual-editor/editor/blocks/shared/*
.../blockEditorRegistry.ts, .../blockSplitMerge.ts, .../splitContent.ts, .../useBlockTiptap.ts, .../index.ts
New shared modules: block editor registry with pending-cursor API, split/merge handlers coordinating editor content and block array updates, content-splitting utilities, and useBlockTiptap hook (editor lifecycle, keyboard handlers, content sync).
Rich-Text Toolbar & Icon
resources/js/visual-editor/editor/components/RichTextToolbar.tsx, .../__tests__/RichTextToolbar.test.tsx, .../Icon.tsx, .../editor.css, .../index.ts
New toolbar component rendering bold/italic/link controls and heading level switcher; Icon wrapper for Font Awesome with accessibility handling; styling and tests for toolbar behaviors and link popover.
Component Integration
resources/js/visual-editor/editor/components/BlockWrapper.tsx, resources/js/visual-editor/editor/components/EditorShell.tsx
Switched BlockWrapper drag-handle to Icon/faGripVertical; integrated RichTextToolbar into EditorShell chrome.
Block Registry Entrypoint
resources/js/visual-editor/editor/blocks/index.ts
Added central exports and registerCoreBlocks() that registers paragraph and heading blocks.
Store & Initialization
resources/js/visual-editor/editor/store/ids.ts, resources/js/visual-editor/editor/store/index.ts, resources/js/visual-editor/editor/main.tsx
Added createClientId() (UUID or timestamp+counter fallback), re-exported it from store index, updated editor bootstrap to register core blocks and initialize store with generated client IDs.

Sequence Diagrams

sequenceDiagram
    participant User
    participant BlockEditor as Block TipTap Editor
    participant SplitHandler as handleBlockEnter
    participant Store as EditorStore
    participant Registry as BlockEditorRegistry

    User->>BlockEditor: Press Enter
    BlockEditor->>SplitHandler: invoke splitAtCursor(clientId, editor)
    SplitHandler->>BlockEditor: serialize leftHtml / rightHtml
    SplitHandler->>Store: update left block attributes (emitUpdate: false)
    SplitHandler->>Store: createClientId() -> newId
    SplitHandler->>Store: replaceBlocks(insert new paragraph with newId)
    SplitHandler->>Registry: setPendingCursor(newId, 'start')
    SplitHandler->>BlockEditor: return true (handled)
    Store->>Registry: notify subscribers -> render new block/editor
Loading
sequenceDiagram
    participant User
    participant Store as EditorStore
    participant Toolbar as RichTextToolbar
    participant Registry as BlockEditorRegistry
    participant Tiptap as Block TipTap Editor

    User->>Store: Select block
    Store->>Toolbar: selection changed
    Toolbar->>Registry: getBlockEditor(selectedClientId)
    Registry->>Toolbar: return Tiptap
    User->>Toolbar: Click Bold button
    Toolbar->>Tiptap: chain().focus().toggleBold().run()
    Tiptap->>Toolbar: onUpdate -> getHTML()
    Toolbar->>Store: updateBlockAttributes(selectedClientId, html)
    Store->>BlockEditor: re-render with updated attributes
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐇 I hopped in, nibbling code by night,

Headings leapt up, paragraphs took flight,
Bold and italic danced in a row,
Links popped open, gentle and slow,
Split and merge — the editor hums delight. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.51% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: paragraph/heading blocks with shared rich-text toolbar' accurately summarizes the main changes in this PR, which adds paragraph and heading block implementations alongside a new rich-text toolbar component.
Linked Issues check ✅ Passed The PR comprehensively implements all coding requirements from issue #272: paragraph block with split/merge [#272], heading block with level switching [#272], shared RichTextToolbar with bold/italic/link controls [#272], block editor registry and pending-cursor tracking [#272], and required Vitest test coverage [#272].
Out of Scope Changes check ✅ Passed All changes are in scope. The PR adds paragraph/heading blocks, a shared toolbar, supporting utilities (blockEditorRegistry, blockSplitMerge, useBlockTiptap, splitContent), Font Awesome integration for icons, test suites, and CSS styling—all directly aligned with issue #272 objectives. No unrelated features or refactoring are present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/272-paragraph-heading-rich-text-toolbar

Comment @coderabbitai help to get the list of available commands and usage tips.

@ViewFromTheBox ViewFromTheBox moved this from Backlog to In Review in ArtisanPack UI Overview Apr 14, 2026
@ViewFromTheBox
ViewFromTheBox marked this pull request as ready for review April 14, 2026 16:23

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (8)
resources/js/visual-editor/editor/store/ids.ts (1)

9-15: Consider normalizing client ID format across environments.

Line 10 returns a raw UUID while Line 15 returns a ve-... ID. Keeping one stable shape (for example always prefixing with ve-) makes logs/test selectors/debugging more consistent.

Suggested tweak
 export function createClientId(): string {
     if (typeof globalThis.crypto?.randomUUID === 'function') {
-        return globalThis.crypto.randomUUID();
+        return `ve-${globalThis.crypto.randomUUID()}`;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/editor/store/ids.ts` around lines 9 - 15, The ID
generator currently returns a raw UUID when globalThis.crypto.randomUUID exists
but returns a prefixed fallback
(`ve-${Date.now().toString(36)}-${counter.toString(36)}`) otherwise; normalize
to a single shape by always returning IDs with the "ve-" prefix. In the branch
that uses globalThis.crypto.randomUUID, wrap the UUID with the same "ve-" prefix
(so the branch using globalThis.crypto.randomUUID returns `ve-<uuid>`), and keep
the existing fallback that uses counter and Date so both paths produce IDs with
the `ve-` prefix; update references to globalThis.crypto.randomUUID, counter,
and the template literal to implement this change.
resources/js/visual-editor/editor/components/Icon.tsx (1)

13-20: Consider conditionally passing title to avoid empty title attributes.

When title is an empty string, it will still be passed to FontAwesomeIcon, potentially rendering an empty title attribute in the DOM. For cleaner output:

✨ Suggested refinement
     return (
         <FontAwesomeIcon
             icon={icon}
             className={className}
-            title={title}
+            title={hasTitle ? title : undefined}
             role={hasTitle ? 'img' : undefined}
             aria-hidden={hasTitle ? undefined : true}
         />
     );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/editor/components/Icon.tsx` around lines 13 - 20,
The Icon component is currently passing title to FontAwesomeIcon even when title
is an empty string; change the JSX so the title prop is only provided when
non-empty (use the existing hasTitle check) — e.g., pass title={hasTitle ? title
: undefined} or conditionally spread the title prop in the <FontAwesomeIcon>
element to avoid rendering an empty title attribute.
resources/js/visual-editor/editor/blocks/__tests__/heading.test.tsx (1)

67-80: Unnecessary async keyword on test function.

The test function is marked async but doesn't await anything meaningful—act() here is synchronous. This is harmless but inconsistent with the non-async test at line 58.

✨ Minor cleanup
-    it('updates the rendered heading level when attributes.level changes', async () => {
+    it('updates the rendered heading level when attributes.level changes', () => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/editor/blocks/__tests__/heading.test.tsx` around
lines 67 - 80, The test function labeled "updates the rendered heading level
when attributes.level changes" is unnecessarily declared async; remove the async
keyword from the it(...) callback so the test matches the synchronous behavior
(no awaits) used elsewhere. Locate the test block that calls makeHeading('h1',
'<h2>Title</h2>', 2), createEditorStore([block]), renderBlock(store, block),
act(() => store.getState().updateBlockAttributes('h1', { level: 4 })); and
getBlockEditor('h1'), and change its declaration to a plain synchronous
function.
resources/js/visual-editor/editor/components/__tests__/RichTextToolbar.test.tsx (1)

105-126: Missing null check for editor before assertion.

The test at line 115 uses editor! directly without a preceding null check, unlike the pattern at lines 90-91 which asserts expect(editor).not.toBeNull() first.

Suggested fix for consistency
         const editor = getBlockEditor('p1');
+        expect(editor).not.toBeNull();

         await act(async () => {
             editor!.commands.selectAll();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@resources/js/visual-editor/editor/components/__tests__/RichTextToolbar.test.tsx`
around lines 105 - 126, The test "toggles italic on the active editor" uses
getBlockEditor('p1') and then dereferences editor! without asserting it's not
null; add a null-check assertion (e.g., expect(editor).not.toBeNull())
immediately after obtaining editor to match the pattern used earlier, then
proceed to call editor.commands.selectAll() and
expect(editor.isActive('italic')).toBe(true) so the test fails cleanly if
getBlockEditor returns null.
resources/js/visual-editor/editor/blocks/shared/splitContent.ts (1)

15-40: Potential type safety issue with DOMSerializer output.

DOMSerializer.serializeNode() can return either an HTMLElement or a Text node depending on the node type. The cast to HTMLElement on lines 33-34 could fail if an unexpected node type is serialized, though for paragraph/heading nodes this should always produce an element.

Defensive type check
     const leftDom = serializer.serializeNode(leftNode) as HTMLElement;
     const rightDom = serializer.serializeNode(rightNode) as HTMLElement;

+    if (!(leftDom instanceof HTMLElement) || !(rightDom instanceof HTMLElement)) {
+        return { leftHtml: '', rightHtml: '' };
+    }
+
     return {
         leftHtml: leftDom.outerHTML,
         rightHtml: rightDom.outerHTML,
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/editor/blocks/shared/splitContent.ts` around lines
15 - 40, The call to serializer.serializeNode in splitAtCursor may return a Text
node, but the code unconditionally casts to HTMLElement (leftDom/rightDom);
update splitAtCursor to defensively handle non-HTMLElement results from
serializer.serializeNode(serialized) by checking if the returned value is an
instance of HTMLElement and, if not, wrapping the Text node (or other Node) into
a container element (e.g., createElement('div') and append the node or use
node.textContent) before reading outerHTML; ensure both leftDom and rightDom are
processed this way so leftHtml/rightHtml are always strings.
resources/js/visual-editor/editor/components/RichTextToolbar.tsx (1)

153-200: Consider restoring editor focus when closing popover via Escape.

When the user presses Escape to close the link popover, the editor focus is not explicitly restored. This could leave focus in a limbo state since the input will be unmounted.

Suggested enhancement
             } else if (event.key === 'Escape') {
                 event.preventDefault();
+                editor.commands.focus();
                 setOpen(false);
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/editor/components/RichTextToolbar.tsx` around
lines 153 - 200, The popover close paths (Escape handler in onKeyDown and the
setOpen(false) branches in applyLink/removeLink) don't explicitly restore editor
focus, which can leave focus in limbo when the input is unmounted; update the
Escape branch in onKeyDown to call editor.chain().focus().run() before
setOpen(false), and likewise ensure removeLink and the "unset" branch in
applyLink call editor.chain().focus().run() (or keep the existing focus chain
calls) so the editor regains focus whenever the link popover is closed; locate
these changes in the LinkControl component (functions onKeyDown, applyLink,
removeLink).
resources/js/visual-editor/editor/blocks/heading/edit.tsx (1)

46-64: Stale closure: editor referenced before assignment.

The onEnter and onBackspaceAtStart callbacks reference editor, but editor is the return value of useBlockTiptap. At the time these callbacks are defined, editor captures the value from the previous render (initially null). The null checks work, but the logic relies on the refs pattern inside useBlockTiptap to get the current callbacks.

This works correctly because useBlockTiptap stores callbacks in refs and reads from .current at invocation time. However, the pattern is subtle and the if (!editor) checks are checking a potentially stale value.

A cleaner pattern would be to accept the editor as a parameter in the callbacks or structure them differently, but given the refs pattern in useBlockTiptap handles this correctly, this is a minor readability concern rather than a bug.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/editor/blocks/heading/edit.tsx` around lines 46 -
64, The callbacks close over a potentially stale editor variable; instead accept
the current editor instance passed by useBlockTiptap (or read it from a ref) and
forward it to the handlers: change the onEnter and onBackspaceAtStart props to
signatures like (editorInstance) => handleBlockEnter(store, clientId,
editorInstance) and (editorInstance) => handleBlockBackspace(store, clientId,
editorInstance), remove the stale null-checks against the outer editor variable,
and ensure handleBlockEnter and handleBlockBackspace are called with the
supplied editor instance; see useBlockTiptap, onEnter, onBackspaceAtStart,
handleBlockEnter, and handleBlockBackspace.
resources/js/visual-editor/editor/blocks/shared/blockSplitMerge.ts (1)

121-135: Regex-based HTML manipulation may be fragile with complex attributes.

The extractInnerHtml and wrapInSameTag functions use regex to parse HTML. While this works for the simple paragraph/heading tags currently in use, it could break with:

  • Tags containing > in attribute values: <p data-x="a>b">
  • Multi-line attributes or content

For Phase 1 scope with controlled output from TipTap, this is acceptable, but consider using DOM parsing for robustness in future phases.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/editor/blocks/shared/blockSplitMerge.ts` around
lines 121 - 135, The regex used in extractInnerHtml and wrapInSameTag is fragile
for tags with attributes containing ">" or multiline attributes; replace the
regex approach by using DOM parsing: in extractInnerHtml(parseOuter) use
DOMParser to parse outerHtml and return the
document.body.firstElementChild?.innerHTML || outerHtml, and in
wrapInSameTag(templateHtml, innerHtml) parse templateHtml with DOMParser, grab
the first element (including its tagName and attributes), create a new element
with that tagName, copy attributes from the parsed template element, set its
innerHTML = innerHtml, and serialize/return the outerHTML; if parsing fails,
fall back to the current behavior (return innerHtml or outerHtml) to preserve
safety.
🤖 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/components/editor.css`:
- Around line 193-202: The .ve-sr-only rule uses the deprecated clip property;
update the .ve-sr-only CSS declaration to remove or replace clip: rect(0, 0, 0,
0); and use the modern equivalent clip-path: inset(50%); (keep the rest of the
properties intact) so the element remains visually hidden but accessible for
screen readers; locate the .ve-sr-only selector in editor.css to make this
change.

---

Nitpick comments:
In `@resources/js/visual-editor/editor/blocks/__tests__/heading.test.tsx`:
- Around line 67-80: The test function labeled "updates the rendered heading
level when attributes.level changes" is unnecessarily declared async; remove the
async keyword from the it(...) callback so the test matches the synchronous
behavior (no awaits) used elsewhere. Locate the test block that calls
makeHeading('h1', '<h2>Title</h2>', 2), createEditorStore([block]),
renderBlock(store, block), act(() =>
store.getState().updateBlockAttributes('h1', { level: 4 })); and
getBlockEditor('h1'), and change its declaration to a plain synchronous
function.

In `@resources/js/visual-editor/editor/blocks/heading/edit.tsx`:
- Around line 46-64: The callbacks close over a potentially stale editor
variable; instead accept the current editor instance passed by useBlockTiptap
(or read it from a ref) and forward it to the handlers: change the onEnter and
onBackspaceAtStart props to signatures like (editorInstance) =>
handleBlockEnter(store, clientId, editorInstance) and (editorInstance) =>
handleBlockBackspace(store, clientId, editorInstance), remove the stale
null-checks against the outer editor variable, and ensure handleBlockEnter and
handleBlockBackspace are called with the supplied editor instance; see
useBlockTiptap, onEnter, onBackspaceAtStart, handleBlockEnter, and
handleBlockBackspace.

In `@resources/js/visual-editor/editor/blocks/shared/blockSplitMerge.ts`:
- Around line 121-135: The regex used in extractInnerHtml and wrapInSameTag is
fragile for tags with attributes containing ">" or multiline attributes; replace
the regex approach by using DOM parsing: in extractInnerHtml(parseOuter) use
DOMParser to parse outerHtml and return the
document.body.firstElementChild?.innerHTML || outerHtml, and in
wrapInSameTag(templateHtml, innerHtml) parse templateHtml with DOMParser, grab
the first element (including its tagName and attributes), create a new element
with that tagName, copy attributes from the parsed template element, set its
innerHTML = innerHtml, and serialize/return the outerHTML; if parsing fails,
fall back to the current behavior (return innerHtml or outerHtml) to preserve
safety.

In `@resources/js/visual-editor/editor/blocks/shared/splitContent.ts`:
- Around line 15-40: The call to serializer.serializeNode in splitAtCursor may
return a Text node, but the code unconditionally casts to HTMLElement
(leftDom/rightDom); update splitAtCursor to defensively handle non-HTMLElement
results from serializer.serializeNode(serialized) by checking if the returned
value is an instance of HTMLElement and, if not, wrapping the Text node (or
other Node) into a container element (e.g., createElement('div') and append the
node or use node.textContent) before reading outerHTML; ensure both leftDom and
rightDom are processed this way so leftHtml/rightHtml are always strings.

In
`@resources/js/visual-editor/editor/components/__tests__/RichTextToolbar.test.tsx`:
- Around line 105-126: The test "toggles italic on the active editor" uses
getBlockEditor('p1') and then dereferences editor! without asserting it's not
null; add a null-check assertion (e.g., expect(editor).not.toBeNull())
immediately after obtaining editor to match the pattern used earlier, then
proceed to call editor.commands.selectAll() and
expect(editor.isActive('italic')).toBe(true) so the test fails cleanly if
getBlockEditor returns null.

In `@resources/js/visual-editor/editor/components/Icon.tsx`:
- Around line 13-20: The Icon component is currently passing title to
FontAwesomeIcon even when title is an empty string; change the JSX so the title
prop is only provided when non-empty (use the existing hasTitle check) — e.g.,
pass title={hasTitle ? title : undefined} or conditionally spread the title prop
in the <FontAwesomeIcon> element to avoid rendering an empty title attribute.

In `@resources/js/visual-editor/editor/components/RichTextToolbar.tsx`:
- Around line 153-200: The popover close paths (Escape handler in onKeyDown and
the setOpen(false) branches in applyLink/removeLink) don't explicitly restore
editor focus, which can leave focus in limbo when the input is unmounted; update
the Escape branch in onKeyDown to call editor.chain().focus().run() before
setOpen(false), and likewise ensure removeLink and the "unset" branch in
applyLink call editor.chain().focus().run() (or keep the existing focus chain
calls) so the editor regains focus whenever the link popover is closed; locate
these changes in the LinkControl component (functions onKeyDown, applyLink,
removeLink).

In `@resources/js/visual-editor/editor/store/ids.ts`:
- Around line 9-15: The ID generator currently returns a raw UUID when
globalThis.crypto.randomUUID exists but returns a prefixed fallback
(`ve-${Date.now().toString(36)}-${counter.toString(36)}`) otherwise; normalize
to a single shape by always returning IDs with the "ve-" prefix. In the branch
that uses globalThis.crypto.randomUUID, wrap the UUID with the same "ve-" prefix
(so the branch using globalThis.crypto.randomUUID returns `ve-<uuid>`), and keep
the existing fallback that uses counter and Date so both paths produce IDs with
the `ve-` prefix; update references to globalThis.crypto.randomUUID, counter,
and the template literal to implement this change.
🪄 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: 44f488a1-0a97-4912-8e8f-e753cf600556

📥 Commits

Reviewing files that changed from the base of the PR and between 494df2a and a1d83f2.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json, !package-lock.json
📒 Files selected for processing (23)
  • package.json
  • resources/js/visual-editor/editor/blocks/__tests__/heading.test.tsx
  • resources/js/visual-editor/editor/blocks/__tests__/paragraph.test.tsx
  • resources/js/visual-editor/editor/blocks/heading/edit.tsx
  • resources/js/visual-editor/editor/blocks/heading/index.ts
  • resources/js/visual-editor/editor/blocks/index.ts
  • resources/js/visual-editor/editor/blocks/paragraph/edit.tsx
  • resources/js/visual-editor/editor/blocks/paragraph/index.ts
  • resources/js/visual-editor/editor/blocks/shared/blockEditorRegistry.ts
  • resources/js/visual-editor/editor/blocks/shared/blockSplitMerge.ts
  • resources/js/visual-editor/editor/blocks/shared/index.ts
  • resources/js/visual-editor/editor/blocks/shared/splitContent.ts
  • resources/js/visual-editor/editor/blocks/shared/useBlockTiptap.ts
  • resources/js/visual-editor/editor/components/BlockWrapper.tsx
  • resources/js/visual-editor/editor/components/EditorShell.tsx
  • resources/js/visual-editor/editor/components/Icon.tsx
  • resources/js/visual-editor/editor/components/RichTextToolbar.tsx
  • resources/js/visual-editor/editor/components/__tests__/RichTextToolbar.test.tsx
  • resources/js/visual-editor/editor/components/editor.css
  • resources/js/visual-editor/editor/components/index.ts
  • resources/js/visual-editor/editor/main.tsx
  • resources/js/visual-editor/editor/store/ids.ts
  • resources/js/visual-editor/editor/store/index.ts

Comment thread resources/js/visual-editor/editor/components/editor.css
`clip: rect(...)` has been deprecated in favor of `clip-path: inset(50%)`
for the visually-hidden pattern. Swap it so the rule keeps working in
modern browsers without deprecation warnings.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

Phase 1.10: Paragraph block, heading block, shared rich-text toolbar

1 participant