Skip to content

feat(Editor): support external editor instances#6678

Open
sandros94 wants to merge 4 commits into
v4from
feat/custom-editor-instance
Open

feat(Editor): support external editor instances#6678
sandros94 wants to merge 4 commits into
v4from
feat/custom-editor-instance

Conversation

@sandros94

Copy link
Copy Markdown
Member

🔗 Linked issue

N/A, discussed privately on discord and surfaced in comarkdown/comark#164

❓ Type of change

  • 📖 Documentation (updates to the documentation or readme)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • 👌 Enhancement (improving an existing functionality)
  • ✨ New feature (a non-breaking change that adds functionality)
  • 🧹 Chore (updates to the build process or auxiliary tools and libraries)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

📚 Description

<UEditor> builds its own StarterKit, so there was no way to bring an extended one. This adds an editor prop: pass your own TipTap instance and <UEditor> becomes a shell — theme, handlers and the child components (toolbar, menus) wire to it, while your editor owns the schema, content and v-model. Opt-in and non-breaking, the built-in path is untouched.

The motivating case is editors backed by a custom kit like comark-tiptap (lossless markdown ↔ AST ↔ ProseMirror), which can't coexist with the built-in StarterKit. Mode is detected on prop presence, so an editor created asynchronously (undefined on first render) still works, with a #fallback slot for that time window.

One thing worth a second look before merge: in external mode we auto-inject the theme's base (prose) classes onto the editable via editorProps. Editors that ship their own styling — comark included — could collide or double-up there, so the rendered result is worth an eyeball. There's an :editor-props="false" escape hatch for full control.

📝 Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly.

@sandros94 sandros94 self-assigned this Jul 5, 2026
@sandros94
sandros94 requested a review from benjamincanac as a code owner July 5, 2026 13:09
@sandros94 sandros94 added feature v4 #4488 p2-medium Notable issue or useful enhancement labels Jul 5, 2026
@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a3369960-5b8e-4896-88fc-70bf1a03a09b

📥 Commits

Reviewing files that changed from the base of the PR and between 2067be3 and d09668d.

📒 Files selected for processing (3)
  • src/runtime/components/Editor.vue
  • src/theme/editor.ts
  • test/components/Editor.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/runtime/components/Editor.vue

📝 Walkthrough

Walkthrough

This PR adds external TipTap editor support to UEditor. The component distinguishes bound external editors from internally created editors, supports reactive or asynchronous editor instances, adds fallback rendering, and exposes prose styling control. Theme typography and placeholder selectors now support external editor wrappers. Documentation and tests cover the new workflow and behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding support for external editor instances in UEditor.
Description check ✅ Passed The description is directly related to the PR and accurately explains the external editor support and fallback behavior.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/custom-editor-instance

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
test/components/Editor.spec.ts (1)

78-92: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the dev warning is actually emitted.

The spy silences console.warn but never verifies it was called, so the "inert props" warning behavior documented in Editor.vue isn't actually covered by this test.

✅ Suggested assertion
       warn.mockRestore()
       wrapper.unmount()
+      expect(warn).toHaveBeenCalledWith(expect.stringContaining('ignores'))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/components/Editor.spec.ts` around lines 78 - 92, The `Editor.spec.ts`
test currently silences `console.warn` but never checks that the inert-props
warning is emitted. In the `it('ignores engine props...')` case, keep the
`vi.spyOn(console, 'warn')` on `warn`, then add an assertion that it was called
when mounting `Editor` with `modelValue` and `contentType` alongside an external
`editor`. Use the `Editor` component and the `warn` spy to verify the dev-only
warning behavior documented in `Editor.vue`, then restore the spy as before.
src/runtime/composables/useComponentProps.ts (1)

55-58: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep composable exports on the use* naming convention.

isPropBound is a setup-context helper exported from src/runtime/composables; rename it to useIsPropBound, or move it to a non-composable utils module if it should remain a predicate helper. As per coding guidelines, src/runtime/composables/**/*.ts: “Composables must use use* naming convention with camelCase (e.g., useFormField.ts).”

Proposed rename
-export function isPropBound(name: string): boolean {
+export function useIsPropBound(name: string): boolean {
   const vnode = getCurrentInstance()?.vnode
   if (!vnode || !vnode.props) return false
   return camelCase(name) in vnode.props || kebabCase(name) in vnode.props
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/composables/useComponentProps.ts` around lines 55 - 58, The
exported setup helper currently violates the composables naming rule because
`isPropBound` is exposed from `src/runtime/composables`; rename the exported
function in `useComponentProps.ts` to `useIsPropBound` and update any internal
references to match, or move it out of the composables folder if you want to
keep the predicate-style name. Make sure the exported symbol follows the `use*`
camelCase convention alongside the existing `getCurrentInstance`, `camelCase`,
and `kebabCase` usage in that module.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/content/docs/2.components/editor.md`:
- Around line 180-183: Clarify the v-model ownership note in the editor
documentation: the current sentence in the note about the external editor owning
content/schema and v-model control sounds awkward. Rewrite the guidance around
the editor mode description to state plainly that in this mode the engine props
are ignored and the external editor instance must manage its own v-model,
instead of saying “not UEditor component.”

In `@src/runtime/components/Editor.vue`:
- Around line 302-305: The watchEffect in Editor.vue exits early when
externalEditor.value is set and props.editorProps becomes false, which leaves
previously injected Nuxt UI props attached. Update the editorProps handling in
this effect so that when props.editorProps is false after having been enabled,
you restore the captured original editor props on the external editor instance
before returning. Use the existing externalEditor and props.editorProps logic in
watchEffect to locate the change.
- Around line 150-162: The base editor class is being lost when `editorProps`
are merged because `defu()` preserves the first `attributes.class` it
encounters, so a user-supplied class overrides the Nuxt UI theme class. Update
`baseEditorProps` and the `editorProps` computed merge in `Editor.vue` to
combine `class` values before calling `defu`, ensuring both the default styling
from `ui.value.base` and any consumer-provided `editorProps.attributes.class`
are preserved.

---

Nitpick comments:
In `@src/runtime/composables/useComponentProps.ts`:
- Around line 55-58: The exported setup helper currently violates the
composables naming rule because `isPropBound` is exposed from
`src/runtime/composables`; rename the exported function in
`useComponentProps.ts` to `useIsPropBound` and update any internal references to
match, or move it out of the composables folder if you want to keep the
predicate-style name. Make sure the exported symbol follows the `use*` camelCase
convention alongside the existing `getCurrentInstance`, `camelCase`, and
`kebabCase` usage in that module.

In `@test/components/Editor.spec.ts`:
- Around line 78-92: The `Editor.spec.ts` test currently silences `console.warn`
but never checks that the inert-props warning is emitted. In the `it('ignores
engine props...')` case, keep the `vi.spyOn(console, 'warn')` on `warn`, then
add an assertion that it was called when mounting `Editor` with `modelValue` and
`contentType` alongside an external `editor`. Use the `Editor` component and the
`warn` spy to verify the dev-only warning behavior documented in `Editor.vue`,
then restore the spy as before.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f37f8e32-3d25-49f8-baef-49aebc2779aa

📥 Commits

Reviewing files that changed from the base of the PR and between 5c986dd and ab2a472.

📒 Files selected for processing (4)
  • docs/content/docs/2.components/editor.md
  • src/runtime/components/Editor.vue
  • src/runtime/composables/useComponentProps.ts
  • test/components/Editor.spec.ts

Comment thread docs/content/docs/2.components/editor.md
Comment thread src/runtime/components/Editor.vue Outdated
Comment thread src/runtime/components/Editor.vue Outdated
@pkg-pr-new

pkg-pr-new Bot commented Jul 5, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/@nuxt/ui@6678

commit: 2067be3

…ances

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sandros94

Copy link
Copy Markdown
Member Author

For transparency: both the last commit (2067be3) and this comment were drafted with Fable's (Claude) assistance, but reviewed by me.

The diff looks bigger than it is — for normal <UEditor> usage nothing changes: same rendered output, same API, and every ui.base/app.config override that won before still wins.

What actually changed:

  1. we no longer touch the external editor instance at all. The previous revision injected editorProps into it, which meant potentially overwriting options the owner sets after creation and leaving our classes behind on unmount
  2. typography moved from base to a new prose slot (+ prose prop, default true), applied on the component-owned content wrapper instead of the editable element
  3. all targets are scoped to .ProseMirror and :where()-wrapped

The :where() part is IMO the nicest bit when paired with TipTap: theme defaults keep zero specificity, so anything an extension sets via HTMLAttributes (or a self-styled kit brings along) always wins instead of fighting the previous [&_p]-style selectors. Scoping to .ProseMirror also keeps the menus appended inside the wrapper (mention/emoji/drag handle) untouched. Bonus: placeholder styling now works for external editors too.

Trade-offs to be aware of:

  • external editors don't inherit the editable-element attrs for free anymore (autocomplete, spacing…), docs show the snippet for full parity
  • migration note worth a changelog line: replacing ui.base to strip typography won't work anymore, that's :prose="false" now

Big picture, this unlocks bringing your own TipTap instance (custom starter kits/schemas like comark-tiptap) while <UEditor> stays the shell: theme, toolbar and menus wired for free.

Happy to adjust any of this — WDYT @benjamincanac

data-slot="content"
:class="ui.content({ class: props.ui?.content })"
:class="[ui.content({ class: props.ui?.content }), ui.prose({ class: props.ui?.prose })]"
v-bind="isExternalEditor ? $attrs : undefined"

@sandros94 sandros94 Jul 6, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

this is the only thing I'm not fully sure about. I added it because I do tend to use it when in a pinch, but feel free to change @benjamincanac (the v-bind="isExternalEditor ? $attrs : undefined" part)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
test/components/Editor.spec.ts (1)

65-77: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Shallow clone can mask nested mutations.

{ ...editor.options } only clones one level deep, so originalOptions.editorProps and the post-mount currentOptions.editorProps are the same object reference (editorProps). If the component mutates editorProps.attributes.class in place (rather than replacing the object), this test won't catch it — toEqual will trivially pass since both snapshots point to the identical mutated object.

Use a deep clone for the "before" snapshot to make the mutation check meaningful.

♻️ Proposed fix using deep clone
     const editorProps = { attributes: { class: 'custom-class' } }
     const editor = createEditor('<p>External content</p>', { editorProps })
     // `options.element` is set by `EditorContent` when mounting the editor.
-    const { element: _, ...originalOptions } = { ...editor.options }
+    const { element: _, ...originalOptions } = structuredClone(editor.options)
     const wrapper = await mountSuspended(Editor, { props: { editor } })

     expect(editor.options.editorProps).toBe(editorProps)
     const { element: __, ...currentOptions } = { ...editor.options }
     expect(currentOptions).toEqual(originalOptions)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/components/Editor.spec.ts` around lines 65 - 77, The mutation check in
Editor.spec.ts is currently using a shallow copy of editor.options, which lets
nested changes inside editorProps go unnoticed. Update the test around
createEditor and mountSuspended(Editor) to take a deep snapshot of the pre-mount
options instead of spreading editor.options, so the comparison against
currentOptions will catch in-place mutations to nested fields like
editorProps.attributes. Keep the existing assertions around
editor.options.editorProps and the unmount flow, but make the “before” snapshot
fully independent from the live options object.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/components/Editor.spec.ts`:
- Around line 65-77: The mutation check in Editor.spec.ts is currently using a
shallow copy of editor.options, which lets nested changes inside editorProps go
unnoticed. Update the test around createEditor and mountSuspended(Editor) to
take a deep snapshot of the pre-mount options instead of spreading
editor.options, so the comparison against currentOptions will catch in-place
mutations to nested fields like editorProps.attributes. Keep the existing
assertions around editor.options.editorProps and the unmount flow, but make the
“before” snapshot fully independent from the live options object.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d4b2f90e-d358-4e6f-86ba-c8ac41f9abf3

📥 Commits

Reviewing files that changed from the base of the PR and between 27618a7 and 2067be3.

⛔ Files ignored due to path filters (2)
  • test/components/__snapshots__/Editor-vue.spec.ts.snap is excluded by !**/*.snap
  • test/components/__snapshots__/Editor.spec.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (5)
  • docs/content/docs/2.components/editor.md
  • src/runtime/components/Editor.vue
  • src/runtime/composables/useComponentProps.ts
  • src/theme/editor.ts
  • test/components/Editor.spec.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/runtime/composables/useComponentProps.ts
  • docs/content/docs/2.components/editor.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature p2-medium Notable issue or useful enhancement v4 #4488

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant