477 lines
16 KiB
TypeScript
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 }
|