Skip to content

feat(Editor): support external editor instances - #6678

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

feat(Editor): support external editor instances#6678
sandros94 wants to merge 10 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

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds external TipTap editor support to UEditor. The component detects bound external editors, supports reactive or asynchronous instances, adds fallback rendering, and exposes prose styling control. Theme typography and placeholder selectors 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 and concisely describes the main change: support for external editor instances in Editor.
Description check ✅ Passed The description accurately explains the external editor feature, its behavior, compatibility, asynchronous support, and documentation updates.
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: f5f6c91

…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

Comment thread src/runtime/components/Editor.vue Outdated
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)

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.

Ok, I've found what was not satisfying me about this change earlier, but fixed in 87b90b6.

If you, @benjamincanac, would prefer to drop the $attrs altogether I'm fine with that too

@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>
Copilot AI lite review requested due to automatic review settings August 5, 2026 19:47
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

Copilot AI 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.

Pull request overview

Adds an “external editor” mode to <UEditor>, allowing consumers to provide their own TipTap Editor instance so the component becomes a shell that applies Nuxt UI theming and wires toolbars/menus without owning schema/content/lifecycle.

Changes:

  • Added an editor prop (supports refs) to use a provided TipTap editor instance, plus a #fallback slot for SSR/async editor creation windows.
  • Introduced prose styling support for external editors by scoping theme typography/placeholder classes onto the content wrapper (with :where() to keep specificity low).
  • Expanded test coverage and updated snapshots/docs to validate external-editor behavior and styling.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
test/components/Editor.spec.ts Adds new assertions for built-in vs external editor behavior, styling, fallthrough attrs, and lifecycle expectations.
test/components/snapshots/Editor-vue.spec.ts.snap Updates snapshots for the editor DOM/classes after styling/scoping changes.
src/theme/editor.ts Refactors editor theme classes to support re-scoped “prose” styling on the content wrapper.
src/runtime/composables/useComponentProps.ts Adds isPropBound helper to detect prop presence even when bound to undefined.
src/runtime/components/Editor.vue Implements external-editor mode, fallback slot, inert-prop dev warnings, and wrapper-scoped prose styling.
docs/content/docs/2.components/editor.md Documents external editor usage, fallback slot, and the prose opt-out.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/runtime/components/Editor.vue
@codspeed-hq

codspeed-hq Bot commented Aug 5, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing feat/custom-editor-instance (f5f6c91) with v4 (a7f26a3)

Open in CodSpeed

`d09668d75` only refreshed the nuxt snapshots, leaving the vue ones
frozen at the intermediate state where prose classes lived on `content`.
Now byte-identical to `v4`, confirming default rendering is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 5, 2026 22:02

Copilot AI 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.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/runtime/components/Editor.vue:386

  • In external-editor mode, v-bind="$attrs" forwards a caller-supplied data-slot onto the inner EditorContent, which can overwrite data-slot="content". The codebase convention is that a caller-supplied data-slot should only win on the component root; inner elements should keep their own data-slot values.
        :editor="editor"
        data-slot="content"
        :class="ui.content({ class: props.ui?.content })"
        v-bind="isExternalEditor ? $attrs : undefined"
      />

docs/content/docs/2.components/editor.md:214

  • This note says the external editor instance is "never modified", but mounting TipTap's <EditorContent> necessarily assigns editor.options.element while it is mounted (as also noted in the tests). Consider clarifying this to avoid surprising consumers who diff editor options.
The editor instance is never modified: the theme's [prose classes](#prose) style your content from a wrapper element, so an external editor inherits the default typography automatically. Attributes of the editable element itself remain yours — for parity with the built-in editor, set the theme's `base` slot classes at creation:

sandros94 and others added 2 commits August 6, 2026 00:28
`v-bind="$attrs"` in external mode let a caller-supplied `data-slot`
clobber `data-slot="content"` (Vue 3 merge order). Omit it to match the
built-in path, which already strips it before injecting `editorProps`
attributes — a fallthrough `data-slot` only renames the root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 5, 2026 22:28

Copilot AI 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.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

@sandros94

sandros94 commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Small disclaimer as some might see my latest commits with claude co-author: I'm on vacation without my laptop, but ended up needing a working pkg.pr.new release so I guided claude in what to edit exactly via mobile 🥲

EDIT: I found couple of things I might want to investigate once I get back, as working on a phone's screen is sometime a nightmare for me coudln't reproduce

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v4 #4488

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants