844 lines
27 KiB
TypeScript
844 lines
27 KiB
TypeScript
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 0–1 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 0–1 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 ?? "",
|
||
})
|
||
}
|