ultisuite-client/lib/drive/extensions/docs-table-column-resizing.ts
R3D347HR4Y ad1370ea7e
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat: enhance configuration and add new demo layouts
- Introduced turbopack alias for canvas in next.config.mjs.
- Updated package.json scripts for development and branding tasks.
- Added new dependencies for Tiptap extensions.
- Implemented new demo layouts for agenda, contacts, drive, and mail applications.
- Enhanced globals.css for improved theming and splash screen animations.
- Added OAuth callback handling for drive mounts.
- Updated layout components to integrate new demo shells and improve structure.
2026-06-12 19:10:24 +02:00

427 lines
13 KiB
TypeScript

import type { Node as ProseMirrorNode } from "@tiptap/pm/model"
import { Plugin, PluginKey, type EditorState, type Transaction } from "@tiptap/pm/state"
import {
Decoration,
DecorationSet,
type EditorView,
type NodeView,
} from "@tiptap/pm/view"
import {
TableMap,
TableView,
cellAround,
pointsAtCell,
} from "@tiptap/pm/tables"
type Dragging = { startX: number; startWidth: number }
class DocsResizeState {
activeHandle: number
dragging: Dragging | false
constructor(activeHandle: number, dragging: Dragging | false) {
this.activeHandle = activeHandle
this.dragging = dragging
}
apply(tr: Transaction): DocsResizeState {
const action = tr.getMeta(docsColumnResizingPluginKey) as
| { setHandle?: number; setDragging?: Dragging | null }
| undefined
if (action && action.setHandle != null) {
return new DocsResizeState(action.setHandle, false)
}
if (action && action.setDragging !== undefined) {
return new DocsResizeState(this.activeHandle, action.setDragging ?? false)
}
if (this.activeHandle > -1 && tr.docChanged) {
let handle = tr.mapping.map(this.activeHandle, -1)
if (!pointsAtCell(tr.doc.resolve(handle))) handle = -1
return new DocsResizeState(handle, this.dragging)
}
return this
}
}
export const docsColumnResizingPluginKey = new PluginKey<DocsResizeState>(
"docsTableColumnResizing"
)
function readDocsPageScale(view: EditorView): number {
const stack = view.dom.closest("[data-docs-page-stack]")
if (!stack) return 1
const scale = Number.parseFloat(stack.getAttribute("data-docs-page-scale") ?? "1")
return Number.isFinite(scale) && scale > 0 ? scale : 1
}
/** Keep table full width; only colgroup drives column sizes (avoids border jump on hover/drag). */
export function docsUpdateColumnsOnResize(
node: ProseMirrorNode,
colgroup: HTMLTableColElement,
table: HTMLTableElement,
defaultCellMinWidth: number,
overrideCol?: number,
overrideValue?: number
): void {
let totalWidth = 0
let fixedWidth = true
let nextDOM = colgroup.firstChild as HTMLElement | null
const row = node.firstChild
if (!row) return
for (let i = 0, col = 0; i < row.childCount; i++) {
const { colspan, colwidth } = row.child(i).attrs as {
colspan: number
colwidth?: number[] | null
}
for (let j = 0; j < colspan; j++, col++) {
const hasWidth = overrideCol === col ? overrideValue : colwidth && colwidth[j]
const cssWidth = hasWidth ? `${hasWidth}px` : ""
totalWidth += hasWidth || defaultCellMinWidth
if (!hasWidth) fixedWidth = false
if (!nextDOM) {
const colEl = document.createElement("col")
colEl.style.width = cssWidth
colgroup.appendChild(colEl)
} else {
if (nextDOM.style.width !== cssWidth) nextDOM.style.width = cssWidth
nextDOM = nextDOM.nextSibling as HTMLElement | null
}
}
}
while (nextDOM) {
const after = nextDOM.nextSibling
nextDOM.parentNode?.removeChild(nextDOM)
nextDOM = after as HTMLElement | null
}
table.style.width = "100%"
table.style.minWidth = fixedWidth ? "" : `${totalWidth}px`
}
export class DocsTableView extends TableView {
constructor(node: ProseMirrorNode, defaultCellMinWidth: number, _view?: EditorView) {
super(node, defaultCellMinWidth)
docsUpdateColumnsOnResize(node, this.colgroup, this.table, defaultCellMinWidth)
}
update(node: ProseMirrorNode): boolean {
if (node.type !== this.node.type) return false
this.node = node
docsUpdateColumnsOnResize(node, this.colgroup, this.table, this.defaultCellMinWidth)
return true
}
}
function tableNodeName(state: EditorState): string {
for (const name of Object.keys(state.schema.nodes)) {
if (state.schema.nodes[name].spec.tableRole === "table") return name
}
return "table"
}
function domCellAround(target: EventTarget | null): HTMLElement | null {
let el = target as HTMLElement | null
while (el && el.nodeName !== "TD" && el.nodeName !== "TH") {
el =
el.classList?.contains("ProseMirror") ? null : (el.parentNode as HTMLElement | null)
}
return el
}
function edgeCell(
view: EditorView,
event: MouseEvent,
side: "left" | "right",
handleWidth: number
): number {
const offset = side === "right" ? -handleWidth : handleWidth
const found = view.posAtCoords({
left: event.clientX + offset,
top: event.clientY,
})
if (!found) return -1
const { pos } = found
const $cell = cellAround(view.state.doc.resolve(pos))
if (!$cell) return -1
if (side === "right") return $cell.pos
const map = TableMap.get($cell.node(-1))
const start = $cell.start(-1)
const index = map.map.indexOf($cell.pos - start)
return index % map.width === 0 ? -1 : start + map.map[index - 1]
}
function currentColWidth(
view: EditorView,
cellPos: number,
{ colspan, colwidth }: { colspan: number; colwidth?: number[] | null }
): number {
const stored = colwidth && colwidth[colwidth.length - 1]
if (stored) return stored
const dom = view.domAtPos(cellPos)
const cellDom = dom.node.childNodes[dom.offset] as HTMLElement
let domWidth = cellDom.offsetWidth
let parts = colspan
if (colwidth) {
for (let i = 0; i < colspan; i++) {
if (colwidth[i]) {
domWidth -= colwidth[i]!
parts--
}
}
}
return domWidth / parts
}
function draggedWidth(dragging: Dragging, event: MouseEvent, minWidth: number, scale: number) {
const offset = (event.clientX - dragging.startX) / scale
return Math.max(minWidth, dragging.startWidth + offset)
}
function updateHandle(view: EditorView, value: number) {
view.dispatch(view.state.tr.setMeta(docsColumnResizingPluginKey, { setHandle: value }))
}
function displayColumnWidth(
view: EditorView,
cell: number,
width: number,
defaultCellMinWidth: number
) {
const $cell = view.state.doc.resolve(cell)
const table = $cell.node(-1)
const start = $cell.start(-1)
const col =
TableMap.get(table).colCount($cell.pos - start) + ($cell.nodeAfter?.attrs.colspan ?? 1) - 1
let dom: Node | null = view.domAtPos($cell.start(-1)).node
while (dom && dom.nodeName !== "TABLE") dom = dom.parentNode
if (!dom || dom.nodeName !== "TABLE") return
docsUpdateColumnsOnResize(
table,
(dom as HTMLTableElement).firstChild as HTMLTableColElement,
dom as HTMLTableElement,
defaultCellMinWidth,
col,
width
)
}
function zeroes(n: number) {
return Array<number>(n).fill(0)
}
function updateColumnWidth(view: EditorView, cell: number, width: number) {
const $cell = view.state.doc.resolve(cell)
const table = $cell.node(-1)
const map = TableMap.get(table)
const start = $cell.start(-1)
const col = map.colCount($cell.pos - start) + ($cell.nodeAfter?.attrs.colspan ?? 1) - 1
const tr = view.state.tr
for (let row = 0; row < map.height; row++) {
const mapIndex = row * map.width + col
if (row && map.map[mapIndex] === map.map[mapIndex - map.width]) continue
const pos = map.map[mapIndex]
const attrs = table.nodeAt(pos)!.attrs
const index = attrs.colspan === 1 ? 0 : col - map.colCount(pos)
if (attrs.colwidth && attrs.colwidth[index] === width) continue
const colwidth = attrs.colwidth ? attrs.colwidth.slice() : zeroes(attrs.colspan)
colwidth[index] = width
tr.setNodeMarkup(start + pos, null, { ...attrs, colwidth })
}
if (tr.docChanged) view.dispatch(tr)
}
function handleDecorations(state: EditorState, cell: number) {
const decorations: Decoration[] = []
const $cell = state.doc.resolve(cell)
const table = $cell.node(-1)
if (!table) return DecorationSet.empty
const map = TableMap.get(table)
const start = $cell.start(-1)
const col =
map.colCount($cell.pos - start) + ($cell.nodeAfter?.attrs.colspan ?? 1) - 1
const pluginState = docsColumnResizingPluginKey.getState(state)
for (let row = 0; row < map.height; row++) {
const index = col + row * map.width
if (
(col === map.width - 1 || map.map[index] !== map.map[index + 1]) &&
(row === 0 || map.map[index] !== map.map[index - map.width])
) {
const cellPos = map.map[index]
const pos = start + cellPos + table.nodeAt(cellPos)!.nodeSize - 1
const dom = document.createElement("div")
dom.className = "column-resize-handle"
if (pluginState?.dragging) {
decorations.push(
Decoration.node(start + cellPos, start + cellPos + table.nodeAt(cellPos)!.nodeSize, {
class: "column-resize-dragging",
})
)
}
decorations.push(Decoration.widget(pos, dom))
}
}
return DecorationSet.create(state.doc, decorations)
}
function handleMouseMove(
view: EditorView,
event: MouseEvent,
handleWidth: number,
lastColumnResizable: boolean
) {
if (!view.editable) return
const pluginState = docsColumnResizingPluginKey.getState(view.state)
if (!pluginState || pluginState.dragging) return
const target = domCellAround(event.target)
let cell = -1
if (target) {
const { left, right } = target.getBoundingClientRect()
if (event.clientX - left <= handleWidth) cell = edgeCell(view, event, "left", handleWidth)
else if (right - event.clientX <= handleWidth) cell = edgeCell(view, event, "right", handleWidth)
}
if (cell === pluginState.activeHandle) return
if (!lastColumnResizable && cell !== -1) {
const $cell = view.state.doc.resolve(cell)
const table = $cell.node(-1)
const map = TableMap.get(table)
const tableStart = $cell.start(-1)
if (
map.colCount($cell.pos - tableStart) + ($cell.nodeAfter?.attrs.colspan ?? 1) - 1 ===
map.width - 1
) {
return
}
}
updateHandle(view, cell)
}
function handleMouseLeave(view: EditorView) {
if (!view.editable) return
const pluginState = docsColumnResizingPluginKey.getState(view.state)
if (pluginState && pluginState.activeHandle > -1 && !pluginState.dragging) {
updateHandle(view, -1)
}
}
function handleMouseDown(
view: EditorView,
event: MouseEvent,
cellMinWidth: number,
defaultCellMinWidth: number
) {
if (!view.editable) return false
const win = view.dom.ownerDocument.defaultView ?? window
const pluginState = docsColumnResizingPluginKey.getState(view.state)
if (!pluginState || pluginState.activeHandle === -1 || pluginState.dragging) return false
const cell = view.state.doc.nodeAt(pluginState.activeHandle)
if (!cell) return false
const width = currentColWidth(view, pluginState.activeHandle, {
colspan: cell.attrs.colspan as number,
colwidth: cell.attrs.colwidth as number[] | null | undefined,
})
const scale = readDocsPageScale(view)
view.dispatch(
view.state.tr.setMeta(docsColumnResizingPluginKey, {
setDragging: { startX: event.clientX, startWidth: width },
})
)
const finish = (ev: MouseEvent) => {
win.removeEventListener("mouseup", finish)
win.removeEventListener("mousemove", move)
const state = docsColumnResizingPluginKey.getState(view.state)
if (state?.dragging) {
updateColumnWidth(
view,
state.activeHandle,
draggedWidth(state.dragging, ev, cellMinWidth, scale)
)
view.dispatch(view.state.tr.setMeta(docsColumnResizingPluginKey, { setDragging: null }))
}
}
const move = (ev: MouseEvent) => {
if (!ev.buttons) return finish(ev)
const state = docsColumnResizingPluginKey.getState(view.state)
if (!state?.dragging) return
displayColumnWidth(
view,
state.activeHandle,
draggedWidth(state.dragging, ev, cellMinWidth, scale),
defaultCellMinWidth
)
}
displayColumnWidth(view, pluginState.activeHandle, width, defaultCellMinWidth)
win.addEventListener("mouseup", finish)
win.addEventListener("mousemove", move)
event.preventDefault()
return true
}
export function docsColumnResizing({
handleWidth = 5,
cellMinWidth = 25,
defaultCellMinWidth = 100,
View = DocsTableView,
lastColumnResizable = true,
}: {
handleWidth?: number
cellMinWidth?: number
defaultCellMinWidth?: number
View?: (new (node: ProseMirrorNode, cellMinWidth: number, view: EditorView) => NodeView) | null
lastColumnResizable?: boolean
} = {}) {
const plugin = new Plugin<DocsResizeState>({
key: docsColumnResizingPluginKey,
state: {
init(_, state) {
const tableName = tableNodeName(state)
if (View && plugin.spec.props?.nodeViews) {
plugin.spec.props.nodeViews[tableName] = (node, view) =>
new View(node, defaultCellMinWidth, view)
}
return new DocsResizeState(-1, false)
},
apply(tr, prev) {
return prev.apply(tr)
},
},
props: {
attributes(state) {
const pluginState = docsColumnResizingPluginKey.getState(state)
return pluginState && pluginState.activeHandle > -1 ? { class: "resize-cursor" } : {}
},
handleDOMEvents: {
mousemove: (view, event) => {
handleMouseMove(view, event, handleWidth, lastColumnResizable)
},
mouseleave: (view) => {
handleMouseLeave(view)
},
mousedown: (view, event) => {
return handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth)
},
},
decorations(state) {
const pluginState = docsColumnResizingPluginKey.getState(state)
if (pluginState && pluginState.activeHandle > -1) {
return handleDecorations(state, pluginState.activeHandle)
}
return undefined
},
nodeViews: {},
},
})
return plugin
}