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

844 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export type DocsGraphicType = "image" | "shape" | "gradient" | "draw"
export type DocsGradientType = "linear" | "radial"
export type DocsGraphicPlacement = "inline" | "block" | "absolute"
export type DocsGraphicPositionMode = "move-with-text" | "fixed-on-page"
/** Text wrap / layering relative to body text */
export type DocsGraphicWrap =
| "inline"
| "square"
| "tight"
| "through"
| "top-bottom"
| "behind"
| "in-front"
export type DocsGraphicFloatSide = "left" | "right" | "center"
export type DocsShapeType = "rect" | "ellipse" | "line" | "arrow"
export type DocsCropShape = "rect" | "ellipse"
export type DocsImageFit = "contain" | "cover" | "crop"
export type DocsImageLayoutFit = "contain" | "cover"
export type DocsImageFitAnchor = 0 | 0.5 | 1
export type DocsGraphicAttrs = {
graphicType: DocsGraphicType
src: string | null
alt: string
assetId: string | null
shapeType: DocsShapeType
fill: string
stroke: string
strokeWidth: number
gradientCss: string
gradientType: DocsGradientType
gradientAngle: number
gradientColor1: string
gradientColor2: string
width: number
height: number
placement: DocsGraphicPlacement
wrap: DocsGraphicWrap
floatSide: DocsGraphicFloatSide
x: number
y: number
positionMode: DocsGraphicPositionMode
/** ProseMirror doc position; -1 when unset */
anchorPos: number
pageIndex: number
pageX: number
pageY: number
wrapMarginMm: number
rotationDeg: number
zIndex: number
/** Normalized crop region 01 relative to source image */
cropX: number
cropY: number
cropWidth: number
cropHeight: number
cropShape: DocsCropShape
/** When true, corner resize keeps width/height ratio (images). */
lockAspectRatio: boolean
/** How the image fits its frame when no crop transform is applied. */
imageFit: DocsImageFit
/** Horizontal focal point for contain/cover (0 = left, 0.5 = center, 1 = right). */
imageFitAnchorH: DocsImageFitAnchor
/** Vertical focal point for contain/cover (0 = top, 0.5 = center, 1 = bottom). */
imageFitAnchorV: DocsImageFitAnchor
opacity: number
shadow: string
/** -1..1, 0 = neutral */
brightness: number
/** -1..1, 0 = neutral */
contrast: number
/** Recolor preset id, "" = none */
recolor: string
altTitle: string
/** Serialized Excalidraw scene JSON for re-editing vector drawings. */
drawScene: string | null
/** UltiDraw file id when the drawing is linked to Drive (edit opens UltiDraw). */
drawDriveFileId: string | null
}
export const DOCS_GRAPHIC_DEFAULTS: DocsGraphicAttrs = {
graphicType: "image",
src: null,
alt: "",
assetId: null,
shapeType: "rect",
fill: "#4285f4",
stroke: "#1a73e8",
strokeWidth: 2,
gradientCss: "",
gradientType: "linear",
gradientAngle: 180,
gradientColor1: "#4285f4",
gradientColor2: "#34a853",
width: 240,
height: 160,
placement: "block",
wrap: "square",
floatSide: "left",
x: 0,
y: 0,
positionMode: "move-with-text",
anchorPos: -1,
pageIndex: 0,
pageX: 0,
pageY: 0,
wrapMarginMm: 3,
rotationDeg: 0,
zIndex: 0,
cropX: 0,
cropY: 0,
cropWidth: 1,
cropHeight: 1,
cropShape: "rect",
lockAspectRatio: true,
imageFit: "contain",
imageFitAnchorH: 0.5,
imageFitAnchorV: 0.5,
opacity: 1,
shadow: "",
brightness: 0,
contrast: 0,
recolor: "",
altTitle: "",
drawScene: null,
drawDriveFileId: null,
}
export type DocsGraphicRecolorPreset = {
id: string
label: string
/** CSS filter chain approximating the Google Docs recolor tint */
filter: string
}
export const DOCS_GRAPHIC_RECOLOR_PRESETS: DocsGraphicRecolorPreset[] = [
{ id: "", label: "Aucune recolorisation", filter: "" },
{ id: "grayscale", label: "Niveaux de gris", filter: "grayscale(1)" },
{ id: "sepia", label: "Sépia", filter: "sepia(1)" },
{ id: "washout", label: "Délavé", filter: "grayscale(0.4) brightness(1.4) saturate(0.6)" },
{ id: "negative", label: "Négatif", filter: "invert(1)" },
{ id: "blue-light", label: "Bleu clair", filter: "grayscale(1) sepia(1) hue-rotate(175deg) saturate(2.4) brightness(1.15)" },
{ id: "blue-dark", label: "Bleu foncé", filter: "grayscale(1) sepia(1) hue-rotate(185deg) saturate(3.2) brightness(0.75)" },
{ id: "teal", label: "Bleu canard", filter: "grayscale(1) sepia(1) hue-rotate(130deg) saturate(2.2)" },
{ id: "green", label: "Vert", filter: "grayscale(1) sepia(1) hue-rotate(60deg) saturate(2.4)" },
{ id: "yellow", label: "Jaune", filter: "grayscale(1) sepia(1) hue-rotate(10deg) saturate(2.8) brightness(1.1)" },
{ id: "orange", label: "Orange", filter: "grayscale(1) sepia(1) hue-rotate(-15deg) saturate(3)" },
{ id: "red", label: "Rouge", filter: "grayscale(1) sepia(1) hue-rotate(-45deg) saturate(3.4)" },
{ id: "purple", label: "Violet", filter: "grayscale(1) sepia(1) hue-rotate(220deg) saturate(2.4)" },
]
/** CSS filter string combining recolor preset + brightness/contrast. */
export function computeGraphicFilterCss(
attrs: Pick<DocsGraphicAttrs, "brightness" | "contrast" | "recolor">
): string {
const parts: string[] = []
const preset = DOCS_GRAPHIC_RECOLOR_PRESETS.find((p) => p.id === attrs.recolor)
if (preset?.filter) parts.push(preset.filter)
if (attrs.brightness !== 0) {
parts.push(`brightness(${clampRange(1 + attrs.brightness, 0, 2)})`)
}
if (attrs.contrast !== 0) {
parts.push(`contrast(${clampRange(1 + attrs.contrast, 0, 2)})`)
}
return parts.join(" ")
}
function clampRange(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
export const DOCS_GRAPHIC_WRAP_LABELS: Record<DocsGraphicWrap, string> = {
inline: "En ligne avec le texte",
square: "Carré",
tight: "Rapproché",
through: "À travers",
"top-bottom": "Haut et bas",
behind: "Derrière le texte",
"in-front": "Devant le texte",
}
export const DOCS_GRAPHIC_PLACEMENT_LABELS: Record<DocsGraphicPlacement, string> = {
inline: "En ligne",
block: "Bloc",
absolute: "Position absolue",
}
export const DOCS_GRAPHIC_POSITION_MODE_LABELS: Record<DocsGraphicPositionMode, string> = {
"move-with-text": "Déplacer avec le texte",
"fixed-on-page": "Position fixe sur la page",
}
export function buildGradientCss(
angle: number,
color1: string,
color2: string,
type: DocsGradientType = "linear"
): string {
if (type === "radial") {
return `radial-gradient(circle, ${color1}, ${color2})`
}
return `linear-gradient(${angle}deg, ${color1}, ${color2})`
}
export function parseGraphicAttrs(raw: Record<string, unknown>): DocsGraphicAttrs {
const num = (key: keyof DocsGraphicAttrs, fallback: number) => {
const value = raw[key]
return typeof value === "number" && Number.isFinite(value) ? value : fallback
}
const str = (key: keyof DocsGraphicAttrs, fallback: string) => {
const value = raw[key]
return typeof value === "string" ? value : fallback
}
const gradientAngle = num("gradientAngle", DOCS_GRAPHIC_DEFAULTS.gradientAngle)
const gradientColor1 = str("gradientColor1", DOCS_GRAPHIC_DEFAULTS.gradientColor1)
const gradientColor2 = str("gradientColor2", DOCS_GRAPHIC_DEFAULTS.gradientColor2)
const gradientCssRaw = str("gradientCss", "")
const gradientType: DocsGradientType =
raw.gradientType === "radial" || raw.gradientType === "linear"
? raw.gradientType
: gradientCssRaw.includes("radial-gradient")
? "radial"
: DOCS_GRAPHIC_DEFAULTS.gradientType
const gradientCss =
gradientCssRaw ||
(raw.graphicType === "gradient"
? buildGradientCss(gradientAngle, gradientColor1, gradientColor2, gradientType)
: "")
// Absolute placement and behind/in-front wraps always live on the page layer.
const forcedFixed =
raw.placement === "absolute" || raw.wrap === "behind" || raw.wrap === "in-front"
const positionMode: DocsGraphicPositionMode = forcedFixed
? "fixed-on-page"
: raw.positionMode === "fixed-on-page" || raw.positionMode === "move-with-text"
? raw.positionMode
: DOCS_GRAPHIC_DEFAULTS.positionMode
const anchorRaw = raw.anchorPos
const anchorPos =
typeof anchorRaw === "number" && Number.isFinite(anchorRaw) ? anchorRaw : -1
const legacyX = num("x", 0)
const legacyY = num("y", 0)
// Node attrs always carry pageX/pageY defaults (0), so presence alone can't
// signal migration: treat (0,0) page coords with non-zero legacy x/y as
// unmigrated legacy positioning.
const rawPageX = typeof raw.pageX === "number" && Number.isFinite(raw.pageX) ? raw.pageX : null
const rawPageY = typeof raw.pageY === "number" && Number.isFinite(raw.pageY) ? raw.pageY : null
const pageCoordsProvided =
(rawPageX != null && rawPageX !== 0) || (rawPageY != null && rawPageY !== 0)
const pageX = pageCoordsProvided ? (rawPageX ?? 0) : legacyX || (rawPageX ?? 0)
const pageY = pageCoordsProvided ? (rawPageY ?? 0) : legacyY || (rawPageY ?? 0)
return {
graphicType:
raw.graphicType === "shape" ||
raw.graphicType === "gradient" ||
raw.graphicType === "image" ||
raw.graphicType === "draw"
? raw.graphicType
: DOCS_GRAPHIC_DEFAULTS.graphicType,
src: typeof raw.src === "string" ? raw.src : null,
alt: str("alt", ""),
shapeType:
raw.shapeType === "ellipse" ||
raw.shapeType === "line" ||
raw.shapeType === "arrow" ||
raw.shapeType === "rect"
? raw.shapeType
: DOCS_GRAPHIC_DEFAULTS.shapeType,
fill: str("fill", DOCS_GRAPHIC_DEFAULTS.fill),
stroke: str("stroke", DOCS_GRAPHIC_DEFAULTS.stroke),
strokeWidth: num("strokeWidth", DOCS_GRAPHIC_DEFAULTS.strokeWidth),
gradientCss,
gradientType,
gradientAngle,
gradientColor1,
gradientColor2,
width: Math.max(24, num("width", DOCS_GRAPHIC_DEFAULTS.width)),
height: Math.max(24, num("height", DOCS_GRAPHIC_DEFAULTS.height)),
placement:
raw.placement === "inline" || raw.placement === "block" || raw.placement === "absolute"
? raw.placement
: DOCS_GRAPHIC_DEFAULTS.placement,
wrap:
raw.wrap === "inline" ||
raw.wrap === "square" ||
raw.wrap === "tight" ||
raw.wrap === "through" ||
raw.wrap === "top-bottom" ||
raw.wrap === "behind" ||
raw.wrap === "in-front"
? raw.wrap
: DOCS_GRAPHIC_DEFAULTS.wrap,
floatSide:
raw.floatSide === "left" || raw.floatSide === "right" || raw.floatSide === "center"
? raw.floatSide
: DOCS_GRAPHIC_DEFAULTS.floatSide,
assetId: typeof raw.assetId === "string" && raw.assetId ? raw.assetId : null,
x: legacyX,
y: legacyY,
positionMode,
anchorPos,
pageIndex: Math.max(0, num("pageIndex", 0)),
pageX,
pageY,
wrapMarginMm: Math.max(0, num("wrapMarginMm", DOCS_GRAPHIC_DEFAULTS.wrapMarginMm)),
rotationDeg: num("rotationDeg", 0),
zIndex: num("zIndex", 0),
cropX: clamp01(num("cropX", 0)),
cropY: clamp01(num("cropY", 0)),
cropWidth: clamp01(num("cropWidth", 1), 1),
cropHeight: clamp01(num("cropHeight", 1), 1),
cropShape: raw.cropShape === "ellipse" ? "ellipse" : "rect",
lockAspectRatio: raw.lockAspectRatio === false ? false : true,
imageFit:
raw.imageFit === "cover" || raw.imageFit === "crop" ? raw.imageFit : "contain",
imageFitAnchorH: parseImageFitAnchor(raw.imageFitAnchorH, DOCS_GRAPHIC_DEFAULTS.imageFitAnchorH),
imageFitAnchorV: parseImageFitAnchor(raw.imageFitAnchorV, DOCS_GRAPHIC_DEFAULTS.imageFitAnchorV),
opacity: clamp01(num("opacity", 1), 1),
shadow: str("shadow", ""),
brightness: clampSigned(num("brightness", 0)),
contrast: clampSigned(num("contrast", 0)),
recolor: str("recolor", ""),
altTitle: str("altTitle", ""),
drawScene: typeof raw.drawScene === "string" && raw.drawScene ? raw.drawScene : null,
drawDriveFileId:
typeof raw.drawDriveFileId === "string" && raw.drawDriveFileId
? raw.drawDriveFileId
: null,
}
}
function clampSigned(value: number): number {
if (!Number.isFinite(value)) return 0
return Math.min(1, Math.max(-1, value))
}
function clamp01(value: number, fallback = 0): number {
if (!Number.isFinite(value)) return fallback
return Math.min(1, Math.max(0, value))
}
function parseImageFitAnchor(value: unknown, fallback: DocsImageFitAnchor): DocsImageFitAnchor {
if (value === 0 || value === 0.5 || value === 1) return value
if (typeof value === "number" && Number.isFinite(value)) {
if (value <= 0.25) return 0
if (value >= 0.75) return 1
return 0.5
}
return fallback
}
function anchorOffset(anchor: DocsImageFitAnchor, frameSize: number, contentSize: number): number {
return anchor * (frameSize - contentSize)
}
export function layoutFitForImage(
fit: DocsImageFit
): DocsImageLayoutFit {
return fit === "cover" ? "cover" : "contain"
}
export function usesCropImageFit(fit: DocsImageFit): boolean {
return fit === "crop"
}
export function computeImageFitStyle(
attrs: Pick<DocsGraphicAttrs, "imageFit" | "imageFitAnchorH" | "imageFitAnchorV">
): { objectFit: DocsImageLayoutFit; objectPosition: string } {
const layoutFit = layoutFitForImage(attrs.imageFit)
return {
objectFit: layoutFit,
objectPosition: `${attrs.imageFitAnchorH * 100}% ${attrs.imageFitAnchorV * 100}%`,
}
}
export function hasActiveCrop(
attrs: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">
): boolean {
return (
attrs.cropX > 0 ||
attrs.cropY > 0 ||
attrs.cropWidth < 1 ||
attrs.cropHeight < 1
)
}
/** Pixel rect where object-fit contain/cover image draws inside its frame. */
export function computeImageContentRect(
frameWidth: number,
frameHeight: number,
naturalWidth: number,
naturalHeight: number,
fit: DocsImageLayoutFit = "contain",
anchorH: DocsImageFitAnchor = 0.5,
anchorV: DocsImageFitAnchor = 0.5
): { left: number; top: number; width: number; height: number } {
if (frameWidth <= 0 || frameHeight <= 0 || naturalWidth <= 0 || naturalHeight <= 0) {
return { left: 0, top: 0, width: Math.max(frameWidth, 1), height: Math.max(frameHeight, 1) }
}
const frameAspect = frameWidth / frameHeight
const imageAspect = naturalWidth / naturalHeight
if (fit === "cover") {
if (imageAspect > frameAspect) {
const width = frameHeight * imageAspect
const height = frameHeight
return {
left: anchorOffset(anchorH, frameWidth, width),
top: 0,
width,
height,
}
}
const width = frameWidth
const height = frameWidth / imageAspect
return {
left: 0,
top: anchorOffset(anchorV, frameHeight, height),
width,
height,
}
}
if (imageAspect > frameAspect) {
const width = frameWidth
const height = frameWidth / imageAspect
return {
left: 0,
top: anchorOffset(anchorV, frameHeight, height),
width,
height,
}
}
const width = frameHeight * imageAspect
const height = frameHeight
return {
left: anchorOffset(anchorH, frameWidth, width),
top: 0,
width,
height: frameHeight,
}
}
const MIN_CROP_SIZE = 0.05
/** Resize normalized crop region from pointer delta; uses drag origin, not live attrs. */
export function resizeCropRegion(
handle: string,
origin: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">,
dxNorm: number,
dyNorm: number
): Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight"> {
let cropX = origin.cropX
let cropY = origin.cropY
let cropWidth = origin.cropWidth
let cropHeight = origin.cropHeight
if (handle.includes("w")) {
const dx = Math.max(-origin.cropX, Math.min(origin.cropWidth - MIN_CROP_SIZE, dxNorm))
cropX = origin.cropX + dx
cropWidth = origin.cropWidth - dx
} else if (handle.includes("e")) {
cropWidth = Math.max(MIN_CROP_SIZE, Math.min(1 - origin.cropX, origin.cropWidth + dxNorm))
}
if (handle.includes("n")) {
const dy = Math.max(-origin.cropY, Math.min(origin.cropHeight - MIN_CROP_SIZE, dyNorm))
cropY = origin.cropY + dy
cropHeight = origin.cropHeight - dy
} else if (handle.includes("s")) {
cropHeight = Math.max(MIN_CROP_SIZE, Math.min(1 - origin.cropY, origin.cropHeight + dyNorm))
}
return {
cropX: roundCrop(cropX),
cropY: roundCrop(cropY),
cropWidth: roundCrop(cropWidth),
cropHeight: roundCrop(cropHeight),
}
}
function roundCrop(value: number): number {
return Math.round(value * 10000) / 10000
}
export type CropDisplayGeometry = {
/** Full source image draw rect (may extend past the frame in cover mode). */
imageRect: { left: number; top: number; width: number; height: number }
/** Crop window matching the image as currently shown in the document. */
windowRect: { left: number; top: number; width: number; height: number }
cropRect: { left: number; top: number; width: number; height: number }
}
/** Crop window = displayed image bounds; crop handles shrink from that origin. */
export function computeCropEditWindowRect(
attrs: Pick<DocsGraphicAttrs, "imageFit" | "imageFitAnchorH" | "imageFitAnchorV">,
frameWidth: number,
frameHeight: number,
naturalWidth: number,
naturalHeight: number
): { left: number; top: number; width: number; height: number } {
if (attrs.imageFit === "crop") {
return { left: 0, top: 0, width: frameWidth, height: frameHeight }
}
const layoutFit = layoutFitForImage(attrs.imageFit)
const imageRect = computeImageContentRect(
frameWidth,
frameHeight,
naturalWidth,
naturalHeight,
layoutFit,
attrs.imageFitAnchorH,
attrs.imageFitAnchorV
)
if (layoutFit === "cover") {
return { left: 0, top: 0, width: frameWidth, height: frameHeight }
}
return imageRect
}
/** Full source image rect when a baked crop is shown in the frame. */
export function computeBakedCropImageRect(
sourceCrop: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">,
frameWidth: number,
_frameHeight: number,
naturalWidth: number,
naturalHeight: number
): { left: number; top: number; width: number; height: number } {
const cropWpx = Math.max(sourceCrop.cropWidth, 0.01) * naturalWidth
const scale = frameWidth / cropWpx
return {
left: -(sourceCrop.cropX * naturalWidth * scale),
top: -(sourceCrop.cropY * naturalHeight * scale),
width: naturalWidth * scale,
height: naturalHeight * scale,
}
}
/** Image draw area + crop window in frame pixel coordinates. */
export function computeCropDisplayGeometry(
attrs: Pick<
DocsGraphicAttrs,
| "cropX"
| "cropY"
| "cropWidth"
| "cropHeight"
| "imageFit"
| "imageFitAnchorH"
| "imageFitAnchorV"
>,
frameWidth: number,
frameHeight: number,
naturalWidth: number,
naturalHeight: number,
baseSource?: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight"> | null
): CropDisplayGeometry {
if (baseSource && hasActiveCrop(baseSource)) {
const imageRect = computeBakedCropImageRect(
baseSource,
frameWidth,
frameHeight,
naturalWidth,
naturalHeight
)
const windowRect = imageRect
return {
imageRect,
windowRect,
cropRect: {
left: windowRect.left + attrs.cropX * windowRect.width,
top: windowRect.top + attrs.cropY * windowRect.height,
width: Math.max(1, attrs.cropWidth * windowRect.width),
height: Math.max(1, attrs.cropHeight * windowRect.height),
},
}
}
const editFit: DocsImageFit = attrs.imageFit === "crop" ? "contain" : attrs.imageFit
const layoutFit = layoutFitForImage(editFit)
const imageRect = computeImageContentRect(
frameWidth,
frameHeight,
naturalWidth,
naturalHeight,
layoutFit,
attrs.imageFitAnchorH,
attrs.imageFitAnchorV
)
const windowRect = computeCropEditWindowRect(
{ ...attrs, imageFit: editFit },
frameWidth,
frameHeight,
naturalWidth,
naturalHeight
)
return {
imageRect,
windowRect,
cropRect: {
left: windowRect.left + attrs.cropX * windowRect.width,
top: windowRect.top + attrs.cropY * windowRect.height,
width: Math.max(1, attrs.cropWidth * windowRect.width),
height: Math.max(1, attrs.cropHeight * windowRect.height),
},
}
}
/** Map window-relative crop (edit session) to source-normalized 01 coords. */
export function windowCropInGeometryToSource(
windowCrop: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">,
imageRect: { left: number; top: number; width: number; height: number },
windowRect: { left: number; top: number; width: number; height: number }
): Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight"> {
const cropFrameLeft = windowRect.left + windowCrop.cropX * windowRect.width
const cropFrameTop = windowRect.top + windowCrop.cropY * windowRect.height
const cropFrameW = Math.max(windowCrop.cropWidth * windowRect.width, 1)
const cropFrameH = Math.max(windowCrop.cropHeight * windowRect.height, 1)
const cropX = (cropFrameLeft - imageRect.left) / Math.max(imageRect.width, 1)
const cropY = (cropFrameTop - imageRect.top) / Math.max(imageRect.height, 1)
const cropWidth = cropFrameW / Math.max(imageRect.width, 1)
const cropHeight = cropFrameH / Math.max(imageRect.height, 1)
const clampedX = Math.min(Math.max(cropX, 0), 1)
const clampedY = Math.min(Math.max(cropY, 0), 1)
return {
cropX: roundCrop(clampedX),
cropY: roundCrop(clampedY),
cropWidth: roundCrop(Math.min(Math.max(cropWidth, MIN_CROP_SIZE), 1 - clampedX)),
cropHeight: roundCrop(Math.min(Math.max(cropHeight, MIN_CROP_SIZE), 1 - clampedY)),
}
}
/** Initial window-relative crop when re-opening edit on a baked crop. */
export function computeCropReeditInitialRegion(
baseSource: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">,
frameWidth: number,
frameHeight: number,
naturalWidth: number,
naturalHeight: number
): Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight"> {
const imageRect = computeBakedCropImageRect(
baseSource,
frameWidth,
frameHeight,
naturalWidth,
naturalHeight
)
const cropX = Math.min(Math.max(-imageRect.left / Math.max(imageRect.width, 1), 0), 1)
const cropY = Math.min(Math.max(-imageRect.top / Math.max(imageRect.height, 1), 0), 1)
const cropWidth = Math.min(
Math.max(frameWidth / Math.max(imageRect.width, 1), MIN_CROP_SIZE),
1 - cropX
)
const cropHeight = Math.min(
Math.max(frameHeight / Math.max(imageRect.height, 1), MIN_CROP_SIZE),
1 - cropY
)
return {
cropX: roundCrop(cropX),
cropY: roundCrop(cropY),
cropWidth: roundCrop(cropWidth),
cropHeight: roundCrop(cropHeight),
}
}
/** Narrow an existing source crop with a window-relative edit crop. */
export function compositeSourceCrop(
base: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">,
window: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">
): Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight"> {
return {
cropX: roundCrop(base.cropX + window.cropX * base.cropWidth),
cropY: roundCrop(base.cropY + window.cropY * base.cropHeight),
cropWidth: roundCrop(base.cropWidth * window.cropWidth),
cropHeight: roundCrop(base.cropHeight * window.cropHeight),
}
}
/** Merge session pan into persisted crop origin on apply. */
export function mergeCropPanIntoAttrs(
attrs: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">,
panX: number,
panY: number
): Pick<DocsGraphicAttrs, "cropX" | "cropY"> {
return {
cropX: roundCrop(
Math.min(Math.max(attrs.cropX + panX, 0), Math.max(0, 1 - attrs.cropWidth))
),
cropY: roundCrop(
Math.min(Math.max(attrs.cropY + panY, 0), Math.max(0, 1 - attrs.cropHeight))
),
}
}
/** Frame size + attrs after applying a crop edit (window becomes output size). */
export function computeCropApplyPatch(
attrs: Pick<
DocsGraphicAttrs,
| "cropX"
| "cropY"
| "cropWidth"
| "cropHeight"
| "imageFit"
| "imageFitAnchorH"
| "imageFitAnchorV"
| "width"
| "height"
>,
panX: number,
panY: number,
naturalWidth: number,
naturalHeight: number,
baseSource?: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight"> | null
): Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight" | "width" | "height" | "imageFit"> {
const merged = { ...attrs, ...mergeCropPanIntoAttrs(attrs, panX, panY) }
const geometry = computeCropDisplayGeometry(
merged,
attrs.width,
attrs.height,
naturalWidth,
naturalHeight,
baseSource
)
const sourceCrop = windowCropInGeometryToSource(
merged,
geometry.imageRect,
geometry.windowRect
)
return {
cropX: sourceCrop.cropX,
cropY: sourceCrop.cropY,
cropWidth: sourceCrop.cropWidth,
cropHeight: sourceCrop.cropHeight,
width: Math.max(24, Math.round(geometry.cropRect.width)),
height: Math.max(24, Math.round(geometry.cropRect.height)),
imageFit: "crop",
}
}
/** Crop region maps 1:1 to frame (frame aspect matches crop after apply). */
export function computeCropImageStyle(
attrs: Pick<
DocsGraphicAttrs,
"cropX" | "cropY" | "cropWidth" | "cropHeight" | "cropShape"
>,
frameWidth: number,
frameHeight: number,
naturalWidth: number,
naturalHeight: number
): { img: Record<string, string | number>; clipPath?: string } {
if (!hasActiveCrop(attrs)) return { img: {} }
if (frameWidth <= 0 || frameHeight <= 0 || naturalWidth <= 0 || naturalHeight <= 0) {
return { img: {} }
}
const imageRect = computeBakedCropImageRect(
attrs,
frameWidth,
frameHeight,
naturalWidth,
naturalHeight
)
const img: Record<string, string | number> = {
position: "absolute",
left: imageRect.left,
top: imageRect.top,
width: imageRect.width,
height: imageRect.height,
maxWidth: "none",
}
const clipPath =
attrs.cropShape === "ellipse" ? "ellipse(50% 50% at 50% 50%)" : undefined
return { img, clipPath }
}
/** Pan the source image under a fixed crop window (drag follows pointer). */
export function panCropRegion(
origin: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">,
dxNorm: number,
dyNorm: number
): Pick<DocsGraphicAttrs, "cropX" | "cropY"> {
return {
cropX: roundCrop(
Math.min(Math.max(origin.cropX + dxNorm, 0), Math.max(0, 1 - origin.cropWidth))
),
cropY: roundCrop(
Math.min(Math.max(origin.cropY + dyNorm, 0), Math.max(0, 1 - origin.cropHeight))
),
}
}
export function imageAttrsToGraphic(raw: Record<string, unknown>): Partial<DocsGraphicAttrs> {
const width =
typeof raw.width === "number"
? raw.width
: typeof raw.width === "string"
? Number(raw.width) || DOCS_GRAPHIC_DEFAULTS.width
: DOCS_GRAPHIC_DEFAULTS.width
const height =
typeof raw.height === "number"
? raw.height
: typeof raw.height === "string"
? Number(raw.height) || DOCS_GRAPHIC_DEFAULTS.height
: DOCS_GRAPHIC_DEFAULTS.height
return parseGraphicAttrs({
graphicType: "image",
src: raw.src,
alt: raw.alt ?? "",
width,
height,
placement: raw.placement ?? "inline",
wrap: raw.wrap ?? "inline",
floatSide: raw.floatSide ?? "left",
x: raw.x ?? 0,
y: raw.y ?? 0,
rotationDeg: raw.rotationDeg ?? 0,
zIndex: raw.zIndex ?? 0,
cropX: raw.cropX ?? 0,
cropY: raw.cropY ?? 0,
cropWidth: raw.cropWidth ?? 1,
cropHeight: raw.cropHeight ?? 1,
cropShape: raw.cropShape ?? "rect",
assetId: raw.assetId ?? null,
opacity: raw.opacity ?? 1,
shadow: raw.shadow ?? "",
})
}