Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/2026-07-12-add-ruby-text-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/extension-ruby-text': minor
---

Add official RubyText extension for HTML ruby text annotations with non-editable annotations and mark-based document storage. The click-to-edit annotation editor can be replaced with a custom element via the `renderAnnotationEditor` option.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/extension-table': patch
---

Fix inserting a table with an empty cell or header (e.g. via `insertContent`/`insertContentAt`) throwing `RangeError: Invalid content for node tableCell/tableHeader: <>`. Empty `<td>`/`<th>` elements are now backfilled with the cell's default block content, matching the behavior you already get from `setContent`.
5 changes: 5 additions & 0 deletions .changeset/fix-markdown-block-token-blank-line-erosion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/markdown': patch
---

Fixed blank lines being dropped after block elements (headings, tables, etc.) when parsing markdown. Blank lines were being absorbed into the block token instead of being preserved, causing content to lose a blank line on each parse/serialize cycle.
5 changes: 5 additions & 0 deletions .changeset/fix-nodeview-getpos-throw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/core': patch
---

Node view `getPos()` now returns `undefined` instead of throwing when the position cannot be resolved yet, for example when React 19 renders a node view component while the editor view is still updating.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/core': patch
---

Fixed `onContentError` throwing when calling `editor.commands` from inside the handler on initial load with invalid content. The editor now has a usable state (seeded from the stripped fallback document) before `onContentError` fires.
5 changes: 5 additions & 0 deletions .changeset/fix-vue-nodeview-content-tbody-quiet-lake-fox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tiptap/vue-3": patch
---

Fix `<node-view-content as="tbody">` (and similar restricted-content elements) rendering with an extra wrapper `<div>` nested inside them, which broke tables in Vue node views. Note: keep `<node-view-content>` mounted (use `v-show`, not `v-if`) — conditionally remounting it can leave ProseMirror attached to the old element.
1 change: 1 addition & 0 deletions SCOPES.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
- extension-strike
- extension-subscript
- extension-superscript
- extension-ruby-text
- extension-table
- extension-table-of-contents
- extension-text
Expand Down
Empty file.
69 changes: 69 additions & 0 deletions demos/src/Marks/RubyText/React/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// fallow-ignore-file unused-file
import './styles.scss'

import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import RubyText from '@tiptap/extension-ruby-text'
import Text from '@tiptap/extension-text'
import { EditorContent, useEditor } from '@tiptap/react'
import React from 'react'

export default () => {
const [rt, setRt] = React.useState('')

const editor = useEditor({
extensions: [Document, Paragraph, Text, RubyText],
content: `
<p><ruby>東京<rt>とうきょう</rt></ruby>は日本の首都です。</p>
<p><ruby>漢字<rt>かんじ</rt></ruby>の上にルビを表示できます。</p>
`,
onSelectionUpdate: ({ editor: currentEditor }) => {
setRt(currentEditor.getAttributes('rubyText').rt ?? '')
},
})

if (!editor) {
return null
}

return (
<>
<div className="control-group">
<form
className="button-group"
onSubmit={event => {
event.preventDefault()
editor.chain().focus().setRubyText({ rt }).run()
}}
>
<label>
Ruby text (rt):
<input
data-test-id="ruby-text-input"
type="text"
value={rt}
onChange={event => setRt(event.target.value)}
/>
</label>
<button
data-test-id="set-ruby-text-button"
type="submit"
disabled={editor.state.selection.empty}
>
Set ruby text
</button>
<button
data-test-id="unset-ruby-text-button"
type="button"
onClick={() => editor.chain().focus().unsetRubyText().run()}
disabled={!editor.isActive('rubyText')}
>
Unset ruby text
</button>
</form>
</div>

<EditorContent editor={editor} />
</>
)
}
24 changes: 24 additions & 0 deletions demos/src/Marks/RubyText/React/styles.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// fallow-ignore-file unused-file
.tiptap {
:first-child {
margin-top: 0;
}

ruby {
ruby-position: over;
}

rt {
cursor: text;
pointer-events: auto;

input {
font: inherit;
margin: 0;
}
}
}

.control-group input {
margin-left: 0.5rem;
}
Empty file.
93 changes: 93 additions & 0 deletions demos/src/Marks/RubyText/Vue/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<!-- fallow-ignore-file unused-file -->
<template>
<div v-if="editor" class="container">
<div class="control-group">
<form class="button-group" @submit.prevent="editor.chain().focus().setRubyText({ rt }).run()">
<label>
Ruby text (rt):
<input data-test-id="ruby-text-input" type="text" v-model="rt" />
</label>
<button
data-test-id="set-ruby-text-button"
type="submit"
:disabled="editor.state.selection.empty"
>
Set ruby text
</button>
<button
data-test-id="unset-ruby-text-button"
type="button"
@click="editor.chain().focus().unsetRubyText().run()"
:disabled="!editor.isActive('rubyText')"
>
Unset ruby text
</button>
</form>
</div>
<editor-content :editor="editor" />
</div>
</template>

<script>
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import RubyText from '@tiptap/extension-ruby-text'
import Text from '@tiptap/extension-text'
import { Editor, EditorContent } from '@tiptap/vue-3'

export default {
components: {
EditorContent,
},

data() {
return {
editor: null,
rt: '',
}
},

mounted() {
this.editor = new Editor({
extensions: [Document, Paragraph, Text, RubyText],
content: `
<p><ruby>東京<rt>とうきょう</rt></ruby>は日本の首都です。</p>
<p><ruby>漢字<rt>かんじ</rt></ruby>の上にルビを表示できます。</p>
`,
onSelectionUpdate: ({ editor }) => {
this.rt = editor.getAttributes('rubyText').rt ?? ''
},
})
},

beforeUnmount() {
this.editor.destroy()
},
}
</script>

<style lang="scss">
.tiptap {
:first-child {
margin-top: 0;
}

ruby {
ruby-position: over;
}

rt {
cursor: text;
pointer-events: auto;

input {
font: inherit;
margin: 0;
}
}
}

.control-group input {
margin-left: 0.5rem;
}
</style>
71 changes: 71 additions & 0 deletions demos/src/Marks/RubyText/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { expect, test } from '@playwright/test'

import { getEditor, setEditorContent } from '../../../test/helpers.js'

const demoName = 'RubyText'
const frameworkPaths = ['React', 'Vue']
const demoPath = '/src/Marks'

test.describe(`${demoPath}/${demoName}`, () => {
frameworkPaths.forEach(frameworkPath => {
const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`

test.describe(`${frameworkPath}`, () => {
test.beforeEach(async ({ page }) => {
await page.goto(fullDemoPath)
})

test('renders the annotation as non-editable text', async ({ page }) => {
await setEditorContent(page, '<p><ruby>漢字<rt>かんじ</rt></ruby></p>')

const rt = page.locator('.tiptap rt').first()

await expect(rt).toHaveText('かんじ')
await expect(rt).toHaveAttribute('contenteditable', 'false')
})

test('edits an annotation inline', async ({ page }) => {
await setEditorContent(page, '<p><ruby>漢字<rt>かんじ</rt></ruby></p>')

const editor = await getEditor(page)
const rt = page.locator('.tiptap rt').first()

await rt.click()
const input = rt.locator('input')
await input.fill('かんじ(新)')
await input.press('Enter')

expect(await editor.evaluate((el: any) => el.editor.getHTML())).toContain('かんじ(新)')
await expect(rt).toHaveText('かんじ(新)')
})

test('submitting ruby text applies annotation to selection', async ({ page }) => {
await setEditorContent(page, '<p>東京</p>')

const editor = await getEditor(page)

await editor.click()
await page.keyboard.press('Home')
await page.keyboard.press('Shift+ArrowRight')
await page.keyboard.press('Shift+ArrowRight')

await expect
.poll(() =>
editor.evaluate(
(el: any) => el.editor.state.selection.to - el.editor.state.selection.from,
),
)
.toBe(2)

const rubyTextInput = page.locator('[data-test-id="ruby-text-input"]')

await rubyTextInput.fill('とうきょう')
await rubyTextInput.press('Enter')

const html = await editor.evaluate((el: any) => el.editor.getHTML())
expect(html).toContain('とうきょう')
expect(html).toContain('東京')
})
})
})
})
40 changes: 40 additions & 0 deletions packages/core/__tests__/nodeViewGetPos.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { NodeView } from '@tiptap/core'
import type { NodeViewRendererProps } from '@tiptap/core'
import { describe, expect, it } from 'vitest'

const createProps = (getPos: () => number | undefined) => {
return {
editor: {},
extension: {},
node: {},
decorations: [],
innerDecorations: {},
view: {},
HTMLAttributes: {},
getPos,
} as unknown as NodeViewRendererProps
}

describe('NodeView getPos', () => {
it('returns the position from prosemirror', () => {
const nodeView = new NodeView(
null,
createProps(() => 7),
)

expect(nodeView.getPos()).toBe(7)
})

it('returns undefined instead of throwing while the view tree is mid-update', () => {
// prosemirror-view's posBeforeChild throws a TypeError when the node
// view desc is not attached to its parent yet.
const nodeView = new NodeView(
null,
createProps(() => {
throw new TypeError("Cannot read properties of undefined (reading 'size')")
}),
)

expect(nodeView.getPos()).toBeUndefined()
})
})
Loading
Loading