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
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:
5
packages/extension-ruby-text/src/index.ts
Normal file
5
packages/extension-ruby-text/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { RubyText } from './ruby-text.js'
|
||||
|
||||
export * from './ruby-text.js'
|
||||
|
||||
export default RubyText
|
||||
295
packages/extension-ruby-text/src/ruby-text-decoration-plugin.ts
Normal file
295
packages/extension-ruby-text/src/ruby-text-decoration-plugin.ts
Normal 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),
|
||||
},
|
||||
})
|
||||
}
|
||||
174
packages/extension-ruby-text/src/ruby-text.ts
Normal file
174
packages/extension-ruby-text/src/ruby-text.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user