ultisuite-client/lib/drive/docs-list-actions.ts
R3D347HR4Y 303b2b1074
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
wow
2026-06-11 01:22:40 +02:00

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
}