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(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 ): 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 = { 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 }