(null)
const save = useCallback(async (text: string) => {
const next = oneLine(text)
if (next === saved.current || next === pending.current) return
if (!next && !latest.current.clearable) return
pending.current = next
try {
if (await latest.current.onCommit(next)) saved.current = next
} finally {
if (pending.current === next) pending.current = null
}
}, [])
const editor = useEditor({
extensions: [OneLine, Paragraph, Text, Undo],
content: doc(value),
editable: !readOnly,
editorProps: {
attributes: {
'aria-label': label,
role: 'textbox',
'aria-multiline': 'false',
'aria-placeholder': placeholder,
...(readOnly ? { 'aria-readonly': 'true' } : {}),
class: 'outline-none',
},
handleKeyDown: (view, event) => {
if (event.key === 'Enter') {
event.preventDefault()
void save(view.state.doc.textContent)
latest.current.onEnter?.()
return true
}
if (event.key === 'Escape') {
view.dom.blur()
return true
}
return false
},
// A paste is its plain text on this one line, whatever it was copied from.
handlePaste: (view, event) => {
const text = event.clipboardData?.getData('text/plain')
if (text == null) return false
view.dispatch(view.state.tr.insertText(text.replace(/\s+/g, ' ')))
return true
},
},
onFocus: ({ editor: e }) => latest.current.onText?.(e.getText()),
onUpdate: ({ editor: e }) => {
setEmpty(e.isEmpty)
latest.current.onText?.(e.getText())
},
onBlur: ({ editor: e }) => {
latest.current.onText?.(null)
// Left empty where empty is not a value: the saved text comes back, so
// the page never shows the placeholder while everyone else sees the text.
if (e.isEmpty && saved.current && !latest.current.clearable) {
e.commands.setContent(doc(saved.current), { emitUpdate: false })
setEmpty(false)
return
}
// Left unchanged while someone else changed it: their text comes in now,
// as it would have had you not been in it.
const { value: current } = latest.current
if (oneLine(e.getText()) === saved.current && current !== saved.current && pending.current === null) {
e.commands.setContent(doc(current), { emitUpdate: false })
saved.current = current
setEmpty(!current)
return
}
void save(e.getText())
},
}, [readOnly])
// A change made elsewhere is loaded while you are not in the field and hold
// nothing unsaved; your own save coming back is left alone.
useEffect(() => {
if (!editor || editor.isDestroyed || value === saved.current || editor.isFocused) return
if (oneLine(editor.getText()) !== saved.current) return
editor.commands.setContent(doc(value), { emitUpdate: false })
saved.current = value
setEmpty(!value)
}, [editor, value])
useImperativeHandle(ref, () => ({
focus: (at = 'start') => editor?.commands.focus(at),
}), [editor])
return (
{/* Empty — never saved, or just deleted — shows what it is for, muted,
and is not part of the text. Drawn over the editor, where the text starts. */}
{empty && !readOnly && (
{placeholder}
)}
)
}