This commit is contained in:
Danila Poyarkov 2026-04-06 14:40:19 +03:00
parent 07674d1919
commit cc7f055dfc
47 changed files with 372 additions and 159 deletions

View file

@ -1,6 +1,6 @@
import { computeAccurateBounds } from '../vector/bezier'
import { PEN_HANDLE_RADIUS, PEN_VERTEX_RADIUS } from '../constants'
import { vectorNetworkToPath } from '../vector'
import { computeAccurateBounds } from '../vector/bezier'
import type { VectorVertex, VectorSegment, VectorRegion, SceneGraph } from '../scene-graph'
import type { Vector } from '../types'

View file

@ -1,7 +1,7 @@
import { converter, toGamut } from 'culori'
import { normalizeColor } from './'
import { copyFill, copyStroke } from '../scene-graph/copy'
import { normalizeColor } from './'
import type { SceneNode } from '../scene-graph'
import type { Color } from '../types'
@ -111,7 +111,11 @@ export function parseOkHCLPayload(value: string): OkHCLPayload | null {
}
}
function createOkHCLPayload(kind: 'fill' | 'stroke', index: number, color: OkHCLColor): OkHCLPayload {
function createOkHCLPayload(
kind: 'fill' | 'stroke',
index: number,
color: OkHCLColor
): OkHCLPayload {
return {
version: 1,
kind,
@ -120,7 +124,11 @@ function createOkHCLPayload(kind: 'fill' | 'stroke', index: number, color: OkHCL
}
}
function filterOkHCLPayloads(entries: string[], kind?: 'fill' | 'stroke', index?: number): string[] {
function filterOkHCLPayloads(
entries: string[],
kind?: 'fill' | 'stroke',
index?: number
): string[] {
return entries.filter((entry) => {
const payload = parseOkHCLPayload(entry)
if (!payload) return true
@ -129,7 +137,11 @@ function filterOkHCLPayloads(entries: string[], kind?: 'fill' | 'stroke', index?
})
}
export function setNodeFillOkHCL(node: SceneNode, index: number, color: OkHCLColor): Partial<SceneNode> {
export function setNodeFillOkHCL(
node: SceneNode,
index: number,
color: OkHCLColor
): Partial<SceneNode> {
const fills = node.fills.map(copyFill)
if (index < 0 || index >= fills.length) throw new Error(`Fill ${index} not found`)
const fill = fills[index]
@ -140,7 +152,11 @@ export function setNodeFillOkHCL(node: SceneNode, index: number, color: OkHCLCol
opacity: rgba.a
}
const payloads = filterOkHCLPayloads(node.pluginData.map((entry) => entry.value), 'fill', index)
const payloads = filterOkHCLPayloads(
node.pluginData.map((entry) => entry.value),
'fill',
index
)
payloads.push(serializeOkHCLPayload(createOkHCLPayload('fill', index, color)))
return {
@ -149,7 +165,11 @@ export function setNodeFillOkHCL(node: SceneNode, index: number, color: OkHCLCol
}
}
export function setNodeStrokeOkHCL(node: SceneNode, index: number, color: OkHCLColor): Partial<SceneNode> {
export function setNodeStrokeOkHCL(
node: SceneNode,
index: number,
color: OkHCLColor
): Partial<SceneNode> {
const strokes = node.strokes.map(copyStroke)
if (index < 0 || index >= strokes.length) throw new Error(`Stroke ${index} not found`)
const stroke = strokes[index]
@ -160,7 +180,11 @@ export function setNodeStrokeOkHCL(node: SceneNode, index: number, color: OkHCLC
opacity: rgba.a
}
const payloads = filterOkHCLPayloads(node.pluginData.map((entry) => entry.value), 'stroke', index)
const payloads = filterOkHCLPayloads(
node.pluginData.map((entry) => entry.value),
'stroke',
index
)
payloads.push(serializeOkHCLPayload(createOkHCLPayload('stroke', index, color)))
return {
@ -170,16 +194,32 @@ export function setNodeStrokeOkHCL(node: SceneNode, index: number, color: OkHCLC
}
export function clearNodeFillOkHCL(node: SceneNode, index: number): Partial<SceneNode> {
const okhclValues = filterOkHCLPayloads(node.pluginData.map((entry) => entry.value), 'fill', index)
const okhclValues = filterOkHCLPayloads(
node.pluginData.map((entry) => entry.value),
'fill',
index
)
return {
pluginData: okhclValues.map((value) => ({ pluginId: 'open-pencil', key: OKHCL_PLUGIN_KEY, value }))
pluginData: okhclValues.map((value) => ({
pluginId: 'open-pencil',
key: OKHCL_PLUGIN_KEY,
value
}))
}
}
export function clearNodeStrokeOkHCL(node: SceneNode, index: number): Partial<SceneNode> {
const okhclValues = filterOkHCLPayloads(node.pluginData.map((entry) => entry.value), 'stroke', index)
const okhclValues = filterOkHCLPayloads(
node.pluginData.map((entry) => entry.value),
'stroke',
index
)
return {
pluginData: okhclValues.map((value) => ({ pluginId: 'open-pencil', key: OKHCL_PLUGIN_KEY, value }))
pluginData: okhclValues.map((value) => ({
pluginId: 'open-pencil',
key: OKHCL_PLUGIN_KEY,
value
}))
}
}
@ -191,9 +231,17 @@ export function getNodeOkHCLPayloads(node: SceneNode): OkHCLPayload[] {
}
export function getFillOkHCL(node: SceneNode, index: number): OkHCLPayload | null {
return getNodeOkHCLPayloads(node).find((payload) => payload.kind === 'fill' && payload.index === index) ?? null
return (
getNodeOkHCLPayloads(node).find(
(payload) => payload.kind === 'fill' && payload.index === index
) ?? null
)
}
export function getStrokeOkHCL(node: SceneNode, index: number): OkHCLPayload | null {
return getNodeOkHCLPayloads(node).find((payload) => payload.kind === 'stroke' && payload.index === index) ?? null
return (
getNodeOkHCLPayloads(node).find(
(payload) => payload.kind === 'stroke' && payload.index === index
) ?? null
)
}

View file

@ -1,7 +1,7 @@
import { parseColor, colorToFill } from '../color'
import { TRANSPARENT } from '../constants'
import { createIconFromPaths } from '../icons/render'
import { fetchIcons } from '../icons'
import { createIconFromPaths } from '../icons/render'
import { computeAllLayouts } from '../layout'
import { isTreeNode } from './tree'
@ -405,7 +405,8 @@ function applyLayoutOverrides(
applyAutoLayoutSizing(o, props, w, h)
}
o.layoutDirection = parseDirection(props.flow ?? (!isText ? props.dir : undefined)) ?? o.layoutDirection
o.layoutDirection =
parseDirection(props.flow ?? (!isText ? props.dir : undefined)) ?? o.layoutDirection
if (props.gap !== undefined) o.itemSpacing = props.gap as number

View file

@ -1,10 +1,10 @@
import { buildFigmaClipboardHTML, importClipboardNodes, parseFigmaClipboard } from '../clipboard'
import { selectionToJSX } from '../design-jsx'
import { computeImageHash } from '../figma-api'
import { collectFontKeys } from '../text/fonts'
import { computeBounds } from '../geometry'
import { renderNodesToSVG } from '../io/formats/svg'
import { computeAllLayouts } from '../layout'
import { selectionToJSX } from '../design-jsx'
import { collectFontKeys } from '../text/fonts'
import type { Fill, SceneGraph, SceneNode } from '../scene-graph'
import type { Vector } from '../types'

View file

@ -1,6 +1,6 @@
import { copyEffects, copyFill, copyStyleRuns, copyStroke } from '../scene-graph/copy'
import { resolveOkHCLForPreview } from '../color/management'
import { rgbaToOkHCL } from '../color/okhcl'
import { copyEffects, copyFill, copyStyleRuns, copyStroke } from '../scene-graph/copy'
import type { DocumentColorSpace, SceneNode } from '../scene-graph'
import type { EditorContext } from './types'

View file

@ -1,10 +1,10 @@
import { prefetchFigmaSchema } from '../clipboard'
import { CANVAS_BG_COLOR, IS_BROWSER } from '../constants'
import { loadFont as defaultLoadFont } from '../text/fonts'
import { computeAllLayouts, computeLayout, setTextMeasurer } from '../layout'
import { SceneGraph } from '../scene-graph'
import { TextEditor } from '../text/editor'
import { UndoManager } from '../scene-graph/undo'
import { TextEditor } from '../text/editor'
import { loadFont as defaultLoadFont } from '../text/fonts'
import { createAlignmentActions } from './alignment'
import { createClipboardActions } from './clipboard'
import { createColorSpaceActions } from './color-space'

View file

@ -1,6 +1,6 @@
import { CANVAS_BG_COLOR } from '../constants'
import { collectFontKeys } from '../text/fonts'
import { computeAllLayouts } from '../layout'
import { collectFontKeys } from '../text/fonts'
import type { Color } from '../types'
import type { EditorContext } from './types'

View file

@ -1,9 +1,9 @@
import type { SkiaRenderer } from '../canvas/renderer'
import type { SceneGraph, VectorSegment, VectorVertex } from '../scene-graph'
import type { SnapGuide } from '../scene-graph/snap'
import type { UndoManager } from '../scene-graph/undo'
import type { TextEditor } from '../text/editor'
import type { Color, Rect, Vector } from '../types'
import type { UndoManager } from '../scene-graph/undo'
import type { CanvasKit } from 'canvaskit-wasm'
export type Tool =

View file

@ -1,8 +1,8 @@
import { computeAllLayouts } from '../layout'
import type { SceneNode } from '../scene-graph'
import type { Rect, Vector } from '../types'
import type { UndoEntry } from '../scene-graph/undo'
import type { Rect, Vector } from '../types'
import type { EditorContext } from './types'
export function createUndoActions(ctx: EditorContext) {

View file

@ -1,13 +1,7 @@
import { IS_BROWSER } from '../constants'
import { copyFills, copyStrokes, copyEffects } from '../scene-graph/copy'
import {
FigmaNodeProxy,
INTERNAL_ID,
MIXED,
type FigmaFontName,
type NodeProxyHost
} from './proxy'
import { computeBounds } from '../geometry'
import { copyFills, copyStrokes, copyEffects } from '../scene-graph/copy'
import { FigmaNodeProxy, INTERNAL_ID, MIXED, type FigmaFontName, type NodeProxyHost } from './proxy'
import type { RasterExportFormat } from '../io/formats/raster'
import type {

View file

@ -1,8 +1,9 @@
import { normalizeColor } from '../color'
import { getFillOkHCL, getStrokeOkHCL, setNodeFillOkHCL, setNodeStrokeOkHCL } from '../color/okhcl'
import { copyFills, copyStrokes, copyEffects } from '../scene-graph/copy'
import { FONT_WEIGHT_NAMES } from '../text/fonts'
import { getFillOkHCL, getStrokeOkHCL, setNodeFillOkHCL, setNodeStrokeOkHCL } from '../color/okhcl'
import type { OkHCLColor, OkHCLPayload } from '../color/okhcl'
/* eslint-disable max-lines -- Figma Plugin API proxy; FigmaAPI already in separate file */
import type {
SceneGraph,
@ -14,7 +15,6 @@ import type {
LayoutMode
} from '../scene-graph'
import type { Rect } from '../types'
import type { OkHCLColor, OkHCLPayload } from '../color/okhcl'
const MIXED = Symbol('mixed')
@ -961,9 +961,11 @@ export class FigmaNodeProxy {
// --- Plugin data ---
getPluginData(key: string): string {
return this._raw().pluginData.find(
(entry) => entry.pluginId === OPEN_PENCIL_PLUGIN_DATA_NAMESPACE && entry.key === key
)?.value ?? ''
return (
this._raw().pluginData.find(
(entry) => entry.pluginId === OPEN_PENCIL_PLUGIN_DATA_NAMESPACE && entry.key === key
)?.value ?? ''
)
}
setPluginData(key: string, value: string): void {
@ -978,15 +980,17 @@ export class FigmaNodeProxy {
}
getPluginDataKeys(): string[] {
return this._raw().pluginData
.filter((entry) => entry.pluginId === OPEN_PENCIL_PLUGIN_DATA_NAMESPACE)
return this._raw()
.pluginData.filter((entry) => entry.pluginId === OPEN_PENCIL_PLUGIN_DATA_NAMESPACE)
.map((entry) => entry.key)
}
getSharedPluginData(namespace: string, key: string): string {
return this._raw().sharedPluginData.find(
(entry) => entry.namespace === namespace && entry.key === key
)?.value ?? ''
return (
this._raw().sharedPluginData.find(
(entry) => entry.namespace === namespace && entry.key === key
)?.value ?? ''
)
}
setSharedPluginData(namespace: string, key: string, value: string): void {
@ -1001,8 +1005,8 @@ export class FigmaNodeProxy {
}
getSharedPluginDataKeys(namespace: string): string[] {
return this._raw().sharedPluginData
.filter((entry) => entry.namespace === namespace)
return this._raw()
.sharedPluginData.filter((entry) => entry.namespace === namespace)
.map((entry) => entry.key)
}
@ -1019,7 +1023,10 @@ export class FigmaNodeProxy {
}
setStrokeOkHCL(color: OkHCLColor, index = 0): void {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], setNodeStrokeOkHCL(this._raw(), index, color))
this[INTERNAL_GRAPH].updateNode(
this[INTERNAL_ID],
setNodeStrokeOkHCL(this._raw(), index, color)
)
}
// --- Serialization ---

View file

@ -102,7 +102,11 @@ function effectOverflow(effects?: Effect[]) {
for (const effect of effects ?? []) {
if (!effect.visible) continue
if (effect.type !== 'DROP_SHADOW' && effect.type !== 'LAYER_BLUR' && effect.type !== 'FOREGROUND_BLUR') {
if (
effect.type !== 'DROP_SHADOW' &&
effect.type !== 'LAYER_BLUR' &&
effect.type !== 'FOREGROUND_BLUR'
) {
continue
}
const blurSpread = effect.radius + effect.spread

View file

@ -1,8 +1,8 @@
import { parseColor } from '../color'
import type { IconData } from './'
import type { SceneGraph, SceneNode, Stroke } from '../scene-graph'
import type { Color } from '../types'
import type { IconData } from './'
const STROKE_CAP_MAP: Record<string, SceneNode['strokeCap']> = {
butt: 'NONE',

View file

@ -1,5 +1,4 @@
import { sceneNodeToJSX, selectionToJSX } from '../design-jsx'
import { exportFigFile, parseFigFile } from './formats/fig'
import { parsePenFile } from './formats/pen'
import { headlessRenderNodes, renderNodesToImage, type RasterExportFormat } from './formats/raster'

View file

@ -1,8 +1,6 @@
import { deflateSync } from 'fflate'
import { CANVAS_BG_COLOR, IS_BROWSER, IS_TAURI } from '../../../constants'
import { compressFigDataSync } from './compress'
import { renderThumbnail } from '../raster'
import { initCodec, getCompiledSchema, getSchemaBytes } from '../../../kiwi/codec'
import { stringToGuid } from '../../../kiwi/convert'
import {
@ -13,9 +11,11 @@ import {
makeDocumentNodeChange,
makeCanvasNodeChange
} from '../../../kiwi/serialize'
import { renderThumbnail } from '../raster'
import { compressFigDataSync } from './compress'
import type { NodeChange } from '../../../kiwi/codec'
import type { SkiaRenderer } from '../../../canvas'
import type { NodeChange } from '../../../kiwi/codec'
import type { SceneGraph, VariableValue } from '../../../scene-graph'
import type { GUID } from '../../../types'
import type { CanvasKit } from 'canvaskit-wasm'

View file

@ -1,7 +1,6 @@
/* eslint-disable max-lines -- JSX export formats share helpers and node walking logic */
import { colorToHex8, colorToCSSCompact } from '../../../color'
import { DEFAULT_FONT_FAMILY } from '../../../constants'
import { resolveNodeTextDirection } from '../../../text/direction'
import {
pxToSpacing,
colorToTwClass,
@ -10,6 +9,7 @@ import {
borderRadiusToTw,
opacityToTw
} from '../../../design-jsx/tailwind'
import { resolveNodeTextDirection } from '../../../text/direction'
import type {
SceneGraph,

View file

@ -1,4 +1,7 @@
import { SceneGraph } from '../../../scene-graph'
import { copyEffects, copyFills, copyStrokes } from '../../../scene-graph/copy'
import { populateInstanceChildren } from '../../../scene-graph/instances'
import { parseSVGPath } from '../svg/parse-path'
import {
applyCornerRadius,
applyPadding,
@ -20,16 +23,8 @@ import {
type PenNode,
type VarContext
} from './convert'
import { parseSVGPath } from '../svg/parse-path'
import { SceneGraph } from '../../../scene-graph'
import { populateInstanceChildren } from '../../../scene-graph/instances'
import type {
LayoutMode,
LayoutSizing,
SceneNode,
VectorNetwork
} from '../../../scene-graph'
import type { LayoutMode, LayoutSizing, SceneNode, VectorNetwork } from '../../../scene-graph'
function scaleVectorNetwork(vn: VectorNetwork, targetW: number, targetH: number): void {
if (vn.vertices.length === 0) return

View file

@ -1,5 +1,4 @@
import { SkiaRenderer } from '../../../canvas'
import { renderNodesToImage, renderThumbnail, type ExportFormat } from './render'
import type { SceneGraph } from '../../../scene-graph'

View file

@ -1,8 +1,8 @@
import { computeVisualBounds } from '../../../geometry'
import { extractExportGraph } from '../../subgraph'
import type { RenderColorSpace } from '../../../color/management'
import type { SkiaRenderer } from '../../../canvas'
import type { RenderColorSpace } from '../../../color/management'
import type { SceneGraph } from '../../../scene-graph'
import type { CanvasKit, Canvas } from 'canvaskit-wasm'

View file

@ -1,6 +1,5 @@
import { colorToHex } from '../../../color'
import { colorToDisplayCss, getDefaultRenderColorSpace } from '../../../color/management'
import { svg, type SVGNode } from './node'
import { round } from './paths'

View file

@ -1,6 +1,5 @@
import { computeContentBounds } from '../raster'
import { resolveNodeTextDirection } from '../../../text/direction'
import { computeContentBounds } from '../raster'
import {
nextDefId,
formatColor,
@ -24,8 +23,6 @@ export { geometryBlobToSVGPath, vectorNetworkToSVGPaths } from './paths'
import { svg, renderSVGNode } from './node'
import type { SVGExportContext } from './defs'
import type { SVGNode } from './node'
import type {
SceneGraph,
SceneNode,
@ -33,6 +30,8 @@ import type {
Stroke,
CharacterStyleOverride
} from '../../../scene-graph'
import type { SVGExportContext } from './defs'
import type { SVGNode } from './node'
// --- Node rendering ---

View file

@ -1,9 +1,4 @@
import type {
SceneNode,
VectorNetwork,
VectorSegment,
VectorVertex
} from '../../../scene-graph'
import type { SceneNode, VectorNetwork, VectorSegment, VectorVertex } from '../../../scene-graph'
const CMD_CLOSE = 0
const CMD_MOVE_TO = 1

View file

@ -1,6 +1,6 @@
import type { SkiaRenderer } from '../canvas'
import type { RenderColorSpace } from '../color/management'
import type { JSXFormat } from '../design-jsx'
import type { SkiaRenderer } from '../canvas'
import type { SceneGraph } from '../scene-graph'
import type { RasterExportFormat } from './formats/raster'
import type { CanvasKit } from 'canvaskit-wasm'

View file

@ -620,8 +620,9 @@ function convertTextProps(
styleRuns: importStyleRuns(nc),
textTruncation: (nc.textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED',
textDirection:
(getOpenPencilPluginValue(nc, TEXT_DIRECTION_PLUGIN_KEY) as SceneNode['textDirection'] | null) ||
'AUTO'
(getOpenPencilPluginValue(nc, TEXT_DIRECTION_PLUGIN_KEY) as
| SceneNode['textDirection']
| null) || 'AUTO'
}
}
@ -680,8 +681,7 @@ function convertLayoutProps(
layoutDirection:
(getOpenPencilPluginValue(nc, LAYOUT_DIRECTION_PLUGIN_KEY) as
| SceneNode['layoutDirection']
| null) ||
'AUTO'
| null) || 'AUTO'
}
}

View file

@ -53,7 +53,11 @@ function createStrokePaints(context: SceneNodeToKiwiContext, node: SceneNode): P
}))
}
function applyNodeVisualProps(context: SceneNodeToKiwiContext, node: SceneNode, nc: KiwiNodeChange): void {
function applyNodeVisualProps(
context: SceneNodeToKiwiContext,
node: SceneNode,
nc: KiwiNodeChange
): void {
if (node.independentStrokeWeights) {
nc.borderStrokeWeightsIndependent = true
nc.borderTopWeight = node.borderTopWeight

View file

@ -1,10 +1,18 @@
import { defineRule } from '../rule'
export default defineRule({
meta: { id: 'effect-style-required', category: 'design-tokens', description: 'Effects should use shared effect presets or tokens' },
meta: {
id: 'effect-style-required',
category: 'design-tokens',
description: 'Effects should use shared effect presets or tokens'
},
check(node, context) {
const visibleEffects = node.effects.filter((effect) => effect.visible)
if (visibleEffects.length === 0) return
context.report({ node, message: `Effect without shared style: ${visibleEffects.map((effect) => `${effect.type} ${effect.radius}px`).join(', ')}`, suggest: 'Extract reusable shadows and blurs into shared presets or variables' })
context.report({
node,
message: `Effect without shared style: ${visibleEffects.map((effect) => `${effect.type} ${effect.radius}px`).join(', ')}`,
suggest: 'Extract reusable shadows and blurs into shared presets or variables'
})
}
})

View file

@ -1,11 +1,20 @@
import { defineRule } from '../rule'
export default defineRule({
meta: { id: 'min-text-size', category: 'accessibility', description: 'Text should be large enough to be readable (minimum 12px)' },
meta: {
id: 'min-text-size',
category: 'accessibility',
description: 'Text should be large enough to be readable (minimum 12px)'
},
match: ['TEXT'],
check(node, context) {
const config = context.getConfig() as { minSize?: number } | undefined
const minSize = config?.minSize ?? 12
if (node.fontSize < minSize) context.report({ node, message: `Text size ${node.fontSize}px is below minimum ${minSize}px`, suggest: `Increase to at least ${minSize}px for readability` })
if (node.fontSize < minSize)
context.report({
node,
message: `Text size ${node.fontSize}px is below minimum ${minSize}px`,
suggest: `Increase to at least ${minSize}px for readability`
})
}
})

View file

@ -1,13 +1,33 @@
import { defineRule } from '../rule'
export default defineRule({
meta: { id: 'no-hardcoded-colors', category: 'design-tokens', description: 'Colors should use variables instead of hardcoded values' },
match: ['RECTANGLE','ELLIPSE','FRAME','TEXT','VECTOR','LINE','POLYGON','STAR','COMPONENT','INSTANCE'],
meta: {
id: 'no-hardcoded-colors',
category: 'design-tokens',
description: 'Colors should use variables instead of hardcoded values'
},
match: [
'RECTANGLE',
'ELLIPSE',
'FRAME',
'TEXT',
'VECTOR',
'LINE',
'POLYGON',
'STAR',
'COMPONENT',
'INSTANCE'
],
check(node, context) {
const checkPaints = (paints: typeof node.fills, field: 'fills' | 'strokes') => {
for (const paint of paints) {
if (paint.type !== 'SOLID' || !paint.visible || !paint.color || node.boundVariables[field]) continue
context.report({ node, message: `Hardcoded ${field === 'fills' ? 'fill' : 'stroke'} color detected`, suggest: 'Bind this color to a design variable for consistency' })
if (paint.type !== 'SOLID' || !paint.visible || !paint.color || node.boundVariables[field])
continue
context.report({
node,
message: `Hardcoded ${field === 'fills' ? 'fill' : 'stroke'} color detected`,
suggest: 'Bind this color to a design variable for consistency'
})
}
}
checkPaints(node.fills, 'fills')

View file

@ -1,11 +1,19 @@
import { defineRule } from '../rule'
export default defineRule({
meta: { id: 'no-mixed-styles', category: 'typography', description: 'Text layers should not mix multiple styles in one node' },
meta: {
id: 'no-mixed-styles',
category: 'typography',
description: 'Text layers should not mix multiple styles in one node'
},
match: ['TEXT'],
check(node, context) {
if (node.text.length > 1 && node.styleRunCount > 0) {
context.report({ node, message: 'Text layer has mixed font styles', suggest: 'Split into separate text layers or unify the text style' })
context.report({
node,
message: 'Text layer has mixed font styles',
suggest: 'Split into separate text layers or unify the text style'
})
}
}
})

View file

@ -1,11 +1,19 @@
import { defineRule } from '../rule'
export default defineRule({
meta: { id: 'text-style-required', category: 'typography', description: 'Text layers should use shared typography tokens or styles' },
meta: {
id: 'text-style-required',
category: 'typography',
description: 'Text layers should use shared typography tokens or styles'
},
match: ['TEXT'],
check(node, context) {
if (node.text.length <= 2) return
if (node.boundVariables.fontSize || node.boundVariables.fontFamily) return
context.report({ node, message: 'Text layer without typography variable bindings', suggest: 'Bind font size or font family to a shared text token when possible' })
context.report({
node,
message: 'Text layer without typography variable bindings',
suggest: 'Bind font size or font family to a shared text token when possible'
})
}
})

View file

@ -1,7 +1,7 @@
import { rotatedBBox } from '../geometry'
import type { SceneNode } from './'
import type { Rect } from '../types'
import type { SceneNode } from './'
const SNAP_THRESHOLD = 5

View file

@ -1,6 +1,7 @@
import type { LayoutDirection, SceneNode, TextDirection } from '../scene-graph'
const RTL_CHAR_RE = /\p{Script=Arabic}|\p{Script=Hebrew}|\p{Script=Syriac}|\p{Script=Thaana}|\p{Script=Nko}|\p{Script=Adlam}/u
const RTL_CHAR_RE =
/\p{Script=Arabic}|\p{Script=Hebrew}|\p{Script=Syriac}|\p{Script=Thaana}|\p{Script=Nko}|\p{Script=Adlam}/u
const LTR_CHAR_RE = /\p{Script=Latin}|\p{Script=Cyrillic}|\p{Script=Greek}/u
export function detectTextDirection(text: string): Exclude<TextDirection, 'AUTO'> {
@ -18,7 +19,9 @@ export function resolveTextDirection(
return direction === 'AUTO' ? detectTextDirection(text) : direction
}
export function resolveNodeTextDirection(node: Pick<SceneNode, 'textDirection' | 'text'>): 'LTR' | 'RTL' {
export function resolveNodeTextDirection(
node: Pick<SceneNode, 'textDirection' | 'text'>
): 'LTR' | 'RTL' {
return resolveTextDirection(node.textDirection, node.text)
}

View file

@ -1,5 +1,6 @@
import type { SkiaRenderer } from '../canvas'
import { resolveNodeTextDirection } from './direction'
import type { SkiaRenderer } from '../canvas'
import type { SceneNode } from '../scene-graph'
import type { Rect } from '../types'
import type { CanvasKit, Paragraph } from 'canvaskit-wasm'
@ -258,8 +259,7 @@ export class TextEditor {
if (lineNum < 0) return
const metrics = s.paragraph.getLineMetricsAt(lineNum)
if (!metrics) return
s.cursor =
s.textDirection === 'RTL' ? metrics.endExcludingWhitespaces : metrics.startIndex
s.cursor = s.textDirection === 'RTL' ? metrics.endExcludingWhitespaces : metrics.startIndex
}
moveToLineEnd(extend = false): void {
@ -270,8 +270,7 @@ export class TextEditor {
if (lineNum < 0) return
const metrics = s.paragraph.getLineMetricsAt(lineNum)
if (!metrics) return
s.cursor =
s.textDirection === 'RTL' ? metrics.startIndex : metrics.endExcludingWhitespaces
s.cursor = s.textDirection === 'RTL' ? metrics.startIndex : metrics.endExcludingWhitespaces
}
moveWordLeft(extend = false): void {

View file

@ -1,6 +1,6 @@
import { parseColor } from '../color'
import { createIconFromPaths } from '../icons/render'
import { fetchIcons, searchIconsBatch } from '../icons'
import { createIconFromPaths } from '../icons/render'
import { defineTool, nodeSummary } from './schema'
import type { FigmaNodeProxy } from '../figma-api'

View file

@ -91,7 +91,11 @@ export function updateRGBChannel(color: Color, channel: 'r' | 'g' | 'b', value25
}
}
export function updateHSLChannel(model: ColorPickerModel, channel: 'h' | 's' | 'l', value: number): Color {
export function updateHSLChannel(
model: ColorPickerModel,
channel: 'h' | 's' | 'l',
value: number
): Color {
const next = {
...model.hsl,
[channel]: channel === 'h' ? value : clampPercent(value)
@ -106,7 +110,11 @@ export function updateHSLChannel(model: ColorPickerModel, channel: 'h' | 's' | '
return rekaToAppColor(next)
}
export function updateHSBChannel(model: ColorPickerModel, channel: 'h' | 's' | 'b', value: number): Color {
export function updateHSBChannel(
model: ColorPickerModel,
channel: 'h' | 's' | 'b',
value: number
): Color {
return rekaToAppColor({
...model.hsb,
[channel]: channel === 'h' ? value : clampPercent(value)
@ -132,10 +140,7 @@ export function createOkHCLSliderPreviewModel(color: OkHCLColor): OkHCLSliderPre
okhclHue: okhclToRGBA({
...color,
c: Math.max(color.c, OKHCL_HUE_PREVIEW_MIN_CHROMA),
l:
color.l <= 0 || color.l >= 1
? OKHCL_HUE_PREVIEW_FALLBACK_LIGHTNESS
: color.l
l: color.l <= 0 || color.l >= 1 ? OKHCL_HUE_PREVIEW_FALLBACK_LIGHTNESS : color.l
}),
okhclChroma: okhclToRGBA(color),
okhclLightness: okhclToRGBA(color)

View file

@ -51,7 +51,9 @@ export function useExport() {
hasSelection.value ? 'selection' : 'page'
)
const activeName = computed(() =>
activeTarget.value === 'selection' ? (selectedNodeName.value ?? 'Export') : currentPageName.value
activeTarget.value === 'selection'
? (selectedNodeName.value ?? 'Export')
: currentPageName.value
)
const activeSettings = computed(() =>
activeTarget.value === 'selection' ? selectionSettings.value : pageSettings.value

View file

@ -1,10 +1,16 @@
import { computed, ref } from 'vue'
import { useEditor } from '@open-pencil/vue/context/editorContext'
import { useSceneComputed } from '@open-pencil/vue/internal/useSceneComputed'
import { useI18n } from '@open-pencil/vue/i18n'
import { useSceneComputed } from '@open-pencil/vue/internal/useSceneComputed'
import type { SceneNode, LayoutSizing, LayoutAlign, LayoutCounterAlign, GridTrack } from '@open-pencil/core'
import type {
SceneNode,
LayoutSizing,
LayoutAlign,
LayoutCounterAlign,
GridTrack
} from '@open-pencil/core'
type AlignCell = { primary: LayoutAlign; counter: LayoutCounterAlign }
@ -51,7 +57,9 @@ export function useLayout() {
const { panels } = useI18n()
const node = useSceneComputed<SceneNode | null>(() => editor.getSelectedNode() ?? null)
const layoutDirection = computed<SceneNode['layoutDirection']>(() => node.value?.layoutDirection ?? 'AUTO')
const layoutDirection = computed<SceneNode['layoutDirection']>(
() => node.value?.layoutDirection ?? 'AUTO'
)
const isInAutoLayout = computed(() => {
const n = node.value
@ -87,7 +95,8 @@ export function useLayout() {
{ value: 'FIXED', label: panels.value.sizingFixed }
]
if (isFlex.value) options.push({ value: 'HUG', label: panels.value.sizingHug })
if (isInAutoLayout.value || isFlex.value) options.push({ value: 'FILL', label: panels.value.sizingFill })
if (isInAutoLayout.value || isFlex.value)
options.push({ value: 'FILL', label: panels.value.sizingFill })
return options
})
@ -96,7 +105,8 @@ export function useLayout() {
{ value: 'FIXED', label: panels.value.sizingFixed }
]
if (isFlex.value) options.push({ value: 'HUG', label: panels.value.sizingHug })
if (isInAutoLayout.value || isFlex.value) options.push({ value: 'FILL', label: panels.value.sizingFill })
if (isInAutoLayout.value || isFlex.value)
options.push({ value: 'FILL', label: panels.value.sizingFill })
return options
})
@ -184,7 +194,11 @@ export function useLayout() {
function setLayoutDirection(direction: SceneNode['layoutDirection']) {
if (!node.value) return
editor.updateNodeWithUndo(node.value.id, { layoutDirection: direction }, 'Change layout direction')
editor.updateNodeWithUndo(
node.value.id,
{ layoutDirection: direction },
'Change layout direction'
)
}
function updateGridTrack(

View file

@ -22,18 +22,22 @@ export function useOkHCL() {
}
function getFillOkHCLColor(node: SceneNode | null, index: number): OkHCLColor | null {
return node ? getFillOkHCL(node, index)?.color ?? null : null
return node ? (getFillOkHCL(node, index)?.color ?? null) : null
}
function getStrokeOkHCLColor(node: SceneNode | null, index: number): OkHCLColor | null {
return node ? getStrokeOkHCL(node, index)?.color ?? null : null
return node ? (getStrokeOkHCL(node, index)?.color ?? null) : null
}
function ensureFillOkHCL(node: SceneNode, index: number) {
const color =
getFillOkHCLColor(node, index) ??
rgbaToOkHCL(node.fills[index]?.color ?? { r: 0, g: 0, b: 0, a: 1 })
editor.updateNodeWithUndo(node.id, setNodeFillOkHCL(node, index, color), 'Update fill color model')
editor.updateNodeWithUndo(
node.id,
setNodeFillOkHCL(node, index, color),
'Update fill color model'
)
}
function ensureStrokeOkHCL(node: SceneNode, index: number) {

View file

@ -199,4 +199,4 @@
"noResults": "无结果",
"share": "分享"
}
}
}

View file

@ -6,7 +6,11 @@ import ColorPicker from './ColorPicker.vue'
import type { Color } from '@open-pencil/core'
import type { OkHCLControls } from '@open-pencil/vue/ColorPicker/types'
const { editable = false, color, okhcl = null } = defineProps<{
const {
editable = false,
color,
okhcl = null
} = defineProps<{
color: Color
editable?: boolean
okhcl?: OkHCLControls | null
@ -15,8 +19,15 @@ const emit = defineEmits<{ update: [color: Color] }>()
</script>
<template>
<ColorInputRoot :color="color" :editable="editable" :okhcl="okhcl" @update="emit('update', $event)">
<template #default="{ editable: isEditable, hex, updateFromHex, updateColor, okhcl: okhclControls }">
<ColorInputRoot
:color="color"
:editable="editable"
:okhcl="okhcl"
@update="emit('update', $event)"
>
<template
#default="{ editable: isEditable, hex, updateFromHex, updateColor, okhcl: okhclControls }"
>
<div class="flex items-center gap-1.5">
<ColorPicker :color="color" :okhcl="okhclControls" @update="updateColor($event)" />
<input

View file

@ -48,12 +48,13 @@ const okhclSliderPreview = computed(() =>
const okhclSliderGradient = computed(() =>
okhcl?.okhcl ? createOkHCLSliderGradientModel(okhcl.okhcl) : null
)
const fieldOptions = computed(() =>
okhcl?.fieldOptions ?? [
{ value: 'rgb', label: panels.value.colorFormatRgb },
{ value: 'hsl', label: panels.value.colorFormatHsl },
{ value: 'hsb', label: panels.value.colorFormatHsb }
]
const fieldOptions = computed(
() =>
okhcl?.fieldOptions ?? [
{ value: 'rgb', label: panels.value.colorFormatRgb },
{ value: 'hsl', label: panels.value.colorFormatHsl },
{ value: 'hsb', label: panels.value.colorFormatHsb }
]
)
const fieldFormat = computed(() => okhcl?.fieldFormat ?? 'rgb')
const isOkHCLFormat = computed(() => fieldFormat.value === 'okhcl' && okhcl)
@ -150,17 +151,64 @@ function updateOkHCLChannel(channel: 'h' | 'c' | 'l' | 'a', value: number) {
/>
<div class="min-w-0 flex flex-col gap-2">
<div v-if="fieldFormat === 'rgb'" class="grid grid-cols-[repeat(3,minmax(0,1fr))] gap-px overflow-hidden rounded border border-border bg-border">
<input type="number" class="bg-input px-2 py-1 text-xs text-surface outline-none" :value="Math.round(rgbColor.r)" min="0" max="255" @change="updateRGBChannelValue('r', +($event.target as HTMLInputElement).value)" />
<input type="number" class="bg-input px-2 py-1 text-xs text-surface outline-none" :value="Math.round(rgbColor.g)" min="0" max="255" @change="updateRGBChannelValue('g', +($event.target as HTMLInputElement).value)" />
<input type="number" class="bg-input px-2 py-1 text-xs text-surface outline-none" :value="Math.round(rgbColor.b)" min="0" max="255" @change="updateRGBChannelValue('b', +($event.target as HTMLInputElement).value)" />
<div
v-if="fieldFormat === 'rgb'"
class="grid grid-cols-[repeat(3,minmax(0,1fr))] gap-px overflow-hidden rounded border border-border bg-border"
>
<input
type="number"
class="bg-input px-2 py-1 text-xs text-surface outline-none"
:value="Math.round(rgbColor.r)"
min="0"
max="255"
@change="updateRGBChannelValue('r', +($event.target as HTMLInputElement).value)"
/>
<input
type="number"
class="bg-input px-2 py-1 text-xs text-surface outline-none"
:value="Math.round(rgbColor.g)"
min="0"
max="255"
@change="updateRGBChannelValue('g', +($event.target as HTMLInputElement).value)"
/>
<input
type="number"
class="bg-input px-2 py-1 text-xs text-surface outline-none"
:value="Math.round(rgbColor.b)"
min="0"
max="255"
@change="updateRGBChannelValue('b', +($event.target as HTMLInputElement).value)"
/>
</div>
<template v-else-if="fieldFormat === 'hsl'">
<div class="grid grid-cols-[repeat(3,minmax(0,1fr))] gap-px overflow-hidden rounded border border-border bg-border">
<input type="number" class="bg-input px-2 py-1 text-xs text-surface outline-none" :value="Math.round(hslColor.h ?? 0)" min="0" max="360" @change="updateHSLChannelValue('h', +($event.target as HTMLInputElement).value)" />
<input type="number" class="bg-input px-2 py-1 text-xs text-surface outline-none" :value="Math.round(hslColor.s ?? 0)" min="0" max="100" @change="updateHSLChannelValue('s', +($event.target as HTMLInputElement).value)" />
<input type="number" class="bg-input px-2 py-1 text-xs text-surface outline-none" :value="Math.round(hslColor.l ?? 0)" min="0" max="100" @change="updateHSLChannelValue('l', +($event.target as HTMLInputElement).value)" />
<div
class="grid grid-cols-[repeat(3,minmax(0,1fr))] gap-px overflow-hidden rounded border border-border bg-border"
>
<input
type="number"
class="bg-input px-2 py-1 text-xs text-surface outline-none"
:value="Math.round(hslColor.h ?? 0)"
min="0"
max="360"
@change="updateHSLChannelValue('h', +($event.target as HTMLInputElement).value)"
/>
<input
type="number"
class="bg-input px-2 py-1 text-xs text-surface outline-none"
:value="Math.round(hslColor.s ?? 0)"
min="0"
max="100"
@change="updateHSLChannelValue('s', +($event.target as HTMLInputElement).value)"
/>
<input
type="number"
class="bg-input px-2 py-1 text-xs text-surface outline-none"
:value="Math.round(hslColor.l ?? 0)"
min="0"
max="100"
@change="updateHSLChannelValue('l', +($event.target as HTMLInputElement).value)"
/>
</div>
<PickerSlider
@ -199,10 +247,33 @@ function updateOkHCLChannel(channel: 'h' | 'c' | 'l' | 'a', value: number) {
</template>
<template v-else-if="fieldFormat === 'hsb'">
<div class="grid grid-cols-[repeat(3,minmax(0,1fr))] gap-px overflow-hidden rounded border border-border bg-border">
<input type="number" class="bg-input px-2 py-1 text-xs text-surface outline-none" :value="Math.round(hsbColor.h)" min="0" max="360" @change="updateHSBChannelValue('h', +($event.target as HTMLInputElement).value)" />
<input type="number" class="bg-input px-2 py-1 text-xs text-surface outline-none" :value="Math.round(hsbColor.s)" min="0" max="100" @change="updateHSBChannelValue('s', +($event.target as HTMLInputElement).value)" />
<input type="number" class="bg-input px-2 py-1 text-xs text-surface outline-none" :value="Math.round(hsbColor.b)" min="0" max="100" @change="updateHSBChannelValue('b', +($event.target as HTMLInputElement).value)" />
<div
class="grid grid-cols-[repeat(3,minmax(0,1fr))] gap-px overflow-hidden rounded border border-border bg-border"
>
<input
type="number"
class="bg-input px-2 py-1 text-xs text-surface outline-none"
:value="Math.round(hsbColor.h)"
min="0"
max="360"
@change="updateHSBChannelValue('h', +($event.target as HTMLInputElement).value)"
/>
<input
type="number"
class="bg-input px-2 py-1 text-xs text-surface outline-none"
:value="Math.round(hsbColor.s)"
min="0"
max="100"
@change="updateHSBChannelValue('s', +($event.target as HTMLInputElement).value)"
/>
<input
type="number"
class="bg-input px-2 py-1 text-xs text-surface outline-none"
:value="Math.round(hsbColor.b)"
min="0"
max="100"
@change="updateHSBChannelValue('b', +($event.target as HTMLInputElement).value)"
/>
</div>
<PickerSlider

View file

@ -34,7 +34,9 @@ const {
checkerboard?: boolean
thumbFill?: string
testId?: string
ui?: Partial<Record<'root' | 'label' | 'track' | 'gradient' | 'range' | 'thumb' | 'input', string>>
ui?: Partial<
Record<'root' | 'label' | 'track' | 'gradient' | 'range' | 'thumb' | 'input', string>
>
}>()
const emit = defineEmits<{
@ -74,10 +76,7 @@ function thumbLeft(): string {
:value="modelValue"
@input="emit('update:modelValue', +($event.target as HTMLInputElement).value)"
/>
<div
:class="cls.thumb"
:style="{ left: thumbLeft(), background: thumbFill }"
/>
<div :class="cls.thumb" :style="{ left: thumbLeft(), background: thumbFill }" />
</div>
<input
type="number"

View file

@ -142,10 +142,7 @@ watch(open, (v) => {
:class="itemCls"
@select="store.zoomToLevel(preset.level)"
>
<icon-lucide-check
v-if="isActivePreset(preset.level)"
class="absolute left-2 size-3.5"
/>
<icon-lucide-check v-if="isActivePreset(preset.level)" class="absolute left-2 size-3.5" />
<span class="flex-1">{{ preset.label }}</span>
<span v-if="preset.shortcut" class="text-[11px] text-muted">{{ preset.shortcut }}</span>
</DropdownMenuItem>

View file

@ -22,7 +22,12 @@ interface SelectUi {
indicator?: string
}
const { options, placeholder, ui, testId = 'app-select-trigger' } = defineProps<{
const {
options,
placeholder,
ui,
testId = 'app-select-trigger'
} = defineProps<{
options: { value: T; label: string }[]
placeholder?: string
ui?: SelectUi

View file

@ -8,7 +8,8 @@ const pickerSlider = tv({
track: 'relative flex h-3 flex-1 items-center rounded-md',
gradient: 'absolute inset-0 rounded-md',
range: 'absolute inset-0 z-10 h-full w-full cursor-pointer appearance-none opacity-0',
thumb: 'pointer-events-none absolute top-1/2 size-3.5 -translate-y-1/2 rounded-full border-2 border-white shadow-sm',
thumb:
'pointer-events-none absolute top-1/2 size-3.5 -translate-y-1/2 rounded-full border-2 border-white shadow-sm',
input: 'w-14 rounded border border-border bg-input px-1 py-0.5 text-right text-xs text-surface'
},
variants: {
@ -25,12 +26,11 @@ const pickerSlider = tv({
}
})
export type PickerSliderUi = Partial<Record<'root' | 'label' | 'track' | 'gradient' | 'range' | 'thumb' | 'input', string>>
export type PickerSliderUi = Partial<
Record<'root' | 'label' | 'track' | 'gradient' | 'range' | 'thumb' | 'input', string>
>
export function usePickerSliderUI(options?: {
checkerboard?: boolean
ui?: PickerSliderUi
}) {
export function usePickerSliderUI(options?: { checkerboard?: boolean; ui?: PickerSliderUi }) {
const slots = pickerSlider({ checkerboard: options?.checkerboard })
return {
root: twMerge(slots.root(), options?.ui?.root),

View file

@ -44,9 +44,7 @@ export const DEFAULT_COLLAB_STATE: CollabState = {
export function useCollab(storeOrGetter: EditorStore | (() => EditorStore)) {
const getStore = () =>
typeof storeOrGetter === 'function'
? (storeOrGetter as () => EditorStore)()
: storeOrGetter
typeof storeOrGetter === 'function' ? (storeOrGetter as () => EditorStore)() : storeOrGetter
const storedName = useLocalStorage('op-collab-name', '')
const state = ref<CollabState>({
connected: false,

View file

@ -44,4 +44,12 @@ function setupGlobalErrorHandler() {
})
}
export const toast = { info, warning, error, remove, toasts, setupGlobalErrorHandler, TOAST_DURATION }
export const toast = {
info,
warning,
error,
remove,
toasts,
setupGlobalErrorHandler,
TOAST_DURATION
}