Stabilize pro editing without heavy office runtime
The workspace now carries the pro editing flow, streaming completion path, and lighter Office preview state as one checkpoint so the remote has the current runnable project shape. Constraint: Preserve the current workspace as a single reviewable project commit while excluding local agent state and verification artifacts. Removed stale Univer runtime dependencies from the lockfile so installs match package.json. Rejected: Commit runtime screenshots, .omx state, and coverage files | they are local artifacts rather than source state. Confidence: medium Scope-risk: broad Directive: Keep package.json and package-lock.json synchronized when changing frontend dependencies. Tested: npm run build; C:\Users\ydy\.conda\envs\llmwebsite\python.exe -m pytest backend/tests/test_main_endpoints.py backend/tests/test_main_cancel.py backend/tests/test_llm.py backend/tests/test_llm_extended.py -v -o addopts= (44 passed). Not-tested: Full pytest with repository coverage addopts currently reports 0% coverage because pytest-cov watches backend.* module names while tests import top-level backend modules. Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
665
src/plugins/proBlockPlugin.ts
Normal file
665
src/plugins/proBlockPlugin.ts
Normal file
@@ -0,0 +1,665 @@
|
||||
import { createApp, h, reactive } from 'vue'
|
||||
import { parserCtx, serializerCtx } from '@milkdown/kit/core'
|
||||
import { $ctx, $node, $prose, $remark, $view } from '@milkdown/kit/utils'
|
||||
import { Plugin, PluginKey, Selection } from '@milkdown/prose/state'
|
||||
import { type Node as ProseNode, Slice, type Schema } from '@milkdown/prose/model'
|
||||
import { Decoration, DecorationSet, type EditorView, type NodeView } from '@milkdown/prose/view'
|
||||
import ProBlockCrepe from '../components/ProBlockCrepe.vue'
|
||||
import { extractDocBlockContextFromMarkdown } from '../utils/docBlock.js'
|
||||
import { extractTextFromOCR, getOcrCache } from '../utils/ocrCache'
|
||||
import { PRO_BLOCK_NODE_TYPE, PRO_DISPLAY_LABEL, PRO_TRIGGER_TEXT, parseProBlockSyntax, serializeProBlockSyntax } from '../utils/proBlock.js'
|
||||
|
||||
const PRO_BLOCK_INPUT_PLUGIN_KEY = new PluginKey('milkdown-pro-block-input')
|
||||
const PRO_BLOCK_HIGHLIGHT_PLUGIN_KEY = new PluginKey<DecorationSet>('milkdown-pro-block-highlight')
|
||||
const PRO_BLOCK_INPUT_META = 'pro-block-input-meta'
|
||||
const PRO_CONTEXT_LIMIT = 32 * 1024
|
||||
const FALLBACK_BLOCK_SEPARATOR = '\n\n'
|
||||
const FALLBACK_LEAF_TEXT = '\n'
|
||||
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
|
||||
|
||||
interface ProBlockConfig {
|
||||
fetchSuggestionStream: (payload: {
|
||||
prefix: string
|
||||
suffix: string
|
||||
languageId: string
|
||||
signal?: AbortSignal
|
||||
model?: string
|
||||
temperature?: number
|
||||
onChunk?: (chunk: string) => void
|
||||
}) => Promise<string>
|
||||
getProModel: () => string
|
||||
showError: (message: string) => void
|
||||
isThinkingActive: () => boolean
|
||||
setThinkingActive: (value: boolean) => void
|
||||
}
|
||||
|
||||
interface ProRequestPayload {
|
||||
prefix: string
|
||||
suffix: string
|
||||
languageId: string
|
||||
blocked: boolean
|
||||
}
|
||||
|
||||
function serializeRangeToMarkdown(
|
||||
doc: ProseNode,
|
||||
from: number,
|
||||
to: number,
|
||||
schema: Schema,
|
||||
serializer: (content: ProseNode) => string
|
||||
): string {
|
||||
if (from >= to) return ''
|
||||
const fallback = doc.textBetween(from, to, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
|
||||
if (typeof serializer !== 'function') return fallback
|
||||
|
||||
const slice = doc.slice(from, to)
|
||||
if (slice.content.size <= 0) return ''
|
||||
|
||||
try {
|
||||
const sliceDoc = schema.topNodeType.createAndFill(undefined, slice.content)
|
||||
return sliceDoc ? serializer(sliceDoc) : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function transformProBlockChildren(node: any) {
|
||||
if (!node || !Array.isArray(node.children)) return
|
||||
|
||||
node.children = node.children.map((child: any) => {
|
||||
const replacement = parseProParagraphNode(child)
|
||||
if (replacement) return replacement
|
||||
transformProBlockChildren(child)
|
||||
return child
|
||||
})
|
||||
}
|
||||
|
||||
function parseProParagraphNode(node: any) {
|
||||
if (!node || node.type !== 'paragraph' || !Array.isArray(node.children)) return null
|
||||
if (node.children.length !== 1) return null
|
||||
|
||||
const textNode = node.children[0]
|
||||
if (!textNode || textNode.type !== 'text' || typeof textNode.value !== 'string') return null
|
||||
|
||||
const parsed = parseProBlockSyntax(textNode.value)
|
||||
if (!parsed) return null
|
||||
|
||||
return {
|
||||
type: PRO_BLOCK_NODE_TYPE,
|
||||
content: parsed.content,
|
||||
autoStart: false,
|
||||
}
|
||||
}
|
||||
|
||||
function findProBlockReplacements(doc: ProseNode) {
|
||||
const replacements: Array<{ from: number; to: number; content: string; autoStart: boolean }> = []
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name !== 'paragraph' || node.childCount !== 1) return true
|
||||
|
||||
const firstChild = node.firstChild
|
||||
if (!firstChild?.isText) return true
|
||||
|
||||
const parsed = parseProBlockSyntax(node.textContent)
|
||||
if (!parsed) return true
|
||||
|
||||
replacements.push({
|
||||
from: pos,
|
||||
to: pos + node.nodeSize,
|
||||
content: parsed.content,
|
||||
autoStart: parsed.autoStart,
|
||||
})
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
return replacements
|
||||
}
|
||||
|
||||
function buildOcrContext(doc: ProseNode) {
|
||||
const lines: string[] = []
|
||||
|
||||
doc.descendants((node) => {
|
||||
if (!IMAGE_NODE_TYPES.has(node.type.name)) return true
|
||||
const src = typeof node.attrs?.src === 'string' ? node.attrs.src : ''
|
||||
if (!src) return true
|
||||
const ocrText = getOcrCache(src)
|
||||
if (!ocrText) return true
|
||||
|
||||
const preview = extractTextFromOCR(ocrText, 120)
|
||||
if (!preview) return true
|
||||
|
||||
const label = typeof node.attrs?.alt === 'string' && node.attrs.alt.trim() ? node.attrs.alt.trim() : 'image'
|
||||
lines.push(` <OCR:${preview}>`)
|
||||
return true
|
||||
})
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function createHighlightDecorations(doc: ProseNode, from: number, to: number) {
|
||||
if (to <= from) return DecorationSet.empty
|
||||
|
||||
const decorations = [
|
||||
Decoration.inline(from, to, { class: 'pro-block-accepted-highlight' }),
|
||||
]
|
||||
|
||||
doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (!node.isBlock) return true
|
||||
const nodeFrom = pos
|
||||
const nodeTo = pos + node.nodeSize
|
||||
if (nodeFrom >= from && nodeTo <= to) {
|
||||
decorations.push(Decoration.node(nodeFrom, nodeTo, { class: 'pro-block-accepted-block' }))
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
|
||||
function replaceWithParsedMarkdownSlice(tr: any, from: number, to: number, parsedDoc: ProseNode) {
|
||||
if (!parsedDoc || parsedDoc.content.size <= 0) return null
|
||||
|
||||
const parsedSlice = Slice.maxOpen(parsedDoc.content)
|
||||
if (!parsedSlice || parsedSlice.size <= 0) return null
|
||||
|
||||
tr.replaceRange(from, to, parsedSlice)
|
||||
const startPos = tr.mapping.map(from, -1)
|
||||
const endPos = tr.mapping.map(to, 1)
|
||||
if (endPos <= startPos) return null
|
||||
return { from: startPos, to: endPos }
|
||||
}
|
||||
|
||||
function insertProBlockNode(view: EditorView, autoStart: boolean) {
|
||||
const proBlockType = view.state.schema.nodes[PRO_BLOCK_NODE_TYPE]
|
||||
if (!proBlockType) return false
|
||||
|
||||
const { from, to } = view.state.selection
|
||||
const blockNode = proBlockType.create({ content: '', autoStart })
|
||||
const tr = view.state.tr.replaceRangeWith(from, to, blockNode)
|
||||
const nextPos = Math.min(from + blockNode.nodeSize, tr.doc.content.size)
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(nextPos), 1))
|
||||
view.dispatch(tr.scrollIntoView())
|
||||
view.focus()
|
||||
return true
|
||||
}
|
||||
|
||||
function replaceParagraphWithProBlock(
|
||||
view: EditorView,
|
||||
from: number,
|
||||
to: number,
|
||||
content: string,
|
||||
autoStart: boolean
|
||||
) {
|
||||
const proBlockType = view.state.schema.nodes[PRO_BLOCK_NODE_TYPE]
|
||||
if (!proBlockType) return false
|
||||
|
||||
const blockNode = proBlockType.create({
|
||||
content,
|
||||
autoStart: autoStart && content === '',
|
||||
})
|
||||
const tr = view.state.tr.replaceWith(from, to, blockNode)
|
||||
const nextPos = Math.min(from + blockNode.nodeSize, tr.doc.content.size)
|
||||
tr.setMeta(PRO_BLOCK_INPUT_META, true)
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(nextPos), 1))
|
||||
view.dispatch(tr.scrollIntoView())
|
||||
view.focus()
|
||||
return true
|
||||
}
|
||||
|
||||
function tryHandleProTriggerTextInput(view: EditorView, insertedText: string) {
|
||||
if (!insertedText || !insertedText.includes(']')) return false
|
||||
|
||||
const { state } = view
|
||||
const { $from, from, to } = state.selection
|
||||
const paragraph = $from.parent
|
||||
if (!paragraph || paragraph.type.name !== 'paragraph') return false
|
||||
if (!$from.sameParent(state.selection.$to)) return false
|
||||
|
||||
const paragraphDepth = $from.depth
|
||||
const paragraphStart = $from.start(paragraphDepth)
|
||||
const startOffset = from - paragraphStart
|
||||
const endOffset = to - paragraphStart
|
||||
const nextText = `${paragraph.textContent.slice(0, startOffset)}${insertedText}${paragraph.textContent.slice(endOffset)}`
|
||||
const parsed = parseProBlockSyntax(nextText)
|
||||
if (!parsed?.autoStart || parsed.content) return false
|
||||
|
||||
const blockFrom = $from.before(paragraphDepth)
|
||||
const blockTo = blockFrom + paragraph.nodeSize
|
||||
return replaceParagraphWithProBlock(view, blockFrom, blockTo, parsed.content, parsed.autoStart)
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown) {
|
||||
return Boolean(error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError')
|
||||
}
|
||||
|
||||
export const proBlockConfigCtx = $ctx<ProBlockConfig, 'proBlockConfig'>({
|
||||
fetchSuggestionStream: async () => '',
|
||||
getProModel: () => '',
|
||||
showError: () => {},
|
||||
isThinkingActive: () => false,
|
||||
setThinkingActive: () => {},
|
||||
}, 'proBlockConfig')
|
||||
|
||||
class ProBlockNodeView implements NodeView {
|
||||
node: ProseNode
|
||||
view: EditorView
|
||||
getPos: (() => number) | boolean
|
||||
dom: HTMLElement
|
||||
app: ReturnType<typeof createApp> | null = null
|
||||
props: Record<string, any>
|
||||
parser: (markdown: string) => Promise<ProseNode>
|
||||
serializer: (content: ProseNode) => string
|
||||
config: ProBlockConfig
|
||||
abortController: AbortController | null = null
|
||||
requestSeq = 0
|
||||
redoCount = 0
|
||||
destroyed = false
|
||||
highlightTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
constructor(
|
||||
node: ProseNode,
|
||||
view: EditorView,
|
||||
getPos: (() => number) | boolean,
|
||||
parser: (markdown: string) => Promise<ProseNode>,
|
||||
serializer: (content: ProseNode) => string,
|
||||
config: ProBlockConfig
|
||||
) {
|
||||
this.node = node
|
||||
this.view = view
|
||||
this.getPos = getPos
|
||||
this.parser = parser
|
||||
this.serializer = serializer
|
||||
this.config = config
|
||||
this.dom = document.createElement('div')
|
||||
this.dom.className = 'pro-block-node-view'
|
||||
this.props = reactive({
|
||||
stage: node.attrs.content ? 'done' : 'idle',
|
||||
previewContent: '',
|
||||
savedContent: node.attrs.content || '',
|
||||
isBusy: false,
|
||||
title: PRO_DISPLAY_LABEL,
|
||||
activateAction: () => {
|
||||
void this.startThinking(false)
|
||||
},
|
||||
discardAction: () => {
|
||||
this.discardResult()
|
||||
},
|
||||
redoAction: () => {
|
||||
void this.startThinking(true)
|
||||
},
|
||||
acceptAction: () => {
|
||||
void this.acceptResult()
|
||||
},
|
||||
})
|
||||
this.mount()
|
||||
queueMicrotask(() => {
|
||||
if (!this.destroyed) {
|
||||
this.consumeAutoStart()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
mount() {
|
||||
this.app = createApp({
|
||||
render: () => h(ProBlockCrepe, { ...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 {
|
||||
// Ignore and fall back to DOM lookup.
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
this.view.dispatch(this.view.state.tr.setNodeMarkup(pos, undefined, nextAttrs))
|
||||
}
|
||||
|
||||
setStage(stage: 'idle' | 'thinking' | 'streaming' | 'done', previewContent = '') {
|
||||
this.props.stage = stage
|
||||
this.props.previewContent = previewContent
|
||||
this.props.isBusy = stage === 'thinking' || stage === 'streaming'
|
||||
}
|
||||
|
||||
buildRequestPayload(): ProRequestPayload {
|
||||
const pos = this.getPosValue()
|
||||
if (pos === undefined) {
|
||||
return {
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
languageId: 'markdown',
|
||||
blocked: true,
|
||||
}
|
||||
}
|
||||
|
||||
const doc = this.view.state.doc
|
||||
const schema = this.view.state.schema
|
||||
const prefixMarkdown = serializeRangeToMarkdown(doc, 0, pos, schema, this.serializer)
|
||||
|| doc.textBetween(0, pos, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
|
||||
const suffixStart = Math.min(pos + this.node.nodeSize, doc.content.size)
|
||||
const suffixMarkdown = serializeRangeToMarkdown(doc, suffixStart, doc.content.size, schema, this.serializer)
|
||||
|| doc.textBetween(suffixStart, doc.content.size, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
|
||||
|
||||
const ocrContext = buildOcrContext(doc)
|
||||
const docContext = extractDocBlockContextFromMarkdown(`${prefixMarkdown}\n\n${suffixMarkdown}`, 1600)
|
||||
const fullPrefix = [ocrContext, docContext, prefixMarkdown].filter(Boolean).join('\n\n')
|
||||
|
||||
return {
|
||||
prefix: fullPrefix,
|
||||
suffix: suffixMarkdown,
|
||||
languageId: 'markdown',
|
||||
blocked: fullPrefix.length + suffixMarkdown.length > PRO_CONTEXT_LIMIT,
|
||||
}
|
||||
}
|
||||
|
||||
consumeAutoStart() {
|
||||
if (!this.node.attrs.autoStart) return
|
||||
this.updateAttrs({ autoStart: false })
|
||||
void this.startThinking(false)
|
||||
}
|
||||
|
||||
stopThinkingLock() {
|
||||
this.config.setThinkingActive(false)
|
||||
}
|
||||
|
||||
abortActive(reason = 'abort') {
|
||||
if (!this.abortController) return
|
||||
this.abortController.abort(reason)
|
||||
this.abortController = null
|
||||
this.stopThinkingLock()
|
||||
}
|
||||
|
||||
discardResult() {
|
||||
this.abortActive('discard')
|
||||
this.redoCount = 0
|
||||
this.props.savedContent = ''
|
||||
this.setStage('idle', '')
|
||||
this.updateAttrs({ content: '', autoStart: false })
|
||||
}
|
||||
|
||||
async startThinking(isRedo: boolean) {
|
||||
if (this.props.isBusy) return
|
||||
const previousContent = this.node.attrs.content || ''
|
||||
let requestSeq = this.requestSeq
|
||||
let requestStarted = false
|
||||
|
||||
try {
|
||||
if (this.config.isThinkingActive()) {
|
||||
this.config.showError('当前已有一个 PRO 思考正在进行,请先等待它完成。')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = this.buildRequestPayload()
|
||||
if (payload.blocked) {
|
||||
this.config.showError('PRO 模式上下文超过 32KB,无法继续思考。')
|
||||
return
|
||||
}
|
||||
|
||||
requestSeq = this.requestSeq + 1
|
||||
this.requestSeq = requestSeq
|
||||
this.abortController = new AbortController()
|
||||
this.config.setThinkingActive(true)
|
||||
requestStarted = true
|
||||
this.setStage('thinking', '')
|
||||
|
||||
const temperature = Math.min(1.2, 0.7 + (isRedo ? (this.redoCount + 1) * 0.2 : 0))
|
||||
|
||||
const result = await this.config.fetchSuggestionStream({
|
||||
...payload,
|
||||
signal: this.abortController.signal,
|
||||
model: this.config.getProModel(),
|
||||
temperature,
|
||||
onChunk: (chunk) => {
|
||||
if (this.destroyed || this.requestSeq !== requestSeq) return
|
||||
const nextContent = `${this.props.previewContent || ''}${chunk}`
|
||||
this.setStage('streaming', nextContent)
|
||||
},
|
||||
})
|
||||
|
||||
if (this.destroyed || this.requestSeq !== requestSeq) return
|
||||
|
||||
const content = String(result || this.props.previewContent || '').trim()
|
||||
this.redoCount = isRedo ? this.redoCount + 1 : 0
|
||||
this.props.savedContent = content
|
||||
this.setStage(content ? 'done' : 'idle', '')
|
||||
this.updateAttrs({ content, autoStart: false })
|
||||
} catch (error) {
|
||||
if (!isAbortError(error)) {
|
||||
this.config.showError(error instanceof Error ? error.message : 'PRO 模式思考失败')
|
||||
}
|
||||
|
||||
this.props.savedContent = previousContent
|
||||
this.setStage(previousContent ? 'done' : 'idle', '')
|
||||
} finally {
|
||||
if (requestStarted && this.requestSeq === requestSeq) {
|
||||
this.abortController = null
|
||||
this.stopThinkingLock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async acceptResult() {
|
||||
if (this.props.isBusy) return
|
||||
|
||||
const source = String(this.props.savedContent || this.node.attrs.content || '').trim()
|
||||
if (!source) return
|
||||
|
||||
const pos = this.getPosValue()
|
||||
if (pos === undefined) return
|
||||
|
||||
const from = pos
|
||||
const to = pos + this.node.nodeSize
|
||||
const tr = this.view.state.tr
|
||||
let insertedRange: { from: number; to: number } | null = null
|
||||
|
||||
try {
|
||||
const parsedDoc = await this.parser(source)
|
||||
insertedRange = replaceWithParsedMarkdownSlice(tr, from, to, parsedDoc)
|
||||
} catch {
|
||||
insertedRange = null
|
||||
}
|
||||
|
||||
if (!insertedRange) {
|
||||
tr.insertText(source, from, to)
|
||||
insertedRange = { from, to: from + source.length }
|
||||
}
|
||||
|
||||
const endPos = Math.min(insertedRange.to, tr.doc.content.size)
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(endPos), 1))
|
||||
tr.setMeta(PRO_BLOCK_HIGHLIGHT_PLUGIN_KEY, { set: insertedRange })
|
||||
this.view.dispatch(tr.scrollIntoView())
|
||||
this.view.focus()
|
||||
|
||||
if (this.highlightTimer) {
|
||||
clearTimeout(this.highlightTimer)
|
||||
}
|
||||
this.highlightTimer = setTimeout(() => {
|
||||
if (this.destroyed) return
|
||||
this.view.dispatch(this.view.state.tr.setMeta(PRO_BLOCK_HIGHLIGHT_PLUGIN_KEY, { clear: true }))
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
update(node: ProseNode) {
|
||||
if (node.type !== this.node.type) return false
|
||||
this.node = node
|
||||
|
||||
if (!this.props.isBusy) {
|
||||
this.props.savedContent = node.attrs.content || ''
|
||||
this.props.previewContent = ''
|
||||
this.props.stage = node.attrs.content ? 'done' : 'idle'
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
stopEvent(event: Event) {
|
||||
const target = event.target as Node | null
|
||||
return Boolean(target && this.dom.contains(target))
|
||||
}
|
||||
|
||||
ignoreMutation() {
|
||||
return true
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true
|
||||
this.abortActive('destroy')
|
||||
if (this.highlightTimer) {
|
||||
clearTimeout(this.highlightTimer)
|
||||
this.highlightTimer = null
|
||||
}
|
||||
this.app?.unmount()
|
||||
this.app = null
|
||||
}
|
||||
}
|
||||
|
||||
export const proBlockRemark = $remark('proBlockRemark', () => () => {
|
||||
return (tree: any) => {
|
||||
transformProBlockChildren(tree)
|
||||
}
|
||||
})
|
||||
|
||||
export const proBlockNode = $node(PRO_BLOCK_NODE_TYPE, () => ({
|
||||
group: 'block',
|
||||
atom: true,
|
||||
isolating: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
marks: '',
|
||||
attrs: {
|
||||
content: { default: '' },
|
||||
autoStart: { default: false },
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'div[data-pro-block="true"]',
|
||||
getAttrs: (dom) => ({
|
||||
content: (dom as HTMLElement).getAttribute('data-pro-content') || '',
|
||||
autoStart: ((dom as HTMLElement).getAttribute('data-pro-autostart') || '') === 'true',
|
||||
}),
|
||||
},
|
||||
],
|
||||
toDOM: (node) => [
|
||||
'div',
|
||||
{
|
||||
'data-pro-block': 'true',
|
||||
'data-pro-content': node.attrs.content || '',
|
||||
'data-pro-autostart': String(Boolean(node.attrs.autoStart)),
|
||||
},
|
||||
],
|
||||
parseMarkdown: {
|
||||
match: (node) => node.type === PRO_BLOCK_NODE_TYPE,
|
||||
runner: (state, node, type) => {
|
||||
state.addNode(type, {
|
||||
content: String(node.content || ''),
|
||||
autoStart: false,
|
||||
})
|
||||
},
|
||||
},
|
||||
toMarkdown: {
|
||||
match: (node) => node.type.name === PRO_BLOCK_NODE_TYPE,
|
||||
runner: (state, node) => {
|
||||
state.addNode('paragraph', [{
|
||||
type: 'text',
|
||||
value: serializeProBlockSyntax(node.attrs.content),
|
||||
}])
|
||||
},
|
||||
},
|
||||
leafText: (node) => serializeProBlockSyntax(node.attrs.content),
|
||||
}))
|
||||
|
||||
export const proBlockView = $view(proBlockNode, (ctx) => {
|
||||
const parser = ctx.get(parserCtx)
|
||||
const serializer = ctx.get(serializerCtx)
|
||||
const config = ctx.get(proBlockConfigCtx.key)
|
||||
return (node, view, getPos) => new ProBlockNodeView(node, view, getPos, parser, serializer, config)
|
||||
})
|
||||
|
||||
export const proBlockInputPlugin = $prose(() => {
|
||||
return new Plugin({
|
||||
key: PRO_BLOCK_INPUT_PLUGIN_KEY,
|
||||
props: {
|
||||
handleTextInput: (view, _from, _to, text) => {
|
||||
return tryHandleProTriggerTextInput(view, text)
|
||||
},
|
||||
handleKeyDown: (view, event) => {
|
||||
const pressed = (event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === 'p'
|
||||
if (!pressed) return false
|
||||
event.preventDefault()
|
||||
return insertProBlockNode(view, false)
|
||||
},
|
||||
},
|
||||
appendTransaction: (transactions, _oldState, newState) => {
|
||||
if (!transactions.some((transaction) => transaction.docChanged)) return null
|
||||
if (transactions.some((transaction) => transaction.getMeta(PRO_BLOCK_INPUT_META))) return null
|
||||
|
||||
const proBlockType = newState.schema.nodes[PRO_BLOCK_NODE_TYPE]
|
||||
if (!proBlockType) return null
|
||||
|
||||
const replacements = findProBlockReplacements(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,
|
||||
proBlockType.create({
|
||||
content: replacement.content,
|
||||
autoStart: replacement.autoStart && replacement.content === '',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (!tr.docChanged) return null
|
||||
tr.setMeta(PRO_BLOCK_INPUT_META, true)
|
||||
return tr
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
export const proBlockHighlightPlugin = $prose(() => {
|
||||
return new Plugin<DecorationSet>({
|
||||
key: PRO_BLOCK_HIGHLIGHT_PLUGIN_KEY,
|
||||
state: {
|
||||
init: () => DecorationSet.empty,
|
||||
apply: (tr, value) => {
|
||||
const meta = tr.getMeta(PRO_BLOCK_HIGHLIGHT_PLUGIN_KEY)
|
||||
if (meta?.clear) {
|
||||
return DecorationSet.empty
|
||||
}
|
||||
if (meta?.set) {
|
||||
return createHighlightDecorations(tr.doc, meta.set.from, meta.set.to)
|
||||
}
|
||||
return value.map(tr.mapping, tr.doc)
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations: (state) => PRO_BLOCK_HIGHLIGHT_PLUGIN_KEY.getState(state),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
export function insertProBlockAtSelection(view: EditorView, autoStart = false) {
|
||||
return insertProBlockNode(view, autoStart)
|
||||
}
|
||||
|
||||
export { PRO_TRIGGER_TEXT }
|
||||
Reference in New Issue
Block a user