Files
NotesAgentic/frontend/src/features/editor/headingFolding.ts
T

137 lines
6.6 KiB
TypeScript

import { $prose } from '@milkdown/kit/utils'
import { Plugin, PluginKey, TextSelection, type EditorState } from '@milkdown/kit/prose/state'
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
import type { Node } from '@milkdown/kit/prose/model'
import { t } from '@/i18n'
export const headingFoldKey = new PluginKey<Set<number>>('heading-folding')
type Section = { from: number; body: number; end: number; level: number }
const sectionCache = new WeakMap<Node, Section[]>()
const decorationCache = new WeakMap<Node, WeakMap<Set<number>, DecorationSet>>()
/** A section ends at the next sibling heading of the same or a higher rank. */
export function headingSections(doc: Node): Section[] {
const cached = sectionCache.get(doc)
if (cached) return cached
const sections: Section[] = []
function visit(parent: Node, start: number) {
const children: { node: Node; pos: number }[] = []
parent.forEach((node, offset) => children.push({ node, pos: start + offset }))
const following: { pos: number; level: number }[] = []
for (let i = children.length - 1; i >= 0; i--) {
const { node, pos } = children[i]!
if (node.type.name === 'heading') {
while (following.length && following[following.length - 1]!.level > node.attrs.level) following.pop()
const next = following[following.length - 1]
const body = pos + node.nodeSize
const end = next?.pos ?? start + parent.content.size
if (end > body) sections.push({ from: pos, body, end, level: Number(node.attrs.level) })
following.push({ pos, level: Number(node.attrs.level) })
}
if (!node.isTextblock && node.childCount) visit(node, pos + 1)
}
}
visit(doc, 0)
sections.sort((a, b) => a.from - b.from)
sectionCache.set(doc, sections)
return sections
}
export function headingFoldTransaction(state: EditorState, action: 'toggle' | 'all' | 'none', position?: number) {
const sections = headingSections(state.doc)
const folded = new Set(headingFoldKey.getState(state) ?? [])
if (action === 'none') folded.clear()
else if (action === 'all') sections.forEach(section => folded.add(section.from))
else {
const section = position === undefined
? sections.filter(item => item.from <= state.selection.from && item.end >= state.selection.from).pop()
: sections.find(item => item.from === position)
if (!section) return null
if (folded.has(section.from)) folded.delete(section.from)
else folded.add(section.from)
}
const tr = state.tr
const enclosing = sections.find(section => folded.has(section.from) && state.selection.to >= section.body && state.selection.from < section.end)
if (action === 'all' && sections.length) tr.setSelection(TextSelection.near(state.doc.resolve(sections[0]!.from + 1))).scrollIntoView()
else if (enclosing) tr.setSelection(TextSelection.near(state.doc.resolve(enclosing.from + 1)))
return tr.setMeta(headingFoldKey, folded).setMeta('addToHistory', false)
}
export const headingFoldingPlugin = $prose(() => new Plugin<Set<number>>({
key: headingFoldKey,
state: {
init: () => new Set(),
apply(tr, previous) {
const explicit = tr.getMeta(headingFoldKey) as Set<number> | undefined
if (explicit) return explicit
if (!previous.size) return previous
const sections = headingSections(tr.doc)
if (!tr.docChanged) {
if (!tr.selectionSet) return previous
const opened = sections.filter(section => previous.has(section.from) && tr.selection.to >= section.body && tr.selection.from < section.end)
if (!opened.length) return previous
const next = new Set(previous)
opened.forEach(section => next.delete(section.from))
return next
}
const positions = new Set(sections.map(section => section.from))
const mapped = new Set<number>()
for (const old of previous) {
const result = tr.mapping.mapResult(old, 1)
if (!result.deleted && positions.has(result.pos)) mapped.add(result.pos)
}
// Outline jumps, find and keyboard navigation must never leave a hidden caret.
if (tr.selectionSet || tr.docChanged) {
for (const section of sections) if (tr.selection.to >= section.body && tr.selection.from < section.end) mapped.delete(section.from)
}
return mapped
},
},
props: {
decorations(state) {
const folded = headingFoldKey.getState(state) ?? new Set<number>()
const cached = decorationCache.get(state.doc)?.get(folded)
if (cached) return cached
const sections = headingSections(state.doc)
const decorations: Decoration[] = []
for (const section of sections) {
const collapsed = folded.has(section.from)
decorations.push(Decoration.widget(section.from + 1, view => {
const button = document.createElement('button')
button.type = 'button'; button.className = 'heading-fold-toggle'; button.contentEditable = 'false'
button.setAttribute('aria-expanded', String(!collapsed))
button.setAttribute('aria-label', `${collapsed ? t('展开', 'Expand') : t('折叠', 'Collapse')} H${section.level} ${state.doc.nodeAt(section.from)?.textContent ?? ''}`)
button.onmousedown = event => event.preventDefault()
button.onclick = event => {
event.preventDefault()
const tr = headingFoldTransaction(view.state, 'toggle', section.from)
if (tr) view.dispatch(tr)
}
return button
}, { key: `${section.from}:${collapsed}:${state.doc.nodeAt(section.from)?.textContent}`, side: -1, stopEvent: () => true }))
}
const hidden: { body: number; end: number }[] = []
for (const section of sections) {
if (!folded.has(section.from)) continue
const previous = hidden[hidden.length - 1]
if (previous && section.body <= previous.end) previous.end = Math.max(previous.end, section.end)
else hidden.push({ body: section.body, end: section.end })
}
let rangeIndex = 0
if (hidden.length) state.doc.descendants((node, pos) => {
if (!node.isBlock) return
while (hidden[rangeIndex] && pos >= hidden[rangeIndex]!.end) rangeIndex++
const range = hidden[rangeIndex]
if (range && pos >= range.body && pos + node.nodeSize <= range.end) {
decorations.push(Decoration.node(pos, pos + node.nodeSize, { class: 'heading-fold-hidden' }))
return false
}
})
const result = DecorationSet.create(state.doc, decorations)
let byState = decorationCache.get(state.doc)
if (!byState) { byState = new WeakMap(); decorationCache.set(state.doc, byState) }
byState.set(folded, result)
return result
},
},
}))