* feat(canvas): enhance font handling and text measurement

- Introduced a new `cssFontFamily` utility to ensure proper quoting of font family names in canvas text rendering.
- Updated text measurement functions to utilize `cssFontFamily`, improving compatibility with various font formats.
- Added a `systemFontFamilies` set in `SkiaFontManager` to manage system fonts that cannot be loaded into CanvasKit, ensuring fallback to bitmap rendering.
- Implemented a hook to enumerate system fonts, enhancing the font selection experience in the UI.
- Added a new `FontPicker` component for improved font selection, integrating bundled and system fonts with search functionality.
- Updated translations for new font-related UI elements across multiple languages.

This update significantly improves text rendering accuracy and user experience when selecting fonts.

* feat(figma): enhance clipboard data processing and font handling

- Introduced a new mechanism to identify and handle known non-Google fonts, preventing unnecessary requests to the Google Fonts CDN for system and proprietary fonts.
- Improved the Figma clipboard data extraction process by simplifying error handling and removing excessive debug logs, enhancing performance and readability.
- Added functionality to convert unresolved image references in Figma clipboard data into placeholder rectangles, ensuring better visual fidelity when images are missing.
- Enhanced instance conversion logic to merge symbol properties into instances, ensuring that clipboard data retains necessary layout and visual properties.

This update significantly improves the handling of Figma clipboard data and font management, leading to a more robust user experience.

* refactor(figma): enhance clipboard data processing and style resolution

- Improved the Figma clipboard data extraction by adding optional HTML parsing to enrich nodes with style hints, ensuring better fidelity in text rendering.
- Updated the `figmaClipboardToNodes` function to log conversion results and handle unresolved image references more effectively.
- Refactored style resolution logic to ensure inline properties are correctly applied from style references, enhancing the overall rendering accuracy of Figma imports.

This update significantly enhances the handling of Figma clipboard data and style management, leading to a more robust user experience.

* feat(canvas): add image fill support and enhancements to fill section

- Introduced support for image fills in the SkiaRenderer, allowing for dynamic image rendering with various fit modes and adjustments.
- Added a new ImageFillPopover component for managing image fill properties, including exposure, contrast, and saturation adjustments.
- Updated the FillSection component to include an option for image fills, enhancing the user interface for fill selection.
- Improved localization for new image-related features across multiple languages.

This update significantly enhances the capabilities of the canvas rendering system, providing users with more options for fill types and customization.

* feat(canvas): enhance text shadow rendering and fill section UI

- Implemented a new method for drawing text shadows as blurred copies of glyphs, aligning with Figma's drop-shadow behavior.
- Updated the `drawText` method to incorporate shadow effects for text nodes, improving visual fidelity.
- Adjusted the FillSection component to use a fixed width for better layout consistency.

This update significantly enhances text rendering capabilities and improves the user interface for fill options.

* refactor(figma): simplify arc ellipse conversion logic

- Removed unnecessary position adjustments for flipped nodes, as the extractPosition function already computes the correct visual top-left.
- Cleaned up comments to clarify the handling of arc properties without rotation adjustments, streamlining the conversion process for arc ellipses.

This update enhances the clarity and efficiency of the arc ellipse conversion logic in the Figma node converters.

* fix(canvas): use drawImageRect for image fill modes instead of broken shader scaling

CanvasKit's Image.makeShaderOptions() fails to render when the localMatrix
contains scaling factors with Clamp/Decal tile modes. Only Repeat mode
works reliably with shader scaling.

- Tile mode: keep shader + TileMode.Repeat (works correctly)
- Fill/Fit/Crop/Stretch: use drawImageRect with canvas clipping
- Add drawImageFillRect() for non-tile image fill rendering
- makeFillPaint() returns optional imageFillDraw info for drawRect

* fix(panels): fix image adjustment reset button not working

The reset button called onAdjustmentChange in a loop, but each call
spread from the same stale fill reference, so only the last adjustment
was actually reset. Added onResetAdjustments callback that resets all
adjustment values in a single atomic update.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
This commit is contained in:
Kayshen Xu 2026-03-16 09:33:57 +08:00 committed by GitHub
parent 90bbcb16fd
commit ebe1346d24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2308 additions and 411 deletions

View file

@ -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) {

View file

@ -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

View file

@ -107,6 +107,8 @@ export class SkiaFontManager {
private loadedFamilies = new Set<string>()
/** Font families that failed to load — prevents repeated fetch attempts */
private failedFamilies = new Set<string>()
/** System fonts that render via bitmap — not a failure, just not loadable into CanvasKit */
private systemFontFamilies = new Set<string>()
/** In-flight font fetch promises to avoid duplicate requests */
private pendingFetches = new Map<string, Promise<boolean>>()
@ -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<string, boolean>()
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`. */

View file

@ -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(', ')
}

View file

@ -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(

View file

@ -382,7 +382,7 @@ export default function TopBar() {
{/* Center section — file name */}
<div className="flex-1 flex items-center justify-center min-w-0">
<span className="text-xs text-foreground truncate">
<span className="text-xs text-foreground truncate" suppressHydrationWarning>
{displayName}
</span>
{isDirty && (

View file

@ -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<PenNode>) => 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<PenNode>)
}
@ -81,101 +96,124 @@ export default function FillSection({
onUpdate({ fill: [{ type: 'solid', color }] } as Partial<PenNode>)
}
const handleOpacityChange = (val: number) => {
if (!firstFill) return
const opacity = Math.max(0, Math.min(100, val)) / 100
onUpdate({ fill: [{ ...firstFill, opacity }] } as Partial<PenNode>)
}
const handleAngleChange = (angle: number) => {
if (firstFill?.type === 'linear_gradient') {
onUpdate({
fill: [{ ...firstFill, angle }],
} as Partial<PenNode>)
onUpdate({ fill: [{ ...firstFill, angle }] } as Partial<PenNode>)
}
}
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<PenNode>)
onUpdate({ fill: [{ ...firstFill, stops: newStops }] } as Partial<PenNode>)
}
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<PenNode>)
onUpdate({ fill: [{ ...firstFill, stops: newStops }] } as Partial<PenNode>)
}
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<PenNode>)
onUpdate({ fill: [{ ...firstFill, stops }] } as Partial<PenNode>)
}
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<PenNode>)
onUpdate({ fill: [{ ...firstFill, stops }] } as Partial<PenNode>)
}
const handleRemoveFill = () => {
onUpdate({ fill: [] } as Partial<PenNode>)
}
const handleImageFitChange = (mode: string) => {
if (firstFill?.type !== 'image') return
onUpdate({ fill: [{ ...firstFill, mode: mode as ImageFill['mode'] }] } as Partial<PenNode>)
}
// Gradient preview swatch
const gradientCss = firstFill ? gradientPreviewCss(firstFill) : undefined
return (
<div className="space-y-1.5">
<SectionHeader
title={t('fill.title')}
actions={
<Button
variant="ghost"
size="icon-sm"
onClick={() => setShowTypeSelector(!showTypeSelector)}
>
<Button variant="ghost" size="icon-sm" onClick={() => handleTypeChange('solid')}>
<Plus className="w-3.5 h-3.5" />
</Button>
}
/>
{showTypeSelector && (
<Select value={fillType} onValueChange={handleTypeChange}>
<SelectTrigger className="h-6 text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILL_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{t(opt.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Fill row: swatch + type label + opacity + remove */}
{firstFill && (
<div className="flex items-center gap-1.5 h-7">
{/* Color/gradient/image swatch */}
{fillType === 'solid' && !isVariableRef(currentColor) && (
<div
className="w-5 h-5 rounded border border-border shrink-0 cursor-pointer"
style={{ backgroundColor: currentColor }}
/>
)}
{(fillType === 'linear_gradient' || fillType === 'radial_gradient') && gradientCss && (
<div
className="w-5 h-5 rounded border border-border shrink-0"
style={{ background: gradientCss }}
/>
)}
{fillType === 'image' && (
<div className="w-5 h-5 rounded border border-border shrink-0 bg-muted flex items-center justify-center">
<ImageIcon className="w-3 h-3 text-muted-foreground" />
</div>
)}
{/* Type selector */}
<Select value={fillType} onValueChange={handleTypeChange}>
<SelectTrigger className="h-6 text-[11px] flex-1 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILL_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{t(opt.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Opacity */}
<NumberInput
value={fillOpacity}
onChange={handleOpacityChange}
min={0}
max={100}
suffix="%"
className="w-14"
/>
{/* Remove */}
<Button variant="ghost" size="icon-sm" onClick={handleRemoveFill}>
<X className="w-3 h-3" />
</Button>
</div>
)}
{/* Solid fill: color picker + variable picker */}
{fillType === 'solid' && (
<div className="flex items-center gap-1">
<div className="flex-1">
@ -196,8 +234,8 @@ export default function FillSection({
</div>
)}
{(fillType === 'linear_gradient' ||
fillType === 'radial_gradient') && (
{/* Gradient fill: angle + color stops */}
{(fillType === 'linear_gradient' || fillType === 'radial_gradient') && (
<div className="space-y-1.5">
{fillType === 'linear_gradient' && (
<NumberInput
@ -215,11 +253,7 @@ export default function FillSection({
<span className="text-[10px] text-muted-foreground">
{t('fill.stops')}
</span>
<Button
variant="ghost"
size="icon-sm"
onClick={handleAddStop}
>
<Button variant="ghost" size="icon-sm" onClick={handleAddStop}>
<Plus className="w-3 h-3" />
</Button>
</div>
@ -235,14 +269,10 @@ export default function FillSection({
min={0}
max={100}
suffix="%"
className="w-16"
className="w-[72px]"
/>
{currentStops.length > 2 && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleRemoveStop(i)}
>
<Button variant="ghost" size="icon-sm" onClick={() => handleRemoveStop(i)}>
<X className="w-3 h-3" />
</Button>
)}
@ -251,6 +281,87 @@ export default function FillSection({
</div>
</div>
)}
{/* Image fill: preview + upload + fit mode */}
{fillType === 'image' && firstFill?.type === 'image' && (
<ImageFillEditor fill={firstFill} onUpdate={onUpdate} onFitChange={handleImageFitChange} />
)}
</div>
)
}
function ImageFillEditor({
fill,
onUpdate,
onFitChange,
}: {
fill: ImageFill
onUpdate: (updates: Partial<PenNode>) => void
onFitChange: (mode: string) => void
}) {
const { t } = useTranslation()
const [triggerRect, setTriggerRect] = useState<DOMRect | null>(null)
const triggerRef = useRef<HTMLButtonElement>(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 (
<div>
<button
ref={triggerRef}
type="button"
onClick={handleToggle}
className="w-full flex items-center gap-2 h-8 px-1.5 rounded border border-border hover:bg-accent/50 transition-colors cursor-pointer"
>
<div className="w-6 h-6 rounded border border-border shrink-0 bg-muted overflow-hidden flex items-center justify-center">
{hasImage ? (
<img src={fill.url} alt="" className="w-full h-full object-cover" />
) : (
<ImageIcon className="w-3 h-3 text-muted-foreground" />
)}
</div>
<span className="text-[11px] text-foreground flex-1 text-left truncate">
{t(`image.${fitMode === 'fit' ? 'fitMode' : fitMode}`)}
</span>
</button>
{triggerRect && (
<ImageFillPopover
imageSrc={fill.url}
fitMode={fitMode}
triggerRect={triggerRect}
adjustments={{
exposure: fill.exposure,
contrast: fill.contrast,
saturation: fill.saturation,
temperature: fill.temperature,
tint: fill.tint,
highlights: fill.highlights,
shadows: fill.shadows,
}}
onFitModeChange={(mode) => onFitChange(mode)}
onAdjustmentChange={(key, value) => {
onUpdate({ fill: [{ ...fill, [key]: value }] } as Partial<PenNode>)
}}
onResetAdjustments={() => {
onUpdate({ fill: [{ ...fill, exposure: 0, contrast: 0, saturation: 0, temperature: 0, tint: 0, highlights: 0, shadows: 0 }] } as Partial<PenNode>)
}}
onImageChange={(dataUrl) => {
onUpdate({ fill: [{ ...fill, url: dataUrl }] } as Partial<PenNode>)
}}
onClose={handleClose}
/>
)}
</div>
)
}

View file

@ -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<HTMLDivElement>(null)
const fileRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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(
<div
ref={panelRef}
className="fixed z-[100] bg-popover border border-border rounded-lg shadow-lg"
style={{ left, top, width: PANEL_WIDTH }}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2">
<span className="text-[11px] font-medium text-foreground">{t('image.title')}</span>
<Button variant="ghost" size="icon-sm" onClick={onClose}>
<X className="w-3 h-3" />
</Button>
</div>
{/* Fit mode row */}
<div className="px-3 pb-2">
<div className="flex items-center gap-0.5 bg-secondary rounded-md p-0.5">
{(['fill', 'fit', 'crop', 'tile'] as FitMode[]).map((m) => (
<button
key={m}
type="button"
title={t(`image.${m === 'fit' ? 'fitMode' : m}`)}
onClick={() => onFitModeChange(m)}
className={`flex-1 flex items-center justify-center h-6 rounded text-[10px] font-medium transition-colors ${
fitMode === m
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t(`image.${m === 'fit' ? 'fitMode' : m}`)}
</button>
))}
</div>
</div>
{/* Image preview / upload */}
<div className="px-3 pb-2">
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
<button
type="button"
onClick={() => fileRef.current?.click()}
className="w-full h-28 rounded-md border border-dashed border-border bg-muted/50 hover:bg-muted transition-colors flex items-center justify-center overflow-hidden cursor-pointer relative group"
>
{hasImage ? (
<>
<img src={imageSrc} alt="" className="w-full h-full object-contain" />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Upload className="w-4 h-4 text-white" />
</div>
</>
) : (
<div className="flex flex-col items-center gap-1 text-muted-foreground">
<Upload className="w-5 h-5" />
<span className="text-[10px]">{t('image.clickToUpload')}</span>
</div>
)}
</button>
</div>
<Separator />
{/* Adjustments */}
<div className="px-3 py-2">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
{t('image.adjustments')}
</span>
{hasAdjustments && (
<button
type="button"
onClick={handleResetAll}
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-0.5"
>
<RotateCcw className="w-2.5 h-2.5" />
{t('image.reset')}
</button>
)}
</div>
<div className="space-y-2.5">
{ADJUSTMENT_KEYS.map((a) => (
<AdjustmentRow
key={a.key}
label={t(a.labelKey)}
value={adjustments[a.key] ?? 0}
onChange={(v) => onAdjustmentChange(a.key, v)}
/>
))}
</div>
</div>
</div>,
document.body,
)
}
function AdjustmentRow({
label,
value,
onChange,
}: {
label: string
value: number
onChange: (v: number) => void
}) {
return (
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground w-[68px] shrink-0 truncate">
{label}
</span>
<Slider
min={-100}
max={100}
step={1}
value={[value]}
onValueChange={([v]) => onChange(v)}
className="flex-1"
/>
<span className="text-[10px] text-muted-foreground w-7 text-right tabular-nums shrink-0">
{value}
</span>
</div>
)
}

View file

@ -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<DOMRect | null>(null)
const triggerRef = useRef<HTMLButtonElement>(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 (
<div className="space-y-1.5">
<SectionHeader title="Image" />
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground shrink-0">Fit</span>
<Select value={node.objectFit ?? 'fill'} onValueChange={(v) => onUpdate({ objectFit: v as ImageFitMode })}>
<SelectTrigger className="flex-1 h-6 text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIT_MODE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<SectionHeader title={t('image.title')} />
<button
ref={triggerRef}
type="button"
onClick={handleToggle}
className="w-full flex items-center gap-2 h-8 px-1.5 rounded border border-border hover:bg-accent/50 transition-colors cursor-pointer"
>
<div className="w-6 h-6 rounded border border-border shrink-0 bg-muted overflow-hidden flex items-center justify-center">
{hasImage ? (
<img src={node.src} alt="" className="w-full h-full object-cover" />
) : (
<ImageIcon className="w-3 h-3 text-muted-foreground" />
)}
</div>
<span className="text-[11px] text-foreground flex-1 text-left truncate">
{t(`image.${fitMode === 'fit' ? 'fitMode' : fitMode}`)}
</span>
</button>
{triggerRect && (
<ImageFillPopover
imageSrc={node.src}
fitMode={fitMode}
triggerRect={triggerRect}
adjustments={{
exposure: node.exposure,
contrast: node.contrast,
saturation: node.saturation,
temperature: node.temperature,
tint: node.tint,
highlights: node.highlights,
shadows: node.shadows,
}}
onFitModeChange={(mode) => onUpdate({ objectFit: mode as ImageFitMode })}
onAdjustmentChange={(key, value) => onUpdate({ [key]: value } as Partial<ImageNode>)}
onResetAdjustments={() => onUpdate({ exposure: 0, contrast: 0, saturation: 0, temperature: 0, tint: 0, highlights: 0, shadows: 0 } as Partial<ImageNode>)}
onImageChange={(dataUrl) => onUpdate({ src: dataUrl })}
onClose={handleClose}
/>
)}
</div>
)
}

View file

@ -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<PenNode>) => 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({
<SectionHeader title={t('text.typography')} />
{/* Font family */}
<Select
<FontPicker
value={fontFamily}
onValueChange={(v) =>
onUpdate({ fontFamily: v } as Partial<PenNode>)
}
>
<SelectTrigger className="h-6 text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FONT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={(v) => onUpdate({ fontFamily: v } as Partial<PenNode>)}
/>
{/* Weight + Size */}
<div className="grid grid-cols-2 gap-1">

View file

@ -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<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(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 (
<div ref={containerRef} className={cn('relative', className)} onKeyDown={handleKeyDown}>
{/* Trigger button */}
<button
type="button"
onClick={() => setOpen(!open)}
className={cn(
'flex items-center justify-between w-full h-6 px-2 text-[11px] rounded-md',
'border border-border bg-card text-foreground',
'hover:bg-secondary/50 transition-colors',
'focus:outline-none focus:ring-1 focus:ring-ring',
)}
style={{ fontFamily: value }}
>
<span className="truncate">{currentDisplay}</span>
<ChevronDown className="w-3 h-3 shrink-0 text-muted-foreground ml-1" />
</button>
{/* Dropdown */}
{open && (
<div
className={cn(
'absolute z-50 top-full left-0 mt-1 w-56',
'rounded-md border border-border bg-card shadow-lg',
'flex flex-col max-h-72',
)}
>
{/* Search */}
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border">
<Search className="w-3 h-3 text-muted-foreground shrink-0" />
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => 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',
)}
/>
</div>
{/* Font list */}
<div ref={listRef} className="overflow-y-auto flex-1 py-1">
{loading && (
<div className="flex items-center justify-center py-3 gap-1.5 text-[11px] text-muted-foreground">
<Loader2 className="w-3 h-3 animate-spin" />
{t('text.font.loading')}
</div>
)}
{/* Bundled fonts group */}
{bundled.length > 0 && (
<>
<div className="px-2 py-0.5 text-[9px] font-medium text-muted-foreground uppercase tracking-wider">
{t('text.font.bundled')}
</div>
{bundled.map((font, i) => (
<FontItem
key={font.family}
font={font}
selected={displayName(value) === font.family}
highlighted={highlightIndex === i}
onSelect={handleSelect}
/>
))}
</>
)}
{/* System fonts group */}
{system.length > 0 && (
<>
<div className="px-2 py-0.5 mt-1 text-[9px] font-medium text-muted-foreground uppercase tracking-wider">
{t('text.font.system')}
</div>
{system.map((font, i) => (
<FontItem
key={font.family}
font={font}
selected={displayName(value) === font.family}
highlighted={highlightIndex === bundled.length + i}
onSelect={handleSelect}
/>
))}
</>
)}
{!loading && filtered.length === 0 && (
<div className="px-2 py-3 text-[11px] text-muted-foreground text-center">
{t('text.font.noResults')}
</div>
)}
</div>
</div>
)}
</div>
)
}
function FontItem({
font,
selected,
highlighted,
onSelect,
}: {
font: FontInfo
selected: boolean
highlighted: boolean
onSelect: (font: FontInfo) => void
}) {
return (
<button
type="button"
data-font-item
onClick={() => onSelect(font)}
className={cn(
'w-full text-left px-2 py-1 text-[11px] truncate',
'transition-colors cursor-pointer',
selected
? 'bg-primary/10 text-foreground'
: highlighted
? 'bg-secondary text-foreground'
: 'text-foreground hover:bg-secondary/50',
)}
style={{ fontFamily: font.family }}
>
{font.family}
</button>
)
}

View file

@ -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<boolean> {
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<boolean> {
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()

View file

@ -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<string[]> | null = null
async function querySystemFonts(): Promise<string[]> {
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<Array<{ family: string }>> }).queryLocalFonts()
const families = new Set<string>()
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<string[]>(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 }
}

View file

@ -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) => {

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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': 'लेआउट',

View file

@ -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',

View file

@ -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': 'レイアウト',

View file

@ -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': '레이아웃',

View file

@ -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',

View file

@ -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': 'Раскладка',

View file

@ -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': 'เลย์เอาต์',

View file

@ -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',

View file

@ -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',

View file

@ -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': '佈局',

View file

@ -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': '布局',

View file

@ -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' }],
}),

View file

@ -77,8 +77,6 @@ interface FigmaClipboardData {
* <span data-buffer="BASE64_BINARY"></span>
*/
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(/<!--\(figma\)(?:-->)?([\s\S]*?)<!--\(figma\)-->/)
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. "<!--(figmeta)BASE64<!--(figmeta)-->")
metaB64 = attrMetaMatch[1]
@ -118,25 +114,12 @@ export function extractFigmaClipboardData(html: string): FigmaClipboardData | nu
const encodedBufferMatch = html.match(/&lt;!--\(figma\)--&gt;([\s\S]*?)&lt;!--\(figma\)--&gt;/)
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:', /<!--\(figmeta\)-->/.test(html),
'Has figma comment:', /<!--\(figma\)-->/.test(html),
'Has data-metadata attr:', /data-metadata=/.test(html),
'Has data-buffer attr:', /data-buffer=/.test(html),
'Has encoded figmeta:', /&lt;!--\(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 &amp;/&lt;/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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&nbsp;/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<string, HtmlStyleHint> {
// Remove the binary data comment blocks to isolate the styled HTML content
const cleanHtml = html
.replace(/<!--\(figmeta\)[\s\S]*?<!--\(figmeta\)-->/g, '')
.replace(/<!--\(figma\)[\s\S]*?<!--\(figma\)-->/g, '')
const hints = new Map<string, HtmlStyleHint>()
// Match elements with style attributes and text content.
// Captures: style attribute value, text content between tags.
const elemRegex = /style="([^"]*)"[^>]*>([^<]+)</gi
let match
while ((match = elemRegex.exec(cleanHtml)) !== null) {
const styleAttr = match[1]
const rawText = decodeHtmlEntities(match[2]).trim()
if (!rawText || rawText.length > 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<string, HtmlStyleHint>,
): 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<string, HtmlStyleHint>,
): 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
}

View file

@ -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
}
}

View file

@ -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<string, any>) {
// 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
}
}

View file

@ -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<number>()
// Build adjacency for chain walking
const adj = new Map<number, number[]>()
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)}`)
}
}

View file

@ -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 {

View file

@ -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 =