diff --git a/src/canvas/canvas-text-measure.ts b/src/canvas/canvas-text-measure.ts index 1925eddc..6a6058d4 100644 --- a/src/canvas/canvas-text-measure.ts +++ b/src/canvas/canvas-text-measure.ts @@ -1,4 +1,5 @@ import type { PenNode } from '@/types/pen' +import { cssFontFamily } from './skia/skia-paint-utils' // --------------------------------------------------------------------------- // Sizing parser (shared by layout engine and text height estimation) @@ -208,7 +209,7 @@ function countWrappedLinesCanvas2D( } const fw = typeof fontWeight === 'number' ? String(fontWeight) : (fontWeight ?? '400') - ctx.font = `${fw} ${fontSize}px ${fontFamily}` + ctx.font = `${fw} ${fontSize}px ${cssFontFamily(fontFamily)}` let total = 0 for (const rawLine of rawLines) { diff --git a/src/canvas/skia/skia-engine.ts b/src/canvas/skia/skia-engine.ts index a122607b..1968c788 100644 --- a/src/canvas/skia/skia-engine.ts +++ b/src/canvas/skia/skia-engine.ts @@ -15,7 +15,7 @@ import { import { parseSizing, defaultLineHeight } from '../canvas-text-measure' import { SkiaRenderer, type RenderNode } from './skia-renderer' import { SpatialIndex } from './skia-hit-test' -import { parseColor, wrapLine } from './skia-paint-utils' +import { parseColor, wrapLine, cssFontFamily } from './skia-paint-utils' import { viewportMatrix, zoomToPoint as vpZoomToPoint, @@ -82,7 +82,7 @@ function premeasureTextHeights(nodes: PenNode[]): PenNode[] { const fontWeight = tNode.fontWeight ?? '400' const fontFamily = tNode.fontFamily ?? 'Inter, -apple-system, "Noto Sans SC", "PingFang SC", system-ui, sans-serif' const ctx = getMeasureCtx() - ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}` + ctx.font = `${fontWeight} ${fontSize}px ${cssFontFamily(fontFamily)}` // Fixed-width text with auto height: wrap and measure actual height const wrapWidth = (tNode.width as number) + fontSize * 0.2 diff --git a/src/canvas/skia/skia-font-manager.ts b/src/canvas/skia/skia-font-manager.ts index 05f8529c..e4a580a1 100644 --- a/src/canvas/skia/skia-font-manager.ts +++ b/src/canvas/skia/skia-font-manager.ts @@ -107,6 +107,8 @@ export class SkiaFontManager { private loadedFamilies = new Set() /** Font families that failed to load — prevents repeated fetch attempts */ private failedFamilies = new Set() + /** System fonts that render via bitmap — not a failure, just not loadable into CanvasKit */ + private systemFontFamilies = new Set() /** In-flight font fetch promises to avoid duplicate requests */ private pendingFetches = new Map>() @@ -128,6 +130,11 @@ export class SkiaFontManager { return family.toLowerCase() in BUNDLED_FONTS } + /** Check if a font is a system font that should use bitmap rendering */ + isSystemFont(family: string): boolean { + return this.systemFontFamilies.has(family.toLowerCase()) || isSystemFont(family) + } + /** * Build a font fallback chain for the Paragraph API. * Only includes fonts actually registered in the TypefaceFontProvider. @@ -174,7 +181,6 @@ export class SkiaFontManager { try { this.provider.registerFont(data, familyName) this.loadedFamilies.add(familyName.toLowerCase()) - console.log(`[FontManager] Registered "${familyName}" (${(data.byteLength / 1024).toFixed(1)}KB)`) return true } catch (e) { console.warn(`[FontManager] Failed to register "${familyName}":`, e) @@ -189,6 +195,7 @@ export class SkiaFontManager { const key = family.toLowerCase() if (this.loadedFamilies.has(key)) return true if (this.failedFamilies.has(key)) return false + if (this.systemFontFamilies.has(key)) return false const existing = this.pendingFetches.get(key) if (existing) return existing @@ -198,8 +205,13 @@ export class SkiaFontManager { const result = await promise this.pendingFetches.delete(key) if (!result) { - this.failedFamilies.add(key) - console.warn(`[FontManager] Font "${family}" unavailable, will not retry`) + if (isSystemFont(family)) { + // System font — not a failure, just can't be loaded into CanvasKit. + // Renderer will use bitmap (Canvas 2D) which supports all system fonts. + this.systemFontFamilies.add(key) + } else { + this.failedFamilies.add(key) + } } return result } @@ -229,7 +241,6 @@ export class SkiaFontManager { // 2. Skip Google Fonts for system/proprietary fonts that won't exist there if (isSystemFont(family)) { - console.log(`[FontManager] "${family}" is a system font, skipping Google Fonts`) return false } @@ -243,7 +254,6 @@ export class SkiaFontManager { urls.map(async (url) => { const resp = await fetch(url) if (!resp.ok) { - console.warn(`[FontManager] Failed to fetch ${url}: ${resp.status}`) return null } return resp.arrayBuffer() @@ -259,10 +269,8 @@ export class SkiaFontManager { const regName = urls[i].includes('-ext-') ? family + ' Ext' : family if (this.registerFont(buf, regName)) registered++ } - console.log(`[FontManager] Local fonts for "${family}": ${registered}/${urls.length} registered`) return registered > 0 } catch (e) { - console.warn(`[FontManager] Local font fetch error for "${family}":`, e) return false } } @@ -328,30 +336,80 @@ export class SkiaFontManager { this.provider.delete() this.loadedFamilies.clear() this.failedFamilies.clear() + this.systemFontFamilies.clear() this.pendingFetches.clear() } } /** - * Known system/proprietary fonts that are NOT on Google Fonts. - * Avoids pointless 400 requests and CORS errors. + * Detect whether a font is available locally on the user's OS using Canvas 2D + * text measurement. If the measured width differs from a known fallback font, + * the font is installed. Results are cached to avoid repeated measurements. */ -const SYSTEM_FONT_PATTERNS = [ - // Apple - 'pingfang', 'sf pro', 'sf mono', 'sf compact', 'helvetica neue', 'helvetica', - 'apple sd gothic', 'hiragino', +const localFontCache = new Map() + +function isFontLocallyAvailable(family: string): boolean { + const key = family.toLowerCase() + const cached = localFontCache.get(key) + if (cached !== undefined) return cached + + if (typeof document === 'undefined') return false + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + if (!ctx) return false + + // Measure with two different generic fallbacks to avoid false positives + const testStr = 'mmmmmmmmmmlli1|' + ctx.font = '72px monospace' + const monoWidth = ctx.measureText(testStr).width + ctx.font = '72px serif' + const serifWidth = ctx.measureText(testStr).width + ctx.font = `72px "${family}", monospace` + const testMonoWidth = ctx.measureText(testStr).width + ctx.font = `72px "${family}", serif` + const testSerifWidth = ctx.measureText(testStr).width + + // If the font changes the measurement against BOTH fallbacks, it's installed + const available = testMonoWidth !== monoWidth && testSerifWidth !== serifWidth + localFontCache.set(key, available) + return available +} + +/** + * Known font family prefixes/patterns that are NOT on Google Fonts. + * These are system fonts, proprietary fonts, or vendor-specific fonts + * that should never be fetched from Google Fonts CDN. + */ +const NON_GOOGLE_FONT_PATTERNS = [ // Microsoft - 'microsoft yahei', 'segoe ui', 'consolas', 'arial', - // CJK - 'noto sans cjk', 'youshebiaotihei', 'simhei', 'simsun', 'fangsong', 'kaiti', - // Proprietary / non-Google - 'd-din', 'din pro', 'din-pro', 'avenir', 'futura', 'proxima nova', 'gotham', - 'brandon grotesque', 'aktiv grotesk', 'circular', + /^microsoft/i, /^ms /i, /^segoe/i, /^simhei/i, /^simsun/i, + /^kaiti/i, /^fangsong/i, /^youyuan/i, /^lishu/i, /^dengxian/i, + // Apple / macOS + /^sf /i, /^sf-/i, /^apple/i, /^pingfang/i, /^hiragino/i, + /^helvetica/i, /^menlo/i, /^monaco/i, /^lucida grande/i, + /^avenir/i, /^\.apple/i, + // Proprietary / DIN variants + /^d-din/i, /^din[ -]/i, /^din$/i, /^proxima/i, /^gotham/i, + /^futura/i, /^akzidenz/i, /^univers/i, /^frutiger/i, + // Chinese custom fonts + /^youshebiaotihei/i, /^youshebiaoti/i, + /^fz/i, /^alibaba/i, /^huawen/i, /^stk/i, /^st[hf]/i, + /^source han /i, /^noto sans cjk/i, /^noto serif cjk/i, + // Japanese + /^yu gothic/i, /^yu mincho/i, /^meiryo/i, /^ms gothic/i, /^ms mincho/i, + // System generics + /^system-ui/i, /^-apple-system/i, /^blinkmacsystemfont/i, + /^arial/i, /^times new roman/i, /^courier new/i, /^georgia/i, + /^verdana/i, /^tahoma/i, /^trebuchet/i, /^impact/i, + /^comic sans/i, /^consolas/i, /^calibri/i, /^cambria/i, ] +function isKnownNonGoogleFont(family: string): boolean { + return NON_GOOGLE_FONT_PATTERNS.some(p => p.test(family.trim())) +} + function isSystemFont(family: string): boolean { - const lower = family.toLowerCase() - return SYSTEM_FONT_PATTERNS.some(p => lower.includes(p)) + return isFontLocallyAvailable(family) || isKnownNonGoogleFont(family) } /** Fetch with timeout — rejects if response doesn't arrive within `ms`. */ diff --git a/src/canvas/skia/skia-paint-utils.ts b/src/canvas/skia/skia-paint-utils.ts index 67cbf7bd..5c31c164 100644 --- a/src/canvas/skia/skia-paint-utils.ts +++ b/src/canvas/skia/skia-paint-utils.ts @@ -144,3 +144,29 @@ export function wrapLine(ctx: CanvasRenderingContext2D, text: string, maxW: numb } if (current) out.push(current) } + +/** CSS generic font families that must NOT be quoted */ +const GENERIC_FAMILIES = new Set([ + 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', + 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', + '-apple-system', 'blinkmacsystemfont', +]) + +/** + * Quote font family names for CSS `font` shorthand. + * Names with spaces MUST be quoted, otherwise Canvas 2D parses each word + * as a separate family (e.g. "Bodoni 72 Smallcaps" → "Bodoni", "72", "Smallcaps"). + */ +export function cssFontFamily(family: string): string { + return family.split(',').map(f => { + const trimmed = f.trim() + if (!trimmed) return trimmed + // Already quoted + if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'"))) return trimmed + // Generic families must not be quoted + if (GENERIC_FAMILIES.has(trimmed.toLowerCase())) return trimmed + // Quote everything else (safe even for single-word names) + return `"${trimmed}"` + }).join(', ') +} diff --git a/src/canvas/skia/skia-renderer.ts b/src/canvas/skia/skia-renderer.ts index 94834a85..33176143 100644 --- a/src/canvas/skia/skia-renderer.ts +++ b/src/canvas/skia/skia-renderer.ts @@ -1,6 +1,6 @@ import type { CanvasKit, Canvas, Paint, Font, Typeface, Image as SkImage, Paragraph } from 'canvaskit-wasm' import type { PenNode, ContainerProps, TextNode, EllipseNode, LineNode, PolygonNode, PathNode, ImageNode, IconFontNode } from '@/types/pen' -import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@/types/styles' +import type { PenFill, PenStroke, PenEffect, ShadowEffect, ImageFill } from '@/types/styles' import { DEFAULT_FILL, DEFAULT_STROKE, DEFAULT_STROKE_WIDTH } from '../canvas-constants' import { defaultLineHeight } from '../canvas-text-measure' import { lookupIconByName } from '@/services/ai/icon-resolver' @@ -15,6 +15,7 @@ import { resolveStrokeColor, resolveStrokeWidth, wrapLine, + cssFontFamily, } from './skia-paint-utils' import { sanitizeSvgPath, hasInvalidNumbers, tryManualPathParse } from './skia-path-utils' import { @@ -134,7 +135,7 @@ export class SkiaRenderer { opacity: number, absX: number, absY: number, - ): Paint { + ): { paint: Paint; imageFillDraw?: { fill: ImageFill; w: number; h: number; absX: number; absY: number; opacity: number } } { const ck = this.ck const paint = new ck.Paint() paint.setStyle(ck.PaintStyle.Fill) @@ -144,13 +145,13 @@ export class SkiaRenderer { const c = parseColor(ck, fills) c[3] *= opacity paint.setColor(c) - return paint + return { paint } } if (!fills || fills.length === 0) { const c = parseColor(ck, DEFAULT_FILL) c[3] *= opacity paint.setColor(c) - return paint + return { paint } } const first = fills[0] @@ -211,9 +212,217 @@ export class SkiaRenderer { c[3] *= fillOpacity paint.setColor(c) } + } else if (first.type === 'image') { + const result = this.applyImageFillToPaint(paint, first, w, h, opacity, absX, absY) + if (result.needsDrawImageRect && result.fill) { + return { paint, imageFillDraw: { fill: result.fill, w: result.w!, h: result.h!, absX: result.absX!, absY: result.absY!, opacity: result.opacity! } } + } } - return paint + return { paint } + } + + /** + * Apply an image fill to a Paint object using an image shader. + * If the image is not yet loaded, a placeholder color is used. + */ + /** + * Apply an image fill to a Paint object. + * For tile mode: uses a shader with TileMode.Repeat. + * For fill/fit/crop/stretch: sets a placeholder paint and returns + * draw info so the caller can use drawImageRect (shader scaling + * is unreliable in CanvasKit for Clamp/Decal tile modes). + */ + private applyImageFillToPaint( + paint: Paint, + fill: ImageFill, + w: number, h: number, + opacity: number, + absX: number, absY: number, + ): { needsDrawImageRect: boolean; fill?: ImageFill; w?: number; h?: number; absX?: number; absY?: number; opacity?: number } { + const ck = this.ck + const fillOpacity = (fill.opacity ?? 1) * opacity + const url = fill.url + if (!url) { + const c = parseColor(ck, '#e5e7eb') + c[3] *= fillOpacity + paint.setColor(c) + return { needsDrawImageRect: false } + } + + const cached = this.imageLoader.get(url) + if (cached === undefined) { + this.imageLoader.request(url) + } + if (!cached) { + const c = parseColor(ck, '#e5e7eb') + c[3] *= fillOpacity + paint.setColor(c) + return { needsDrawImageRect: false } + } + + const imgW = cached.width() + const imgH = cached.height() + if (imgW <= 0 || imgH <= 0) return { needsDrawImageRect: false } + + const mode = fill.mode ?? 'fill' + + // Tile mode: use shader (works reliably with Repeat + translation matrix) + if (mode === 'tile') { + const dispX = absX + (w - imgW) / 2 + const dispY = absY + (h - imgH) / 2 + const localMatrix = Float32Array.of( + 1, 0, -dispX, + 0, 1, -dispY, + 0, 0, 1, + ) + const shader = cached.makeShaderOptions( + ck.TileMode.Repeat, ck.TileMode.Repeat, + ck.FilterMode.Linear, ck.MipmapMode.None, + localMatrix, + ) + if (shader) { + paint.setShader(shader) + if (fillOpacity < 1) paint.setAlphaf(fillOpacity) + const cf = this.buildImageAdjustmentFilter(fill) + if (cf) paint.setColorFilter(cf) + } + return { needsDrawImageRect: false } + } + + // For fill/fit/crop/stretch: use transparent paint, caller draws image via drawImageRect + paint.setColor(Float32Array.of(0, 0, 0, 0)) + return { needsDrawImageRect: true, fill, w, h, absX, absY, opacity: fillOpacity } + } + + /** + * Draw an image fill using drawImageRect (for fill/fit/crop/stretch modes). + * Must be called after clipping to the shape bounds. + */ + private drawImageFillRect( + canvas: Canvas, + fill: ImageFill, + w: number, h: number, + absX: number, absY: number, + fillOpacity: number, + ) { + const ck = this.ck + const url = fill.url + if (!url) return + + const cached = this.imageLoader.get(url) + if (!cached) return + + const imgW = cached.width() + const imgH = cached.height() + if (imgW <= 0 || imgH <= 0) return + + const mode = fill.mode ?? 'fill' + const paint = new ck.Paint() + paint.setAntiAlias(true) + if (fillOpacity < 1) paint.setAlphaf(fillOpacity) + + const adjFilter = this.buildImageAdjustmentFilter(fill) + if (adjFilter) paint.setColorFilter(adjFilter) + + if (mode === 'fit') { + // Contain: entire image visible, centered, with letterbox + const scale = Math.min(w / imgW, h / imgH) + const dw = imgW * scale + const dh = imgH * scale + const dx = absX + (w - dw) / 2 + const dy = absY + (h - dh) / 2 + canvas.drawImageRect( + cached, + ck.LTRBRect(0, 0, imgW, imgH), + ck.LTRBRect(dx, dy, dx + dw, dy + dh), + paint, + ) + } else if (mode === 'stretch') { + // Stretch: distort to fill entire area + canvas.drawImageRect( + cached, + ck.LTRBRect(0, 0, imgW, imgH), + ck.LTRBRect(absX, absY, absX + w, absY + h), + paint, + ) + } else { + // 'fill', 'crop': cover, centered, excess clipped by parent clip + const scale = Math.max(w / imgW, h / imgH) + const dw = imgW * scale + const dh = imgH * scale + const dx = absX + (w - dw) / 2 + const dy = absY + (h - dh) / 2 + canvas.drawImageRect( + cached, + ck.LTRBRect(0, 0, imgW, imgH), + ck.LTRBRect(dx, dy, dx + dw, dy + dh), + paint, + ) + } + + paint.delete() + } + + /** + * Build a CanvasKit ColorFilter from image adjustment values. + * Builds a single 4x5 color matrix combining all adjustments. + * + * Matrix layout (row-major 4×5): + * R' = m[0]*r + m[1]*g + m[2]*b + m[3]*a + m[4] + * G' = m[5]*r + m[6]*g + m[7]*b + m[8]*a + m[9] + * B' = m[10]*r+ m[11]*g+ m[12]*b + m[13]*a+ m[14] + * A' = m[15]*r+ m[16]*g+ m[17]*b + m[18]*a+ m[19] + */ + private buildImageAdjustmentFilter(adj: { + exposure?: number; contrast?: number; saturation?: number + temperature?: number; tint?: number; highlights?: number; shadows?: number + }) { + const ck = this.ck + const exp = (adj.exposure ?? 0) / 100 + const con = (adj.contrast ?? 0) / 100 + const sat = (adj.saturation ?? 0) / 100 + const temp = (adj.temperature ?? 0) / 100 + const tintVal = (adj.tint ?? 0) / 100 + const hi = (adj.highlights ?? 0) / 100 + const sh = (adj.shadows ?? 0) / 100 + + if (exp === 0 && con === 0 && sat === 0 && temp === 0 && tintVal === 0 && hi === 0 && sh === 0) { + return null + } + + // Exposure: brightness multiplier + const e = 1 + exp * 1.5 + + // Contrast: scale around 0.5 midpoint + const c = 1 + con + const cOff = 0.5 * (1 - c) + + // Saturation: luminance-preserving mix + const s = 1 + sat + const lr = 0.2126, lg = 0.7152, lb = 0.0722 + const sr = (1 - s) * lr, sg = (1 - s) * lg, sb = (1 - s) * lb + + // Combined scale factor for each matrix cell: contrast * exposure * saturation + // Order: saturate → exposure → contrast + // saturated_R = (sr+s)*r + sg*g + sb*b + // exposed_R = e * saturated_R + // final_R = c * exposed_R + cOff + offsets + const f = c * e + + // Offsets: temperature (warm/cool), tint, highlights, shadows + const offR = cOff + temp * 0.15 + (hi + sh * 0.5) * 0.1 + const offG = cOff + tintVal * 0.15 + (hi + sh * 0.5) * 0.1 + const offB = cOff - temp * 0.15 + (hi + sh * 0.5) * 0.1 + + const m = [ + f * (sr + s), f * sg, f * sb, 0, offR, + f * sr, f * (sg + s), f * sb, 0, offG, + f * sr, f * sg, f * (sb + s), 0, offB, + 0, 0, 0, 1, 0, + ] + + return ck.ColorFilter.MakeMatrix(m) } // Stroke paint @@ -302,9 +511,11 @@ export class SkiaRenderer { canvas.rotate(rotation, absX + absW / 2, absY + absH / 2) } - // Apply shadow + // Apply shadow (text uses glyph-shaped shadow, not rectangle) const effects = 'effects' in node ? (node as PenNode & { effects?: PenEffect[] }).effects : undefined - this.applyShadowDirect(canvas, effects, absX, absY, absW, absH) + if (node.type !== 'text') { + this.applyShadowDirect(canvas, effects, absX, absY, absW, absH) + } switch (node.type) { case 'frame': @@ -328,7 +539,7 @@ export class SkiaRenderer { this.drawIconFont(canvas, node, absX, absY, absW, absH, opacity) break case 'text': - this.drawText(canvas, node, absX, absY, absW, absH, opacity) + this.drawText(canvas, node, absX, absY, absW, absH, opacity, effects) break case 'image': this.drawImage(canvas, node, absX, absY, absW, absH, opacity) @@ -395,7 +606,7 @@ export class SkiaRenderer { const isContainer = node.type === 'frame' || node.type === 'group' // Fill - const fillPaint = this.makeFillPaint( + const { paint: fillPaint, imageFillDraw } = this.makeFillPaint( hasFill ? fills : (isContainer ? 'transparent' : undefined), w, h, opacity, x, y, ) @@ -413,6 +624,22 @@ export class SkiaRenderer { } fillPaint.delete() + // Image fill (fill/fit/crop/stretch): draw via drawImageRect with clipping + if (imageFillDraw) { + canvas.save() + if (hasRoundedCorners) { + const maxR = Math.min(w / 2, h / 2) + canvas.clipRRect( + ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), Math.min(cr[0], maxR), Math.min(cr[0], maxR)), + ck.ClipOp.Intersect, true, + ) + } else { + canvas.clipRect(ck.LTRBRect(x, y, x + w, y + h), ck.ClipOp.Intersect, true) + } + this.drawImageFillRect(canvas, imageFillDraw.fill, imageFillDraw.w, imageFillDraw.h, imageFillDraw.absX, imageFillDraw.absY, imageFillDraw.opacity) + canvas.restore() + } + // Stroke const strokePaint = this.makeStrokePaint(stroke, opacity) if (strokePaint) { @@ -445,7 +672,7 @@ export class SkiaRenderer { const path = ck.Path.MakeFromSVGString(arcD) if (path) { path.offset(x, y) - const fillPaint = this.makeFillPaint(fills, w, h, opacity, x, y) + const { paint: fillPaint } = this.makeFillPaint(fills, w, h, opacity, x, y) fillPaint.setAntiAlias(true) canvas.drawPath(path, fillPaint) fillPaint.delete() @@ -454,7 +681,7 @@ export class SkiaRenderer { return } - const fillPaint = this.makeFillPaint(fills, w, h, opacity, x, y) + const { paint: fillPaint } = this.makeFillPaint(fills, w, h, opacity, x, y) canvas.drawOval(ck.LTRBRect(x, y, x + w, y + h), fillPaint) fillPaint.delete() @@ -510,7 +737,7 @@ export class SkiaRenderer { } path.close() - const fillPaint = this.makeFillPaint(fills, w, h, opacity, x, y) + const { paint: fillPaint } = this.makeFillPaint(fills, w, h, opacity, x, y) canvas.drawPath(path, fillPaint) fillPaint.delete() @@ -553,7 +780,7 @@ export class SkiaRenderer { if (!path) { // Render fallback with the node's fill color (not debug red) if (w > 0 && h > 0) { - const fillPaint = this.makeFillPaint(fills, w, h, opacity, x, y) + const { paint: fillPaint } = this.makeFillPaint(fills, w, h, opacity, x, y) canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), fillPaint) fillPaint.delete() } @@ -598,7 +825,7 @@ export class SkiaRenderer { // Fill — use EvenOdd for compound paths (multiple sub-paths), Winding for simple paths if (hasExplicitFill || !hasVisibleStroke) { - const fillPaint = this.makeFillPaint( + const { paint: fillPaint } = this.makeFillPaint( hasExplicitFill ? fills : undefined, w, h, opacity, x, y, ) @@ -723,6 +950,11 @@ export class SkiaRenderer { // Check if primary font family is loaded; if not, try async load const primaryFamily = fontFamily.split(',')[0].trim().replace(/['"]/g, '') if (!this.fontManager.isFontReady(primaryFamily)) { + // System fonts can't be loaded into CanvasKit — use bitmap rendering + // which supports all OS-installed fonts via Canvas 2D API + if (this.fontManager.isSystemFont(primaryFamily)) { + return false + } this.fontManager.ensureFont(primaryFamily).then((ok) => { if (ok) { this.clearParaCache() @@ -861,6 +1093,55 @@ export class SkiaRenderer { return true } + /** + * Draw text shadow as a blurred copy of the actual text glyphs, + * matching Figma's drop-shadow behavior (shadow follows glyph outlines). + */ + private drawTextShadow( + canvas: Canvas, node: PenNode, + x: number, y: number, w: number, h: number, + opacity: number, + shadow: ShadowEffect, + ) { + const ck = this.ck + const tNode = node as TextNode + + // Create a shadow-colored version of the text node + const shadowFillColor = shadow.color ?? '#00000066' + const shadowNode = { + ...tNode, + fill: [{ type: 'solid' as const, color: shadowFillColor }], + } as PenNode + + const sx = x + shadow.offsetX + const sy = y + shadow.offsetY + + if (shadow.blur > 0) { + // Use saveLayer with blur ImageFilter to blur the text glyphs + const paint = new ck.Paint() + if (opacity < 1) paint.setAlphaf(opacity) + const sigma = shadow.blur / 2 + const filter = ck.ImageFilter.MakeBlur(sigma, sigma, ck.TileMode.Decal, null) + paint.setImageFilter(filter) + canvas.saveLayer(paint) + paint.delete() + + // Draw shadow text (vector path first, then bitmap fallback) + const vectorOk = this.drawTextVector(canvas, shadowNode, sx, sy, w, h, 1) + if (!vectorOk) { + this.drawTextBitmap(canvas, shadowNode, sx, sy, w, h, 1) + } + + canvas.restore() + } else { + // No blur — just draw offset text with shadow color + const vectorOk = this.drawTextVector(canvas, shadowNode, sx, sy, w, h, opacity) + if (!vectorOk) { + this.drawTextBitmap(canvas, shadowNode, sx, sy, w, h, opacity) + } + } + } + /** * Render text using browser Canvas 2D API (supports all system fonts including CJK), * then draw the rasterized result as a CanvasKit image. Results are cached. @@ -869,11 +1150,28 @@ export class SkiaRenderer { canvas: Canvas, node: PenNode, x: number, y: number, w: number, h: number, opacity: number, + effects?: PenEffect[], ) { + // Draw text shadow as blurred copy of the text glyphs (not a rectangle) + const shadow = effects?.find((e): e is ShadowEffect => e.type === 'shadow') + if (shadow) { + this.drawTextShadow(canvas, node, x, y, w, h, opacity, shadow) + } + // Try vector text first (true Skia Paragraph API — no pixelation at any zoom) const vectorOk = this.drawTextVector(canvas, node, x, y, w, h, opacity) if (vectorOk) return + // Fallback to bitmap text rendering + this.drawTextBitmap(canvas, node, x, y, w, h, opacity) + } + + /** Bitmap text rendering fallback — supports all system fonts via Canvas 2D API. */ + private drawTextBitmap( + canvas: Canvas, node: PenNode, + x: number, y: number, w: number, h: number, + opacity: number, + ) { const ck = this.ck const tNode = node as TextNode const content = typeof tNode.content === 'string' @@ -905,7 +1203,7 @@ export class SkiaRenderer { // Set up measurement context const measureCanvas = document.createElement('canvas') const mCtx = measureCanvas.getContext('2d')! - mCtx.font = `${fontWeight} ${fontSize}px ${fontFamily}` + mCtx.font = `${fontWeight} ${fontSize}px ${cssFontFamily(fontFamily)}` const rawLines = content.split('\n') let wrappedLines: string[] @@ -947,7 +1245,7 @@ export class SkiaRenderer { const scale = rawScale <= 2 ? 2 : rawScale <= 4 ? 4 : 8 // Cache key — includes rasterization scale so zoom changes use fresh textures - const cacheKey = `${content}|${fontSize}|${fillColor}|${fontWeight}|${textAlign}|${Math.round(renderW)}|${Math.round(textH)}|${scale}` + const cacheKey = `${content}|${fontSize}|${fillColor}|${fontWeight}|${fontFamily}|${textAlign}|${Math.round(renderW)}|${Math.round(textH)}|${scale}` let img = this.textCache.get(cacheKey) if (img === undefined) { @@ -968,7 +1266,7 @@ export class SkiaRenderer { tmp.height = ch const ctx = tmp.getContext('2d')! ctx.scale(effectiveScale, effectiveScale) - ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}` + ctx.font = `${fontWeight} ${fontSize}px ${cssFontFamily(fontFamily)}` ctx.fillStyle = fillColor ctx.textBaseline = 'top' ctx.textAlign = (textAlign || 'left') as CanvasTextAlign @@ -1058,7 +1356,11 @@ export class SkiaRenderer { return } - // Draw loaded image with optional corner radius clipping + // Draw loaded image with objectFit and optional corner radius clipping + const imgW = cached.width() + const imgH = cached.height() + + // Clip for corner radius if (cr > 0) { canvas.save() const maxR = Math.min(cr, w / 2, h / 2) @@ -1066,18 +1368,72 @@ export class SkiaRenderer { ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), maxR, maxR), ck.ClipOp.Intersect, true, ) + } else { + canvas.save() + canvas.clipRect(ck.LTRBRect(x, y, x + w, y + h), ck.ClipOp.Intersect, true) } + const paint = new ck.Paint() paint.setAntiAlias(true) if (opacity < 1) paint.setAlphaf(opacity) - canvas.drawImageRect( - cached, - ck.LTRBRect(0, 0, cached.width(), cached.height()), - ck.LTRBRect(x, y, x + w, y + h), - paint, - ) + + // Apply image adjustments if any + const adjFilter = this.buildImageAdjustmentFilter(iNode) + if (adjFilter) paint.setColorFilter(adjFilter) + + const fit = iNode.objectFit ?? 'fill' + + if (fit === 'tile') { + // Tile: repeat image at its original pixel size + const tileMatrix = Float32Array.of(1, 0, -x, 0, 1, -y, 0, 0, 1) + const shader = cached.makeShaderOptions( + ck.TileMode.Repeat, ck.TileMode.Repeat, + ck.FilterMode.Linear, ck.MipmapMode.None, + tileMatrix, + ) + if (shader) { + paint.setShader(shader) + canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), paint) + } + } else if (fit === 'fit') { + // Fit (contain): scale uniformly so entire image is visible, centered + // Draw a subtle background so letterbox areas are visible + const bgPaint = new ck.Paint() + bgPaint.setStyle(ck.PaintStyle.Fill) + bgPaint.setColor(parseColor(ck, '#f3f4f6')) + if (opacity < 1) bgPaint.setAlphaf(opacity * 0.3) + else bgPaint.setAlphaf(0.3) + canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), bgPaint) + bgPaint.delete() + + const scale = Math.min(w / imgW, h / imgH) + const dw = imgW * scale + const dh = imgH * scale + const dx = x + (w - dw) / 2 + const dy = y + (h - dh) / 2 + canvas.drawImageRect( + cached, + ck.LTRBRect(0, 0, imgW, imgH), + ck.LTRBRect(dx, dy, dx + dw, dy + dh), + paint, + ) + } else { + // 'fill' and 'crop' (cover): scale uniformly to fill entire area, centered, excess clipped + const scale = Math.max(w / imgW, h / imgH) + const dw = imgW * scale + const dh = imgH * scale + const dx = x + (w - dw) / 2 + const dy = y + (h - dh) / 2 + canvas.drawImageRect( + cached, + ck.LTRBRect(0, 0, imgW, imgH), + ck.LTRBRect(dx, dy, dx + dw, dy + dh), + paint, + ) + } + paint.delete() - if (cr > 0) canvas.restore() + canvas.restore() } private drawImageFallback( diff --git a/src/components/editor/top-bar.tsx b/src/components/editor/top-bar.tsx index ee2e57d4..8fc4872c 100644 --- a/src/components/editor/top-bar.tsx +++ b/src/components/editor/top-bar.tsx @@ -382,7 +382,7 @@ export default function TopBar() { {/* Center section — file name */}
- + {displayName} {isDirty && ( diff --git a/src/components/panels/fill-section.tsx b/src/components/panels/fill-section.tsx index 95d6434e..1177b10d 100644 --- a/src/components/panels/fill-section.tsx +++ b/src/components/panels/fill-section.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import ColorPicker from '@/components/shared/color-picker' import NumberInput from '@/components/shared/number-input' @@ -6,15 +6,17 @@ import SectionHeader from '@/components/shared/section-header' import VariablePicker from '@/components/shared/variable-picker' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Button } from '@/components/ui/button' -import { Plus, X } from 'lucide-react' +import { Plus, X, Image as ImageIcon } from 'lucide-react' import { isVariableRef } from '@/variables/resolve-variables' +import ImageFillPopover from './image-fill-popover' import type { PenNode } from '@/types/pen' -import type { PenFill, GradientStop } from '@/types/styles' +import type { PenFill, GradientStop, ImageFill } from '@/types/styles' const FILL_TYPE_OPTIONS = [ { value: 'solid', labelKey: 'fill.solid' }, { value: 'linear_gradient', labelKey: 'fill.linear' }, { value: 'radial_gradient', labelKey: 'fill.radial' }, + { value: 'image', labelKey: 'fill.image' }, ] function defaultStops(): GradientStop[] { @@ -24,6 +26,20 @@ function defaultStops(): GradientStop[] { ] } +/** Build a CSS gradient preview string for a gradient fill. */ +function gradientPreviewCss(fill: PenFill): string | undefined { + if (fill.type === 'linear_gradient') { + const angle = fill.angle ?? 0 + const stops = fill.stops.map(s => `${s.color} ${Math.round(s.offset * 100)}%`).join(', ') + return `linear-gradient(${angle}deg, ${stops})` + } + if (fill.type === 'radial_gradient') { + const stops = fill.stops.map(s => `${s.color} ${Math.round(s.offset * 100)}%`).join(', ') + return `radial-gradient(circle, ${stops})` + } + return undefined +} + interface FillSectionProps { fills?: PenFill[] onUpdate: (updates: Partial) => void @@ -36,7 +52,6 @@ export default function FillSection({ const { t } = useTranslation() const firstFill = fills?.[0] const fillType = firstFill?.type ?? 'solid' - const [showTypeSelector, setShowTypeSelector] = useState(false) const currentColor = firstFill?.type === 'solid' ? firstFill.color : '#d1d5db' @@ -51,28 +66,28 @@ export default function FillSection({ ? firstFill.stops : defaultStops() + const fillOpacity = firstFill && 'opacity' in firstFill + ? Math.round((firstFill.opacity ?? 1) * 100) + : 100 + const handleTypeChange = (type: string) => { let newFills: PenFill[] if (type === 'solid') { newFills = [{ type: 'solid', color: currentColor }] } else if (type === 'linear_gradient') { - newFills = [ - { - type: 'linear_gradient', - angle: currentAngle, - stops: currentStops, - }, - ] + newFills = [{ + type: 'linear_gradient', + angle: currentAngle, + stops: currentStops, + }] + } else if (type === 'radial_gradient') { + newFills = [{ + type: 'radial_gradient', + cx: 0.5, cy: 0.5, radius: 0.5, + stops: currentStops, + }] } else { - newFills = [ - { - type: 'radial_gradient', - cx: 0.5, - cy: 0.5, - radius: 0.5, - stops: currentStops, - }, - ] + newFills = [{ type: 'image', url: '' }] } onUpdate({ fill: newFills } as Partial) } @@ -81,101 +96,124 @@ export default function FillSection({ onUpdate({ fill: [{ type: 'solid', color }] } as Partial) } + const handleOpacityChange = (val: number) => { + if (!firstFill) return + const opacity = Math.max(0, Math.min(100, val)) / 100 + onUpdate({ fill: [{ ...firstFill, opacity }] } as Partial) + } + const handleAngleChange = (angle: number) => { if (firstFill?.type === 'linear_gradient') { - onUpdate({ - fill: [{ ...firstFill, angle }], - } as Partial) + onUpdate({ fill: [{ ...firstFill, angle }] } as Partial) } } const handleStopColorChange = (index: number, color: string) => { - if ( - !firstFill || - (firstFill.type !== 'linear_gradient' && - firstFill.type !== 'radial_gradient') - ) - return + if (!firstFill || (firstFill.type !== 'linear_gradient' && firstFill.type !== 'radial_gradient')) return const newStops = [...firstFill.stops] newStops[index] = { ...newStops[index], color } - onUpdate({ - fill: [{ ...firstFill, stops: newStops }], - } as Partial) + onUpdate({ fill: [{ ...firstFill, stops: newStops }] } as Partial) } const handleStopOffsetChange = (index: number, offset: number) => { - if ( - !firstFill || - (firstFill.type !== 'linear_gradient' && - firstFill.type !== 'radial_gradient') - ) - return + if (!firstFill || (firstFill.type !== 'linear_gradient' && firstFill.type !== 'radial_gradient')) return const newStops = [...firstFill.stops] newStops[index] = { ...newStops[index], offset: offset / 100 } - onUpdate({ - fill: [{ ...firstFill, stops: newStops }], - } as Partial) + onUpdate({ fill: [{ ...firstFill, stops: newStops }] } as Partial) } const handleAddStop = () => { - if ( - !firstFill || - (firstFill.type !== 'linear_gradient' && - firstFill.type !== 'radial_gradient') - ) - return + if (!firstFill || (firstFill.type !== 'linear_gradient' && firstFill.type !== 'radial_gradient')) return const stops = [...firstFill.stops] const lastOffset = stops[stops.length - 1]?.offset ?? 0.5 stops.push({ offset: Math.min(1, lastOffset + 0.1), color: '#888888' }) - onUpdate({ - fill: [{ ...firstFill, stops }], - } as Partial) + onUpdate({ fill: [{ ...firstFill, stops }] } as Partial) } const handleRemoveStop = (index: number) => { - if ( - !firstFill || - (firstFill.type !== 'linear_gradient' && - firstFill.type !== 'radial_gradient') - ) - return + if (!firstFill || (firstFill.type !== 'linear_gradient' && firstFill.type !== 'radial_gradient')) return if (firstFill.stops.length <= 2) return const stops = firstFill.stops.filter((_, i) => i !== index) - onUpdate({ - fill: [{ ...firstFill, stops }], - } as Partial) + onUpdate({ fill: [{ ...firstFill, stops }] } as Partial) } + const handleRemoveFill = () => { + onUpdate({ fill: [] } as Partial) + } + + const handleImageFitChange = (mode: string) => { + if (firstFill?.type !== 'image') return + onUpdate({ fill: [{ ...firstFill, mode: mode as ImageFill['mode'] }] } as Partial) + } + + // Gradient preview swatch + const gradientCss = firstFill ? gradientPreviewCss(firstFill) : undefined + return (
setShowTypeSelector(!showTypeSelector)} - > + } /> - {showTypeSelector && ( - + {/* Fill row: swatch + type label + opacity + remove */} + {firstFill && ( +
+ {/* Color/gradient/image swatch */} + {fillType === 'solid' && !isVariableRef(currentColor) && ( +
+ )} + {(fillType === 'linear_gradient' || fillType === 'radial_gradient') && gradientCss && ( +
+ )} + {fillType === 'image' && ( +
+ +
+ )} + + {/* Type selector */} + + + {/* Opacity */} + + + {/* Remove */} + +
)} + {/* Solid fill: color picker + variable picker */} {fillType === 'solid' && (
@@ -196,8 +234,8 @@ export default function FillSection({
)} - {(fillType === 'linear_gradient' || - fillType === 'radial_gradient') && ( + {/* Gradient fill: angle + color stops */} + {(fillType === 'linear_gradient' || fillType === 'radial_gradient') && (
{fillType === 'linear_gradient' && ( {t('fill.stops')} -
@@ -235,14 +269,10 @@ export default function FillSection({ min={0} max={100} suffix="%" - className="w-16" + className="w-[72px]" /> {currentStops.length > 2 && ( - )} @@ -251,6 +281,87 @@ export default function FillSection({
)} + + {/* Image fill: preview + upload + fit mode */} + {fillType === 'image' && firstFill?.type === 'image' && ( + + )} +
+ ) +} + +function ImageFillEditor({ + fill, + onUpdate, + onFitChange, +}: { + fill: ImageFill + onUpdate: (updates: Partial) => void + onFitChange: (mode: string) => void +}) { + const { t } = useTranslation() + const [triggerRect, setTriggerRect] = useState(null) + const triggerRef = useRef(null) + const hasImage = fill.url && !fill.url.startsWith('__') + const fitMode = fill.mode ?? 'fill' + + const handleClose = useCallback(() => setTriggerRect(null), []) + + const handleToggle = () => { + if (triggerRect) { + setTriggerRect(null) + } else if (triggerRef.current) { + setTriggerRect(triggerRef.current.getBoundingClientRect()) + } + } + + return ( +
+ + + {triggerRect && ( + onFitChange(mode)} + onAdjustmentChange={(key, value) => { + onUpdate({ fill: [{ ...fill, [key]: value }] } as Partial) + }} + onResetAdjustments={() => { + onUpdate({ fill: [{ ...fill, exposure: 0, contrast: 0, saturation: 0, temperature: 0, tint: 0, highlights: 0, shadows: 0 }] } as Partial) + }} + onImageChange={(dataUrl) => { + onUpdate({ fill: [{ ...fill, url: dataUrl }] } as Partial) + }} + onClose={handleClose} + /> + )}
) } diff --git a/src/components/panels/image-fill-popover.tsx b/src/components/panels/image-fill-popover.tsx new file mode 100644 index 00000000..623535ea --- /dev/null +++ b/src/components/panels/image-fill-popover.tsx @@ -0,0 +1,248 @@ +import { useRef, useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { X, Upload, RotateCcw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Slider } from '@/components/ui/slider' +import { Separator } from '@/components/ui/separator' +import type { ImageFitMode } from '@/types/pen' + +type FitMode = ImageFitMode | 'stretch' + +interface AdjustmentValues { + exposure?: number + contrast?: number + saturation?: number + temperature?: number + tint?: number + highlights?: number + shadows?: number +} + +interface ImageFillPopoverProps { + imageSrc?: string + fitMode: FitMode + adjustments: AdjustmentValues + /** Bounding rect of the trigger element for positioning */ + triggerRect: DOMRect + onFitModeChange: (mode: FitMode) => void + onAdjustmentChange: (key: keyof AdjustmentValues, value: number) => void + onResetAdjustments?: () => void + onImageChange?: (dataUrl: string) => void + onClose: () => void +} + +const PANEL_WIDTH = 220 +const PANEL_GAP = 8 + +const ADJUSTMENT_KEYS: { key: keyof AdjustmentValues; labelKey: string }[] = [ + { key: 'exposure', labelKey: 'image.exposure' }, + { key: 'contrast', labelKey: 'image.contrast' }, + { key: 'saturation', labelKey: 'image.saturation' }, + { key: 'temperature', labelKey: 'image.temperature' }, + { key: 'tint', labelKey: 'image.tint' }, + { key: 'highlights', labelKey: 'image.highlights' }, + { key: 'shadows', labelKey: 'image.shadows' }, +] + +export type { AdjustmentValues, FitMode } + +export default function ImageFillPopover({ + imageSrc, + fitMode, + adjustments, + triggerRect, + onFitModeChange, + onAdjustmentChange, + onResetAdjustments, + onImageChange, + onClose, +}: ImageFillPopoverProps) { + const { t } = useTranslation() + const panelRef = useRef(null) + const fileRef = useRef(null) + const [panelHeight, setPanelHeight] = useState(0) + + // Measure panel height for vertical centering + useEffect(() => { + if (panelRef.current) { + setPanelHeight(panelRef.current.offsetHeight) + } + }) + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + onClose() + } + } + // Use setTimeout to avoid the opening click triggering immediate close + const timer = setTimeout(() => { + document.addEventListener('mousedown', handler) + }, 0) + return () => { + clearTimeout(timer) + document.removeEventListener('mousedown', handler) + } + }, [onClose]) + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file || !file.type.startsWith('image/')) return + const reader = new FileReader() + reader.onload = () => { + onImageChange?.(reader.result as string) + } + reader.readAsDataURL(file) + e.target.value = '' + } + + const hasAdjustments = ADJUSTMENT_KEYS.some((a) => (adjustments[a.key] ?? 0) !== 0) + const handleResetAll = () => { + if (onResetAdjustments) { + onResetAdjustments() + } else { + for (const a of ADJUSTMENT_KEYS) { + onAdjustmentChange(a.key, 0) + } + } + } + + const hasImage = imageSrc && !imageSrc.startsWith('__') + + // Position: to the left of the trigger element + const left = triggerRect.left - PANEL_WIDTH - PANEL_GAP + // Vertically align with the trigger top, clamped to viewport + let top = triggerRect.top + if (panelHeight > 0 && top + panelHeight > window.innerHeight - 8) { + top = Math.max(8, window.innerHeight - panelHeight - 8) + } + + return createPortal( +
+ {/* Header */} +
+ {t('image.title')} + +
+ + {/* Fit mode row */} +
+
+ {(['fill', 'fit', 'crop', 'tile'] as FitMode[]).map((m) => ( + + ))} +
+
+ + {/* Image preview / upload */} +
+ + +
+ + + + {/* Adjustments */} +
+
+ + {t('image.adjustments')} + + {hasAdjustments && ( + + )} +
+
+ {ADJUSTMENT_KEYS.map((a) => ( + onAdjustmentChange(a.key, v)} + /> + ))} +
+
+
, + document.body, + ) +} + +function AdjustmentRow({ + label, + value, + onChange, +}: { + label: string + value: number + onChange: (v: number) => void +}) { + return ( +
+ + {label} + + onChange(v)} + className="flex-1" + /> + + {value} + +
+ ) +} diff --git a/src/components/panels/image-section.tsx b/src/components/panels/image-section.tsx index 28bc5bfc..ba16799f 100644 --- a/src/components/panels/image-section.tsx +++ b/src/components/panels/image-section.tsx @@ -1,13 +1,9 @@ +import { useState, useCallback, useRef } from 'react' +import { useTranslation } from 'react-i18next' import type { ImageNode, ImageFitMode } from '@/types/pen' import SectionHeader from '@/components/shared/section-header' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' - -const FIT_MODE_OPTIONS: { value: string; label: string }[] = [ - { value: 'fill', label: 'Fill' }, - { value: 'fit', label: 'Fit' }, - { value: 'crop', label: 'Crop' }, - { value: 'tile', label: 'Tile' }, -] +import { Image as ImageIcon } from 'lucide-react' +import ImageFillPopover from './image-fill-popover' interface ImageSectionProps { node: ImageNode @@ -15,24 +11,66 @@ interface ImageSectionProps { } export default function ImageSection({ node, onUpdate }: ImageSectionProps) { + const { t } = useTranslation() + const [triggerRect, setTriggerRect] = useState(null) + const triggerRef = useRef(null) + + const fitMode = node.objectFit ?? 'fill' + const hasImage = node.src && !node.src.startsWith('__') + + const handleClose = useCallback(() => setTriggerRect(null), []) + + const handleToggle = () => { + if (triggerRect) { + setTriggerRect(null) + } else if (triggerRef.current) { + setTriggerRect(triggerRef.current.getBoundingClientRect()) + } + } + return (
- -
- Fit - -
+ + + + + {triggerRect && ( + onUpdate({ objectFit: mode as ImageFitMode })} + onAdjustmentChange={(key, value) => onUpdate({ [key]: value } as Partial)} + onResetAdjustments={() => onUpdate({ exposure: 0, contrast: 0, saturation: 0, temperature: 0, tint: 0, highlights: 0, shadows: 0 } as Partial)} + onImageChange={(dataUrl) => onUpdate({ src: dataUrl })} + onClose={handleClose} + /> + )}
) } diff --git a/src/components/panels/text-section.tsx b/src/components/panels/text-section.tsx index abcd820d..8d500d0c 100644 --- a/src/components/panels/text-section.tsx +++ b/src/components/panels/text-section.tsx @@ -1,5 +1,6 @@ import NumberInput from '@/components/shared/number-input' import SectionHeader from '@/components/shared/section-header' +import FontPicker from '@/components/shared/font-picker' import { Select, SelectContent, @@ -25,27 +26,6 @@ interface TextSectionProps { onUpdate: (updates: Partial) => void } -const FONT_OPTIONS = [ - // Bundled fonts (always available, vector rendering) - { value: 'Inter', label: 'Inter' }, - { value: 'Poppins', label: 'Poppins' }, - { value: 'Roboto', label: 'Roboto' }, - { value: 'Montserrat', label: 'Montserrat' }, - { value: 'Open Sans', label: 'Open Sans' }, - { value: 'Lato', label: 'Lato' }, - { value: 'Raleway', label: 'Raleway' }, - { value: 'DM Sans', label: 'DM Sans' }, - { value: 'Playfair Display', label: 'Playfair Display' }, - { value: 'Nunito', label: 'Nunito' }, - { value: 'Source Sans 3', label: 'Source Sans 3' }, - // System fonts - { value: 'Arial, sans-serif', label: 'Arial' }, - { value: 'Helvetica, sans-serif', label: 'Helvetica' }, - { value: 'Georgia, serif', label: 'Georgia' }, - { value: 'Times New Roman, serif', label: 'Times' }, - { value: 'Courier New, monospace', label: 'Courier' }, -] - const WEIGHT_OPTIONS = [ { value: '100', labelKey: 'text.weight.thin' }, { value: '300', labelKey: 'text.weight.light' }, @@ -126,23 +106,10 @@ export default function TextSection({ {/* Font family */} - + onChange={(v) => onUpdate({ fontFamily: v } as Partial)} + /> {/* Weight + Size */}
diff --git a/src/components/shared/font-picker.tsx b/src/components/shared/font-picker.tsx new file mode 100644 index 00000000..5b27e676 --- /dev/null +++ b/src/components/shared/font-picker.tsx @@ -0,0 +1,253 @@ +import { useState, useRef, useEffect, useMemo, useCallback } from 'react' +import { cn } from '@/lib/utils' +import { useSystemFonts, type FontInfo } from '@/hooks/use-system-fonts' +import { ChevronDown, Search, Loader2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +interface FontPickerProps { + value: string + onChange: (fontFamily: string) => void + className?: string +} + +/** Extract display name from a font value like "Arial, sans-serif" → "Arial" */ +function displayName(value: string): string { + return value.split(',')[0].trim().replace(/['"]/g, '') +} + +export default function FontPicker({ value, onChange, className }: FontPickerProps) { + const { t } = useTranslation() + const { allFonts, loading } = useSystemFonts() + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const [highlightIndex, setHighlightIndex] = useState(-1) + const containerRef = useRef(null) + const listRef = useRef(null) + const inputRef = useRef(null) + + // Filter fonts by search + const filtered = useMemo(() => { + if (!search) return allFonts + const q = search.toLowerCase() + return allFonts.filter(f => f.family.toLowerCase().includes(q)) + }, [allFonts, search]) + + // Group into bundled and system + const bundled = useMemo(() => filtered.filter(f => f.source === 'bundled'), [filtered]) + const system = useMemo(() => filtered.filter(f => f.source === 'system'), [filtered]) + + // Flat list for keyboard navigation + const flatList = useMemo(() => { + const items: FontInfo[] = [] + items.push(...bundled) + items.push(...system) + return items + }, [bundled, system]) + + const handleSelect = useCallback((font: FontInfo) => { + onChange(font.family) + setOpen(false) + setSearch('') + setHighlightIndex(-1) + }, [onChange]) + + // Close on click outside + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false) + setSearch('') + setHighlightIndex(-1) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open]) + + // Focus search input when opened + useEffect(() => { + if (open) { + requestAnimationFrame(() => inputRef.current?.focus()) + } + }, [open]) + + // Scroll highlighted item into view + useEffect(() => { + if (highlightIndex < 0 || !listRef.current) return + const items = listRef.current.querySelectorAll('[data-font-item]') + items[highlightIndex]?.scrollIntoView({ block: 'nearest' }) + }, [highlightIndex]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!open) { + if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') { + e.preventDefault() + setOpen(true) + } + return + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setHighlightIndex(i => Math.min(i + 1, flatList.length - 1)) + break + case 'ArrowUp': + e.preventDefault() + setHighlightIndex(i => Math.max(i - 1, 0)) + break + case 'Enter': + e.preventDefault() + if (highlightIndex >= 0 && highlightIndex < flatList.length) { + handleSelect(flatList[highlightIndex]) + } + break + case 'Escape': + e.preventDefault() + setOpen(false) + setSearch('') + setHighlightIndex(-1) + break + } + } + + // Reset highlight when search changes + useEffect(() => { + setHighlightIndex(search ? 0 : -1) + }, [search]) + + const currentDisplay = displayName(value) + + return ( +
+ {/* Trigger button */} + + + {/* Dropdown */} + {open && ( +
+ {/* Search */} +
+ + setSearch(e.target.value)} + placeholder={t('text.font.search')} + className={cn( + 'flex-1 bg-transparent text-[11px] text-foreground', + 'placeholder:text-muted-foreground', + 'outline-none border-none p-0', + )} + /> +
+ + {/* Font list */} +
+ {loading && ( +
+ + {t('text.font.loading')} +
+ )} + + {/* Bundled fonts group */} + {bundled.length > 0 && ( + <> +
+ {t('text.font.bundled')} +
+ {bundled.map((font, i) => ( + + ))} + + )} + + {/* System fonts group */} + {system.length > 0 && ( + <> +
+ {t('text.font.system')} +
+ {system.map((font, i) => ( + + ))} + + )} + + {!loading && filtered.length === 0 && ( +
+ {t('text.font.noResults')} +
+ )} +
+
+ )} +
+ ) +} + +function FontItem({ + font, + selected, + highlighted, + onSelect, +}: { + font: FontInfo + selected: boolean + highlighted: boolean + onSelect: (font: FontInfo) => void +}) { + return ( + + ) +} diff --git a/src/hooks/use-figma-paste.ts b/src/hooks/use-figma-paste.ts index d73a6d10..64e16fc9 100644 --- a/src/hooks/use-figma-paste.ts +++ b/src/hooks/use-figma-paste.ts @@ -60,24 +60,11 @@ function getViewportCenter(): { cx: number; cy: number } { * Returns true if Figma nodes were pasted. */ function processFigmaHtml(html: string): boolean { - console.debug('[figma-paste] Figma markers detected, extracting clipboard data...') - const clipData = extractFigmaClipboardData(html) - if (!clipData) { - console.warn('[figma-paste] Failed to extract clipboard data from HTML') - return false - } + if (!clipData) return false - console.debug('[figma-paste] Extracted clipboard data, meta:', clipData.meta, - 'buffer size:', clipData.buffer.byteLength, 'bytes') - - const { nodes, warnings } = figmaClipboardToNodes(clipData.buffer) - console.debug('[figma-paste] Converted', nodes.length, 'nodes, warnings:', warnings) - - if (nodes.length === 0) { - console.warn('[figma-paste] No convertible nodes found:', warnings) - return false - } + const { nodes } = figmaClipboardToNodes(clipData.buffer, html) + if (nodes.length === 0) return false // Center pasted nodes at viewport center const bounds = computeBounds(nodes) @@ -85,9 +72,6 @@ function processFigmaHtml(html: string): boolean { const offsetX = cx - (bounds.minX + bounds.maxX) / 2 const offsetY = cy - (bounds.minY + bounds.maxY) / 2 - console.debug('[figma-paste] Bounds:', bounds, 'viewport center:', { cx, cy }, - 'offset:', { offsetX, offsetY }) - for (const node of nodes) { node.x = (node.x ?? 0) + offsetX node.y = (node.y ?? 0) + offsetY @@ -107,8 +91,6 @@ function processFigmaHtml(html: string): boolean { // Select the pasted nodes useCanvasStore.getState().setSelection(newIds, newIds[0] ?? null) - - console.debug('[figma-paste] Successfully pasted', newIds.length, 'nodes:', newIds) return true } @@ -119,27 +101,20 @@ function processFigmaHtml(html: string): boolean { */ export async function tryPasteFigmaFromClipboard(): Promise { try { - // Try modern Clipboard API first if (navigator.clipboard?.read) { - console.debug('[figma-paste] Reading clipboard via Clipboard API...') const items = await navigator.clipboard.read() for (const item of items) { if (item.types.includes('text/html')) { const blob = await item.getType('text/html') const html = await blob.text() - console.debug('[figma-paste] Got HTML from clipboard, length:', html.length, - 'has figma markers:', isFigmaClipboardHtml(html)) if (isFigmaClipboardHtml(html)) { return processFigmaHtml(html) } } } - console.debug('[figma-paste] No Figma data found in clipboard items') - } else { - console.debug('[figma-paste] Clipboard API not available') } - } catch (err) { - console.warn('[figma-paste] Clipboard API read failed:', err) + } catch { + // Clipboard API may not be available or permission denied } return false } @@ -152,8 +127,6 @@ export async function tryPasteFigmaFromClipboard(): Promise { export function useFigmaPaste() { useEffect(() => { const handlePaste = (e: ClipboardEvent) => { - console.debug('[figma-paste] paste event fired, target:', (e.target as HTMLElement)?.tagName) - // Skip if user is typing in an input/textarea/contentEditable const target = e.target as HTMLElement if ( @@ -161,14 +134,10 @@ export function useFigmaPaste() { target.tagName === 'TEXTAREA' || target.isContentEditable ) { - console.debug('[figma-paste] Skipping — editable element focused') return } const html = e.clipboardData?.getData('text/html') - console.debug('[figma-paste] clipboard HTML length:', html?.length ?? 0, - 'has figma markers:', html ? isFigmaClipboardHtml(html) : false) - if (!html || !isFigmaClipboardHtml(html)) return e.preventDefault() diff --git a/src/hooks/use-system-fonts.ts b/src/hooks/use-system-fonts.ts new file mode 100644 index 00000000..51de0703 --- /dev/null +++ b/src/hooks/use-system-fonts.ts @@ -0,0 +1,104 @@ +import { useState, useEffect } from 'react' + +export interface FontInfo { + family: string + source: 'bundled' | 'system' +} + +/** Bundled font families (always available, vector rendering) */ +const BUNDLED_FAMILIES = [ + 'Inter', + 'Poppins', + 'Roboto', + 'Montserrat', + 'Open Sans', + 'Lato', + 'Raleway', + 'DM Sans', + 'Playfair Display', + 'Nunito', + 'Source Sans 3', +] + +/** Common system fonts shown even when queryLocalFonts is not available */ +const FALLBACK_SYSTEM_FONTS = [ + 'Arial', + 'Helvetica', + 'Helvetica Neue', + 'Georgia', + 'Times New Roman', + 'Courier New', + 'Verdana', + 'Trebuchet MS', + 'Tahoma', + 'Impact', + 'Comic Sans MS', +] + +/** Cached system font families to avoid re-querying */ +let cachedSystemFonts: string[] | null = null +let fetchPromise: Promise | null = null + +async function querySystemFonts(): Promise { + if (cachedSystemFonts) return cachedSystemFonts + + if (fetchPromise) return fetchPromise + + fetchPromise = (async () => { + try { + // queryLocalFonts() is available in Chromium 103+ and Electron + if ('queryLocalFonts' in window) { + const fonts = await (window as unknown as { queryLocalFonts: () => Promise> }).queryLocalFonts() + const families = new Set() + for (const font of fonts) { + families.add(font.family) + } + // Remove bundled fonts from system list to avoid duplicates + const bundledSet = new Set(BUNDLED_FAMILIES.map(f => f.toLowerCase())) + const systemFonts = [...families] + .filter(f => !bundledSet.has(f.toLowerCase())) + .sort((a, b) => a.localeCompare(b)) + cachedSystemFonts = systemFonts + return systemFonts + } + } catch { + // Permission denied or API not available + } + cachedSystemFonts = FALLBACK_SYSTEM_FONTS + return FALLBACK_SYSTEM_FONTS + })() + + return fetchPromise +} + +/** + * Hook to enumerate system fonts via the Local Font Access API. + * Falls back to a common font list if the API is unavailable. + */ +export function useSystemFonts() { + const [systemFonts, setSystemFonts] = useState(cachedSystemFonts ?? []) + const [loading, setLoading] = useState(!cachedSystemFonts) + + useEffect(() => { + if (cachedSystemFonts) { + setSystemFonts(cachedSystemFonts) + setLoading(false) + return + } + let cancelled = false + querySystemFonts().then(fonts => { + if (!cancelled) { + setSystemFonts(fonts) + setLoading(false) + } + }) + return () => { cancelled = true } + }, []) + + const allFonts: FontInfo[] = [ + ...BUNDLED_FAMILIES.map(f => ({ family: f, source: 'bundled' as const })), + ...systemFonts.map(f => ({ family: f, source: 'system' as const })), + ] + + return { allFonts, systemFonts, bundledFonts: BUNDLED_FAMILIES, loading } +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index d25a34d4..e63ba141 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -20,35 +20,37 @@ import id from '@/i18n/locales/id' export const SUPPORTED_LANGS = ['en', 'zh', 'zh-TW', 'ja', 'ko', 'fr', 'es', 'de', 'pt', 'ru', 'hi', 'tr', 'th', 'vi', 'id'] -// Initialize with 'en' for SSR hydration safety. -// Language detection is deferred to post-hydration via detectLanguagePostHydration(). -i18n - .use(initReactI18next) - .init({ - lng: 'en', - resources: { - en: { translation: en }, - zh: { translation: zh }, - 'zh-TW': { translation: zhTW }, - ja: { translation: ja }, - ko: { translation: ko }, - fr: { translation: fr }, - es: { translation: es }, - de: { translation: de }, - pt: { translation: pt }, - ru: { translation: ru }, - hi: { translation: hi }, - tr: { translation: tr }, - th: { translation: th }, - vi: { translation: vi }, - id: { translation: id }, - }, - supportedLngs: SUPPORTED_LANGS, - fallbackLng: 'en', - interpolation: { - escapeValue: false, - }, - }) +const resources = { + en: { translation: en }, + zh: { translation: zh }, + 'zh-TW': { translation: zhTW }, + ja: { translation: ja }, + ko: { translation: ko }, + fr: { translation: fr }, + es: { translation: es }, + de: { translation: de }, + pt: { translation: pt }, + ru: { translation: ru }, + hi: { translation: hi }, + tr: { translation: tr }, + th: { translation: th }, + vi: { translation: vi }, + id: { translation: id }, +} + +if (!i18n.isInitialized) { + i18n + .use(initReactI18next) + .init({ + lng: 'en', + resources, + supportedLngs: SUPPORTED_LANGS, + fallbackLng: 'en', + interpolation: { + escapeValue: false, + }, + }) +} // Persist language changes i18n.on('languageChanged', (lng) => { diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index c514e344..6dcb45f8 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -130,9 +130,29 @@ const de: TranslationKeys = { 'fill.solid': 'Vollton', 'fill.linear': 'Linear', 'fill.radial': 'Radial', + 'fill.image': 'Bild', 'fill.stops': 'Stops', 'fill.angle': 'Winkel', + // ── Image ── + 'image.title': 'Bild', + 'image.fit': 'Anpassungsmodus', + 'image.fill': 'Füllen', + 'image.fitMode': 'Anpassen', + 'image.crop': 'Zuschneiden', + 'image.tile': 'Kacheln', + 'image.clickToUpload': 'Zum Hochladen klicken', + 'image.changeImage': 'Bild ändern', + 'image.adjustments': 'Anpassungen', + 'image.exposure': 'Belichtung', + 'image.contrast': 'Kontrast', + 'image.saturation': 'Sättigung', + 'image.temperature': 'Temperatur', + 'image.tint': 'Farbton', + 'image.highlights': 'Lichter', + 'image.shadows': 'Schatten', + 'image.reset': 'Zurücksetzen', + // ── Stroke ── 'stroke.title': 'Kontur', @@ -184,6 +204,11 @@ const de: TranslationKeys = { 'text.weight.semibold': 'Halbfett', 'text.weight.bold': 'Fett', 'text.weight.black': 'Schwarz', + 'text.font.search': 'Schriften suchen\u2026', + 'text.font.bundled': 'Mitgeliefert', + 'text.font.system': 'System', + 'text.font.loading': 'Schriften werden geladen\u2026', + 'text.font.noResults': 'Keine Schriften gefunden', // ── Text Layout ── 'textLayout.title': 'Layout', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index a56271cc..9bac2da5 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -126,9 +126,29 @@ const en = { 'fill.solid': 'Solid', 'fill.linear': 'Linear', 'fill.radial': 'Radial', + 'fill.image': 'Image', 'fill.stops': 'Stops', 'fill.angle': 'Angle', + // ── Image ── + 'image.title': 'Image', + 'image.fit': 'Fit Mode', + 'image.fill': 'Fill', + 'image.fitMode': 'Fit', + 'image.crop': 'Crop', + 'image.tile': 'Tile', + 'image.clickToUpload': 'Click to upload', + 'image.changeImage': 'Change image', + 'image.adjustments': 'Adjustments', + 'image.exposure': 'Exposure', + 'image.contrast': 'Contrast', + 'image.saturation': 'Saturation', + 'image.temperature': 'Temperature', + 'image.tint': 'Tint', + 'image.highlights': 'Highlights', + 'image.shadows': 'Shadows', + 'image.reset': 'Reset', + // ── Stroke ── 'stroke.title': 'Stroke', @@ -180,6 +200,11 @@ const en = { 'text.weight.semibold': 'Semibold', 'text.weight.bold': 'Bold', 'text.weight.black': 'Black', + 'text.font.search': 'Search fonts…', + 'text.font.bundled': 'Bundled', + 'text.font.system': 'System', + 'text.font.loading': 'Loading fonts…', + 'text.font.noResults': 'No fonts found', // ── Text Layout ── 'textLayout.title': 'Layout', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index f7a6cc6b..8f80c0a7 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -130,9 +130,29 @@ const es: TranslationKeys = { 'fill.solid': 'Sólido', 'fill.linear': 'Lineal', 'fill.radial': 'Radial', + 'fill.image': 'Imagen', 'fill.stops': 'Paradas', 'fill.angle': 'Ángulo', + // ── Image ── + 'image.title': 'Imagen', + 'image.fit': 'Modo de ajuste', + 'image.fill': 'Rellenar', + 'image.fitMode': 'Ajustar', + 'image.crop': 'Recortar', + 'image.tile': 'Mosaico', + 'image.clickToUpload': 'Haz clic para subir', + 'image.changeImage': 'Cambiar imagen', + 'image.adjustments': 'Ajustes', + 'image.exposure': 'Exposición', + 'image.contrast': 'Contraste', + 'image.saturation': 'Saturación', + 'image.temperature': 'Temperatura', + 'image.tint': 'Tinte', + 'image.highlights': 'Luces', + 'image.shadows': 'Sombras', + 'image.reset': 'Restablecer', + // ── Stroke ── 'stroke.title': 'Trazo', @@ -184,6 +204,11 @@ const es: TranslationKeys = { 'text.weight.semibold': 'Semibold', 'text.weight.bold': 'Bold', 'text.weight.black': 'Black', + 'text.font.search': 'Buscar fuentes\u2026', + 'text.font.bundled': 'Incluidas', + 'text.font.system': 'Sistema', + 'text.font.loading': 'Cargando fuentes\u2026', + 'text.font.noResults': 'No se encontraron fuentes', // ── Text Layout ── 'textLayout.title': 'Diseño', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 80389481..ba4def07 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -130,9 +130,29 @@ const fr: TranslationKeys = { 'fill.solid': 'Uni', 'fill.linear': 'Linéaire', 'fill.radial': 'Radial', + 'fill.image': 'Image', 'fill.stops': 'Arrêts', 'fill.angle': 'Angle', + // ── Image ── + 'image.title': 'Image', + 'image.fit': "Mode d'ajustement", + 'image.fill': 'Remplir', + 'image.fitMode': 'Ajuster', + 'image.crop': 'Recadrer', + 'image.tile': 'Mosaïque', + 'image.clickToUpload': 'Cliquez pour télécharger', + 'image.changeImage': "Changer l'image", + 'image.adjustments': 'Réglages', + 'image.exposure': 'Exposition', + 'image.contrast': 'Contraste', + 'image.saturation': 'Saturation', + 'image.temperature': 'Température', + 'image.tint': 'Teinte', + 'image.highlights': 'Hautes lumières', + 'image.shadows': 'Ombres', + 'image.reset': 'Réinitialiser', + // ── Stroke ── 'stroke.title': 'Contour', @@ -184,6 +204,11 @@ const fr: TranslationKeys = { 'text.weight.semibold': 'Semibold', 'text.weight.bold': 'Bold', 'text.weight.black': 'Black', + 'text.font.search': 'Rechercher des polices\u2026', + 'text.font.bundled': 'Incluses', + 'text.font.system': 'Système', + 'text.font.loading': 'Chargement des polices\u2026', + 'text.font.noResults': 'Aucune police trouvée', // ── Text Layout ── 'textLayout.title': 'Mise en page', diff --git a/src/i18n/locales/hi.ts b/src/i18n/locales/hi.ts index 1519a87b..db5acd2e 100644 --- a/src/i18n/locales/hi.ts +++ b/src/i18n/locales/hi.ts @@ -128,9 +128,29 @@ const hi: TranslationKeys = { 'fill.solid': 'ठोस', 'fill.linear': 'रैखिक', 'fill.radial': 'वृत्तीय', + 'fill.image': 'चित्र', 'fill.stops': 'स्टॉप', 'fill.angle': 'कोण', + // ── Image ── + 'image.title': 'चित्र', + 'image.fit': 'फिट मोड', + 'image.fill': 'भरें', + 'image.fitMode': 'फिट', + 'image.crop': 'काटें', + 'image.tile': 'टाइल', + 'image.clickToUpload': 'अपलोड करने के लिए क्लिक करें', + 'image.changeImage': 'चित्र बदलें', + 'image.adjustments': 'समायोजन', + 'image.exposure': 'एक्सपोज़र', + 'image.contrast': 'कंट्रास्ट', + 'image.saturation': 'संतृप्ति', + 'image.temperature': 'तापमान', + 'image.tint': 'टिंट', + 'image.highlights': 'हाइलाइट्स', + 'image.shadows': 'छायाएँ', + 'image.reset': 'रीसेट', + // ── Stroke ── 'stroke.title': 'स्ट्रोक', @@ -182,6 +202,11 @@ const hi: TranslationKeys = { 'text.weight.semibold': 'सेमीबोल्ड', 'text.weight.bold': 'बोल्ड', 'text.weight.black': 'ब्लैक', + 'text.font.search': 'फ़ॉन्ट खोजें\u2026', + 'text.font.bundled': 'बंडल', + 'text.font.system': 'सिस्टम', + 'text.font.loading': 'फ़ॉन्ट लोड हो रहे हैं\u2026', + 'text.font.noResults': 'कोई फ़ॉन्ट नहीं मिला', // ── Text Layout ── 'textLayout.title': 'लेआउट', diff --git a/src/i18n/locales/id.ts b/src/i18n/locales/id.ts index ef1b86d7..7c6a13c8 100644 --- a/src/i18n/locales/id.ts +++ b/src/i18n/locales/id.ts @@ -128,9 +128,29 @@ const id: TranslationKeys = { 'fill.solid': 'Solid', 'fill.linear': 'Linear', 'fill.radial': 'Radial', + 'fill.image': 'Gambar', 'fill.stops': 'Titik warna', 'fill.angle': 'Sudut', + // ── Image ── + 'image.title': 'Gambar', + 'image.fit': 'Mode Penyesuaian', + 'image.fill': 'Isi', + 'image.fitMode': 'Sesuaikan', + 'image.crop': 'Potong', + 'image.tile': 'Ubin', + 'image.clickToUpload': 'Klik untuk mengunggah', + 'image.changeImage': 'Ganti gambar', + 'image.adjustments': 'Penyesuaian', + 'image.exposure': 'Eksposur', + 'image.contrast': 'Kontras', + 'image.saturation': 'Saturasi', + 'image.temperature': 'Suhu', + 'image.tint': 'Rona', + 'image.highlights': 'Sorotan', + 'image.shadows': 'Bayangan', + 'image.reset': 'Atur ulang', + // ── Stroke ── 'stroke.title': 'Garis Tepi', @@ -182,6 +202,11 @@ const id: TranslationKeys = { 'text.weight.semibold': 'Semi Tebal', 'text.weight.bold': 'Tebal', 'text.weight.black': 'Sangat Tebal', + 'text.font.search': 'Cari font\u2026', + 'text.font.bundled': 'Bawaan', + 'text.font.system': 'Sistem', + 'text.font.loading': 'Memuat font\u2026', + 'text.font.noResults': 'Font tidak ditemukan', // ── Text Layout ── 'textLayout.title': 'Tata Letak', diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 660a12c7..555629a9 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -132,9 +132,29 @@ const ja: TranslationKeys = { 'fill.solid': '単色', 'fill.linear': '線形グラデーション', 'fill.radial': '放射グラデーション', + 'fill.image': '画像', 'fill.stops': 'カラーストップ', 'fill.angle': '角度', + // ── Image ── + 'image.title': '画像', + 'image.fit': 'フィットモード', + 'image.fill': '塗りつぶし', + 'image.fitMode': 'フィット', + 'image.crop': 'クロップ', + 'image.tile': 'タイル', + 'image.clickToUpload': 'クリックしてアップロード', + 'image.changeImage': '画像を変更', + 'image.adjustments': '調整', + 'image.exposure': '露出', + 'image.contrast': 'コントラスト', + 'image.saturation': '彩度', + 'image.temperature': '色温度', + 'image.tint': '色合い', + 'image.highlights': 'ハイライト', + 'image.shadows': 'シャドウ', + 'image.reset': 'リセット', + // ── Stroke ── 'stroke.title': '線', @@ -186,6 +206,11 @@ const ja: TranslationKeys = { 'text.weight.semibold': 'Semibold', 'text.weight.bold': 'Bold', 'text.weight.black': 'Black', + 'text.font.search': 'フォントを検索\u2026', + 'text.font.bundled': 'バンドル', + 'text.font.system': 'システム', + 'text.font.loading': 'フォントを読み込み中\u2026', + 'text.font.noResults': 'フォントが見つかりません', // ── Text Layout ── 'textLayout.title': 'レイアウト', diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index 0fb65050..6a7f72ed 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -128,9 +128,29 @@ const ko: TranslationKeys = { 'fill.solid': '단색', 'fill.linear': '선형', 'fill.radial': '방사형', + 'fill.image': '이미지', 'fill.stops': '정지점', 'fill.angle': '각도', + // ── Image ── + 'image.title': '이미지', + 'image.fit': '맞춤 모드', + 'image.fill': '채우기', + 'image.fitMode': '맞춤', + 'image.crop': '자르기', + 'image.tile': '타일', + 'image.clickToUpload': '클릭하여 업로드', + 'image.changeImage': '이미지 변경', + 'image.adjustments': '조정', + 'image.exposure': '노출', + 'image.contrast': '대비', + 'image.saturation': '채도', + 'image.temperature': '색온도', + 'image.tint': '틴트', + 'image.highlights': '하이라이트', + 'image.shadows': '그림자', + 'image.reset': '초기화', + // ── Stroke ── 'stroke.title': '선', @@ -182,6 +202,11 @@ const ko: TranslationKeys = { 'text.weight.semibold': 'Semibold', 'text.weight.bold': 'Bold', 'text.weight.black': 'Black', + 'text.font.search': '글꼴 검색\u2026', + 'text.font.bundled': '번들', + 'text.font.system': '시스템', + 'text.font.loading': '글꼴 로드 중\u2026', + 'text.font.noResults': '글꼴을 찾을 수 없습니다', // ── Text Layout ── 'textLayout.title': '레이아웃', diff --git a/src/i18n/locales/pt.ts b/src/i18n/locales/pt.ts index 5ed7e4f7..a043903c 100644 --- a/src/i18n/locales/pt.ts +++ b/src/i18n/locales/pt.ts @@ -130,9 +130,29 @@ const pt: TranslationKeys = { 'fill.solid': 'Sólido', 'fill.linear': 'Linear', 'fill.radial': 'Radial', + 'fill.image': 'Imagem', 'fill.stops': 'Paradas', 'fill.angle': 'Ângulo', + // ── Image ── + 'image.title': 'Imagem', + 'image.fit': 'Modo de ajuste', + 'image.fill': 'Preencher', + 'image.fitMode': 'Ajustar', + 'image.crop': 'Recortar', + 'image.tile': 'Ladrilho', + 'image.clickToUpload': 'Clique para enviar', + 'image.changeImage': 'Alterar imagem', + 'image.adjustments': 'Ajustes', + 'image.exposure': 'Exposição', + 'image.contrast': 'Contraste', + 'image.saturation': 'Saturação', + 'image.temperature': 'Temperatura', + 'image.tint': 'Matiz', + 'image.highlights': 'Realces', + 'image.shadows': 'Sombras', + 'image.reset': 'Redefinir', + // ── Stroke ── 'stroke.title': 'Contorno', @@ -184,6 +204,11 @@ const pt: TranslationKeys = { 'text.weight.semibold': 'Seminegrito', 'text.weight.bold': 'Negrito', 'text.weight.black': 'Preto', + 'text.font.search': 'Pesquisar fontes\u2026', + 'text.font.bundled': 'Incluídas', + 'text.font.system': 'Sistema', + 'text.font.loading': 'Carregando fontes\u2026', + 'text.font.noResults': 'Nenhuma fonte encontrada', // ── Text Layout ── 'textLayout.title': 'Layout', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 5944f531..ea8abfc4 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -130,9 +130,29 @@ const ru: TranslationKeys = { 'fill.solid': 'Сплошная', 'fill.linear': 'Линейная', 'fill.radial': 'Радиальная', + 'fill.image': 'Изображение', 'fill.stops': 'Точки', 'fill.angle': 'Угол', + // ── Image ── + 'image.title': 'Изображение', + 'image.fit': 'Режим подгонки', + 'image.fill': 'Заполнение', + 'image.fitMode': 'Вписать', + 'image.crop': 'Обрезка', + 'image.tile': 'Плитка', + 'image.clickToUpload': 'Нажмите для загрузки', + 'image.changeImage': 'Изменить изображение', + 'image.adjustments': 'Настройки', + 'image.exposure': 'Экспозиция', + 'image.contrast': 'Контрастность', + 'image.saturation': 'Насыщенность', + 'image.temperature': 'Температура', + 'image.tint': 'Оттенок', + 'image.highlights': 'Светлые тона', + 'image.shadows': 'Тени', + 'image.reset': 'Сбросить', + // ── Stroke ── 'stroke.title': 'Обводка', @@ -184,6 +204,11 @@ const ru: TranslationKeys = { 'text.weight.semibold': 'Полужирный', 'text.weight.bold': 'Жирный', 'text.weight.black': 'Сверхжирный', + 'text.font.search': 'Поиск шрифтов\u2026', + 'text.font.bundled': 'Встроенные', + 'text.font.system': 'Системные', + 'text.font.loading': 'Загрузка шрифтов\u2026', + 'text.font.noResults': 'Шрифты не найдены', // ── Text Layout ── 'textLayout.title': 'Раскладка', diff --git a/src/i18n/locales/th.ts b/src/i18n/locales/th.ts index 7f6c4e8b..d175ee77 100644 --- a/src/i18n/locales/th.ts +++ b/src/i18n/locales/th.ts @@ -128,9 +128,29 @@ const th: TranslationKeys = { 'fill.solid': 'ทึบ', 'fill.linear': 'เชิงเส้น', 'fill.radial': 'วงกลม', + 'fill.image': 'รูปภาพ', 'fill.stops': 'จุดหยุด', 'fill.angle': 'มุม', + // ── Image ── + 'image.title': 'รูปภาพ', + 'image.fit': 'โหมดปรับขนาด', + 'image.fill': 'เติมเต็ม', + 'image.fitMode': 'พอดี', + 'image.crop': 'ครอป', + 'image.tile': 'เรียงต่อ', + 'image.clickToUpload': 'คลิกเพื่ออัปโหลด', + 'image.changeImage': 'เปลี่ยนรูปภาพ', + 'image.adjustments': 'การปรับแต่ง', + 'image.exposure': 'การเปิดรับแสง', + 'image.contrast': 'คอนทราสต์', + 'image.saturation': 'ความอิ่มตัว', + 'image.temperature': 'อุณหภูมิสี', + 'image.tint': 'โทนสี', + 'image.highlights': 'ไฮไลท์', + 'image.shadows': 'เงา', + 'image.reset': 'รีเซ็ต', + // ── Stroke ── 'stroke.title': 'เส้นขอบ', @@ -182,6 +202,11 @@ const th: TranslationKeys = { 'text.weight.semibold': 'กึ่งหนา', 'text.weight.bold': 'หนา', 'text.weight.black': 'หนามาก', + 'text.font.search': 'ค้นหาฟอนต์\u2026', + 'text.font.bundled': 'แบบรวม', + 'text.font.system': 'ระบบ', + 'text.font.loading': 'กำลังโหลดฟอนต์\u2026', + 'text.font.noResults': 'ไม่พบฟอนต์', // ── Text Layout ── 'textLayout.title': 'เลย์เอาต์', diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index 6fdc654d..5b6655f6 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -128,9 +128,29 @@ const tr: TranslationKeys = { 'fill.solid': 'Düz', 'fill.linear': 'Doğrusal', 'fill.radial': 'Dairesel', + 'fill.image': 'Görüntü', 'fill.stops': 'Duraklar', 'fill.angle': 'Açı', + // ── Image ── + 'image.title': 'Görüntü', + 'image.fit': 'Sığdırma Modu', + 'image.fill': 'Doldur', + 'image.fitMode': 'Sığdır', + 'image.crop': 'Kırp', + 'image.tile': 'Döşe', + 'image.clickToUpload': 'Yüklemek için tıklayın', + 'image.changeImage': 'Görüntüyü değiştir', + 'image.adjustments': 'Ayarlamalar', + 'image.exposure': 'Pozlama', + 'image.contrast': 'Kontrast', + 'image.saturation': 'Doygunluk', + 'image.temperature': 'Sıcaklık', + 'image.tint': 'Renk Tonu', + 'image.highlights': 'Parlak Tonlar', + 'image.shadows': 'Gölgeler', + 'image.reset': 'Sıfırla', + // ── Stroke ── 'stroke.title': 'Kenarlık', @@ -182,6 +202,11 @@ const tr: TranslationKeys = { 'text.weight.semibold': 'Yarı Kalın', 'text.weight.bold': 'Kalın', 'text.weight.black': 'Siyah', + 'text.font.search': 'Yazı tipi ara\u2026', + 'text.font.bundled': 'Dahili', + 'text.font.system': 'Sistem', + 'text.font.loading': 'Yazı tipleri yükleniyor\u2026', + 'text.font.noResults': 'Yazı tipi bulunamadı', // ── Text Layout ── 'textLayout.title': 'Düzen', diff --git a/src/i18n/locales/vi.ts b/src/i18n/locales/vi.ts index b2fbcdb7..18abedfa 100644 --- a/src/i18n/locales/vi.ts +++ b/src/i18n/locales/vi.ts @@ -128,9 +128,29 @@ const vi: TranslationKeys = { 'fill.solid': 'Đặc', 'fill.linear': 'Tuyến tính', 'fill.radial': 'Toả tròn', + 'fill.image': 'Hình ảnh', 'fill.stops': 'Điểm dừng', 'fill.angle': 'Góc', + // ── Image ── + 'image.title': 'Hình ảnh', + 'image.fit': 'Chế độ vừa', + 'image.fill': 'Lấp đầy', + 'image.fitMode': 'Vừa khít', + 'image.crop': 'Cắt', + 'image.tile': 'Lát gạch', + 'image.clickToUpload': 'Nhấp để tải lên', + 'image.changeImage': 'Đổi hình ảnh', + 'image.adjustments': 'Điều chỉnh', + 'image.exposure': 'Phơi sáng', + 'image.contrast': 'Tương phản', + 'image.saturation': 'Độ bão hòa', + 'image.temperature': 'Nhiệt độ màu', + 'image.tint': 'Sắc thái', + 'image.highlights': 'Vùng sáng', + 'image.shadows': 'Vùng tối', + 'image.reset': 'Đặt lại', + // ── Stroke ── 'stroke.title': 'Viền', @@ -182,6 +202,11 @@ const vi: TranslationKeys = { 'text.weight.semibold': 'Hơi đậm', 'text.weight.bold': 'Đậm', 'text.weight.black': 'Rất đậm', + 'text.font.search': 'Tìm phông chữ\u2026', + 'text.font.bundled': 'Đi kèm', + 'text.font.system': 'Hệ thống', + 'text.font.loading': 'Đang tải phông chữ\u2026', + 'text.font.noResults': 'Không tìm thấy phông chữ', // ── Text Layout ── 'textLayout.title': 'Bố cục', diff --git a/src/i18n/locales/zh-tw.ts b/src/i18n/locales/zh-tw.ts index ec1aff1e..62f01634 100644 --- a/src/i18n/locales/zh-tw.ts +++ b/src/i18n/locales/zh-tw.ts @@ -125,9 +125,29 @@ const zhTW: TranslationKeys = { 'fill.solid': '純色', 'fill.linear': '線性漸層', 'fill.radial': '放射漸層', + 'fill.image': '圖片', 'fill.stops': '色標', 'fill.angle': '角度', + // ── Image ── + 'image.title': '圖片', + 'image.fit': '適應模式', + 'image.fill': '填滿', + 'image.fitMode': '適應', + 'image.crop': '裁切', + 'image.tile': '平鋪', + 'image.clickToUpload': '點擊上傳', + 'image.changeImage': '更換圖片', + 'image.adjustments': '調整', + 'image.exposure': '曝光', + 'image.contrast': '對比度', + 'image.saturation': '飽和度', + 'image.temperature': '色溫', + 'image.tint': '色調', + 'image.highlights': '高光', + 'image.shadows': '陰影', + 'image.reset': '重設', + // ── Stroke ── 'stroke.title': '描邊', @@ -179,6 +199,11 @@ const zhTW: TranslationKeys = { 'text.weight.semibold': '半粗', 'text.weight.bold': '粗體', 'text.weight.black': '極粗', + 'text.font.search': '搜尋字型\u2026', + 'text.font.bundled': '內建', + 'text.font.system': '系統', + 'text.font.loading': '正在載入字型\u2026', + 'text.font.noResults': '找不到字型', // ── Text Layout ── 'textLayout.title': '佈局', diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index f81daf79..e179b30c 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -125,9 +125,29 @@ const zh: TranslationKeys = { 'fill.solid': '纯色', 'fill.linear': '线性渐变', 'fill.radial': '径向渐变', + 'fill.image': '图片', 'fill.stops': '色标', 'fill.angle': '角度', + // ── Image ── + 'image.title': '图片', + 'image.fit': '适应模式', + 'image.fill': '填充', + 'image.fitMode': '适应', + 'image.crop': '裁剪', + 'image.tile': '平铺', + 'image.clickToUpload': '点击上传', + 'image.changeImage': '更换图片', + 'image.adjustments': '调整', + 'image.exposure': '曝光', + 'image.contrast': '对比度', + 'image.saturation': '饱和度', + 'image.temperature': '色温', + 'image.tint': '色调', + 'image.highlights': '高光', + 'image.shadows': '阴影', + 'image.reset': '重置', + // ── Stroke ── 'stroke.title': '描边', @@ -179,6 +199,11 @@ const zh: TranslationKeys = { 'text.weight.semibold': '半粗', 'text.weight.bold': '粗体', 'text.weight.black': '极粗', + 'text.font.search': '搜索字体…', + 'text.font.bundled': '内置字体', + 'text.font.system': '系统字体', + 'text.font.loading': '加载字体中…', + 'text.font.noResults': '未找到字体', // ── Text Layout ── 'textLayout.title': '布局', diff --git a/src/routes/editor.tsx b/src/routes/editor.tsx index 7db18b37..8bd64487 100644 --- a/src/routes/editor.tsx +++ b/src/routes/editor.tsx @@ -4,6 +4,7 @@ import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts' export const Route = createFileRoute('/editor')({ component: EditorPage, + ssr: false, head: () => ({ meta: [{ title: 'OpenPencil Editor' }], }), diff --git a/src/services/figma/figma-clipboard.ts b/src/services/figma/figma-clipboard.ts index b2535c31..928f1333 100644 --- a/src/services/figma/figma-clipboard.ts +++ b/src/services/figma/figma-clipboard.ts @@ -77,8 +77,6 @@ interface FigmaClipboardData { * */ export function extractFigmaClipboardData(html: string): FigmaClipboardData | null { - console.debug('[figma-clipboard] HTML preview (first 500 chars):', html.slice(0, 500)) - let metaB64: string | null = null let bufferB64: string | null = null @@ -89,7 +87,6 @@ export function extractFigmaClipboardData(html: string): FigmaClipboardData | nu const bufferCommentMatch = html.match(/)?([\s\S]*?)/) if (metaCommentMatch && bufferCommentMatch) { - console.debug('[figma-clipboard] Matched comment-wrapped format') metaB64 = metaCommentMatch[1].trim() bufferB64 = bufferCommentMatch[1].trim() } @@ -100,7 +97,6 @@ export function extractFigmaClipboardData(html: string): FigmaClipboardData | nu const attrBufferMatch = html.match(/data-buffer="([^"]*)"/) if (attrMetaMatch && attrBufferMatch) { - console.debug('[figma-clipboard] Matched data-attribute format') // Strip comment wrappers from attribute values if present. // Opening marker may lack --> (e.g. "") metaB64 = attrMetaMatch[1] @@ -118,25 +114,12 @@ export function extractFigmaClipboardData(html: string): FigmaClipboardData | nu const encodedBufferMatch = html.match(/<!--\(figma\)-->([\s\S]*?)<!--\(figma\)-->/) if (encodedMetaMatch && encodedBufferMatch) { - console.debug('[figma-clipboard] Matched HTML-encoded comment format') metaB64 = encodedMetaMatch[1].trim() bufferB64 = encodedBufferMatch[1].trim() } } - if (!metaB64 || !bufferB64) { - console.warn('[figma-clipboard] No matching extraction strategy.', - 'Has figmeta comment:', //.test(html), - 'Has figma comment:', //.test(html), - 'Has data-metadata attr:', /data-metadata=/.test(html), - 'Has data-buffer attr:', /data-buffer=/.test(html), - 'Has encoded figmeta:', /<!--\(figmeta\)/.test(html), - ) - return null - } - - console.debug('[figma-clipboard] meta base64 length:', metaB64.length, - 'buffer base64 length:', bufferB64.length) + if (!metaB64 || !bufferB64) return null try { const metaRaw = decodeBase64(metaB64) @@ -144,18 +127,9 @@ export function extractFigmaClipboardData(html: string): FigmaClipboardData | nu const jsonEnd = metaRaw.lastIndexOf('}') const metaJson = jsonEnd >= 0 ? metaRaw.slice(0, jsonEnd + 1) : metaRaw const meta = JSON.parse(metaJson) - console.debug('[figma-clipboard] Decoded meta:', meta) - const bytes = decodeBase64ToBytes(bufferB64) - - console.debug('[figma-clipboard] Decoded buffer:', bytes.byteLength, 'bytes,', - 'first 8 bytes:', Array.from(bytes.slice(0, 8)).map(b => b.toString(16).padStart(2, '0')).join(' ')) - return { meta, buffer: bytes.buffer as ArrayBuffer } - } catch (err) { - console.error('[figma-clipboard] Decode error:', err, - 'meta b64 preview:', metaB64.slice(0, 80), - 'buffer b64 preview:', bufferB64.slice(0, 80)) + } catch { return null } } @@ -163,17 +137,279 @@ export function extractFigmaClipboardData(html: string): FigmaClipboardData | nu /** * Convert a Figma clipboard buffer into PenNodes. * The buffer uses the same fig-kiwi binary format as .fig files. + * + * @param buffer The decoded binary buffer from the Figma clipboard. + * @param html Optional full clipboard HTML — when provided, styled content + * outside the binary comments is parsed to supplement missing + * style properties (colors, fonts) on the binary-parsed nodes. */ export function figmaClipboardToNodes( buffer: ArrayBuffer, + html?: string, ): { nodes: PenNode[]; warnings: string[] } { const decoded = parseFigFile(buffer) - const { nodes, warnings, imageBlobs } = figmaNodeChangesToPenNodes(decoded, 'openpencil') + // Use 'preserve' layout mode (same as .fig file import) so that: + // 1. Auto-layout children are reversed to correct flow order + // 2. Image nodes get numeric pixel dimensions instead of sizing strings + const { nodes, warnings, imageBlobs } = figmaNodeChangesToPenNodes(decoded, 'preserve') // Resolve embedded image blobs to data URLs if (imageBlobs.size > 0 || decoded.imageFiles.size > 0) { resolveImageBlobs(nodes, imageBlobs, decoded.imageFiles) } + // Handle unresolved image references — clipboard data often lacks image + // binary data. Convert unresolvable image nodes to placeholder rectangles. + fixUnresolvedImages(nodes) + + // Enrich nodes with style hints extracted from the clipboard HTML. + // Figma clipboard HTML contains styled elements (with inline CSS) that + // may carry color/font information lost during binary parsing (e.g. when + // shared style nodes are not included in the clipboard data). + if (html) { + const hints = parseClipboardHtmlStyles(html) + if (hints.size > 0) { + enrichNodesFromHtmlHints(nodes, hints) + } + } + return { nodes, warnings } } + +/** + * Walk the node tree and convert image nodes with unresolved __blob:/__hash: + * references into placeholder rectangles. Clipboard data often lacks the + * actual image binary, so leaving these as image nodes with broken src would + * render as invisible/broken elements. + */ +function fixUnresolvedImages(nodes: PenNode[]): void { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i] + // Convert standalone image nodes with unresolved references to rectangles + if (node.type === 'image' && node.src && (node.src.startsWith('__blob:') || node.src.startsWith('__hash:'))) { + const rect: PenNode = { + type: 'rectangle', + id: node.id, + name: node.name, + x: node.x, + y: node.y, + width: node.width, + height: node.height, + cornerRadius: node.cornerRadius, + opacity: node.opacity, + fill: [{ type: 'solid', color: '#E5E7EB' }], + } + nodes[i] = rect + } + // Fix unresolved image fills on rectangles/ellipses/frames — + // __blob: and __hash: are internal references that the image loader + // cannot fetch; replace with a placeholder solid fill. + if ('fill' in node && Array.isArray(node.fill)) { + for (let j = node.fill.length - 1; j >= 0; j--) { + const fill = node.fill[j] + if (fill.type === 'image' && 'url' in fill) { + const url = (fill as any).url as string + if (url?.startsWith('__blob:') || url?.startsWith('__hash:')) { + node.fill[j] = { type: 'solid', color: '#E5E7EB' } + } + } + } + } + // Recurse into children + if ('children' in node && Array.isArray(node.children)) { + fixUnresolvedImages(node.children) + } + } +} + +// --------------------------------------------------------------------------- +// HTML style extraction — parse the styled portion of Figma clipboard HTML +// to recover color/font information that may be missing from the binary data. +// --------------------------------------------------------------------------- + +interface HtmlStyleHint { + color?: string + fontFamily?: string + fontSize?: number + fontWeight?: number + backgroundColor?: string +} + +/** + * Convert a CSS color value (hex, rgb, rgba) to a #RRGGBB(AA) hex string. + */ +function cssColorToHex(css: string): string | undefined { + const c = css.trim() + if (c.startsWith('#')) { + // Normalize 3-digit to 6-digit hex + if (c.length === 4) { + return `#${c[1]}${c[1]}${c[2]}${c[2]}${c[3]}${c[3]}` + } + return c + } + const rgbaMatch = c.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)/) + if (rgbaMatch) { + const r = parseInt(rgbaMatch[1]).toString(16).padStart(2, '0') + const g = parseInt(rgbaMatch[2]).toString(16).padStart(2, '0') + const b = parseInt(rgbaMatch[3]).toString(16).padStart(2, '0') + if (rgbaMatch[4] !== undefined) { + const a = Math.round(parseFloat(rgbaMatch[4]) * 255) + if (a < 255) return `#${r}${g}${b}${a.toString(16).padStart(2, '0')}` + } + return `#${r}${g}${b}` + } + return undefined +} + +/** + * Decode HTML entities (&#NN; and &/</etc.) in text content. + */ +function decodeHtmlEntities(text: string): string { + return text + .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code))) + .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/ /g, ' ') +} + +/** + * Parse the styled HTML portion of a Figma clipboard to extract text style hints. + * Figma clipboard HTML contains styled elements (p, span, div) with inline CSS + * in addition to the binary data in comment blocks. These inline styles carry + * resolved color/font values that are sometimes missing from the binary format + * (e.g. when a node references a shared style that is not included in the + * clipboard data). + * + * Returns a map keyed by normalized text content → style properties. + */ +function parseClipboardHtmlStyles(html: string): Map { + // Remove the binary data comment blocks to isolate the styled HTML content + const cleanHtml = html + .replace(//g, '') + .replace(//g, '') + + const hints = new Map() + + // Match elements with style attributes and text content. + // Captures: style attribute value, text content between tags. + const elemRegex = /style="([^"]*)"[^>]*>([^<]+) 200) continue + + const hint: HtmlStyleHint = {} + + // color (text color) — avoid matching background-color + const colorMatch = styleAttr.match(/(?:^|;\s*)color:\s*((?:rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}))/) + if (colorMatch) hint.color = cssColorToHex(colorMatch[1]) + + // font-family + const fontMatch = styleAttr.match(/font-family:\s*([^;]+)/) + if (fontMatch) { + const family = fontMatch[1].trim().replace(/['"]/g, '').split(',')[0].trim() + if (family) hint.fontFamily = family + } + + // font-size + const sizeMatch = styleAttr.match(/font-size:\s*(\d+(?:\.\d+)?)px/) + if (sizeMatch) hint.fontSize = parseFloat(sizeMatch[1]) + + // font-weight + const weightMatch = styleAttr.match(/font-weight:\s*(\d+)/) + if (weightMatch) hint.fontWeight = parseInt(weightMatch[1]) + + // background-color (for div/frame enrichment) + const bgMatch = styleAttr.match(/background-color:\s*((?:rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}))/) + if (bgMatch) hint.backgroundColor = cssColorToHex(bgMatch[1]) + + if (Object.keys(hint).length > 0) { + // Use first occurrence — later duplicates may be nested/overridden + if (!hints.has(rawText)) { + hints.set(rawText, hint) + } + } + } + + return hints +} + +/** + * Walk the PenNode tree and fill in missing style properties using hints + * extracted from the clipboard HTML. Only fills in properties that are + * undefined/missing — explicit values from the binary parser are never + * overwritten. + */ +function enrichNodesFromHtmlHints( + nodes: PenNode[], + hints: Map, +): void { + for (const node of nodes) { + if (node.type === 'text') { + // Build plain text content for lookup + const content = typeof node.content === 'string' + ? node.content + : Array.isArray(node.content) + ? node.content.map(s => s.text).join('') + : '' + const trimmed = content.trim() + if (!trimmed) continue + + // Try exact match first, then try individual lines + const hint = hints.get(trimmed) ?? findPartialHint(trimmed, hints) + if (hint) { + // Fill in missing text color + if (!node.fill && hint.color) { + node.fill = [{ type: 'solid', color: hint.color }] + } + // Fill in missing font properties + if (!node.fontFamily && hint.fontFamily) { + node.fontFamily = hint.fontFamily + } + if (!node.fontSize && hint.fontSize) { + node.fontSize = hint.fontSize + } + if (!node.fontWeight && hint.fontWeight) { + node.fontWeight = hint.fontWeight + } + } + } + + // For frames/rectangles without fill, check if HTML has background-color + if ((node.type === 'frame' || node.type === 'rectangle') && !node.fill) { + const name = node.name?.trim() + if (name) { + const hint = hints.get(name) + if (hint?.backgroundColor) { + node.fill = [{ type: 'solid', color: hint.backgroundColor }] + } + } + } + + // Recurse into children + if ('children' in node && Array.isArray(node.children)) { + enrichNodesFromHtmlHints(node.children, hints) + } + } +} + +/** + * Try to find a matching hint for text that may span multiple lines or + * may be a subset of a longer HTML text. + */ +function findPartialHint( + text: string, + hints: Map, +): HtmlStyleHint | undefined { + // Check if text starts with any hint key + for (const [key, hint] of hints) { + if (text.startsWith(key) || key.startsWith(text)) { + return hint + } + } + return undefined +} diff --git a/src/services/figma/figma-node-converters.ts b/src/services/figma/figma-node-converters.ts index cb7e9b35..0d3be78b 100644 --- a/src/services/figma/figma-node-converters.ts +++ b/src/services/figma/figma-node-converters.ts @@ -2,7 +2,7 @@ import type { FigmaNodeChange, FigmaMatrix, FigmaImportLayoutMode, FigmaSymbolOverride, FigmaDerivedSymbolDataEntry, FigmaGUID, } from './figma-types' -import type { PenNode, SizingBehavior, ImageFitMode } from '@/types/pen' +import type { PenNode, SizingBehavior } from '@/types/pen' import { mapFigmaFills } from './figma-fill-mapper' import { mapFigmaStroke } from './figma-stroke-mapper' import { mapFigmaEffects } from './figma-effect-mapper' @@ -170,44 +170,6 @@ function commonProps( // --- Image helpers --- -function hasOnlyImageFill(figma: FigmaNodeChange): boolean { - if (!figma.fillPaints || figma.fillPaints.length === 0) return false - const visible = figma.fillPaints.filter((f) => f.visible !== false) - return visible.length === 1 && visible[0].type === 'IMAGE' -} - -function hashToHex(hash: Uint8Array): string { - return Array.from(hash).map(b => b.toString(16).padStart(2, '0')).join('') -} - -function getImageFillUrl(figma: FigmaNodeChange): string { - const paint = figma.fillPaints?.find((f) => f.type === 'IMAGE' && f.visible !== false) - if (!paint?.image) return '' - - if (paint.image.hash && paint.image.hash.length > 0) { - return `__hash:${hashToHex(paint.image.hash)}` - } - - if (paint.image.dataBlob !== undefined && paint.image.dataBlob !== null) { - return `__blob:${paint.image.dataBlob}` - } - - return '' -} - -function getImageFitMode(figma: FigmaNodeChange): ImageFitMode | undefined { - const paint = figma.fillPaints?.find( - (f) => f.visible !== false && f.type === 'IMAGE', - ) - if (!paint?.imageScaleMode) return undefined - switch (paint.imageScaleMode) { - case 'FIT': return 'fit' - case 'FILL': return 'fill' - case 'TILE': return 'tile' - default: return undefined - } -} - function figmaFillColor(figma: FigmaNodeChange): string | undefined { const paint = figma.fillPaints?.find((f) => f.visible !== false && f.type === 'SOLID') if (!paint?.color) return undefined @@ -319,19 +281,6 @@ function convertFrame( const id = ctx.generateId() const children = convertChildren(treeNode, ctx) - if (hasOnlyImageFill(figma) && children.length === 0) { - return { - type: 'image', - ...commonProps(figma, id), - src: getImageFillUrl(figma), - objectFit: getImageFitMode(figma), - width: resolveWidth(figma, parentStackMode, ctx), - height: resolveHeight(figma, parentStackMode, ctx), - cornerRadius: mapCornerRadius(figma), - effects: mapFigmaEffects(figma.effects), - } - } - // In preserve mode, only apply auto-layout properties for frames that actually // have stackMode set. Frames without stackMode use absolute x,y positioning. // For auto-layout frames, children order must be reversed because the tree @@ -355,7 +304,7 @@ function convertFrame( height: resolveHeight(figma, parentStackMode, ctx), ...layout, cornerRadius: mapCornerRadius(figma), - fill: mapFigmaFills(figma.fillPaints), + fill: mapFigmaFills(figma.fillPaints) ?? mapFigmaFills(figma.backgroundPaints), stroke: mapFigmaStroke(figma), effects: mapFigmaEffects(figma.effects), children: orderedChildren.length > 0 ? orderedChildren : undefined, @@ -407,7 +356,7 @@ function convertComponent( height: resolveHeight(figma, parentStackMode, ctx), ...layout, cornerRadius: mapCornerRadius(figma), - fill: mapFigmaFills(figma.fillPaints), + fill: mapFigmaFills(figma.fillPaints) ?? mapFigmaFills(figma.backgroundPaints), stroke: mapFigmaStroke(figma), effects: mapFigmaEffects(figma.effects), children: orderedChildren.length > 0 ? orderedChildren : undefined, @@ -440,8 +389,12 @@ function convertInstance( figma.size, ctx.symbolTree, ) + // Merge symbol's layout and visual properties into the instance. + // Instances inherit from their master but clipboard data may not + // include inherited properties on the instance node itself. + const mergedFigma = mergeSymbolProps(treeNode.figma, symbolNode.figma) return convertFrame( - { figma: treeNode.figma, children }, + { figma: mergedFigma, children }, parentStackMode, ctx, ) @@ -464,6 +417,43 @@ function convertInstance( return convertFrame(treeNode, parentStackMode, ctx) } +/** + * Merge symbol's properties into an instance node. + * Instances inherit layout and visual properties from their master component, + * but clipboard data may not include these inherited values on the instance. + * Instance's own properties take priority (they are explicit overrides). + */ +function mergeSymbolProps(instance: FigmaNodeChange, symbol: FigmaNodeChange): FigmaNodeChange { + const merged = { ...instance } + + // Layout properties — needed for auto-layout detection and layout generation + const layoutKeys: (keyof FigmaNodeChange)[] = [ + 'stackMode', 'stackSpacing', 'stackPadding', + 'stackHorizontalPadding', 'stackVerticalPadding', + 'stackPaddingRight', 'stackPaddingBottom', + 'stackPrimaryAlignItems', 'stackCounterAlignItems', + 'stackPrimarySizing', 'stackCounterSizing', + 'stackChildPrimaryGrow', 'stackChildAlignSelf', + 'frameMaskDisabled', + ] + + // Visual properties — fills/strokes for the frame itself + const visualKeys: (keyof FigmaNodeChange)[] = [ + 'fillPaints', 'strokePaints', 'strokeWeight', 'strokeAlign', + 'cornerRadius', 'rectangleCornerRadiiIndependent', + 'rectangleTopLeftCornerRadius', 'rectangleTopRightCornerRadius', + 'rectangleBottomLeftCornerRadius', 'rectangleBottomRightCornerRadius', + ] + + for (const key of [...layoutKeys, ...visualKeys]) { + if ((merged as any)[key] === undefined && (symbol as any)[key] !== undefined) { + (merged as any)[key] = (symbol as any)[key] + } + } + + return merged +} + /** * Apply INSTANCE overrides (fills, arcData) and derived data (sizes, transforms) * to SYMBOL children when inlining them into an instance. @@ -814,19 +804,6 @@ function convertRectangle( const figma = treeNode.figma const id = ctx.generateId() - if (hasOnlyImageFill(figma)) { - return { - type: 'image', - ...commonProps(figma, id), - src: getImageFillUrl(figma), - objectFit: getImageFitMode(figma), - width: resolveWidth(figma, parentStackMode, ctx), - height: resolveHeight(figma, parentStackMode, ctx), - cornerRadius: mapCornerRadius(figma), - effects: mapFigmaEffects(figma.effects), - } - } - return { type: 'rectangle', ...commonProps(figma, id), @@ -847,19 +824,6 @@ function convertEllipse( const figma = treeNode.figma const id = ctx.generateId() - if (hasOnlyImageFill(figma)) { - return { - type: 'image', - ...commonProps(figma, id), - src: getImageFillUrl(figma), - objectFit: getImageFitMode(figma), - width: resolveWidth(figma, parentStackMode, ctx), - height: resolveHeight(figma, parentStackMode, ctx), - cornerRadius: Math.round((figma.size?.x ?? 100) / 2), - effects: mapFigmaEffects(figma.effects), - } - } - // Convert Figma arcData (radians) to PenNode arc properties (degrees) const arc = figma.arcData const arcProps = arc ? mapFigmaArcData(arc) : {} @@ -867,30 +831,19 @@ function convertEllipse( // For arc ellipses, absorb flipX/flipY into the arc angles instead of // relying on canvas-level flip (SVG path flip doesn't work well in Fabric.js). - // Also fix the position: when m00=-1 the x in transform is the right edge. + // Note: extractPosition already computes the correct visual top-left for + // flipped nodes via center-based calculation, so no position adjustment needed. if (arcProps.sweepAngle !== undefined || arcProps.startAngle !== undefined || arcProps.innerRadius !== undefined) { const start = arcProps.startAngle ?? 0 const sweep = arcProps.sweepAngle ?? 360 - // Only adjust position for flip when there's no rotation component. - // When rotation is present, extractPosition already computed the correct - // center-based position that accounts for both rotation and flip. - const hasRot = figma.transform && (Math.abs(figma.transform.m01) > 0.001 || Math.abs(figma.transform.m10) > 0.001) if (props.flipX) { arcProps.startAngle = normalizeAngle(180 - start - sweep) arcProps.sweepAngle = sweep - if (!hasRot) { - const w = figma.size?.x ?? 0 - props.x = Math.round((props.x - w) * 100) / 100 - } delete props.flipX } if (props.flipY) { arcProps.startAngle = normalizeAngle(360 - start - sweep) arcProps.sweepAngle = sweep - if (!hasRot) { - const h = figma.size?.y ?? 0 - props.y = Math.round((props.y - h) * 100) / 100 - } delete props.flipY } } diff --git a/src/services/figma/figma-node-mapper.ts b/src/services/figma/figma-node-mapper.ts index 8a0d653c..985d5f1a 100644 --- a/src/services/figma/figma-node-mapper.ts +++ b/src/services/figma/figma-node-mapper.ts @@ -17,10 +17,10 @@ import { } from './figma-node-converters' /** - * Resolve styleIdForFill / styleIdForStrokeFill references to inline paints. - * Figma stores paint styles as separate nodes (styleType='FILL') and references - * them via styleIdForFill on consuming nodes. Nodes with a style ref but no - * inline fillPaints need the style's paints copied in. + * Resolve style references (fill, stroke, text, effect) to inline properties. + * Figma stores styles as separate nodes (styleType='FILL'|'TEXT'|'EFFECT') and + * references them via styleIdFor* on consuming nodes. Nodes with a style ref + * but no inline properties need the style's values copied in. */ function resolveStyleReferences(nodeChanges: FigmaNodeChange[]): void { // Build style map from nodes with styleType @@ -32,25 +32,43 @@ function resolveStyleReferences(nodeChanges: FigmaNodeChange[]): void { } if (styleMap.size === 0) return + function lookupStyle(ref: { guid?: { sessionID: number; localID: number } } | undefined): FigmaNodeChange | undefined { + if (!ref?.guid) return undefined + return styleMap.get(`${ref.guid.sessionID}:${ref.guid.localID}`) + } + /** Resolve style references on a single node-like object. */ function resolveOnNode(nc: Record) { - // Resolve fill style — always use the style's paint when a style reference exists - const fillStyleId = nc.styleIdForFill as { guid?: { sessionID: number; localID: number } } | undefined - if (fillStyleId?.guid) { - const styleKey = `${fillStyleId.guid.sessionID}:${fillStyleId.guid.localID}` - const style = styleMap.get(styleKey) - if (style?.fillPaints?.length) { - nc.fillPaints = style.fillPaints - } + // Resolve fill style + const fillStyle = lookupStyle(nc.styleIdForFill) + if (fillStyle?.fillPaints?.length) { + nc.fillPaints = fillStyle.fillPaints } + // Resolve stroke fill style - const strokeStyleId = nc.styleIdForStrokeFill as { guid?: { sessionID: number; localID: number } } | undefined - if (strokeStyleId?.guid) { - const styleKey = `${strokeStyleId.guid.sessionID}:${strokeStyleId.guid.localID}` - const style = styleMap.get(styleKey) - if (style?.fillPaints?.length) { - nc.strokePaints = style.fillPaints - } + const strokeStyle = lookupStyle(nc.styleIdForStrokeFill) + if (strokeStyle?.fillPaints?.length) { + nc.strokePaints = strokeStyle.fillPaints + } + + // Resolve text style — copies font properties from the TEXT style node + const textStyle = lookupStyle(nc.styleIdForText) + if (textStyle) { + if (!nc.fontName && textStyle.fontName) nc.fontName = textStyle.fontName + if (nc.fontSize === undefined && textStyle.fontSize !== undefined) nc.fontSize = textStyle.fontSize + if (!nc.lineHeight && textStyle.lineHeight) nc.lineHeight = textStyle.lineHeight + if (!nc.letterSpacing && textStyle.letterSpacing) nc.letterSpacing = textStyle.letterSpacing + if (!nc.textAlignHorizontal && textStyle.textAlignHorizontal) nc.textAlignHorizontal = textStyle.textAlignHorizontal + if (!nc.textDecoration && textStyle.textDecoration) nc.textDecoration = textStyle.textDecoration + if (!nc.textCase && textStyle.textCase) nc.textCase = textStyle.textCase + // Text style may also carry fill paints (text color) + if (!nc.fillPaints && textStyle.fillPaints?.length) nc.fillPaints = textStyle.fillPaints + } + + // Resolve effect style + const effectStyle = lookupStyle(nc.styleIdForEffect) + if (effectStyle?.effects?.length && !nc.effects?.length) { + nc.effects = effectStyle.effects } } diff --git a/src/services/figma/figma-vector-decoder.ts b/src/services/figma/figma-vector-decoder.ts index 4e5f8ba1..c36bd7dc 100644 --- a/src/services/figma/figma-vector-decoder.ts +++ b/src/services/figma/figma-vector-decoder.ts @@ -133,7 +133,11 @@ export function decodeFigmaVectorPath( const geometries = (!hasVisibleFills && hasVisibleStrokes) ? (figma.strokeGeometry ?? figma.fillGeometry) : (figma.fillGeometry ?? figma.strokeGeometry) - if (!geometries || geometries.length === 0) return null + + if (!geometries || geometries.length === 0) { + // Try to decode from vectorData.vectorNetworkBlob as fallback + return decodeVectorNetworkBlob(figma, blobs) + } const pathParts: string[] = [] @@ -145,10 +149,173 @@ export function decodeFigmaVectorPath( if (decoded) pathParts.push(decoded) } - if (pathParts.length === 0) return null + if (pathParts.length === 0) { + // Try vectorNetworkBlob fallback + const vnPath = decodeVectorNetworkBlob(figma, blobs) + if (vnPath) return vnPath + return null + } // fillGeometry/strokeGeometry coordinates are already in the node's local // coordinate space (0..size.x, 0..size.y). Do NOT scale by normalizedSize — // that applies only to vectorNetworkBlob, which is not used here. return pathParts.join(' ') +} + +/** + * Decode Figma's vectorNetworkBlob (VectorNetwork) as a fallback when + * fill/stroke geometry blobs are not available. + * + * The vectorNetwork blob format: + * [4 bytes LE] vertex count + * For each vertex: [4 bytes float32 LE x] [4 bytes float32 LE y] + * [4 bytes LE] segment count + * For each segment: + * [4 bytes LE] start vertex index + * [4 bytes LE] end vertex index + * [4 bytes float32 LE] tangentStart.x + * [4 bytes float32 LE] tangentStart.y + * [4 bytes float32 LE] tangentEnd.x + * [4 bytes float32 LE] tangentEnd.y + */ +function decodeVectorNetworkBlob( + figma: FigmaNodeChange, + blobs: (Uint8Array | string)[], +): string | null { + const blobIdx = figma.vectorData?.vectorNetworkBlob + if (blobIdx == null) return null + const blob = blobs[blobIdx] + if (!blob || typeof blob === 'string' || blob.length < 8) return null + + const buf = new ArrayBuffer(blob.byteLength) + new Uint8Array(buf).set(blob) + const view = new DataView(buf) + let offset = 0 + + try { + // Read vertices + const vertexCount = view.getUint32(offset, true); offset += 4 + if (vertexCount > 100000 || offset + vertexCount * 8 > blob.length) return null + + const vertices: { x: number; y: number }[] = [] + for (let i = 0; i < vertexCount; i++) { + const x = view.getFloat32(offset, true); offset += 4 + const y = view.getFloat32(offset, true); offset += 4 + vertices.push({ x, y }) + } + + if (offset + 4 > blob.length) return null + // Read segments + const segmentCount = view.getUint32(offset, true); offset += 4 + if (segmentCount > 100000) return null + + // Build adjacency list: for each vertex, which segments start from it + const segments: { + start: number; end: number + ts: { x: number; y: number }; te: { x: number; y: number } + }[] = [] + + for (let i = 0; i < segmentCount; i++) { + if (offset + 24 > blob.length) break + const startIdx = view.getUint32(offset, true); offset += 4 + const endIdx = view.getUint32(offset, true); offset += 4 + const tsx = view.getFloat32(offset, true); offset += 4 + const tsy = view.getFloat32(offset, true); offset += 4 + const tex = view.getFloat32(offset, true); offset += 4 + const tey = view.getFloat32(offset, true); offset += 4 + + if (startIdx < vertexCount && endIdx < vertexCount) { + segments.push({ + start: startIdx, end: endIdx, + ts: { x: tsx, y: tsy }, te: { x: tex, y: tey }, + }) + } + } + + if (segments.length === 0 || vertices.length === 0) return null + + // Scale from normalizedSize to actual node size + const normW = figma.vectorData?.normalizedSize?.x ?? 1 + const normH = figma.vectorData?.normalizedSize?.y ?? 1 + const nodeW = figma.size?.x ?? normW + const nodeH = figma.size?.y ?? normH + const sx = normW > 0.001 ? nodeW / normW : 1 + const sy = normH > 0.001 ? nodeH / normH : 1 + + // Convert segments to SVG path commands + // Simple approach: each segment becomes an independent moveTo + curveTo/lineTo + const parts: string[] = [] + const used = new Set() + + // Build adjacency for chain walking + const adj = new Map() + for (let i = 0; i < segments.length; i++) { + const s = segments[i] + if (!adj.has(s.start)) adj.set(s.start, []) + adj.get(s.start)!.push(i) + } + + // Walk chains starting from each unused segment + for (let i = 0; i < segments.length; i++) { + if (used.has(i)) continue + + const seg = segments[i] + const sv = vertices[seg.start] + parts.push(`M${r(sv.x * sx)} ${r(sv.y * sy)}`) + used.add(i) + + // Emit this segment + emitSegment(seg, vertices, sx, sy, parts) + + // Follow chain + let current = seg.end + let found = true + while (found) { + found = false + const nexts = adj.get(current) + if (nexts) { + for (const ni of nexts) { + if (used.has(ni)) continue + used.add(ni) + emitSegment(segments[ni], vertices, sx, sy, parts) + current = segments[ni].end + found = true + break + } + } + } + + // Check if path is closed + if (current === seg.start) parts.push('Z') + } + + const result = parts.join(' ') + return result || null + } catch { + return null + } +} + +function emitSegment( + seg: { start: number; end: number; ts: { x: number; y: number }; te: { x: number; y: number } }, + vertices: { x: number; y: number }[], + sx: number, sy: number, + parts: string[], +): void { + const sv = vertices[seg.start] + const ev = vertices[seg.end] + const isStraight = + Math.abs(seg.ts.x) < 0.0001 && Math.abs(seg.ts.y) < 0.0001 && + Math.abs(seg.te.x) < 0.0001 && Math.abs(seg.te.y) < 0.0001 + + if (isStraight) { + parts.push(`L${r(ev.x * sx)} ${r(ev.y * sy)}`) + } else { + // Tangents are relative offsets from start/end vertices + const cp1x = (sv.x + seg.ts.x) * sx + const cp1y = (sv.y + seg.ts.y) * sy + const cp2x = (ev.x + seg.te.x) * sx + const cp2y = (ev.y + seg.te.y) * sy + parts.push(`C${r(cp1x)} ${r(cp1y)} ${r(cp2x)} ${r(cp2y)} ${r(ev.x * sx)} ${r(ev.y * sy)}`) + } } \ No newline at end of file diff --git a/src/types/pen.ts b/src/types/pen.ts index c9646cff..a5c7a702 100644 --- a/src/types/pen.ts +++ b/src/types/pen.ts @@ -175,6 +175,13 @@ export interface ImageNode extends PenNodeBase { height?: SizingBehavior cornerRadius?: number | [number, number, number, number] effects?: PenEffect[] + exposure?: number // -100 to 100 + contrast?: number // -100 to 100 + saturation?: number // -100 to 100 + temperature?: number // -100 to 100 + tint?: number // -100 to 100 + highlights?: number // -100 to 100 + shadows?: number // -100 to 100 } export interface IconFontNode extends PenNodeBase { diff --git a/src/types/styles.ts b/src/types/styles.ts index 0c94664d..857b6473 100644 --- a/src/types/styles.ts +++ b/src/types/styles.ts @@ -46,8 +46,15 @@ export interface RadialGradientFill { export interface ImageFill { type: 'image' url: string - mode?: 'stretch' | 'fill' | 'fit' + mode?: 'fill' | 'fit' | 'crop' | 'tile' | 'stretch' opacity?: number + exposure?: number // -100 to 100 + contrast?: number // -100 to 100 + saturation?: number // -100 to 100 + temperature?: number // -100 to 100 + tint?: number // -100 to 100 + highlights?: number // -100 to 100 + shadows?: number // -100 to 100 } export type PenFill =