snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
# Change Log
## 3.30.3
### Patch Changes
- Updated dependencies [965a880]
- @tiptap/core@3.30.3
- @tiptap/pm@3.30.3
## 3.30.2
### Patch Changes
- Updated dependencies [3dffed5]
- Updated dependencies [214a140]
- @tiptap/core@3.30.2
- @tiptap/pm@3.30.2
## 3.30.1
### Patch Changes
- Updated dependencies [abc8828]
- @tiptap/core@3.30.1
- @tiptap/pm@3.30.1
## 3.30.0
### Patch Changes
- Updated dependencies [0247d39]
- Updated dependencies [58a8953]
- Updated dependencies [51909d3]
- Updated dependencies [3099eef]
- @tiptap/core@3.30.0
- @tiptap/pm@3.30.0
## 3.29.2
### Patch Changes
- @tiptap/core@3.29.2
- @tiptap/pm@3.29.2
## 3.29.1
### Patch Changes
- @tiptap/core@3.29.1
- @tiptap/pm@3.29.1
## 3.29.0
### Minor Changes
- 094bdcd: 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.
### Patch Changes
- Updated dependencies [d26840f]
- Updated dependencies [e150ee0]
- Updated dependencies [935e63f]
- Updated dependencies [b4c5a2d]
- Updated dependencies [a963d48]
- Updated dependencies [51f45b6]
- Updated dependencies [0f63969]
- Updated dependencies [9acaa65]
- @tiptap/core@3.29.0
- @tiptap/pm@3.29.0
## 3.27.3
### Minor Changes
- Add official RubyText extension for HTML ruby text annotations with directly editable annotations and reliable cursor navigation

View File

@@ -0,0 +1,20 @@
# @tiptap/extension-ruby-text
[![Version](https://img.shields.io/npm/v/@tiptap/extension-ruby-text.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-ruby-text)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-ruby-text.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-ruby-text.svg)](https://www.npmjs.com/package/@tiptap/extension-ruby-text)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
This package adds support for [HTML ruby text](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/ruby) (reading guides for CJK text). It is not related to the Ruby programming language.
## Official Documentation
Documentation can be found on the [Tiptap website](https://tiptap.dev).
## License
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).

View File

@@ -0,0 +1,55 @@
{
"name": "@tiptap/extension-ruby-text",
"version": "3.30.3",
"description": "HTML ruby text annotation extension for tiptap",
"keywords": [
"cjk",
"furigana",
"ruby-text",
"tiptap",
"tiptap extension"
],
"homepage": "https://tiptap.dev",
"bugs": {
"url": "https://github.com/ueberdosis/tiptap/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap",
"directory": "packages/extension-ruby-text"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"files": [
"src",
"dist"
],
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": {
"import": "./dist/index.d.ts",
"require": "./dist/index.d.cts"
},
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "vp pack"
},
"devDependencies": {
"@tiptap/core": "workspace:^",
"@tiptap/pm": "workspace:^"
},
"peerDependencies": {
"@tiptap/core": "workspace:*",
"@tiptap/pm": "workspace:*"
}
}

View File

@@ -0,0 +1,5 @@
import { RubyText } from './ruby-text.js'
export * from './ruby-text.js'
export default RubyText

View File

@@ -0,0 +1,295 @@
import type { Editor } from '@tiptap/core'
import type { Mark, MarkType, Node as PMNode } from '@tiptap/pm/model'
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view'
const rubyTextDecorationPluginKey = new PluginKey('rubyTextDecoration')
// Ends a widget's edit session on destroy, so stale submit/dismiss calls do nothing.
const editSessions = new WeakMap<Node, () => void>()
interface RubyTextRange {
from: number
to: number
mark: Mark
}
export interface RubyTextAnnotationEditorProps {
/**
* The current annotation value (`''` when the mark has an empty annotation).
*/
annotation: string
/**
* Applies the given value as the new annotation and closes the editor.
* Calling it more than once (or after `dismiss`) is a no-op.
*/
submit: (value: string) => void
/**
* Closes the editor without changing the annotation.
* Calling it more than once (or after `submit`) is a no-op.
*/
dismiss: () => void
/**
* The editor instance.
*/
editor: Editor
}
export interface RubyTextDecorationPluginOptions {
editor: Editor
allowClickToEdit: boolean
renderAnnotationEditor?: (props: RubyTextAnnotationEditorProps) => HTMLElement
}
// Enter and Escape during IME composition must not close the editor.
// Safari reports some IME keydowns only via the deprecated keyCode 229.
function isImeEvent(event: KeyboardEvent) {
const legacyKeyCode = (event as { keyCode?: number }).keyCode
return event.isComposing || legacyKeyCode === 229
}
/** Default annotation editor: a plain text input. Enter submits, Escape or blur dismisses. */
function defaultRenderAnnotationEditor({
annotation,
submit,
dismiss,
}: RubyTextAnnotationEditorProps) {
const input = document.createElement('input')
input.type = 'text'
input.value = annotation
input.size = Math.max(annotation.length, 1)
input.setAttribute('aria-label', 'Ruby text annotation')
input.addEventListener('focus', () => input.select(), { once: true })
input.addEventListener('blur', () => dismiss())
input.addEventListener('keydown', event => {
if (isImeEvent(event)) {
return
}
if (event.key === 'Escape') {
event.preventDefault()
dismiss()
}
if (event.key === 'Enter') {
event.preventDefault()
submit(input.value)
}
})
return input
}
function createRtElement(
annotation: string,
range: RubyTextRange,
rubyTextType: MarkType,
view: EditorView,
options: RubyTextDecorationPluginOptions,
) {
const rt = document.createElement('rt')
rt.contentEditable = 'false'
rt.textContent = annotation
let editing = false
const restoreEditorFocus = () => {
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, range.to)))
view.focus()
}
if (!options.allowClickToEdit || !view.editable) {
return rt
}
editSessions.set(rt, () => {
editing = false
})
const dismiss = () => {
if (!editing) {
return
}
editing = false
rt.textContent = annotation
restoreEditorFocus()
}
const submit = (value: string) => {
if (!editing) {
return
}
// An unchanged value produces no doc change, so the widget would not
// re-render and the editor element would stay mounted.
if (!view.editable || value === annotation) {
dismiss()
return
}
editing = false
const transaction = view.state.tr.addMark(
range.from,
range.to,
rubyTextType.create({ ...range.mark.attrs, rt: value }),
)
view.dispatch(transaction.setSelection(TextSelection.create(transaction.doc, range.to)))
view.focus()
}
const openEditor = () => {
const renderEditor = options.renderAnnotationEditor ?? defaultRenderAnnotationEditor
const element = renderEditor({ annotation, submit, dismiss, editor: options.editor })
rt.replaceChildren(element)
// Browsers ignore autofocus on inserted nodes, so we focus it manually.
const focusTarget = element.querySelector<HTMLElement>('[autofocus]') ?? element
focusTarget.focus()
}
rt.addEventListener('click', () => {
if (editing || !view.editable) {
return
}
editing = true
openEditor()
})
return rt
}
/** Gets the mark of ruby text from the given node, if it exists. */
function getRubyTextMark(node: PMNode, rubyTextType: MarkType) {
return node.isText ? node.marks.find(candidate => candidate.type === rubyTextType) : undefined
}
/** Adds a ruby text range to an existing array of ranges. */
function addRange(ranges: RubyTextRange[], range: RubyTextRange | null) {
if (range) {
ranges.push(range)
}
}
/** Can the current range be extended with the given mark at the given position? */
function canExtendRange(range: RubyTextRange | null, mark: Mark, pos: number) {
return range?.to === pos && range.mark.eq(mark)
}
/**
* Gets the next ruby text range based on the current node and position.
* @param ranges The array of ruby text ranges to add to.
* @param range The current ruby text range being built.
* @param node The current ProseMirror node being processed.
* @param pos The position of the current node in the document.
* @param rubyTextType The MarkType for ruby text.
* @returns The updated ruby text range, or null if the current node does not have a ruby text mark.
*/
function getNextRange(
ranges: RubyTextRange[],
range: RubyTextRange | null,
node: PMNode,
pos: number,
rubyTextType: MarkType,
) {
const mark = getRubyTextMark(node, rubyTextType)
if (!mark) {
addRange(ranges, range)
return null
}
if (range && canExtendRange(range, mark, pos)) {
range.to += node.nodeSize
return range
}
addRange(ranges, range)
return { from: pos, to: pos + node.nodeSize, mark }
}
function getRubyTextRanges(doc: PMNode, rubyTextType: MarkType) {
const ranges: RubyTextRange[] = []
let range: RubyTextRange | null = null
doc.descendants((node, pos) => {
range = getNextRange(ranges, range, node, pos, rubyTextType)
})
addRange(ranges, range)
return ranges
}
// Events from the annotation editor that ProseMirror must not handle itself.
const STOPPED_EVENT_TYPES = new Set([
'click',
'dblclick',
'contextmenu',
'mousedown',
'mouseup',
'pointerdown',
'pointerup',
'touchstart',
'touchend',
'keydown',
'keypress',
'keyup',
'input',
'beforeinput',
'compositionstart',
'compositionupdate',
'compositionend',
'paste',
'cut',
'copy',
'focus',
'blur',
'focusin',
'focusout',
])
function createDecorations(
doc: PMNode,
rubyTextType: MarkType,
options: RubyTextDecorationPluginOptions,
) {
const decorations = getRubyTextRanges(doc, rubyTextType).map(range => {
const annotation = range.mark.attrs.rt ?? ''
return Decoration.widget(
range.to,
view => createRtElement(annotation, range, rubyTextType, view, options),
{
key: `ruby-text-${range.from}-${range.to}-${annotation}`,
marks: [range.mark],
side: 1,
stopEvent: event => options.allowClickToEdit && STOPPED_EVENT_TYPES.has(event.type),
destroy: node => editSessions.get(node)?.(),
},
)
})
return DecorationSet.create(doc, decorations)
}
export function RubyTextDecorationPlugin(
rubyTextType: MarkType,
options: RubyTextDecorationPluginOptions,
) {
return new Plugin({
key: rubyTextDecorationPluginKey,
state: {
init: (_, state) => createDecorations(state.doc, rubyTextType, options),
apply: (transaction, decorations) =>
transaction.docChanged
? createDecorations(transaction.doc, rubyTextType, options)
: decorations.map(transaction.mapping, transaction.doc),
},
props: {
decorations: state => rubyTextDecorationPluginKey.getState(state),
},
})
}

View File

@@ -0,0 +1,174 @@
import { Mark, mergeAttributes } from '@tiptap/core'
import type { RubyTextAnnotationEditorProps } from './ruby-text-decoration-plugin.js'
import { RubyTextDecorationPlugin } from './ruby-text-decoration-plugin.js'
export type { RubyTextAnnotationEditorProps }
export interface RubyTextOptions {
/**
* HTML attributes to add to the ruby element.
* @default {}
*/
HTMLAttributes: Record<string, any>
/**
* Whether clicking an annotation opens an inline editor.
* @default true
*/
allowClickToEdit: boolean
/**
* Renders a custom element used as the inline annotation editor.
* The returned element is mounted inside the `rt` element. A descendant
* with the `autofocus` attribute is focused, otherwise the element itself.
* When not set, a plain text input is rendered.
* @default undefined
*/
renderAnnotationEditor?: (props: RubyTextAnnotationEditorProps) => HTMLElement
}
export interface RubyTextAttributes {
/**
* The ruby text annotation rendered in the HTML `rt` element.
*/
rt: string | null
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
rubyText: {
/**
* Set a ruby text mark with an annotation on the current selection.
* @example editor.commands.setRubyText({ rt: 'かんじ' })
*/
setRubyText: (attributes: RubyTextAttributes) => ReturnType
/**
* Toggle a ruby text mark on the current selection.
* @example editor.commands.toggleRubyText({ rt: 'かんじ' })
*/
toggleRubyText: (attributes: RubyTextAttributes) => ReturnType
/**
* Remove the ruby text mark from the current selection.
* @example editor.commands.unsetRubyText()
*/
unsetRubyText: () => ReturnType
}
}
}
/**
* This extension adds support for HTML ruby text annotations.
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/ruby
*/
export const RubyText = Mark.create<RubyTextOptions>({
name: 'rubyText',
inclusive: false,
addOptions() {
return {
HTMLAttributes: {},
allowClickToEdit: true,
renderAnnotationEditor: undefined,
}
},
addAttributes() {
return {
rt: {
default: null,
},
}
},
parseHTML() {
return [
{
tag: 'ruby',
contentElement: (node: HTMLElement) => {
const rb = node.querySelector('rb')
if (rb) {
return rb
}
const surrogate = document.createElement('span')
Array.from(node.childNodes).forEach(child => {
if (child.nodeName !== 'RT' && child.nodeName !== 'RP') {
surrogate.appendChild(child.cloneNode(true))
}
})
return surrogate
},
getAttrs: (node: HTMLElement) => {
const rt = node.querySelector('rt')
if (!rt) {
return false
}
return { rt: rt.textContent ?? '' }
},
},
]
},
renderHTML({ HTMLAttributes, mark }) {
return [
'ruby',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
['rb', 0],
...(mark.attrs.rt == null ? [] : [['rt', { contenteditable: 'false' }, mark.attrs.rt]]),
]
},
addCommands() {
return {
setRubyText:
attributes =>
({ commands }) => {
return commands.setMark(this.name, attributes)
},
toggleRubyText:
attributes =>
({ commands }) => {
return commands.toggleMark(this.name, attributes, { extendEmptyMarkRange: true })
},
unsetRubyText:
() =>
({ commands }) => {
return commands.unsetMark(this.name, { extendEmptyMarkRange: true })
},
}
},
addProseMirrorPlugins() {
return [
RubyTextDecorationPlugin(this.type, {
editor: this.editor,
allowClickToEdit: this.options.allowClickToEdit,
renderAnnotationEditor: this.options.renderAnnotationEditor,
}),
]
},
addMarkView() {
return ({ HTMLAttributes }) => {
const ruby = document.createElement('ruby')
Object.entries(mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)).forEach(
([key, value]) => {
if (value != null) {
ruby.setAttribute(key, String(value))
}
},
)
return {
dom: ruby,
contentDOM: ruby,
}
}
},
})

View File

@@ -0,0 +1,489 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { afterEach, describe, expect, it } from 'vite-plus/test'
import { RubyText, type RubyTextOptions } from '../../src/ruby-text.js'
let container: HTMLElement | undefined
function createEditor(content = '<p></p>', options: Partial<RubyTextOptions> = {}) {
const element = document.createElement('div')
document.body.appendChild(element)
container = element
return new Editor({
element,
extensions: [Document, Paragraph, Text, RubyText.configure(options)],
content,
})
}
describe('RubyText', () => {
let editor: Editor
afterEach(() => {
editor?.destroy()
container?.remove()
container = undefined
})
it('stores base text in the document and annotation in mark attributes', () => {
editor = createEditor('<p>漢字</p>')
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
expect(editor.getJSON()).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '漢字',
marks: [{ type: 'rubyText', attrs: { rt: 'かんじ' } }],
},
],
},
],
})
})
it('sets, toggles, and unsets the ruby mark', () => {
editor = createEditor('<p>漢字</p>')
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
expect(editor.isActive('rubyText')).toBe(true)
editor.commands.selectAll()
editor.commands.toggleRubyText({ rt: 'かんじ' })
expect(editor.isActive('rubyText')).toBe(false)
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
editor.commands.selectAll()
editor.commands.unsetRubyText()
expect(editor.getHTML()).toBe('<p>漢字</p>')
})
it('updates the annotation on an existing mark', () => {
editor = createEditor('<p>漢字</p>')
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ(新)' })
expect(editor.getHTML()).toContain('かんじ(新)')
})
it('renders a non-editable rt element', () => {
editor = createEditor('<p>漢字</p>')
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement
expect(editor.getHTML()).toContain('<rt contenteditable="false">かんじ</rt>')
expect(rt.contentEditable).toBe('false')
expect(rt.parentElement?.tagName).toBe('RUBY')
})
it('applies configured HTML attributes to the mark view', () => {
editor = createEditor('<p>漢字</p>', { HTMLAttributes: { class: 'ruby-text' } })
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
expect(editor.view.dom.querySelector('ruby')?.className).toBe('ruby-text')
})
it('preserves an empty annotation', () => {
editor = createEditor('<p>漢字</p>')
editor.commands.selectAll()
editor.commands.setRubyText({ rt: '' })
expect(editor.getHTML()).not.toContain('data-rt')
expect(editor.getHTML()).toContain('<rt contenteditable="false"></rt>')
})
it('does not render an annotation when it is null', () => {
editor = createEditor('<p>漢字</p>')
editor.commands.selectAll()
editor.commands.setRubyText({ rt: null })
expect(editor.getHTML()).toBe('<p><ruby><rb>漢字</rb></ruby></p>')
})
it('updates an annotation after submitting its inline editor', () => {
editor = createEditor('<p>漢字</p>')
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement
rt.click()
const input = rt.querySelector('input') as HTMLInputElement
input.value = 'かんじ(新)'
input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }))
expect(editor.getHTML()).toContain('かんじ(新)')
expect(editor.view.dom.querySelector('ruby > rt')?.textContent).toBe('かんじ(新)')
expect(editor.state.selection.from).toBe(3)
})
it('closes the editor when submitting an unchanged annotation', () => {
editor = createEditor('<p>漢字</p>')
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement
rt.click()
const input = rt.querySelector('input') as HTMLInputElement
input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }))
expect(rt.querySelector('input')).toBeNull()
expect(rt.textContent).toBe('かんじ')
expect(editor.state.selection.from).toBe(3)
})
it('does not open an inline editor when click editing is disabled', () => {
editor = createEditor('<p>漢字</p>', { allowClickToEdit: false })
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement
rt.click()
expect(rt.querySelector('input')).toBeNull()
})
it('does not open an inline editor when the editor is not editable', () => {
editor = createEditor('<p>漢字</p>')
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
editor.setEditable(false)
const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement
rt.click()
expect(rt.querySelector('input')).toBeNull()
expect(editor.getHTML()).toContain('かんじ')
})
it('discards an annotation edit when dismissed', () => {
editor = createEditor('<p>漢字</p>')
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement
rt.click()
const input = rt.querySelector('input') as HTMLInputElement
input.value = 'かんじ(新)'
input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }))
expect(editor.getHTML()).toContain('かんじ')
expect(editor.getHTML()).not.toContain('かんじ(新)')
expect(rt.textContent).toBe('かんじ')
expect(editor.state.selection.from).toBe(3)
})
it('discards an annotation edit when it loses focus', () => {
editor = createEditor('<p>漢字</p>')
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement
rt.click()
const input = rt.querySelector('input') as HTMLInputElement
input.value = 'かんじ(新)'
input.blur()
expect(editor.getHTML()).toContain('かんじ')
expect(editor.getHTML()).not.toContain('かんじ(新)')
expect(rt.textContent).toBe('かんじ')
expect(editor.state.selection.from).toBe(3)
})
it('focuses the default input after opening the inline editor', () => {
editor = createEditor('<p>漢字</p>')
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement
rt.click()
expect(document.activeElement).toBe(rt.querySelector('input'))
})
it('ignores Enter while an IME composition is in progress', () => {
editor = createEditor('<p>漢字</p>')
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement
rt.click()
const input = rt.querySelector('input') as HTMLInputElement
input.value = 'かんじ(新)'
input.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'Enter', isComposing: true }),
)
expect(editor.getHTML()).not.toContain('かんじ(新)')
expect(rt.querySelector('input')).toBe(input)
})
it('renders a custom annotation editor instead of the default input', () => {
editor = createEditor('<p>漢字</p>', {
renderAnnotationEditor: () => {
const button = document.createElement('button')
button.dataset.testid = 'custom-editor'
return button
},
})
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement
rt.click()
expect(rt.querySelector('[data-testid="custom-editor"]')).not.toBeNull()
expect(rt.querySelector('input')).toBeNull()
})
it('focuses the autofocus control inside a custom editor', () => {
editor = createEditor('<p>漢字</p>', {
renderAnnotationEditor: () => {
const wrapper = document.createElement('span')
const input = document.createElement('input')
input.autofocus = true
wrapper.appendChild(input)
return wrapper
},
})
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement
rt.click()
expect(document.activeElement).toBe(rt.querySelector('input'))
})
it('passes the annotation and editor instance to the custom editor', () => {
let receivedAnnotation: string | undefined
let receivedEditor: Editor | undefined
editor = createEditor('<p>漢字</p>', {
renderAnnotationEditor: ({ annotation, editor: editorInstance }) => {
receivedAnnotation = annotation
receivedEditor = editorInstance
return document.createElement('span')
},
})
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement
rt.click()
expect(receivedAnnotation).toBe('かんじ')
expect(receivedEditor).toBe(editor)
})
it('updates the annotation when a custom editor submits a value', () => {
let submit: ((value: string) => void) | undefined
editor = createEditor('<p>漢字</p>', {
renderAnnotationEditor: props => {
submit = props.submit
return document.createElement('span')
},
})
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement
rt.click()
submit?.('かんじ(新)')
expect(editor.getHTML()).toContain('かんじ(新)')
expect(editor.view.dom.querySelector('ruby > rt')?.textContent).toBe('かんじ(新)')
expect(editor.state.selection.from).toBe(3)
})
it('restores the annotation when a custom editor dismisses', () => {
let dismiss: (() => void) | undefined
editor = createEditor('<p>漢字</p>', {
renderAnnotationEditor: props => {
dismiss = props.dismiss
const span = document.createElement('span')
span.dataset.testid = 'custom-editor'
return span
},
})
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement
rt.click()
dismiss?.()
expect(editor.getHTML()).toContain('かんじ')
expect(rt.textContent).toBe('かんじ')
expect(rt.querySelector('[data-testid="custom-editor"]')).toBeNull()
expect(editor.state.selection.from).toBe(3)
})
it('ignores submit and dismiss after the editor is closed', () => {
let submit: ((value: string) => void) | undefined
let dismiss: (() => void) | undefined
let updates = 0
editor = createEditor('<p>漢字</p>', {
renderAnnotationEditor: props => {
submit = props.submit
dismiss = props.dismiss
return document.createElement('span')
},
})
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
editor.on('update', () => {
updates += 1
})
const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement
rt.click()
submit?.('かんじ(新)')
submit?.('かんじ(無視)')
dismiss?.()
expect(updates).toBe(1)
expect(editor.getHTML()).toContain('かんじ(新)')
expect(editor.getHTML()).not.toContain('かんじ(無視)')
})
it('dismisses instead of submitting when the editor became non-editable', () => {
let submit: ((value: string) => void) | undefined
editor = createEditor('<p>漢字</p>', {
renderAnnotationEditor: props => {
submit = props.submit
return document.createElement('span')
},
})
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement
rt.click()
editor.setEditable(false)
expect(submit).toBeDefined()
submit?.('かんじ(新)')
expect(editor.getHTML()).toContain('かんじ')
expect(editor.getHTML()).not.toContain('かんじ(新)')
})
it('ignores submit when the document changed while editing', () => {
let submit: ((value: string) => void) | undefined
editor = createEditor('<p>漢字</p>', {
renderAnnotationEditor: props => {
submit = props.submit
return document.createElement('span')
},
})
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement
rt.click()
expect(submit).toBeDefined()
// Shift the ruby range so the captured submit becomes stale.
editor.commands.insertContentAt(1, 'あ')
submit?.('かんじ(新)')
expect(editor.getHTML()).toContain('かんじ')
expect(editor.getHTML()).not.toContain('かんじ(新)')
})
it('does not invoke the custom editor when click editing is disabled', () => {
let invoked = false
editor = createEditor('<p>漢字</p>', {
allowClickToEdit: false,
renderAnnotationEditor: () => {
invoked = true
return document.createElement('span')
},
})
editor.commands.selectAll()
editor.commands.setRubyText({ rt: 'かんじ' })
const rt = editor.view.dom.querySelector('ruby > rt') as HTMLElement
rt.click()
expect(invoked).toBe(false)
})
it('parses ruby HTML with rb and rt elements', () => {
editor = createEditor('<p><ruby><rb>漢字</rb><rt>かんじ</rt></ruby></p>')
expect(editor.getJSON()).toMatchObject({
content: [
{
content: [
{
text: '漢字',
marks: [{ type: 'rubyText', attrs: { rt: 'かんじ' } }],
},
],
},
],
})
})
it('parses native ruby HTML without an rb element', () => {
editor = createEditor('<p><ruby>東京<rt>とうきょう</rt></ruby>は日本の首都です。</p>')
expect(editor.getJSON()).toMatchObject({
content: [
{
content: [
{
text: '東京',
marks: [{ type: 'rubyText', attrs: { rt: 'とうきょう' } }],
},
{
text: 'は日本の首都です。',
},
],
},
],
})
})
it('does not parse ruby HTML without an annotation as a mark', () => {
editor = createEditor('<p><ruby>漢字</ruby></p>')
expect(editor.getJSON()).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '漢字' }],
},
],
})
})
})

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite-plus'
import { basePackConfig } from '../../pack.config.mjs'
export default defineConfig({
pack: {
entry: ['src/index.ts'],
...basePackConfig(),
},
})