252 lines
7.7 KiB
TypeScript
252 lines
7.7 KiB
TypeScript
import type { Editor } from "@tiptap/react"
|
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model"
|
|
import {
|
|
type DocsBulletStyleId,
|
|
type DocsChecklistStyleId,
|
|
DOCS_DEFAULT_BULLET_STYLE,
|
|
DOCS_DEFAULT_ORDERED_STYLE,
|
|
normalizeBulletStyleId,
|
|
normalizeOrderedStyleId,
|
|
orderedPresetById,
|
|
type DocsOrderedStyleId,
|
|
} from "@/lib/drive/docs-list-styles"
|
|
|
|
type ListKind = "bulletList" | "orderedList" | "taskList"
|
|
|
|
export type DocsListState = {
|
|
isBulletList: boolean
|
|
isOrderedList: boolean
|
|
isTaskList: boolean
|
|
bulletStyleId: DocsBulletStyleId | null | "mixed"
|
|
orderedStyleId: DocsOrderedStyleId | null | "mixed"
|
|
checklistStyleId: DocsChecklistStyleId | null | "mixed"
|
|
orderedStart: number | "mixed"
|
|
}
|
|
|
|
function mergeMixed<T>(values: T[]): T | "mixed" {
|
|
if (values.length === 0) return values[0] as T
|
|
const first = values[0]
|
|
return values.every((value) => value === first) ? first : "mixed"
|
|
}
|
|
|
|
function findEnclosingLists(
|
|
doc: ProseMirrorNode,
|
|
from: number,
|
|
to: number
|
|
): Array<{ pos: number; node: ProseMirrorNode; kind: ListKind }> {
|
|
const results: Array<{ pos: number; node: ProseMirrorNode; kind: ListKind }> = []
|
|
|
|
const visit = (pos: number, node: ProseMirrorNode) => {
|
|
if (node.type.name === "bulletList" || node.type.name === "orderedList" || node.type.name === "taskList") {
|
|
results.push({ pos, node, kind: node.type.name as ListKind })
|
|
}
|
|
}
|
|
|
|
if (from === to) {
|
|
const $pos = doc.resolve(from)
|
|
for (let depth = $pos.depth; depth > 0; depth -= 1) {
|
|
const node = $pos.node(depth)
|
|
if (node.type.name === "bulletList" || node.type.name === "orderedList" || node.type.name === "taskList") {
|
|
visit($pos.before(depth), node)
|
|
break
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
doc.nodesBetween(from, to, (node, pos) => {
|
|
if (node.type.name === "bulletList" || node.type.name === "orderedList" || node.type.name === "taskList") {
|
|
visit(pos, node)
|
|
}
|
|
})
|
|
|
|
return results
|
|
}
|
|
|
|
function updateListAttrs(
|
|
editor: Editor,
|
|
kind: ListKind,
|
|
attrs: Record<string, unknown>
|
|
): boolean {
|
|
const { state } = editor
|
|
const lists = findEnclosingLists(state.doc, state.selection.from, state.selection.to).filter(
|
|
(entry) => entry.kind === kind
|
|
)
|
|
if (lists.length === 0) return false
|
|
|
|
let tr = state.tr
|
|
let changed = false
|
|
for (const { pos, node } of lists) {
|
|
tr = tr.setNodeMarkup(pos, undefined, { ...node.attrs, ...attrs })
|
|
changed = true
|
|
}
|
|
if (!changed) return false
|
|
editor.view.dispatch(tr.scrollIntoView())
|
|
return true
|
|
}
|
|
|
|
function exitOtherLists(editor: Editor, target: ListKind): boolean {
|
|
let chain = editor.chain().focus()
|
|
let changed = false
|
|
if (target !== "taskList" && editor.isActive("taskList")) {
|
|
chain = chain.toggleTaskList()
|
|
changed = true
|
|
}
|
|
if (target !== "orderedList" && editor.isActive("orderedList")) {
|
|
chain = chain.toggleOrderedList()
|
|
changed = true
|
|
}
|
|
if (target !== "bulletList" && editor.isActive("bulletList")) {
|
|
chain = chain.toggleBulletList()
|
|
changed = true
|
|
}
|
|
if (changed) chain.run()
|
|
return changed
|
|
}
|
|
|
|
export function applyDocsBulletStyle(editor: Editor | null, styleId: DocsBulletStyleId): boolean {
|
|
if (!editor || editor.isDestroyed) return false
|
|
exitOtherLists(editor, "bulletList")
|
|
if (!editor.isActive("bulletList")) {
|
|
editor.chain().focus().toggleBulletList().run()
|
|
}
|
|
return updateListAttrs(editor, "bulletList", {
|
|
bulletStyle: styleId,
|
|
})
|
|
}
|
|
|
|
export function applyDocsOrderedStyle(editor: Editor | null, styleId: DocsOrderedStyleId): boolean {
|
|
if (!editor || editor.isDestroyed) return false
|
|
const wasOrdered = editor.isActive("orderedList")
|
|
exitOtherLists(editor, "orderedList")
|
|
if (!editor.isActive("orderedList")) {
|
|
editor.chain().focus().toggleOrderedList().run()
|
|
}
|
|
const preset = orderedPresetById(styleId)
|
|
const attrs: Record<string, unknown> = {
|
|
orderedStyle: styleId,
|
|
type: preset.olType ?? null,
|
|
}
|
|
if (!wasOrdered) attrs.start = 1
|
|
return updateListAttrs(editor, "orderedList", attrs)
|
|
}
|
|
|
|
export function applyDocsChecklistStyle(
|
|
editor: Editor | null,
|
|
styleId: DocsChecklistStyleId
|
|
): boolean {
|
|
if (!editor || editor.isDestroyed) return false
|
|
exitOtherLists(editor, "taskList")
|
|
if (!editor.isActive("taskList")) {
|
|
editor.chain().focus().toggleTaskList().run()
|
|
}
|
|
return updateListAttrs(editor, "taskList", {
|
|
checklistStyle: styleId,
|
|
})
|
|
}
|
|
|
|
export function restartDocsOrderedList(editor: Editor | null): boolean {
|
|
if (!editor || editor.isDestroyed || !editor.isActive("orderedList")) return false
|
|
return updateListAttrs(editor, "orderedList", { start: 1 })
|
|
}
|
|
|
|
export function setDocsOrderedListStart(editor: Editor | null, start: number): boolean {
|
|
if (!editor || editor.isDestroyed || !editor.isActive("orderedList")) return false
|
|
const value = Math.max(1, Math.round(start))
|
|
return updateListAttrs(editor, "orderedList", { start: value })
|
|
}
|
|
|
|
export function continueDocsOrderedList(editor: Editor | null): boolean {
|
|
if (!editor || editor.isDestroyed || !editor.isActive("orderedList")) return false
|
|
|
|
const { state } = editor
|
|
const lists = findEnclosingLists(state.doc, state.selection.from, state.selection.to).filter(
|
|
(entry) => entry.kind === "orderedList"
|
|
)
|
|
if (lists.length === 0) return false
|
|
|
|
const listPos = lists[0].pos
|
|
const $list = state.doc.resolve(listPos)
|
|
const parent = $list.parent
|
|
const indexInParent = $list.index()
|
|
|
|
let nextStart = 1
|
|
for (let i = indexInParent - 1; i >= 0; i -= 1) {
|
|
const sibling = parent.child(i)
|
|
if (sibling.type.name === "orderedList") {
|
|
const start = (sibling.attrs.start as number | undefined) ?? 1
|
|
nextStart = start + sibling.childCount
|
|
break
|
|
}
|
|
}
|
|
|
|
return updateListAttrs(editor, "orderedList", { start: nextStart })
|
|
}
|
|
|
|
export function readDocsListState(editor: Editor | null): DocsListState {
|
|
if (!editor || editor.isDestroyed) {
|
|
return {
|
|
isBulletList: false,
|
|
isOrderedList: false,
|
|
isTaskList: false,
|
|
bulletStyleId: null,
|
|
orderedStyleId: null,
|
|
checklistStyleId: null,
|
|
orderedStart: 1,
|
|
}
|
|
}
|
|
|
|
const lists = findEnclosingLists(
|
|
editor.state.doc,
|
|
editor.state.selection.from,
|
|
editor.state.selection.to
|
|
)
|
|
|
|
const bulletLists = lists.filter((entry) => entry.kind === "bulletList")
|
|
const orderedLists = lists.filter((entry) => entry.kind === "orderedList")
|
|
const taskLists = lists.filter((entry) => entry.kind === "taskList")
|
|
|
|
return {
|
|
isBulletList: editor.isActive("bulletList"),
|
|
isOrderedList: editor.isActive("orderedList"),
|
|
isTaskList: editor.isActive("taskList"),
|
|
bulletStyleId:
|
|
bulletLists.length === 0
|
|
? null
|
|
: mergeMixed(
|
|
bulletLists.map((entry) =>
|
|
normalizeBulletStyleId((entry.node.attrs.bulletStyle as string | undefined) ?? DOCS_DEFAULT_BULLET_STYLE)
|
|
)
|
|
),
|
|
orderedStyleId:
|
|
orderedLists.length === 0
|
|
? null
|
|
: mergeMixed(
|
|
orderedLists.map((entry) =>
|
|
normalizeOrderedStyleId(
|
|
(entry.node.attrs.orderedStyle as string | undefined) ?? DOCS_DEFAULT_ORDERED_STYLE
|
|
)
|
|
)
|
|
),
|
|
checklistStyleId:
|
|
taskLists.length === 0
|
|
? null
|
|
: mergeMixed(
|
|
taskLists.map(
|
|
(entry) =>
|
|
((entry.node.attrs.checklistStyle as DocsChecklistStyleId | undefined) ?? "simple") as DocsChecklistStyleId
|
|
)
|
|
),
|
|
orderedStart:
|
|
orderedLists.length === 0
|
|
? 1
|
|
: mergeMixed(orderedLists.map((entry) => (entry.node.attrs.start as number | undefined) ?? 1)),
|
|
}
|
|
}
|
|
|
|
export function readDocsOrderedListStartDraft(editor: Editor | null): number {
|
|
const state = readDocsListState(editor)
|
|
if (state.orderedStart === "mixed") return 1
|
|
return state.orderedStart
|
|
}
|