Files
llm-in-text/src/plugins/hiddenTextPlugin.ts

246 lines
7.1 KiB
TypeScript
Raw Normal View History

import { createApp, h, reactive } from 'vue'
import { Plugin, PluginKey } from '@milkdown/prose/state'
import { $node, $prose, $remark, $view } from '@milkdown/kit/utils'
import type { Node as ProseNode } from '@milkdown/prose/model'
import type { EditorView, NodeView } from '@milkdown/prose/view'
import HiddenTextCrepe from '../components/HiddenTextCrepe.vue'
import { HIDDEN_TEXT_NODE_TYPE, parseHiddenTextAt, serializeHiddenTextSyntax, splitTextWithHiddenSyntax } from '../utils/hiddenText.js'
const HIDDEN_TEXT_INPUT_PLUGIN_KEY = new PluginKey('milkdown-hidden-text-input')
const HIDDEN_TEXT_INPUT_META = 'hidden-text-input-meta'
function transformHiddenTextChildren(node: any) {
if (!node || !Array.isArray(node.children)) return
const nextChildren: any[] = []
for (const child of node.children) {
if (child?.type === 'text' && typeof child.value === 'string') {
const segments = splitTextWithHiddenSyntax(child.value)
if (segments) {
nextChildren.push(...segments)
continue
}
}
transformHiddenTextChildren(child)
nextChildren.push(child)
}
node.children = nextChildren
}
function findHiddenTextReplacements(doc: ProseNode) {
const replacements: Array<{ from: number; to: number; displayed: string; hidden: string; marks: ProseNode['marks'] }> = []
doc.descendants((node, pos, parent) => {
if (!node.isText || !node.text) return true
if (parent?.type.spec.code) return true
if (node.marks.some((mark) => mark.type.spec.code)) return true
let index = 0
while (index < node.text.length) {
const match = parseHiddenTextAt(node.text, index)
if (!match) {
index += 1
continue
}
replacements.push({
from: pos + match.start,
to: pos + match.end,
displayed: match.displayed,
hidden: match.hidden,
marks: node.marks,
})
index = match.end
}
return true
})
return replacements
}
class HiddenTextNodeView implements NodeView {
node: ProseNode
view: EditorView
getPos: (() => number) | boolean
dom: HTMLElement
app: ReturnType<typeof createApp> | null = null
props: Record<string, any>
constructor(node: ProseNode, view: EditorView, getPos: (() => number) | boolean) {
this.node = node
this.view = view
this.getPos = getPos
this.dom = document.createElement('span')
this.dom.className = 'hidden-text-node-view'
this.dom.setAttribute('contenteditable', 'false')
this.props = reactive({
displayed: node.attrs.displayed,
hidden: node.attrs.hidden,
expanded: node.attrs.expanded,
updateTexts: ({ displayed, hidden }: { displayed: string; hidden: string }) => {
this.updateAttrs({ displayed, hidden })
},
updateExpanded: (expanded: boolean) => this.updateAttrs({ expanded }),
})
this.mount()
}
mount() {
this.app = createApp({
render: () => h(HiddenTextCrepe, this.props),
})
this.app.mount(this.dom)
}
getPosValue() {
if (typeof this.getPos === 'function') {
try {
const pos = this.getPos()
if (typeof pos === 'number') return pos
} catch {
// Fall back to DOM-based resolution below.
}
}
try {
const pos = this.view.posAtDOM(this.dom, 0)
return typeof pos === 'number' ? pos : undefined
} catch {
return undefined
}
}
updateAttrs(patch: Record<string, any>) {
const pos = this.getPosValue()
if (pos === undefined) return
const nextAttrs = { ...this.node.attrs, ...patch }
const nextNode = this.node.type.create(nextAttrs, undefined, this.node.marks)
this.view.dispatch(this.view.state.tr.replaceWith(pos, pos + this.node.nodeSize, nextNode))
}
update(node: ProseNode) {
if (node.type !== this.node.type) return false
this.node = node
this.props.displayed = node.attrs.displayed
this.props.hidden = node.attrs.hidden
this.props.expanded = node.attrs.expanded
return true
}
stopEvent(event: Event) {
const target = event.target as Node | null
return Boolean(target && this.dom.contains(target))
}
ignoreMutation() {
return true
}
destroy() {
this.app?.unmount()
this.app = null
}
}
export const hiddenTextRemark = $remark('hiddenTextRemark', () => () => {
return (tree: any) => {
transformHiddenTextChildren(tree)
}
})
export const hiddenTextNode = $node(HIDDEN_TEXT_NODE_TYPE, () => ({
group: 'inline',
inline: true,
atom: true,
selectable: true,
draggable: false,
attrs: {
displayed: { default: '' },
hidden: { default: '' },
expanded: { default: false },
},
parseDOM: [
{
tag: 'span[data-hidden-text="true"]',
getAttrs: (dom) => ({
displayed: (dom as HTMLElement).getAttribute('data-hidden-display') || '',
hidden: (dom as HTMLElement).getAttribute('data-hidden-value') || '',
expanded: ((dom as HTMLElement).getAttribute('data-hidden-expanded') || '') === 'true',
}),
},
],
toDOM: (node) => [
'span',
{
'data-hidden-text': 'true',
'data-hidden-display': node.attrs.displayed,
'data-hidden-value': node.attrs.hidden,
'data-hidden-expanded': String(Boolean(node.attrs.expanded)),
},
],
parseMarkdown: {
match: (node) => node.type === HIDDEN_TEXT_NODE_TYPE,
runner: (state, node, type) => {
state.addNode(type, {
displayed: String(node.displayed || ''),
hidden: String(node.hidden || ''),
expanded: false,
})
},
},
toMarkdown: {
match: (node) => node.type.name === HIDDEN_TEXT_NODE_TYPE,
runner: (state, node) => {
state.addNode('text', undefined, serializeHiddenTextSyntax(node.attrs.displayed, node.attrs.hidden))
},
},
leafText: (node) => serializeHiddenTextSyntax(node.attrs.displayed, node.attrs.hidden),
}))
export const hiddenTextView = $view(hiddenTextNode, () => {
return (node, view, getPos) => new HiddenTextNodeView(node, view, getPos)
})
export const hiddenTextInputPlugin = $prose(() => {
return new Plugin({
key: HIDDEN_TEXT_INPUT_PLUGIN_KEY,
appendTransaction: (transactions, _oldState, newState) => {
if (!transactions.some((transaction) => transaction.docChanged)) return null
if (transactions.some((transaction) => transaction.getMeta(HIDDEN_TEXT_INPUT_META))) return null
const hiddenTextType = newState.schema.nodes[HIDDEN_TEXT_NODE_TYPE]
if (!hiddenTextType) return null
const replacements = findHiddenTextReplacements(newState.doc)
if (replacements.length === 0) return null
let tr = newState.tr
for (let index = replacements.length - 1; index >= 0; index -= 1) {
const replacement = replacements[index]
tr = tr.replaceWith(
replacement.from,
replacement.to,
hiddenTextType.create(
{
displayed: replacement.displayed,
hidden: replacement.hidden,
expanded: false,
},
undefined,
replacement.marks
)
)
}
if (!tr.docChanged) return null
tr.setMeta(HIDDEN_TEXT_INPUT_META, true)
return tr
},
})
})