mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
- Path icons: use uniform scaling (preserve aspect ratio instead of squishing), fillRule 'evenodd' for compound paths with cutouts, transparent fill for stroke-only icons, strokeUniform to keep stroke width constant - Text centering: use actual Fabric rendered height (fontSize * lineHeight) for cross-axis centering instead of declared height - Hover cursor: use default arrow instead of move/crosshair on elements - Stream reliability: server sends keep-alive pings every 15s during API TTFT, forwards thinking_delta events, client resets timeout on ping/thinking chunks - Remove silent fallback from streamChat to generateCompletion (was causing double requests when server is unresponsive) - Add 3-minute timeout to generateCompletion - AI prompts: instruct model to preserve icon aspect ratios
444 lines
13 KiB
TypeScript
444 lines
13 KiB
TypeScript
import * as fabric from 'fabric'
|
|
import type { PenNode } from '@/types/pen'
|
|
import type {
|
|
PenFill,
|
|
PenStroke,
|
|
PenEffect,
|
|
LinearGradientFill,
|
|
RadialGradientFill,
|
|
ShadowEffect,
|
|
} from '@/types/styles'
|
|
import {
|
|
DEFAULT_FILL,
|
|
DEFAULT_STROKE,
|
|
DEFAULT_STROKE_WIDTH,
|
|
SELECTION_BLUE,
|
|
} from './canvas-constants'
|
|
import { applyRotationControls } from './canvas-controls'
|
|
|
|
function angleToCoords(
|
|
angleDeg: number,
|
|
width: number,
|
|
height: number,
|
|
): { x1: number; y1: number; x2: number; y2: number } {
|
|
const rad = ((angleDeg - 90) * Math.PI) / 180
|
|
const cos = Math.cos(rad)
|
|
const sin = Math.sin(rad)
|
|
return {
|
|
x1: width / 2 - (cos * width) / 2,
|
|
y1: height / 2 - (sin * height) / 2,
|
|
x2: width / 2 + (cos * width) / 2,
|
|
y2: height / 2 + (sin * height) / 2,
|
|
}
|
|
}
|
|
|
|
function createLinearGradient(
|
|
fill: LinearGradientFill,
|
|
width: number,
|
|
height: number,
|
|
): fabric.Gradient<'linear'> {
|
|
const coords = angleToCoords(fill.angle ?? 0, width, height)
|
|
return new fabric.Gradient({
|
|
type: 'linear',
|
|
coords,
|
|
colorStops: fill.stops.map((s) => ({
|
|
offset: s.offset,
|
|
color: s.color,
|
|
})),
|
|
})
|
|
}
|
|
|
|
function createRadialGradient(
|
|
fill: RadialGradientFill,
|
|
width: number,
|
|
height: number,
|
|
): fabric.Gradient<'radial'> {
|
|
const cx = (fill.cx ?? 0.5) * width
|
|
const cy = (fill.cy ?? 0.5) * height
|
|
const r = (fill.radius ?? 0.5) * Math.max(width, height)
|
|
return new fabric.Gradient({
|
|
type: 'radial',
|
|
coords: { x1: cx, y1: cy, r1: 0, x2: cx, y2: cy, r2: r },
|
|
colorStops: fill.stops.map((s) => ({
|
|
offset: s.offset,
|
|
color: s.color,
|
|
})),
|
|
})
|
|
}
|
|
|
|
export function resolveFill(
|
|
fills: PenFill[] | undefined,
|
|
width: number,
|
|
height: number,
|
|
): string | fabric.Gradient<'linear'> | fabric.Gradient<'radial'> {
|
|
if (!fills || fills.length === 0) return DEFAULT_FILL
|
|
const first = fills[0]
|
|
if (first.type === 'solid') return first.color
|
|
if (first.type === 'linear_gradient') {
|
|
return createLinearGradient(first, width, height)
|
|
}
|
|
if (first.type === 'radial_gradient') {
|
|
return createRadialGradient(first, width, height)
|
|
}
|
|
return DEFAULT_FILL
|
|
}
|
|
|
|
export function resolveFillColor(fills?: PenFill[]): string {
|
|
if (!fills || fills.length === 0) return DEFAULT_FILL
|
|
const first = fills[0]
|
|
if (first.type === 'solid') return first.color
|
|
if (
|
|
first.type === 'linear_gradient' ||
|
|
first.type === 'radial_gradient'
|
|
) {
|
|
return first.stops[0]?.color ?? DEFAULT_FILL
|
|
}
|
|
return DEFAULT_FILL
|
|
}
|
|
|
|
export function resolveShadow(
|
|
effects?: PenEffect[],
|
|
): fabric.Shadow | undefined {
|
|
if (!effects) return undefined
|
|
const shadow = effects.find(
|
|
(e): e is ShadowEffect => e.type === 'shadow',
|
|
)
|
|
if (!shadow) return undefined
|
|
return new fabric.Shadow({
|
|
color: shadow.color,
|
|
blur: shadow.blur,
|
|
offsetX: shadow.offsetX,
|
|
offsetY: shadow.offsetY,
|
|
})
|
|
}
|
|
|
|
export function resolveStrokeColor(stroke?: PenStroke): string | undefined {
|
|
if (!stroke) return undefined
|
|
if (stroke.fill && stroke.fill.length > 0) {
|
|
return resolveFillColor(stroke.fill)
|
|
}
|
|
return DEFAULT_STROKE
|
|
}
|
|
|
|
export function resolveStrokeWidth(stroke?: PenStroke): number {
|
|
if (!stroke) return 0
|
|
if (typeof stroke.thickness === 'number') return stroke.thickness
|
|
return stroke.thickness[0] ?? DEFAULT_STROKE_WIDTH
|
|
}
|
|
|
|
function resolveTextContent(
|
|
content: string | { text: string }[],
|
|
): string {
|
|
if (typeof content === 'string') return content
|
|
return content.map((s) => s.text).join('')
|
|
}
|
|
|
|
function sizeToNumber(
|
|
val: number | string | undefined,
|
|
fallback: number,
|
|
): number {
|
|
if (typeof val === 'number') return val
|
|
if (typeof val === 'string') {
|
|
// Handle "fit_content(N)" / "fill_container(N)"
|
|
const m = val.match(/\((\d+(?:\.\d+)?)\)/)
|
|
if (m) return parseFloat(m[1])
|
|
const n = parseFloat(val)
|
|
if (!isNaN(n)) return n
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
function cornerRadiusValue(
|
|
cr: number | [number, number, number, number] | undefined,
|
|
): number {
|
|
if (cr === undefined) return 0
|
|
if (typeof cr === 'number') return cr
|
|
return cr[0]
|
|
}
|
|
|
|
export interface FabricObjectWithPenId extends fabric.FabricObject {
|
|
penNodeId?: string
|
|
}
|
|
|
|
export function createFabricObject(
|
|
node: PenNode,
|
|
): FabricObjectWithPenId | null {
|
|
let obj: FabricObjectWithPenId | null = null
|
|
|
|
const baseProps = {
|
|
left: node.x ?? 0,
|
|
top: node.y ?? 0,
|
|
originX: 'left' as const,
|
|
originY: 'top' as const,
|
|
angle: node.rotation ?? 0,
|
|
opacity: typeof node.opacity === 'number' ? node.opacity : 1,
|
|
}
|
|
|
|
// Resolve effects (shadow)
|
|
const effects = 'effects' in node ? node.effects : undefined
|
|
const shadow = resolveShadow(effects)
|
|
|
|
// Resolve visibility and lock
|
|
const visible = ('visible' in node ? node.visible : undefined) !== false
|
|
const locked = ('locked' in node ? node.locked : undefined) === true
|
|
|
|
switch (node.type) {
|
|
case 'frame': {
|
|
// Frames without explicit fill are transparent containers
|
|
const r = cornerRadiusValue(node.cornerRadius)
|
|
const w = sizeToNumber(node.width, 100)
|
|
const h = sizeToNumber(node.height, 100)
|
|
const hasFill = node.fill && node.fill.length > 0
|
|
obj = new fabric.Rect({
|
|
...baseProps,
|
|
width: w,
|
|
height: h,
|
|
rx: r,
|
|
ry: r,
|
|
fill: hasFill ? resolveFill(node.fill, w, h) : 'transparent',
|
|
stroke: resolveStrokeColor(node.stroke),
|
|
strokeWidth: resolveStrokeWidth(node.stroke),
|
|
}) as FabricObjectWithPenId
|
|
break
|
|
}
|
|
case 'rectangle': {
|
|
const r = cornerRadiusValue(node.cornerRadius)
|
|
const w = sizeToNumber(node.width, 100)
|
|
const h = sizeToNumber(node.height, 100)
|
|
obj = new fabric.Rect({
|
|
...baseProps,
|
|
width: w,
|
|
height: h,
|
|
rx: r,
|
|
ry: r,
|
|
fill: resolveFill(node.fill, w, h),
|
|
stroke: resolveStrokeColor(node.stroke),
|
|
strokeWidth: resolveStrokeWidth(node.stroke),
|
|
}) as FabricObjectWithPenId
|
|
break
|
|
}
|
|
case 'ellipse': {
|
|
const w = sizeToNumber(node.width, 100)
|
|
const h = sizeToNumber(node.height, 100)
|
|
obj = new fabric.Ellipse({
|
|
...baseProps,
|
|
rx: w / 2,
|
|
ry: h / 2,
|
|
fill: resolveFill(node.fill, w, h),
|
|
stroke: resolveStrokeColor(node.stroke),
|
|
strokeWidth: resolveStrokeWidth(node.stroke),
|
|
}) as FabricObjectWithPenId
|
|
break
|
|
}
|
|
case 'line': {
|
|
obj = new fabric.Line(
|
|
[
|
|
node.x ?? 0,
|
|
node.y ?? 0,
|
|
node.x2 ?? (node.x ?? 0) + 100,
|
|
node.y2 ?? (node.y ?? 0),
|
|
],
|
|
{
|
|
...baseProps,
|
|
stroke: resolveStrokeColor(node.stroke) ?? DEFAULT_STROKE,
|
|
strokeWidth: resolveStrokeWidth(node.stroke) || DEFAULT_STROKE_WIDTH,
|
|
fill: '',
|
|
},
|
|
) as FabricObjectWithPenId
|
|
break
|
|
}
|
|
case 'polygon': {
|
|
const w = sizeToNumber(node.width, 100)
|
|
const h = sizeToNumber(node.height, 100)
|
|
const count = node.polygonCount || 6
|
|
const points = Array.from({ length: count }, (_, i) => {
|
|
const angle = (i * 2 * Math.PI) / count - Math.PI / 2
|
|
return {
|
|
x: (w / 2) * Math.cos(angle) + w / 2,
|
|
y: (h / 2) * Math.sin(angle) + h / 2,
|
|
}
|
|
})
|
|
obj = new fabric.Polygon(points, {
|
|
...baseProps,
|
|
fill: resolveFill(node.fill, w, h),
|
|
stroke: resolveStrokeColor(node.stroke),
|
|
strokeWidth: resolveStrokeWidth(node.stroke),
|
|
}) as FabricObjectWithPenId
|
|
// Cache native dimensions before scaling (Polygon width/height is derived from points)
|
|
;(obj as any).__nativeWidth = obj.width
|
|
;(obj as any).__nativeHeight = obj.height
|
|
if (w > 0 && h > 0 && obj.width && obj.height) {
|
|
obj.set({ scaleX: w / obj.width, scaleY: h / obj.height })
|
|
}
|
|
break
|
|
}
|
|
case 'path': {
|
|
const pw = sizeToNumber(node.width, 0)
|
|
const ph = sizeToNumber(node.height, 0)
|
|
const hasExplicitFill = node.fill && node.fill.length > 0
|
|
const hasStroke = !!node.stroke
|
|
// Stroke-only icons (e.g. Lucide-style) must not get a default fill.
|
|
// Use 'transparent' (not 'none' — Fabric.js ignores 'none' and falls back to black).
|
|
const pathFill = hasExplicitFill
|
|
? resolveFill(node.fill, pw || 100, ph || 100)
|
|
: hasStroke
|
|
? 'transparent'
|
|
: DEFAULT_FILL
|
|
obj = new fabric.Path(node.d, {
|
|
...baseProps,
|
|
fill: pathFill,
|
|
stroke: resolveStrokeColor(node.stroke),
|
|
strokeWidth: resolveStrokeWidth(node.stroke),
|
|
strokeUniform: true,
|
|
fillRule: 'evenodd', // Compound paths: inner sub-paths become transparent cutouts
|
|
}) as FabricObjectWithPenId
|
|
// Cache native dimensions before scaling (Path width/height is derived from d)
|
|
;(obj as any).__nativeWidth = obj.width
|
|
;(obj as any).__nativeHeight = obj.height
|
|
if (pw > 0 && ph > 0 && obj.width && obj.height) {
|
|
// Uniform scale — preserve aspect ratio so icons don't get squished
|
|
const uniformScale = Math.min(pw / obj.width, ph / obj.height)
|
|
obj.set({ scaleX: uniformScale, scaleY: uniformScale })
|
|
}
|
|
break
|
|
}
|
|
case 'text': {
|
|
const textContent = resolveTextContent(node.content)
|
|
const w = sizeToNumber(node.width, 0)
|
|
const textProps = {
|
|
...baseProps,
|
|
fontFamily: node.fontFamily ?? 'Inter, sans-serif',
|
|
fontSize: node.fontSize ?? 16,
|
|
fontWeight: (node.fontWeight as string) ?? 'normal',
|
|
fontStyle: node.fontStyle ?? 'normal',
|
|
fill: resolveFillColor(node.fill),
|
|
textAlign: node.textAlign ?? 'left',
|
|
underline: node.underline ?? false,
|
|
linethrough: node.strikethrough ?? false,
|
|
lineHeight: node.lineHeight ?? 1.2,
|
|
}
|
|
// Use Textbox for text with explicit width so textAlign works correctly
|
|
if (w > 0) {
|
|
obj = new fabric.Textbox(textContent, {
|
|
...textProps,
|
|
width: w,
|
|
}) as FabricObjectWithPenId
|
|
} else {
|
|
obj = new fabric.IText(textContent, textProps) as FabricObjectWithPenId
|
|
}
|
|
break
|
|
}
|
|
case 'image': {
|
|
const w = sizeToNumber(node.width, 200)
|
|
const h = sizeToNumber(node.height, 200)
|
|
const r = cornerRadiusValue(node.cornerRadius)
|
|
const imgEl = new Image()
|
|
imgEl.src = node.src
|
|
if (imgEl.complete) {
|
|
obj = new fabric.FabricImage(imgEl, {
|
|
...baseProps,
|
|
width: imgEl.naturalWidth || w,
|
|
height: imgEl.naturalHeight || h,
|
|
scaleX: w / (imgEl.naturalWidth || w),
|
|
scaleY: h / (imgEl.naturalHeight || h),
|
|
rx: r,
|
|
ry: r,
|
|
}) as unknown as FabricObjectWithPenId
|
|
} else {
|
|
// Placeholder while image loads
|
|
const placeholder = new fabric.Rect({
|
|
...baseProps,
|
|
width: w,
|
|
height: h,
|
|
rx: r,
|
|
ry: r,
|
|
fill: '#e5e7eb',
|
|
strokeWidth: 0,
|
|
}) as FabricObjectWithPenId
|
|
placeholder.penNodeId = node.id
|
|
imgEl.onload = () => {
|
|
const canvas = placeholder.canvas
|
|
if (!canvas) return
|
|
const fabricImg = new fabric.FabricImage(imgEl, {
|
|
...baseProps,
|
|
left: placeholder.left,
|
|
top: placeholder.top,
|
|
width: imgEl.naturalWidth,
|
|
height: imgEl.naturalHeight,
|
|
scaleX: w / imgEl.naturalWidth,
|
|
scaleY: h / imgEl.naturalHeight,
|
|
rx: r,
|
|
ry: r,
|
|
}) as unknown as FabricObjectWithPenId
|
|
fabricImg.penNodeId = node.id
|
|
fabricImg.set({
|
|
borderColor: SELECTION_BLUE,
|
|
borderScaleFactor: 2,
|
|
cornerColor: SELECTION_BLUE,
|
|
cornerStrokeColor: '#ffffff',
|
|
cornerStyle: 'rect',
|
|
cornerSize: 8,
|
|
transparentCorners: false,
|
|
borderOpacityWhenMoving: 1,
|
|
padding: 0,
|
|
hoverCursor: 'default',
|
|
})
|
|
fabricImg.setControlVisible('mtr', false)
|
|
applyRotationControls(fabricImg)
|
|
if (shadow) fabricImg.shadow = shadow
|
|
fabricImg.visible = visible
|
|
fabricImg.selectable = !locked
|
|
fabricImg.evented = !locked
|
|
canvas.remove(placeholder)
|
|
canvas.add(fabricImg)
|
|
canvas.requestRenderAll()
|
|
}
|
|
obj = placeholder
|
|
}
|
|
break
|
|
}
|
|
case 'group': {
|
|
const w = sizeToNumber(node.width, 100)
|
|
const h = sizeToNumber(node.height, 100)
|
|
obj = new fabric.Rect({
|
|
...baseProps,
|
|
width: w,
|
|
height: h,
|
|
fill: resolveFill(node.fill, w, h),
|
|
stroke: resolveStrokeColor(node.stroke),
|
|
strokeWidth: resolveStrokeWidth(node.stroke),
|
|
selectable: true,
|
|
}) as FabricObjectWithPenId
|
|
break
|
|
}
|
|
case 'ref': {
|
|
// RefNodes need to be resolved before rendering
|
|
return null
|
|
}
|
|
}
|
|
|
|
if (obj) {
|
|
obj.penNodeId = node.id
|
|
// Selection styling (from HEAD)
|
|
obj.set({
|
|
borderColor: SELECTION_BLUE,
|
|
borderScaleFactor: 2,
|
|
cornerColor: SELECTION_BLUE,
|
|
cornerStrokeColor: '#ffffff',
|
|
cornerStyle: 'rect',
|
|
cornerSize: 8,
|
|
transparentCorners: false,
|
|
borderOpacityWhenMoving: 1,
|
|
padding: 0,
|
|
hoverCursor: 'default',
|
|
})
|
|
obj.setControlVisible('mtr', false)
|
|
applyRotationControls(obj)
|
|
// Shadow, visibility, lock (from theirs)
|
|
if (shadow) obj.shadow = shadow
|
|
obj.visible = visible
|
|
obj.selectable = !locked
|
|
obj.evented = !locked
|
|
}
|
|
return obj
|
|
}
|