257 lines
7.3 KiB
TypeScript
257 lines
7.3 KiB
TypeScript
export type DocsGraphicType = "image" | "shape" | "gradient"
|
||
|
||
export type DocsGraphicPlacement = "inline" | "block" | "absolute"
|
||
|
||
/** 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 DocsGraphicAttrs = {
|
||
graphicType: DocsGraphicType
|
||
src: string | null
|
||
alt: string
|
||
assetId: string | null
|
||
shapeType: DocsShapeType
|
||
fill: string
|
||
stroke: string
|
||
strokeWidth: number
|
||
gradientCss: string
|
||
gradientAngle: number
|
||
gradientColor1: string
|
||
gradientColor2: string
|
||
width: number
|
||
height: number
|
||
placement: DocsGraphicPlacement
|
||
wrap: DocsGraphicWrap
|
||
floatSide: DocsGraphicFloatSide
|
||
x: number
|
||
y: number
|
||
rotationDeg: number
|
||
zIndex: number
|
||
/** Normalized crop region 0–1 relative to source image */
|
||
cropX: number
|
||
cropY: number
|
||
cropWidth: number
|
||
cropHeight: number
|
||
cropShape: DocsCropShape
|
||
opacity: number
|
||
shadow: string
|
||
}
|
||
|
||
export const DOCS_GRAPHIC_DEFAULTS: DocsGraphicAttrs = {
|
||
graphicType: "image",
|
||
src: null,
|
||
alt: "",
|
||
assetId: null,
|
||
shapeType: "rect",
|
||
fill: "#4285f4",
|
||
stroke: "#1a73e8",
|
||
strokeWidth: 2,
|
||
gradientCss: "",
|
||
gradientAngle: 180,
|
||
gradientColor1: "#4285f4",
|
||
gradientColor2: "#34a853",
|
||
width: 240,
|
||
height: 160,
|
||
placement: "block",
|
||
wrap: "square",
|
||
floatSide: "left",
|
||
x: 0,
|
||
y: 0,
|
||
rotationDeg: 0,
|
||
zIndex: 0,
|
||
cropX: 0,
|
||
cropY: 0,
|
||
cropWidth: 1,
|
||
cropHeight: 1,
|
||
cropShape: "rect",
|
||
opacity: 1,
|
||
shadow: "",
|
||
}
|
||
|
||
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 function buildGradientCss(
|
||
angle: number,
|
||
color1: string,
|
||
color2: string
|
||
): string {
|
||
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 gradientCss =
|
||
str("gradientCss", "") ||
|
||
(raw.graphicType === "gradient"
|
||
? buildGradientCss(gradientAngle, gradientColor1, gradientColor2)
|
||
: "")
|
||
|
||
return {
|
||
graphicType:
|
||
raw.graphicType === "shape" || raw.graphicType === "gradient" || raw.graphicType === "image"
|
||
? 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,
|
||
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: num("x", 0),
|
||
y: num("y", 0),
|
||
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",
|
||
opacity: clamp01(num("opacity", 1), 1),
|
||
shadow: str("shadow", ""),
|
||
}
|
||
}
|
||
|
||
function clamp01(value: number, fallback = 0): number {
|
||
if (!Number.isFinite(value)) return fallback
|
||
return Math.min(1, Math.max(0, value))
|
||
}
|
||
|
||
/** CSS styles to render a cropped image inside its frame. */
|
||
export function computeCropImageStyle(attrs: Pick<
|
||
DocsGraphicAttrs,
|
||
"cropX" | "cropY" | "cropWidth" | "cropHeight" | "cropShape"
|
||
>): { img: Record<string, string | number>; clipPath?: string } {
|
||
const hasCrop =
|
||
attrs.cropX > 0 ||
|
||
attrs.cropY > 0 ||
|
||
attrs.cropWidth < 1 ||
|
||
attrs.cropHeight < 1
|
||
|
||
if (!hasCrop) return { img: {} }
|
||
|
||
const scaleX = 1 / Math.max(attrs.cropWidth, 0.01)
|
||
const scaleY = 1 / Math.max(attrs.cropHeight, 0.01)
|
||
const offsetX = -(attrs.cropX * scaleX * 100)
|
||
const offsetY = -(attrs.cropY * scaleY * 100)
|
||
|
||
const img: Record<string, string | number> = {
|
||
width: `${scaleX * 100}%`,
|
||
height: `${scaleY * 100}%`,
|
||
maxWidth: "none",
|
||
objectFit: "cover",
|
||
objectPosition: `${offsetX}% ${offsetY}%`,
|
||
}
|
||
|
||
const clipPath =
|
||
attrs.cropShape === "ellipse" ? "ellipse(50% 50% at 50% 50%)" : undefined
|
||
|
||
return { img, clipPath }
|
||
}
|
||
|
||
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 ?? "",
|
||
})
|
||
}
|