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

477 lines
16 KiB
TypeScript

import { mergeAttributes, Node, type CommandProps } from "@tiptap/core"
import { NodeSelection } from "@tiptap/pm/state"
import { ReactNodeViewRenderer } from "@tiptap/react"
import { DocsGraphicNodeView } from "@/components/drive/richtext/docs-graphic-node-view"
import { bboxToPageCoords, usesPageLayer } from "@/lib/drive/docs-graphic-position"
import {
buildGradientCss,
DOCS_GRAPHIC_DEFAULTS,
type DocsGraphicAttrs,
type DocsGraphicFloatSide,
type DocsGraphicPlacement,
type DocsGraphicPositionMode,
type DocsGraphicType,
type DocsGraphicWrap,
type DocsShapeType,
parseGraphicAttrs,
} from "@/lib/drive/docs-graphic-types"
declare module "@tiptap/core" {
interface Commands<ReturnType> {
docsGraphic: {
insertDocsGraphic: (attrs: Partial<DocsGraphicAttrs>) => ReturnType
updateDocsGraphic: (attrs: Partial<DocsGraphicAttrs>) => ReturnType
setDocsGraphicWrap: (wrap: DocsGraphicWrap) => ReturnType
setDocsGraphicPlacement: (placement: DocsGraphicPlacement) => ReturnType
setDocsGraphicFloatSide: (floatSide: DocsGraphicFloatSide) => ReturnType
setDocsGraphicPositionMode: (mode: DocsGraphicPositionMode) => ReturnType
setDocsGraphicWrapMargin: (mm: number) => ReturnType
setDocsGraphicAnchor: (anchorPos: number) => ReturnType
bringDocsGraphicForward: () => ReturnType
sendDocsGraphicBackward: () => ReturnType
}
}
}
const graphicAttributes = {
graphicType: { default: DOCS_GRAPHIC_DEFAULTS.graphicType },
src: { default: null as string | null },
alt: { default: "" },
shapeType: { default: DOCS_GRAPHIC_DEFAULTS.shapeType },
fill: { default: DOCS_GRAPHIC_DEFAULTS.fill },
stroke: { default: DOCS_GRAPHIC_DEFAULTS.stroke },
strokeWidth: { default: DOCS_GRAPHIC_DEFAULTS.strokeWidth },
gradientCss: { default: "" },
gradientType: { default: DOCS_GRAPHIC_DEFAULTS.gradientType },
gradientAngle: { default: DOCS_GRAPHIC_DEFAULTS.gradientAngle },
gradientColor1: { default: DOCS_GRAPHIC_DEFAULTS.gradientColor1 },
gradientColor2: { default: DOCS_GRAPHIC_DEFAULTS.gradientColor2 },
width: { default: DOCS_GRAPHIC_DEFAULTS.width },
height: { default: DOCS_GRAPHIC_DEFAULTS.height },
placement: { default: DOCS_GRAPHIC_DEFAULTS.placement },
wrap: { default: DOCS_GRAPHIC_DEFAULTS.wrap },
floatSide: { default: DOCS_GRAPHIC_DEFAULTS.floatSide },
x: { default: 0 },
y: { default: 0 },
positionMode: { default: DOCS_GRAPHIC_DEFAULTS.positionMode },
anchorPos: { default: -1 },
pageIndex: { default: 0 },
pageX: { default: 0 },
pageY: { default: 0 },
wrapMarginMm: { default: DOCS_GRAPHIC_DEFAULTS.wrapMarginMm },
rotationDeg: { default: 0 },
zIndex: { default: 0 },
cropX: { default: 0 },
cropY: { default: 0 },
cropWidth: { default: 1 },
cropHeight: { default: 1 },
cropShape: { default: "rect" },
lockAspectRatio: { default: true },
imageFit: { default: "contain" },
imageFitAnchorH: { default: 0.5 },
imageFitAnchorV: { default: 0.5 },
assetId: { default: null as string | null },
opacity: { default: 1 },
shadow: { default: "" },
brightness: { default: 0 },
contrast: { default: 0 },
recolor: { default: "" },
altTitle: { default: "" },
drawScene: {
default: null as string | null,
rendered: false,
parseHTML: (element) => element.getAttribute("data-draw-scene"),
renderHTML: (attributes) =>
attributes.drawScene ? { "data-draw-scene": attributes.drawScene } : {},
},
drawDriveFileId: {
default: null as string | null,
rendered: false,
parseHTML: (element) => element.getAttribute("data-draw-drive-file-id"),
renderHTML: (attributes) =>
attributes.drawDriveFileId
? { "data-draw-drive-file-id": attributes.drawDriveFileId }
: {},
},
}
function mergeGraphicAttrs(partial: Partial<DocsGraphicAttrs>): DocsGraphicAttrs {
const merged = parseGraphicAttrs({ ...DOCS_GRAPHIC_DEFAULTS, ...partial })
if (merged.graphicType === "gradient" && !partial.gradientCss) {
merged.gradientCss = buildGradientCss(
merged.gradientAngle,
merged.gradientColor1,
merged.gradientColor2,
merged.gradientType
)
}
return merged
}
function graphicNodeName(attrs: Partial<DocsGraphicAttrs>): "docsGraphic" | "docsInlineGraphic" {
const merged = mergeGraphicAttrs(attrs)
return merged.placement === "inline" || merged.wrap === "inline"
? "docsInlineGraphic"
: "docsGraphic"
}
function activeGraphicName(editor: { isActive: (name: string) => boolean }): string | null {
if (editor.isActive("docsInlineGraphic")) return "docsInlineGraphic"
if (editor.isActive("docsGraphic")) return "docsGraphic"
return null
}
type GraphicCommandProps = CommandProps
function selectedGraphicNode(
state: import("@tiptap/pm/state").EditorState
): import("@tiptap/pm/model").Node | null {
const selection = state.selection
if (!(selection instanceof NodeSelection)) return null
const node = selection.node
if (node.type.name !== "docsGraphic" && node.type.name !== "docsInlineGraphic") return null
return node
}
function isGraphicNodeName(name: string): name is "docsGraphic" | "docsInlineGraphic" {
return name === "docsGraphic" || name === "docsInlineGraphic"
}
function restoreGraphicNodeSelection(
tr: import("@tiptap/pm/state").Transaction,
preferredPos: number
): void {
const candidates = [preferredPos, preferredPos + 1, preferredPos - 1]
for (const tryPos of candidates) {
if (tryPos < 0 || tryPos > tr.doc.content.size) continue
const nodeAt = tr.doc.nodeAt(tryPos)
if (nodeAt && isGraphicNodeName(nodeAt.type.name)) {
tr.setSelection(NodeSelection.create(tr.doc, tryPos))
return
}
}
let best: { pos: number; dist: number } | null = null
tr.doc.descendants((node, pos) => {
if (!isGraphicNodeName(node.type.name)) return
const dist = Math.abs(pos - preferredPos)
if (!best || dist < best.dist) best = { pos, dist }
})
if (best) tr.setSelection(NodeSelection.create(tr.doc, best.pos))
}
function replaceBlockGraphicWithInline(
tr: import("@tiptap/pm/state").Transaction,
state: import("@tiptap/pm/state").EditorState,
pos: number,
node: import("@tiptap/pm/model").Node,
merged: DocsGraphicAttrs,
inlineType: import("@tiptap/pm/model").NodeType
): number {
const paragraphType = state.schema.nodes.paragraph
if (!paragraphType) return pos
const inlineNode = inlineType.create(merged)
const paragraphNode = paragraphType.create(null, inlineNode)
tr.replaceWith(pos, pos + node.nodeSize, paragraphNode)
return pos + 1
}
function replaceInlineGraphicWithBlock(
tr: import("@tiptap/pm/state").Transaction,
state: import("@tiptap/pm/state").EditorState,
pos: number,
node: import("@tiptap/pm/model").Node,
merged: DocsGraphicAttrs,
blockType: import("@tiptap/pm/model").NodeType
): number {
const $pos = state.doc.resolve(pos)
const parent = $pos.parent
const blockNode = blockType.create(merged)
if (parent.type.name === "paragraph" && parent.childCount === 1) {
const paragraphPos = $pos.before($pos.depth)
tr.replaceWith(paragraphPos, $pos.after($pos.depth), blockNode)
return paragraphPos
}
const paragraphPos = $pos.before($pos.depth)
tr.insert(paragraphPos, blockNode)
const mappedPos = tr.mapping.map(pos)
tr.delete(mappedPos, mappedPos + node.nodeSize)
return paragraphPos
}
function replaceGraphicWithAttrs(
{ state, tr, dispatch }: GraphicCommandProps,
patch: Partial<DocsGraphicAttrs>
): boolean {
const { selection } = state
const node = selectedGraphicNode(state)
if (!node) return false
const currentName = node.type.name as "docsGraphic" | "docsInlineGraphic"
const merged = mergeGraphicAttrs({ ...(node.attrs as Record<string, unknown>), ...patch })
const targetName = graphicNodeName(merged)
const pos = selection.from
let selectionPos = pos
if (targetName === currentName) {
tr.setNodeMarkup(pos, undefined, merged)
} else {
const targetType = state.schema.nodes[targetName]
if (!targetType) return false
if (currentName === "docsGraphic" && targetName === "docsInlineGraphic") {
selectionPos = replaceBlockGraphicWithInline(tr, state, pos, node, merged, targetType)
} else if (currentName === "docsInlineGraphic" && targetName === "docsGraphic") {
selectionPos = replaceInlineGraphicWithBlock(tr, state, pos, node, merged, targetType)
} else {
tr.replaceWith(pos, pos + node.nodeSize, targetType.create(merged))
selectionPos = tr.mapping.map(pos, -1)
}
}
restoreGraphicNodeSelection(tr, selectionPos)
if (dispatch) dispatch(tr)
return true
}
function updateGraphicAttrs(ctx: GraphicCommandProps, patch: Partial<DocsGraphicAttrs>) {
if (selectedGraphicNode(ctx.state)) {
return replaceGraphicWithAttrs(ctx, patch)
}
const name = activeGraphicName(ctx.editor)
if (!name) return false
return ctx.chain().updateAttributes(name, patch).run()
}
/** Read current on-screen page coords of the selected graphic (DOM measure). */
function measureSelectedGraphicPageCoords(): Partial<DocsGraphicAttrs> | null {
if (typeof document === "undefined") return null
const el = document.querySelector(
".docs-graphic--selected, .ProseMirror-selectednode .docs-graphic"
) as HTMLElement | null
const stack = document.querySelector("[data-docs-page-stack]") as HTMLElement | null
if (!el || !stack) return null
const scale = Number.parseFloat(stack.dataset.docsPageScale ?? "1") || 1
const pageHeight = Number.parseFloat(stack.dataset.docsPageHeight ?? "0") || 0
if (pageHeight <= 0) return null
const coords = bboxToPageCoords(
el.getBoundingClientRect(),
stack.getBoundingClientRect(),
scale,
pageHeight
)
return { pageIndex: coords.pageIndex, pageX: coords.pageX, pageY: coords.pageY }
}
/** Patch parts for promoting the selected graphic to the fixed page layer. */
function fixedOnPagePatch(ctx: GraphicCommandProps): Partial<DocsGraphicAttrs> {
const patch: Partial<DocsGraphicAttrs> = {
positionMode: "fixed-on-page",
placement: "absolute",
}
const node = selectedGraphicNode(ctx.state)
const alreadyOnLayer =
node && usesPageLayer(node.attrs as Record<string, unknown>)
if (!alreadyOnLayer) {
// Keep the graphic where it visually sits when leaving the text flow.
Object.assign(patch, measureSelectedGraphicPageCoords())
}
return patch
}
/** Patch parts for returning the selected graphic to the text flow. */
function moveWithTextPatch(currentWrap: unknown): Partial<DocsGraphicAttrs> {
const wrap: DocsGraphicWrap =
currentWrap === "behind" || currentWrap === "in-front"
? "square"
: ((currentWrap as DocsGraphicWrap) ?? "square")
return {
positionMode: "move-with-text",
placement: wrap === "inline" ? "inline" : "block",
wrap,
}
}
function graphicCommands() {
return {
insertDocsGraphic:
(partial: Partial<DocsGraphicAttrs>) =>
({ chain, editor }: GraphicCommandProps) => {
const anchorPos = partial.anchorPos ?? editor.state.selection.from
const attrs = mergeGraphicAttrs({ ...partial, anchorPos })
const type = graphicNodeName(attrs)
const ok = chain().focus().insertContent({ type, attrs }).run()
if (!ok) return false
let graphicPos: number | null = null
let bestDist = Infinity
editor.state.doc.descendants((node, pos) => {
if (!isGraphicNodeName(node.type.name)) return
const dist = Math.abs(pos - anchorPos)
if (dist < bestDist) {
bestDist = dist
graphicPos = pos
}
})
if (graphicPos != null) {
editor.commands.setNodeSelection(graphicPos)
}
return true
},
updateDocsGraphic:
(partial: Partial<DocsGraphicAttrs>) =>
(ctx: GraphicCommandProps) =>
updateGraphicAttrs(ctx, partial),
setDocsGraphicWrap:
(wrap: DocsGraphicWrap) =>
(ctx: GraphicCommandProps) => {
let patch: Partial<DocsGraphicAttrs>
if (wrap === "behind" || wrap === "in-front") {
patch = { ...fixedOnPagePatch(ctx), wrap }
} else {
// Choosing a text-wrap mode puts the graphic back into the flow.
patch = {
wrap,
positionMode: "move-with-text",
placement: wrap === "inline" ? "inline" : "block",
}
}
return replaceGraphicWithAttrs(ctx, patch)
},
setDocsGraphicPlacement:
(placement: DocsGraphicPlacement) =>
(ctx: GraphicCommandProps) => {
if (placement === "absolute") {
return replaceGraphicWithAttrs(ctx, fixedOnPagePatch(ctx))
}
const currentWrap = selectedGraphicNode(ctx.state)?.attrs?.wrap
const patch = moveWithTextPatch(currentWrap)
patch.placement = placement
if (placement === "inline") patch.wrap = "inline"
return replaceGraphicWithAttrs(ctx, patch)
},
setDocsGraphicFloatSide:
(floatSide: DocsGraphicFloatSide) =>
(ctx: GraphicCommandProps) =>
updateGraphicAttrs(ctx, { floatSide }),
setDocsGraphicPositionMode:
(positionMode: DocsGraphicPositionMode) =>
(ctx: GraphicCommandProps) => {
if (positionMode === "fixed-on-page") {
return replaceGraphicWithAttrs(ctx, fixedOnPagePatch(ctx))
}
const currentWrap = selectedGraphicNode(ctx.state)?.attrs?.wrap
return replaceGraphicWithAttrs(ctx, moveWithTextPatch(currentWrap))
},
setDocsGraphicWrapMargin:
(wrapMarginMm: number) =>
(ctx: GraphicCommandProps) =>
updateGraphicAttrs(ctx, { wrapMarginMm: Math.max(0, wrapMarginMm) }),
setDocsGraphicAnchor:
(anchorPos: number) =>
(ctx: GraphicCommandProps) =>
updateGraphicAttrs(ctx, { anchorPos }),
bringDocsGraphicForward:
() =>
(ctx: GraphicCommandProps) => {
const name = activeGraphicName(ctx.editor)
if (!name) return false
const z = Number(ctx.editor.getAttributes(name).zIndex ?? 0)
return ctx.chain().updateAttributes(name, { zIndex: z + 1 }).run()
},
sendDocsGraphicBackward:
() =>
(ctx: GraphicCommandProps) => {
const name = activeGraphicName(ctx.editor)
if (!name) return false
const z = Number(ctx.editor.getAttributes(name).zIndex ?? 0)
return ctx.chain().updateAttributes(name, { zIndex: Math.max(0, z - 1) }).run()
},
}
}
export const DocsGraphic = Node.create({
name: "docsGraphic",
group: "block",
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return graphicAttributes
},
parseHTML() {
return [{ tag: 'div[data-type="docs-graphic"]' }]
},
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes(HTMLAttributes, { "data-type": "docs-graphic" })]
},
addNodeView() {
return ReactNodeViewRenderer(DocsGraphicNodeView)
},
addCommands() {
return graphicCommands()
},
})
export const DocsInlineGraphic = Node.create({
name: "docsInlineGraphic",
group: "inline",
inline: true,
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return graphicAttributes
},
parseHTML() {
return [{ tag: 'span[data-type="docs-inline-graphic"]' }]
},
renderHTML({ HTMLAttributes }) {
return ["span", mergeAttributes(HTMLAttributes, { "data-type": "docs-inline-graphic" })]
},
addNodeView() {
return ReactNodeViewRenderer(DocsGraphicNodeView)
},
})
export function buildInsertGraphicAttrs(
graphicType: DocsGraphicType,
partial: Partial<DocsGraphicAttrs> = {}
): DocsGraphicAttrs {
return mergeGraphicAttrs({ ...DOCS_GRAPHIC_DEFAULTS, graphicType, ...partial })
}
/** Build image attrs with a frame sized to the source aspect ratio. */
export async function buildImageInsertGraphicAttrs(
partial: Partial<DocsGraphicAttrs> & { src: string }
): Promise<DocsGraphicAttrs> {
const { computeImageInsertFrameSize, readImageNaturalSize } = await import(
"../docs-graphic-image-size.ts"
)
try {
const natural = await readImageNaturalSize(partial.src)
const { width, height } = computeImageInsertFrameSize(natural.width, natural.height)
return buildInsertGraphicAttrs("image", {
...partial,
width,
height,
lockAspectRatio: true,
})
} catch {
return buildInsertGraphicAttrs("image", partial)
}
}
export { mergeGraphicAttrs, graphicNodeName }