diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b4f909f2..5b4a47e35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - OkHCL metadata now round-trips through `.fig` plugin data and integrates directly into the main fill/stroke color workflow with preview gamut diagnostics - Vue SDK now exposes reusable color-picker model helpers and solid fill/stroke commit helpers for custom editor shells - Update built-in Z.ai and MiniMax model lists — Z.ai now uses the Anthropic-compatible endpoint for GLM coding models, adds GLM-5.1, and MiniMax adds M2.7 / M2.7-highspeed +- Arabic and RTL support across text rendering, editing, layout, export, and AI tooling — text nodes support `Auto`/`LTR`/`RTL`, auto-layout frames support `Auto`/`LTR`/`RTL` flow, and JSX/AI prompts/tools can now generate and edit both explicitly ### Fixes diff --git a/packages/core/src/direction.ts b/packages/core/src/direction.ts new file mode 100644 index 000000000..d96fff1c3 --- /dev/null +++ b/packages/core/src/direction.ts @@ -0,0 +1,52 @@ +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 LTR_CHAR_RE = /\p{Script=Latin}|\p{Script=Cyrillic}|\p{Script=Greek}/u + +export function detectTextDirection(text: string): Exclude { + for (const char of text) { + if (RTL_CHAR_RE.test(char)) return 'RTL' + if (LTR_CHAR_RE.test(char)) return 'LTR' + } + return 'LTR' +} + +export function resolveTextDirection( + direction: TextDirection, + text: string +): Exclude { + return direction === 'AUTO' ? detectTextDirection(text) : direction +} + +export function resolveNodeTextDirection(node: Pick): 'LTR' | 'RTL' { + return resolveTextDirection(node.textDirection, node.text) +} + +export function resolveNodeLayoutDirection( + node: { layoutDirection?: LayoutDirection }, + inheritedDirection: Exclude = 'LTR' +): Exclude { + return !node.layoutDirection || node.layoutDirection === 'AUTO' + ? inheritedDirection + : node.layoutDirection +} + +export function isLogicalTextAlignStart( + node: Pick +): boolean { + const direction = resolveNodeTextDirection(node) + return ( + (direction === 'LTR' && node.textAlignHorizontal === 'LEFT') || + (direction === 'RTL' && node.textAlignHorizontal === 'RIGHT') + ) +} + +export function isLogicalTextAlignEnd( + node: Pick +): boolean { + const direction = resolveNodeTextDirection(node) + return ( + (direction === 'LTR' && node.textAlignHorizontal === 'RIGHT') || + (direction === 'RTL' && node.textAlignHorizontal === 'LEFT') + ) +} diff --git a/packages/core/src/editor/create.ts b/packages/core/src/editor/create.ts index 174611a7f..948a3c73c 100644 --- a/packages/core/src/editor/create.ts +++ b/packages/core/src/editor/create.ts @@ -1,7 +1,7 @@ import { prefetchFigmaSchema } from '../clipboard' import { CANVAS_BG_COLOR, IS_BROWSER } from '../constants' import { loadFont as defaultLoadFont } from '../fonts' -import { computeLayout, setTextMeasurer } from '../layout' +import { computeAllLayouts, computeLayout, setTextMeasurer } from '../layout' import { SceneGraph } from '../scene-graph' import { TextEditor } from '../text-editor' import { UndoManager } from '../undo' @@ -84,9 +84,7 @@ export function createEditor(options?: EditorOptions) { const node = _graph.getNode(id) if (!node) return - if (node.layoutMode !== 'NONE') { - computeLayout(_graph, id) - } + computeAllLayouts(_graph, id) let parent = node.parentId ? _graph.getNode(node.parentId) : undefined while (parent) { diff --git a/packages/core/src/figma-api-proxy.ts b/packages/core/src/figma-api-proxy.ts index da76197b1..8932c6f65 100644 --- a/packages/core/src/figma-api-proxy.ts +++ b/packages/core/src/figma-api-proxy.ts @@ -462,6 +462,16 @@ export class FigmaNodeProxy { }) } + get textDirection(): string { + return this._raw().textDirection + } + + set textDirection(v: string) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + textDirection: v as SceneNode['textDirection'] + }) + } + get textAlignVertical(): string { return this._raw().textAlignVertical } @@ -564,6 +574,17 @@ export class FigmaNodeProxy { this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { layoutMode: v }) } + get layoutDirection(): string { + const raw = this._raw() + return Object.hasOwn(raw, 'layoutDirection') ? raw.layoutDirection : 'AUTO' + } + + set layoutDirection(v: string) { + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + layoutDirection: v as SceneNode['layoutDirection'] + }) + } + get primaryAxisAlignItems(): string { return this._raw().primaryAxisAlign } @@ -1021,8 +1042,10 @@ export class FigmaNodeProxy { if (n.cornerRadius > 0) obj.cornerRadius = n.cornerRadius if (!n.visible) obj.visible = false if (n.text) obj.characters = n.text + if (n.type === 'TEXT') obj.textDirection = n.textDirection if (n.layoutMode !== 'NONE') { obj.layoutMode = n.layoutMode + obj.layoutDirection = n.layoutDirection obj.itemSpacing = n.itemSpacing } const children = this[INTERNAL_GRAPH].getChildren(this[INTERNAL_ID]) diff --git a/packages/core/src/fonts.ts b/packages/core/src/fonts.ts index 5c8eaba1a..bdc112069 100644 --- a/packages/core/src/fonts.ts +++ b/packages/core/src/fonts.ts @@ -58,7 +58,8 @@ export async function listFamilies(): Promise { } const BUNDLED_FONTS: Record = { - 'Inter|Regular': '/Inter-Regular.ttf' + 'Inter|Regular': '/Inter-Regular.ttf', + 'Noto Naskh Arabic|Regular': '/NotoNaskhArabic-Regular.ttf' } const googleFontsCache = new Map>() @@ -196,7 +197,7 @@ export async function loadFont(family: string, style = 'Regular'): Promise | null = null +const arabicFallbackFamilies: string[] = [] +let arabicFallbackPromise: Promise | null = null function getCJKCandidates(): string[] { if (typeof navigator === 'undefined') return [...CJK_FALLBACK_FAMILIES_LINUX] @@ -361,6 +364,46 @@ export function setCJKFallbackFamily(family: string): void { } } +export async function ensureArabicFallback(): Promise { + if (arabicFallbackFamilies.length > 0) return arabicFallbackFamilies + if (arabicFallbackPromise) return arabicFallbackPromise + + arabicFallbackPromise = (async () => { + for (const family of [ + 'Noto Naskh Arabic', + 'Noto Sans Arabic', + 'Geeza Pro', + 'Arial', + 'Tahoma', + 'Amiri' + ]) { + const buffer = await findLocalFont(family) + if (buffer && registerAndCache(family, 'Regular', buffer)) { + arabicFallbackFamilies.push(family) + } + } + + if (arabicFallbackFamilies.length === 0) { + const data = await loadFont('Noto Naskh Arabic', 'Regular') + if (data) arabicFallbackFamilies.push('Noto Naskh Arabic') + } + + return arabicFallbackFamilies + })() + + return arabicFallbackPromise +} + +export function getArabicFallbackFamilies(): string[] { + return arabicFallbackFamilies +} + +export function setArabicFallbackFamily(family: string): void { + if (!arabicFallbackFamilies.includes(family)) { + arabicFallbackFamilies.push(family) + } +} + export const FONT_WEIGHT_NAMES: Record = { 100: 'Thin', 200: 'Extra Light', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ad74a97b4..edc021817 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -50,9 +50,11 @@ export { type GridPosition, type ConstraintType, type TextAutoResize, + type TextDirection, type TextAlignVertical, type TextCase, type TextDecoration, + type LayoutDirection, type ArcData, type VectorNetwork, type VectorVertex, @@ -150,6 +152,14 @@ export type { FrameCapture, NodeProfile } from './profiler' export { computeLayout, computeAllLayouts, setTextMeasurer } from './layout' export type { TextMeasurer } from './layout' export { getCanvasKit, type CanvasKitOptions } from './canvaskit' +export { + detectTextDirection, + resolveTextDirection, + resolveNodeTextDirection, + resolveNodeLayoutDirection, + isLogicalTextAlignStart, + isLogicalTextAlignEnd +} from './direction' export { FONT_WEIGHT_NAMES, collectFontKeys, @@ -162,9 +172,12 @@ export { markFontLoaded, ensureNodeFont, ensureCJKFallback, + ensureArabicFallback, getCJKFallbackFamily, getCJKFallbackFamilies, + getArabicFallbackFamilies, setCJKFallbackFamily, + setArabicFallbackFamily, styleToWeight, weightToStyle, normalizeFontFamily, diff --git a/packages/core/src/io/formats/jsx/export.ts b/packages/core/src/io/formats/jsx/export.ts index 8a8364838..bbbd27b5f 100644 --- a/packages/core/src/io/formats/jsx/export.ts +++ b/packages/core/src/io/formats/jsx/export.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- JSX export formats share helpers and node walking logic */ import { colorToHex8, colorToCSSCompact } from '@open-pencil/core/color' import { DEFAULT_FONT_FAMILY } from '@open-pencil/core/constants' +import { resolveNodeTextDirection } from '../../../direction' import { pxToSpacing, colorToTwClass, @@ -198,6 +199,7 @@ function collectGridSizingProps(node: SceneNode, props: [string, unknown][]): vo function collectFlexSizingProps(node: SceneNode, props: [string, unknown][]): void { props.push(['flex', node.layoutMode === 'HORIZONTAL' ? 'row' : 'col']) + if (node.layoutDirection === 'RTL') props.push(['dir', 'rtl']) const primaryAxis = node.layoutMode === 'HORIZONTAL' ? 'width' : 'height' const crossAxis = node.layoutMode === 'HORIZONTAL' ? 'height' : 'width' @@ -368,6 +370,7 @@ function collectTextSizingProps( } function collectTextNodeProps(node: SceneNode, props: [string, unknown][]): void { + const direction = resolveNodeTextDirection(node) if (node.fontSize !== 14) props.push(['size', node.fontSize]) if (node.fontFamily && node.fontFamily !== DEFAULT_FONT_FAMILY) props.push(['font', node.fontFamily]) @@ -376,6 +379,7 @@ function collectTextNodeProps(node: SceneNode, props: [string, unknown][]): void else if (node.fontWeight === 500) props.push(['weight', 'medium']) else props.push(['weight', node.fontWeight]) } + if (direction === 'RTL') props.push(['dir', 'rtl']) if (node.textAlignHorizontal !== 'LEFT') { props.push(['textAlign', node.textAlignHorizontal.toLowerCase()]) } @@ -450,6 +454,7 @@ function collectTwGridClasses(node: SceneNode, classes: string[]): void { function collectTwFlexSizingClasses(node: SceneNode, classes: string[]): void { classes.push('flex') + if (node.layoutDirection === 'RTL') classes.push('[direction:rtl]') if (node.layoutMode === 'VERTICAL') classes.push('flex-col') const primaryAxis = node.layoutMode === 'HORIZONTAL' ? 'width' : 'height' @@ -558,6 +563,7 @@ function collectTwAppearanceClasses(node: SceneNode, classes: string[]): void { function collectTwTextClasses(node: SceneNode, classes: string[]): void { classes.push(`text-${fontSizeToTw(node.fontSize)}`) + if (resolveNodeTextDirection(node) === 'RTL') classes.push('[direction:rtl]') if (node.fontFamily && node.fontFamily !== DEFAULT_FONT_FAMILY) { classes.push(`font-${formatTailwindFontFamily(node.fontFamily)}`) } diff --git a/packages/core/src/io/formats/svg/export.ts b/packages/core/src/io/formats/svg/export.ts index e11c9070c..79d2fe228 100644 --- a/packages/core/src/io/formats/svg/export.ts +++ b/packages/core/src/io/formats/svg/export.ts @@ -19,6 +19,7 @@ import { roundedRectPath, arcPath } from './paths' +import { resolveNodeTextDirection } from '../../../direction' export { geometryBlobToSVGPath, vectorNetworkToSVGPaths } from './paths' @@ -166,10 +167,28 @@ function styleOverrideToTspanAttrs( return attrs } +function isLogicalTextEnd(node: SceneNode, direction: 'LTR' | 'RTL'): boolean { + return ( + (direction === 'LTR' && node.textAlignHorizontal === 'RIGHT') || + (direction === 'RTL' && node.textAlignHorizontal === 'LEFT') + ) +} + +function textAnchorForNode(node: SceneNode, direction: 'LTR' | 'RTL'): 'middle' | 'end' | undefined { + if (node.textAlignHorizontal === 'CENTER') return 'middle' + if (isLogicalTextEnd(node, direction)) return 'end' + return undefined +} + +function textXForNode(node: SceneNode, direction: 'LTR' | 'RTL'): number { + if (node.textAlignHorizontal === 'CENTER') return round(node.width / 2) + if (isLogicalTextEnd(node, direction)) return round(node.width) + return 0 +} + function renderTextNode(node: SceneNode, fillAttr: string | null): SVGNode { - let textAnchor: 'middle' | 'end' | undefined - if (node.textAlignHorizontal === 'CENTER') textAnchor = 'middle' - else if (node.textAlignHorizontal === 'RIGHT') textAnchor = 'end' + const direction = resolveNodeTextDirection(node) + const textAnchor = textAnchorForNode(node, direction) let textDecoration: 'underline' | 'line-through' | undefined if (node.textDecoration === 'UNDERLINE') textDecoration = 'underline' @@ -181,14 +200,13 @@ function renderTextNode(node: SceneNode, fillAttr: string | null): SVGNode { 'font-weight': node.fontWeight !== 400 ? node.fontWeight : undefined, 'font-style': node.italic ? 'italic' : undefined, fill: fillAttr ?? undefined, + direction: direction === 'RTL' ? 'rtl' : undefined, 'text-anchor': textAnchor, 'text-decoration': textDecoration, 'letter-spacing': node.letterSpacing ? round(node.letterSpacing) : undefined } - let x = 0 - if (node.textAlignHorizontal === 'CENTER') x = round(node.width / 2) - else if (node.textAlignHorizontal === 'RIGHT') x = round(node.width) + const x = textXForNode(node, direction) const y = node.fontSize || 14 if (node.styleRuns.length > 0) { diff --git a/packages/core/src/kiwi/convert.ts b/packages/core/src/kiwi/convert.ts index 2852768f1..a4fa0de96 100644 --- a/packages/core/src/kiwi/convert.ts +++ b/packages/core/src/kiwi/convert.ts @@ -39,6 +39,10 @@ import type { import type { Color, Matrix, Vector } from '../types' import type { NodeChange, Paint, Effect as KiwiEffect, GUID } from './codec' +const OPEN_PENCIL_PLUGIN_ID = 'open-pencil' +const TEXT_DIRECTION_PLUGIN_KEY = 'textDirection' +const LAYOUT_DIRECTION_PLUGIN_KEY = 'layoutDirection' + export function guidToString(guid: GUID): string { return `${guid.sessionID}:${guid.localID}` } @@ -497,6 +501,13 @@ function extractPluginData(nc: NodeChange): PluginDataEntry[] { })) } +function getOpenPencilPluginValue(nc: NodeChange, key: string): string | null { + return ( + nc.pluginData?.find((entry) => entry.pluginID === OPEN_PENCIL_PLUGIN_ID && entry.key === key) + ?.value ?? null + ) +} + function extractSharedPluginData(nc: NodeChange): SharedPluginDataEntry[] { return extractPluginData(nc).map((entry) => { const slashIndex = entry.key.indexOf('/') @@ -586,6 +597,7 @@ function convertTextProps( | 'maxLines' | 'styleRuns' | 'textTruncation' + | 'textDirection' > { return { text: nc.textData?.characters ?? '', @@ -606,7 +618,10 @@ function convertTextProps( letterSpacing: convertLetterSpacing(nc.letterSpacing, nc.fontSize), maxLines: (nc.maxLines ?? null) as number | null, styleRuns: importStyleRuns(nc), - textTruncation: (nc.textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED' + textTruncation: (nc.textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED', + textDirection: + (getOpenPencilPluginValue(nc, TEXT_DIRECTION_PLUGIN_KEY) as SceneNode['textDirection'] | null) || + 'AUTO' } } @@ -643,6 +658,7 @@ function convertLayoutProps( | 'counterAxisAlignContent' | 'itemReverseZIndex' | 'strokesIncludedInLayout' + | 'layoutDirection' > { return { layoutMode: mapStackMode(nc.stackMode), @@ -660,7 +676,12 @@ function convertLayoutProps( counterAxisAlignContent: (nc.stackCounterAlignContent as string) === 'SPACE_BETWEEN' ? 'SPACE_BETWEEN' : 'AUTO', itemReverseZIndex: (nc.stackReverseZIndex ?? false) as boolean, - strokesIncludedInLayout: (nc.strokesIncludedInLayout ?? false) as boolean + strokesIncludedInLayout: (nc.strokesIncludedInLayout ?? false) as boolean, + layoutDirection: + (getOpenPencilPluginValue(nc, LAYOUT_DIRECTION_PLUGIN_KEY) as + | SceneNode['layoutDirection'] + | null) || + 'AUTO' } } diff --git a/packages/core/src/kiwi/serialize.ts b/packages/core/src/kiwi/serialize.ts index 618813b21..54b429101 100644 --- a/packages/core/src/kiwi/serialize.ts +++ b/packages/core/src/kiwi/serialize.ts @@ -12,6 +12,17 @@ import type { Color, GUID, Matrix } from '../types' import type { NodeChange, Paint, VariableConsumptionEntry } from './codec' const fontDigestCache = new Map() +const OPEN_PENCIL_PLUGIN_ID = 'open-pencil' +const TEXT_DIRECTION_PLUGIN_KEY = 'textDirection' +const LAYOUT_DIRECTION_PLUGIN_KEY = 'layoutDirection' + +function upsertPluginData(node: SceneNode, key: string, value: string): void { + const pluginData = node.pluginData.filter( + (entry) => !(entry.pluginId === OPEN_PENCIL_PLUGIN_ID && entry.key === key) + ) + pluginData.push({ pluginId: OPEN_PENCIL_PLUGIN_ID, key, value }) + node.pluginData = pluginData +} async function computeFontDigest(data: ArrayBuffer): Promise { if (typeof crypto !== 'undefined') { @@ -319,6 +330,7 @@ function serializeTextProps( graph: SceneGraph, fontDigestMap?: Map ): void { + upsertPluginData(node, TEXT_DIRECTION_PLUGIN_KEY, node.textDirection) nc.fontSize = node.fontSize nc.fontName = { family: normalizeFontFamily(node.fontFamily), @@ -342,6 +354,7 @@ function serializeTextProps( } function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void { + upsertPluginData(node, LAYOUT_DIRECTION_PLUGIN_KEY, node.layoutDirection) if (node.layoutMode !== 'NONE' && node.layoutMode !== 'GRID') { nc.stackMode = node.layoutMode nc.stackSpacing = node.itemSpacing diff --git a/packages/core/src/layout.ts b/packages/core/src/layout.ts index 884429769..bf02a73b3 100644 --- a/packages/core/src/layout.ts +++ b/packages/core/src/layout.ts @@ -15,6 +15,8 @@ import Yoga, { type Node as YogaNode } from 'yoga-layout' +import { resolveNodeLayoutDirection } from './direction' + import type { GridTrack, SceneGraph, SceneNode } from './scene-graph' export type TextMeasurer = ( @@ -52,13 +54,29 @@ export function computeLayout(graph: SceneGraph, frameId: string): void { const frame = graph.getNode(frameId) if (!frame || frame.layoutMode === 'NONE') return + const rootDirection = resolveComputedLayoutDirection(graph, frame) const yogaRoot = - frame.layoutMode === 'GRID' ? buildGridTree(graph, frame) : buildYogaTree(graph, frame) - yogaRoot.calculateLayout(undefined, undefined, Direction.LTR) + frame.layoutMode === 'GRID' + ? buildGridTree(graph, frame, rootDirection) + : buildYogaTree(graph, frame, rootDirection) + yogaRoot.calculateLayout( + undefined, + undefined, + rootDirection === 'RTL' ? Direction.RTL : Direction.LTR + ) applyYogaLayout(graph, frame, yogaRoot) freeYogaTree(yogaRoot) } +function resolveComputedLayoutDirection( + graph: SceneGraph, + node: Pick +): 'LTR' | 'RTL' { + const parent = node.parentId ? graph.getNode(node.parentId) : null + const inheritedDirection = parent ? resolveComputedLayoutDirection(graph, parent) : 'LTR' + return resolveNodeLayoutDirection(node, inheritedDirection) +} + export function computeAllLayouts(graph: SceneGraph, scopeId?: string): void { const visited = new Set() computeLayoutsBottomUp(graph, scopeId ?? graph.rootId, visited) @@ -91,8 +109,13 @@ function mapGridTrack(track: GridTrack): { type: GridTrackType; value: number } } } -function configureAsGrid(yogaNode: YogaNode, node: SceneNode): void { +function configureAsGrid( + yogaNode: YogaNode, + node: SceneNode, + direction: Exclude +): void { yogaNode.setDisplay(Display.Grid) + yogaNode.setDirection(direction === 'RTL' ? Direction.RTL : Direction.LTR) yogaNode.setWidth(node.width) if (node.gridTemplateRows.length > 0 || node.height > 0) { yogaNode.setHeight(node.height) @@ -143,9 +166,14 @@ function createGridChildNode(child: SceneNode): YogaNode { return yogaChild } -function buildGridTree(graph: SceneGraph, frame: SceneNode): YogaNode { +function buildGridTree( + graph: SceneGraph, + frame: SceneNode, + inheritedDirection: 'LTR' | 'RTL' +): YogaNode { const root = Yoga.Node.create() - configureAsGrid(root, frame) + const direction = resolveNodeLayoutDirection(frame, inheritedDirection) + configureAsGrid(root, frame, direction) const children = graph.getChildren(frame.id) for (const child of children) { @@ -154,7 +182,12 @@ function buildGridTree(graph: SceneGraph, frame: SceneNode): YogaNode { configureAbsoluteChild(yogaChild, child) root.insertChild(yogaChild, root.getChildCount()) } else { - root.insertChild(createGridChildNode(child), root.getChildCount()) + const yogaChild = createGridChildNode(child) + if (child.layoutMode === 'GRID' || child.layoutMode === 'HORIZONTAL' || child.layoutMode === 'VERTICAL') { + const childDirection = resolveNodeLayoutDirection(child, direction) + yogaChild.setDirection(childDirection === 'RTL' ? Direction.RTL : Direction.LTR) + } + root.insertChild(yogaChild, root.getChildCount()) } } @@ -163,8 +196,13 @@ function buildGridTree(graph: SceneGraph, frame: SceneNode): YogaNode { // --- Flex layout --- -function buildYogaTree(graph: SceneGraph, frame: SceneNode): YogaNode { +function buildYogaTree( + graph: SceneGraph, + frame: SceneNode, + inheritedDirection: 'LTR' | 'RTL' +): YogaNode { const root = Yoga.Node.create() + const direction = resolveNodeLayoutDirection(frame, inheritedDirection) if (frame.primaryAxisSizing === 'FIXED') { if (frame.layoutMode === 'HORIZONTAL') root.setWidth(frame.width) @@ -175,7 +213,7 @@ function buildYogaTree(graph: SceneGraph, frame: SceneNode): YogaNode { else root.setWidth(frame.width) } - configureFlexContainer(root, frame) + configureFlexContainer(root, frame, direction) const children = graph.getChildren(frame.id) for (const child of children) { @@ -186,9 +224,9 @@ function buildYogaTree(graph: SceneGraph, frame: SceneNode): YogaNode { } else if (!child.visible) { yogaChild.setDisplay(Display.None) } else if (child.layoutMode === 'GRID') { - configureChildAsGrid(yogaChild, child, frame, graph) + configureChildAsGrid(yogaChild, child, frame, graph, direction) } else if (child.layoutMode !== 'NONE') { - configureChildAsAutoLayout(yogaChild, child, frame, graph) + configureChildAsAutoLayout(yogaChild, child, frame, graph, direction) } else { configureChildAsLeaf(yogaChild, child, frame) } @@ -207,7 +245,12 @@ function configureAbsoluteChild(yogaChild: YogaNode, child: SceneNode): void { yogaChild.setHeight(child.height) } -function configureFlexContainer(yogaNode: YogaNode, node: SceneNode): void { +function configureFlexContainer( + yogaNode: YogaNode, + node: SceneNode, + direction: Exclude +): void { + yogaNode.setDirection(direction === 'RTL' ? Direction.RTL : Direction.LTR) yogaNode.setFlexDirection( node.layoutMode === 'HORIZONTAL' ? FlexDirection.Row : FlexDirection.Column ) @@ -241,9 +284,12 @@ function configureChildAsGrid( yogaChild: YogaNode, child: SceneNode, parent: SceneNode, - graph: SceneGraph + graph: SceneGraph, + inheritedDirection: 'LTR' | 'RTL' ): void { + const direction = resolveNodeLayoutDirection(child, inheritedDirection) yogaChild.setDisplay(Display.Grid) + yogaChild.setDirection(direction === 'RTL' ? Direction.RTL : Direction.LTR) if (child.gridTemplateColumns.length > 0) { yogaChild.setGridTemplateColumns(child.gridTemplateColumns.map(mapGridTrack)) @@ -312,8 +358,10 @@ function configureChildAsAutoLayout( yogaChild: YogaNode, child: SceneNode, parent: SceneNode, - graph: SceneGraph + graph: SceneGraph, + inheritedDirection: 'LTR' | 'RTL' ): void { + const direction = resolveNodeLayoutDirection(child, inheritedDirection) const isParentRow = parent.layoutMode === 'HORIZONTAL' const isChildRow = child.layoutMode === 'HORIZONTAL' @@ -333,7 +381,7 @@ function configureChildAsAutoLayout( const selfAlign = mapAlignSelf(child.layoutAlignSelf) if (selfAlign != null) yogaChild.setAlignSelf(selfAlign) - configureFlexContainer(yogaChild, child) + configureFlexContainer(yogaChild, child, direction) const grandchildren = graph.getChildren(child.id) for (const gc of grandchildren) { @@ -343,9 +391,9 @@ function configureChildAsAutoLayout( } else if (!gc.visible) { yogaGC.setDisplay(Display.None) } else if (gc.layoutMode === 'GRID') { - configureChildAsGrid(yogaGC, gc, child, graph) + configureChildAsGrid(yogaGC, gc, child, graph, direction) } else if (gc.layoutMode !== 'NONE') { - configureChildAsAutoLayout(yogaGC, gc, child, graph) + configureChildAsAutoLayout(yogaGC, gc, child, graph, direction) } else { configureChildAsLeaf(yogaGC, gc, child) } diff --git a/packages/core/src/render/renderer.ts b/packages/core/src/render/renderer.ts index fb81cadb5..69f54f224 100644 --- a/packages/core/src/render/renderer.ts +++ b/packages/core/src/render/renderer.ts @@ -57,6 +57,17 @@ const TEXT_AUTO_RESIZE_MAP: Record = { height: 'HEIGHT' } +const DIRECTION_MAP: Record = { + auto: 'AUTO', + ltr: 'LTR', + rtl: 'RTL' +} + +function parseDirection(value: unknown): SceneNode['textDirection'] | undefined { + if (typeof value !== 'string') return undefined + return DIRECTION_MAP[value.toLowerCase()] ?? 'AUTO' +} + function parseStroke(value: string, width: number): Stroke { const color = parseColor(value) return { @@ -394,6 +405,8 @@ function applyLayoutOverrides( applyAutoLayoutSizing(o, props, w, h) } + o.layoutDirection = parseDirection(props.flow ?? (!isText ? props.dir : undefined)) ?? o.layoutDirection + if (props.gap !== undefined) o.itemSpacing = props.gap as number if (props.wrap) { @@ -482,6 +495,7 @@ function applyTextOverrides( parentLayout: SceneNode['layoutMode'] ): void { applyTextStyleOverrides(props, o) + o.textDirection = parseDirection(props.dir) ?? o.textDirection applyTextAutoResize(props, o, parentLayout) } diff --git a/packages/core/src/render/tree.ts b/packages/core/src/render/tree.ts index aaf8e3ef0..25c6dfa0c 100644 --- a/packages/core/src/render/tree.ts +++ b/packages/core/src/render/tree.ts @@ -83,6 +83,8 @@ export function node( export type StyleProps = { flex?: 'row' | 'col' | 'column' + flow?: 'auto' | 'ltr' | 'rtl' + dir?: 'auto' | 'ltr' | 'rtl' gap?: number wrap?: boolean rowGap?: number diff --git a/packages/core/src/renderer/renderer.ts b/packages/core/src/renderer/renderer.ts index bd390c3a8..8780b044d 100644 --- a/packages/core/src/renderer/renderer.ts +++ b/packages/core/src/renderer/renderer.ts @@ -429,7 +429,8 @@ export class SkiaRenderer { async loadFonts(): Promise { this.fontProvider = this.ck.TypefaceFontProvider.Make() - const { initFontService, loadFont, ensureCJKFallback } = await import('../fonts') + const { initFontService, loadFont, ensureArabicFallback, ensureCJKFallback } = + await import('../fonts') initFontService(this.ck, this.fontProvider) const fontData = await loadFont(DEFAULT_FONT_FAMILY, 'Regular') @@ -457,6 +458,9 @@ export class SkiaRenderer { void ensureCJKFallback().then((families) => { if (families.length > 0) this.invalidateAllPictures() }) + void ensureArabicFallback().then((families) => { + if (families.length > 0) this.invalidateAllPictures() + }) } /** diff --git a/packages/core/src/renderer/text.ts b/packages/core/src/renderer/text.ts index e7c0380c9..036e4970d 100644 --- a/packages/core/src/renderer/text.ts +++ b/packages/core/src/renderer/text.ts @@ -1,6 +1,7 @@ -import { DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY } from '../constants' -import { isFontLoaded, getCJKFallbackFamilies } from '../fonts' import { resolveRGBAForPreview } from '../color-management' +import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from '../constants' +import { resolveNodeTextDirection } from '../direction' +import { getArabicFallbackFamilies, getCJKFallbackFamilies, isFontLoaded } from '../fonts' import type { SceneNode } from '../scene-graph' import type { CanvasKit, FontWeight, Paragraph, TypefaceFontProvider } from 'canvaskit-wasm' @@ -79,16 +80,20 @@ function buildTruncateOpts( return opts } -function getTextAlign(ck: CanvasKit, align: string) { - switch (align) { +function getParagraphTextAlign( + ck: CanvasKit, + node: Pick +) { + const direction = resolveNodeTextDirection(node) + switch (node.textAlignHorizontal) { case 'CENTER': return ck.TextAlign.Center case 'RIGHT': - return ck.TextAlign.Right + return direction === 'RTL' ? ck.TextAlign.Left : ck.TextAlign.Right case 'JUSTIFIED': return ck.TextAlign.Justify default: - return ck.TextAlign.Left + return direction === 'RTL' ? ck.TextAlign.Right : ck.TextAlign.Left } } @@ -168,18 +173,22 @@ export function buildParagraph( const baseColor = color ?? ck.BLACK const baseFontSize = node.fontSize || DEFAULT_FONT_SIZE const cjkFallbacks = getCJKFallbackFamilies() + const arabicFallbacks = getArabicFallbackFamilies() + const textDirection = resolveNodeTextDirection(node) const truncateOpts = buildTruncateOpts(node, baseFontSize) const fontFamilies = (primary: string) => { const families = [primary] if (primary !== DEFAULT_FONT_FAMILY) families.push(DEFAULT_FONT_FAMILY) + families.push(...arabicFallbacks) families.push(...cjkFallbacks) - return families + return [...new Set(families)] } const paraStyle = new ck.ParagraphStyle({ - textAlign: getTextAlign(ck, node.textAlignHorizontal), + textAlign: getParagraphTextAlign(ck, node), + textDirection: textDirection === 'RTL' ? ck.TextDirection.RTL : ck.TextDirection.LTR, ...truncateOpts, textStyle: { color: baseColor, diff --git a/packages/core/src/rpc/commands.ts b/packages/core/src/rpc/commands.ts index 031686a4e..c29814ec7 100644 --- a/packages/core/src/rpc/commands.ts +++ b/packages/core/src/rpc/commands.ts @@ -276,9 +276,11 @@ export interface NodeResult { cornerRadius: number blendMode: string layoutMode: string + layoutDirection: string fontFamily: string fontSize: number fontWeight: number + textDirection: string text: string | null parent: { id: string; name: string; type: string } | null children: number @@ -316,9 +318,11 @@ export const nodeCommand: RpcCommand = cornerRadius: node.cornerRadius, blendMode: node.blendMode, layoutMode: node.layoutMode, + layoutDirection: node.layoutDirection, fontFamily: node.fontFamily, fontSize: node.fontSize, fontWeight: node.fontWeight, + textDirection: node.textDirection, text: (() => { if (!node.text.length) return null if (node.text.length > 200) return node.text.slice(0, 200) + '…' diff --git a/packages/core/src/scene-graph-instances.ts b/packages/core/src/scene-graph-instances.ts index bd78a9ea3..083d277b2 100644 --- a/packages/core/src/scene-graph-instances.ts +++ b/packages/core/src/scene-graph-instances.ts @@ -16,6 +16,7 @@ const INSTANCE_SYNC_PROPS: (keyof SceneNode)[] = [ 'bottomLeftRadius', 'independentCorners', 'layoutMode', + 'layoutDirection', 'layoutWrap', 'primaryAxisAlign', 'counterAxisAlign', @@ -126,7 +127,14 @@ function syncChildren( copyProp(instChild, compChild, key) } - for (const key of ['name', 'text', 'fontSize', 'fontWeight', 'fontFamily'] as const) { + for (const key of [ + 'name', + 'text', + 'fontSize', + 'fontWeight', + 'fontFamily', + 'textDirection' + ] as const) { const overrideKey = `${instChild.id}:${key}` if (overrideKey in overrides) continue copyProp(instChild, compChild, key) diff --git a/packages/core/src/scene-graph.ts b/packages/core/src/scene-graph.ts index e1aa242e8..f27430682 100644 --- a/packages/core/src/scene-graph.ts +++ b/packages/core/src/scene-graph.ts @@ -177,6 +177,8 @@ export type TextAutoResize = 'NONE' | 'HEIGHT' | 'WIDTH_AND_HEIGHT' | 'TRUNCATE' export type TextAlignVertical = 'TOP' | 'CENTER' | 'BOTTOM' export type TextCase = 'ORIGINAL' | 'UPPER' | 'LOWER' | 'TITLE' export type TextDecoration = 'NONE' | 'UNDERLINE' | 'STRIKETHROUGH' +export type TextDirection = 'AUTO' | 'LTR' | 'RTL' +export type LayoutDirection = 'AUTO' | 'LTR' | 'RTL' export interface CharacterStyleOverride { fontWeight?: number @@ -279,6 +281,7 @@ export interface SceneNode { fontWeight: number italic: boolean textAlignHorizontal: 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED' + textDirection: TextDirection textAlignVertical: TextAlignVertical textAutoResize: TextAutoResize textCase: TextCase @@ -292,6 +295,7 @@ export interface SceneNode { verticalConstraint: ConstraintType layoutMode: LayoutMode + layoutDirection: LayoutDirection layoutWrap: LayoutWrap primaryAxisAlign: LayoutAlign counterAxisAlign: LayoutCounterAlign @@ -435,9 +439,11 @@ function createDefaultNode(type: NodeType, overrides: Partial = {}): fontWeight: 400, italic: false, textAlignHorizontal: 'LEFT', + textDirection: 'AUTO', lineHeight: null, letterSpacing: 0, layoutMode: 'NONE', + layoutDirection: 'AUTO', layoutWrap: 'NO_WRAP', primaryAxisAlign: 'MIN', counterAxisAlign: 'MIN', @@ -801,6 +807,7 @@ export class SceneGraph { 'fontWeight', 'italic', 'textAlignHorizontal', + 'textDirection', 'textAlignVertical', 'lineHeight', 'letterSpacing', diff --git a/packages/core/src/text-editor.ts b/packages/core/src/text-editor.ts index 8dc8636f1..9458d8e6f 100644 --- a/packages/core/src/text-editor.ts +++ b/packages/core/src/text-editor.ts @@ -1,4 +1,5 @@ import type { SkiaRenderer } from './renderer' +import { resolveNodeTextDirection } from './direction' import type { SceneNode } from './scene-graph' import type { Rect } from './types' import type { CanvasKit, Paragraph } from 'canvaskit-wasm' @@ -15,6 +16,7 @@ export interface TextEditorState { cursor: number selectionAnchor: number | null paragraph: Paragraph | null + textDirection: 'LTR' | 'RTL' } export class TextEditor { @@ -57,7 +59,8 @@ export class TextEditor { text: node.text, cursor: node.text.length, selectionAnchor: null, - paragraph: null + paragraph: null, + textDirection: resolveNodeTextDirection(node) } this.rebuildParagraph(node) } @@ -74,6 +77,7 @@ export class TextEditor { const s = this._state if (!s || !this.renderer) return s.paragraph?.delete() + s.textDirection = resolveNodeTextDirection(node) s.paragraph = this.renderer.buildParagraph(node) } @@ -206,7 +210,9 @@ export class TextEditor { return } this.prepareMove(extend) - if (s.cursor > 0) s.cursor-- + if (s.textDirection === 'RTL') { + if (s.cursor < s.text.length) s.cursor++ + } else if (s.cursor > 0) s.cursor-- } moveRight(extend = false): void { @@ -219,7 +225,9 @@ export class TextEditor { return } this.prepareMove(extend) - if (s.cursor < s.text.length) s.cursor++ + if (s.textDirection === 'RTL') { + if (s.cursor > 0) s.cursor-- + } else if (s.cursor < s.text.length) s.cursor++ } moveUp(extend = false): void { @@ -249,7 +257,9 @@ export class TextEditor { const lineNum = s.paragraph.getLineNumberAt(s.cursor) if (lineNum < 0) return const metrics = s.paragraph.getLineMetricsAt(lineNum) - if (metrics) s.cursor = metrics.startIndex + if (!metrics) return + s.cursor = + s.textDirection === 'RTL' ? metrics.endExcludingWhitespaces : metrics.startIndex } moveToLineEnd(extend = false): void { @@ -259,7 +269,9 @@ export class TextEditor { const lineNum = s.paragraph.getLineNumberAt(s.cursor) if (lineNum < 0) return const metrics = s.paragraph.getLineMetricsAt(lineNum) - if (metrics) s.cursor = metrics.endExcludingWhitespaces + if (!metrics) return + s.cursor = + s.textDirection === 'RTL' ? metrics.startIndex : metrics.endExcludingWhitespaces } moveWordLeft(extend = false): void { @@ -301,10 +313,11 @@ export class TextEditor { if (cursor === 0) { lo = 0 hi = 1 + useRight = s.textDirection === 'RTL' } else if (cursor >= text.length) { lo = text.length - 1 hi = text.length - useRight = true + useRight = s.textDirection !== 'RTL' } else { lo = cursor hi = cursor + 1 diff --git a/packages/core/src/tools/modify.ts b/packages/core/src/tools/modify.ts index d8ac8374e..f0522a806 100644 --- a/packages/core/src/tools/modify.ts +++ b/packages/core/src/tools/modify.ts @@ -3,7 +3,7 @@ import { parseColor } from '../color' import { DEFAULT_SHADOW_COLOR } from '../constants' import { defineTool } from './schema' -import type { CharacterStyleOverride, Effect, StyleRun } from '../scene-graph' +import type { CharacterStyleOverride, Effect, SceneNode, StyleRun } from '../scene-graph' import type { Matrix } from '../types' export const setFill = defineTool({ @@ -149,6 +149,16 @@ export const updateNode = defineTool({ corner_radius: { type: 'number', description: 'Corner radius', min: 0 }, visible: { type: 'boolean', description: 'Visibility' }, text: { type: 'string', description: 'Text content (TEXT nodes)' }, + text_direction: { + type: 'string', + description: 'Text direction for TEXT nodes', + enum: ['AUTO', 'LTR', 'RTL'] + }, + flow_direction: { + type: 'string', + description: 'Auto-layout flow direction for FRAME nodes', + enum: ['AUTO', 'LTR', 'RTL'] + }, font_size: { type: 'number', description: 'Font size', min: 1 }, font_weight: { type: 'number', description: 'Font weight (100-900)' }, name: { type: 'string', description: 'Layer name' } @@ -189,6 +199,18 @@ export const updateNode = defineTool({ figma.graph.updateNode(node.id, { text: args.text }) updated.push('text') } + if (args.text_direction !== undefined) { + figma.graph.updateNode(node.id, { + textDirection: args.text_direction as SceneNode['textDirection'] + }) + updated.push('textDirection') + } + if (args.flow_direction !== undefined) { + figma.graph.updateNode(node.id, { + layoutDirection: args.flow_direction as SceneNode['layoutDirection'] + }) + updated.push('layoutDirection') + } if (args.font_size !== undefined) { figma.graph.updateNode(node.id, { fontSize: args.font_size }) updated.push('fontSize') @@ -233,6 +255,11 @@ export const setLayout = defineTool({ type: 'string', description: 'Cross axis alignment (only changes if provided)', enum: ['MIN', 'CENTER', 'MAX', 'STRETCH'] + }, + flow_direction: { + type: 'string', + description: 'Child flow direction for auto-layout. AUTO inherits from parent.', + enum: ['AUTO', 'LTR', 'RTL'] } }, execute: (figma, args) => { @@ -255,6 +282,8 @@ export const setLayout = defineTool({ if (args.spacing !== undefined) node.itemSpacing = args.spacing if (args.align !== undefined) node.primaryAxisAlignItems = args.align if (args.counter_align !== undefined) node.counterAxisAlignItems = args.counter_align + if (args.flow_direction !== undefined) + node.layoutDirection = args.flow_direction as SceneNode['layoutDirection'] if (args.padding !== undefined) { node.paddingTop = args.padding @@ -608,6 +637,11 @@ export const setTextProperties = defineTool({ description: 'Text auto-resize mode', enum: ['NONE', 'WIDTH_AND_HEIGHT', 'HEIGHT', 'TRUNCATE'] }, + direction: { + type: 'string', + description: 'Text direction', + enum: ['AUTO', 'LTR', 'RTL'] + }, text_decoration: { type: 'string', description: 'Text decoration', @@ -631,6 +665,10 @@ export const setTextProperties = defineTool({ node.textAutoResize = args.auto_resize updated.push('textAutoResize') } + if (args.direction !== undefined) { + node.textDirection = args.direction as SceneNode['textDirection'] + updated.push('textDirection') + } if (args.text_decoration !== undefined) { node.textDecoration = args.text_decoration updated.push('textDecoration') diff --git a/packages/core/src/tools/prompts/codegen-prompt.ts b/packages/core/src/tools/prompts/codegen-prompt.ts index a5301a215..baddc8ef0 100644 --- a/packages/core/src/tools/prompts/codegen-prompt.ts +++ b/packages/core/src/tools/prompts/codegen-prompt.ts @@ -118,6 +118,7 @@ export_svg ids=[] → extract vector assets - For Tailwind: use utility classes directly for spacing, font sizes, weights, radius — do NOT wrap them in \`var()\` indirection - Match measurements exactly: font sizes, spacing, border radii, colors - Use auto-layout data to determine flex direction, gap, padding, alignment +- Preserve text direction and container flow direction separately when the design uses RTL. - Absolute positioning only when \`layoutPositioning\` is \`ABSOLUTE\` or layout mode is \`NONE\` - If a node has \`clipsContent: true\`, use \`overflow: hidden\` - Text nodes: preserve font family, size, weight, line height, letter spacing, alignment diff --git a/packages/core/src/xpath.ts b/packages/core/src/xpath.ts index 609852037..2cd13f141 100644 --- a/packages/core/src/xpath.ts +++ b/packages/core/src/xpath.ts @@ -20,7 +20,9 @@ const QUERYABLE_ATTRS = [ 'fontSize', 'fontFamily', 'fontWeight', + 'textDirection', 'layoutMode', + 'layoutDirection', 'itemSpacing', 'paddingTop', 'paddingBottom', diff --git a/packages/vue/src/LayoutControls/LayoutControlsRoot.vue b/packages/vue/src/LayoutControls/LayoutControlsRoot.vue index 9eb97a0b9..bc8e5cc68 100644 --- a/packages/vue/src/LayoutControls/LayoutControlsRoot.vue +++ b/packages/vue/src/LayoutControls/LayoutControlsRoot.vue @@ -8,6 +8,7 @@ const ctx = useLayout() ['panels']['value']) { + return [ + { value: 'FR' as const, label: panels.sizingFillFr }, + { value: 'FIXED' as const, label: panels.sizingFixedPx }, + { value: 'AUTO' as const, label: panels.auto } + ] +} /** * Returns layout-related state and actions for the current selection. @@ -52,8 +48,10 @@ const TRACK_SIZING_OPTIONS: { value: GridTrackSizing; label: string }[] = [ */ export function useLayout() { const editor = useEditor() + const { panels } = useI18n() const node = useSceneComputed(() => editor.getSelectedNode() ?? null) + const layoutDirection = computed(() => node.value?.layoutDirection ?? 'AUTO') const isInAutoLayout = computed(() => { const n = node.value @@ -85,16 +83,20 @@ export function useLayout() { }) const widthSizingOptions = computed(() => { - const options: { value: LayoutSizing; label: string }[] = [{ value: 'FIXED', label: 'Fixed' }] - if (isFlex.value) options.push({ value: 'HUG', label: 'Hug' }) - if (isInAutoLayout.value || isFlex.value) options.push({ value: 'FILL', label: 'Fill' }) + const options: { value: LayoutSizing; label: string }[] = [ + { 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 }) return options }) const heightSizingOptions = computed(() => { - const options: { value: LayoutSizing; label: string }[] = [{ value: 'FIXED', label: 'Fixed' }] - if (isFlex.value) options.push({ value: 'HUG', label: 'Hug' }) - if (isInAutoLayout.value || isFlex.value) options.push({ value: 'FILL', label: 'Fill' }) + const options: { value: LayoutSizing; label: string }[] = [ + { 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 }) return options }) @@ -180,6 +182,11 @@ export function useLayout() { ) } + function setLayoutDirection(direction: SceneNode['layoutDirection']) { + if (!node.value) return + editor.updateNodeWithUndo(node.value.id, { layoutDirection: direction }, 'Change layout direction') + } + function updateGridTrack( prop: 'gridTemplateColumns' | 'gridTemplateRows', index: number, @@ -222,6 +229,7 @@ export function useLayout() { return { editor, node, + layoutDirection, isInAutoLayout, isGrid, isFlex, @@ -232,7 +240,7 @@ export function useLayout() { alignGrid, showIndividualPadding, hasUniformPadding, - trackSizingOptions: TRACK_SIZING_OPTIONS, + trackSizingOptions: createTrackSizingOptions(panels.value), updateProp, commitProp, setWidthSizing, @@ -240,6 +248,7 @@ export function useLayout() { setUniformPadding, commitUniformPadding, setAlignment, + setLayoutDirection, updateGridTrack, addTrack, removeTrack, diff --git a/packages/vue/src/controls/useTypography.ts b/packages/vue/src/controls/useTypography.ts index 392460968..0c0833f57 100644 --- a/packages/vue/src/controls/useTypography.ts +++ b/packages/vue/src/controls/useTypography.ts @@ -8,6 +8,7 @@ import { useNodeFontStatus } from '@open-pencil/vue/shared/useFontStatus' import type { SceneNode, TextDecoration } from '@open-pencil/core' type TextAlign = 'LEFT' | 'CENTER' | 'RIGHT' +type TextDirection = SceneNode['textDirection'] const WEIGHTS = Object.entries(FONT_WEIGHT_NAMES).map(([value, label]) => ({ value: Number(value), @@ -81,6 +82,11 @@ export function useTypography(options: UseTypographyOptions = {}) { ) } + function setDirection(direction: TextDirection) { + if (!node.value) return + editor.updateNodeWithUndo(node.value.id, { textDirection: direction }, 'Change text direction') + } + function toggleBold() { if (!node.value) return setWeight(node.value.fontWeight >= 700 ? 400 : 700) @@ -142,6 +148,7 @@ export function useTypography(options: UseTypographyOptions = {}) { setFamily, setWeight, setAlign, + setDirection, toggleBold, toggleItalic, toggleDecoration, diff --git a/packages/vue/src/i18n/messages.ts b/packages/vue/src/i18n/messages.ts index 9d152bc56..c95acc1b6 100644 --- a/packages/vue/src/i18n/messages.ts +++ b/packages/vue/src/i18n/messages.ts @@ -159,7 +159,17 @@ export const panelMessages = i18n('panels', { colorHintOkhcl: 'H hue · C chroma · L lightness · A alpha', colorPreviewClipped: params('Clipped to {space} preview gamut'), rulers: 'Rulers', - multiplayerCursors: 'Multiplayer cursors' + multiplayerCursors: 'Multiplayer cursors', + direction: 'Direction', + flow: 'Flow', + auto: 'Auto', + columns: 'Columns', + rows: 'Rows', + sizingFixed: 'Fixed', + sizingHug: 'Hug', + sizingFill: 'Fill', + sizingFillFr: 'Fill (fr)', + sizingFixedPx: 'Fixed (px)' }) export const pageMessages = i18n('pages', { diff --git a/packages/vue/src/internal/useSceneComputed.ts b/packages/vue/src/internal/useSceneComputed.ts index 9a39ba552..36c460e8b 100644 --- a/packages/vue/src/internal/useSceneComputed.ts +++ b/packages/vue/src/internal/useSceneComputed.ts @@ -1,5 +1,7 @@ import { computed, type ComputedRef } from 'vue' +import { useEditor } from '@open-pencil/vue/context/editorContext' + /** * Convenience wrapper for scene-derived computed state. * @@ -7,5 +9,11 @@ import { computed, type ComputedRef } from 'vue' * state in higher-level composables. */ export function useSceneComputed(fn: () => T): ComputedRef { - return computed(fn) + const editor = useEditor() + return computed(() => { + void editor.state.sceneVersion + void editor.state.selectedIds + void editor.state.currentPageId + return fn() + }) } diff --git a/packages/vue/src/locales/de.json b/packages/vue/src/locales/de.json index d1d293b6b..902bd242c 100644 --- a/packages/vue/src/locales/de.json +++ b/packages/vue/src/locales/de.json @@ -81,7 +81,7 @@ "autoLayout": "Auto-Layout", "alignment": "Ausrichtung", "appearance": "Darstellung", - "fill": "Füllung", + "sizingFill": "Füllung", "stroke": "Kontur", "effects": "Effekte", "export": "Export", @@ -133,7 +133,17 @@ "mixedEffectsHelp": "Klicke auf +, um gemischte Effekte zu ersetzen", "strokeSides": "Konturseiten", "rulers": "Lineale", - "multiplayerCursors": "Cursor anderer Teilnehmer" + "multiplayerCursors": "Cursor anderer Teilnehmer", + "direction": "Richtung", + "flow": "Fluss", + "auto": "Automatisch", + "columns": "Spalten", + "rows": "Zeilen", + "sizingFixed": "Fest", + "sizingHug": "An Inhalt anpassen", + "sizingFill": "Füllen", + "sizingFillFr": "Füllen (fr)", + "sizingFixedPx": "Fest (px)" }, "pages": { "newPage": "Neue Seite", diff --git a/packages/vue/src/locales/es.json b/packages/vue/src/locales/es.json index 2d9bfc969..6fc64b6f1 100644 --- a/packages/vue/src/locales/es.json +++ b/packages/vue/src/locales/es.json @@ -81,7 +81,7 @@ "autoLayout": "Auto-layout", "alignment": "Alineación", "appearance": "Apariencia", - "fill": "Relleno", + "sizingFill": "Relleno", "stroke": "Trazo", "effects": "Efectos", "export": "Exportar", @@ -133,7 +133,17 @@ "mixedEffectsHelp": "Haz clic en + para reemplazar efectos mixtos", "strokeSides": "Lados del trazo", "rulers": "Reglas", - "multiplayerCursors": "Cursores de otros participantes" + "multiplayerCursors": "Cursores de otros participantes", + "direction": "Dirección", + "flow": "Flujo", + "auto": "Auto", + "columns": "Columnas", + "rows": "Filas", + "sizingFixed": "Fijo", + "sizingHug": "Ajustar al contenido", + "sizingFill": "Rellenar", + "sizingFillFr": "Rellenar (fr)", + "sizingFixedPx": "Fijo (px)" }, "pages": { "newPage": "Nueva página", diff --git a/packages/vue/src/locales/fr.json b/packages/vue/src/locales/fr.json index ae7318454..b8306f238 100644 --- a/packages/vue/src/locales/fr.json +++ b/packages/vue/src/locales/fr.json @@ -81,7 +81,7 @@ "autoLayout": "Auto-layout", "alignment": "Alignement", "appearance": "Apparence", - "fill": "Remplissage", + "sizingFill": "Remplissage", "stroke": "Contour", "effects": "Effets", "export": "Export", @@ -133,7 +133,17 @@ "mixedEffectsHelp": "Cliquez sur + pour remplacer les effets mixtes", "strokeSides": "Côtés du contour", "rulers": "Règles", - "multiplayerCursors": "Curseurs des participants" + "multiplayerCursors": "Curseurs des participants", + "direction": "Direction", + "flow": "Flux", + "auto": "Auto", + "columns": "Colonnes", + "rows": "Lignes", + "sizingFixed": "Fixe", + "sizingHug": "Ajuster au contenu", + "sizingFill": "Remplir", + "sizingFillFr": "Remplir (fr)", + "sizingFixedPx": "Fixe (px)" }, "pages": { "newPage": "Nouvelle page", diff --git a/packages/vue/src/locales/it.json b/packages/vue/src/locales/it.json index 03ce5abd9..2ab7c3767 100644 --- a/packages/vue/src/locales/it.json +++ b/packages/vue/src/locales/it.json @@ -81,7 +81,7 @@ "autoLayout": "Auto-layout", "alignment": "Allineamento", "appearance": "Aspetto", - "fill": "Riempimento", + "sizingFill": "Riempimento", "stroke": "Contorno", "effects": "Effetti", "export": "Esporta", @@ -133,7 +133,17 @@ "mixedEffectsHelp": "Fai clic su + per sostituire gli effetti misti", "strokeSides": "Lati del contorno", "rulers": "Righelli", - "multiplayerCursors": "Cursori degli altri partecipanti" + "multiplayerCursors": "Cursori degli altri partecipanti", + "direction": "Direzione", + "flow": "Flusso", + "auto": "Auto", + "columns": "Colonne", + "rows": "Righe", + "sizingFixed": "Fisso", + "sizingHug": "Adatta al contenuto", + "sizingFill": "Riempi", + "sizingFillFr": "Riempi (fr)", + "sizingFixedPx": "Fisso (px)" }, "pages": { "newPage": "Nuova pagina", diff --git a/packages/vue/src/locales/pl.json b/packages/vue/src/locales/pl.json index dd81dc3c1..df0df6f8f 100644 --- a/packages/vue/src/locales/pl.json +++ b/packages/vue/src/locales/pl.json @@ -81,7 +81,7 @@ "autoLayout": "Auto-layout", "alignment": "Wyrównanie", "appearance": "Wygląd", - "fill": "Wypełnienie", + "sizingFill": "Wypełnienie", "stroke": "Obrys", "effects": "Efekty", "export": "Eksport", @@ -133,7 +133,17 @@ "mixedEffectsHelp": "Kliknij +, aby zastąpić mieszane efekty", "strokeSides": "Boki obrysu", "rulers": "Linijki", - "multiplayerCursors": "Kursory innych uczestników" + "multiplayerCursors": "Kursory innych uczestników", + "direction": "Kierunek", + "flow": "Przepływ", + "auto": "Auto", + "columns": "Kolumny", + "rows": "Wiersze", + "sizingFixed": "Stały", + "sizingHug": "Dopasuj do zawartości", + "sizingFill": "Wypełnij", + "sizingFillFr": "Wypełnij (fr)", + "sizingFixedPx": "Stały (px)" }, "pages": { "newPage": "Nowa strona", diff --git a/packages/vue/src/locales/ru.json b/packages/vue/src/locales/ru.json index 855bb7cee..9bb37ad56 100644 --- a/packages/vue/src/locales/ru.json +++ b/packages/vue/src/locales/ru.json @@ -81,7 +81,7 @@ "autoLayout": "Автораскладка", "alignment": "Выравнивание", "appearance": "Внешний вид", - "fill": "Заливка", + "sizingFill": "Заливка", "stroke": "Обводка", "effects": "Эффекты", "export": "Экспорт", @@ -146,7 +146,17 @@ "colorHintOkhcl": "H тон · C хрома · L светлота · A альфа", "colorPreviewClipped": "Обрезано до гаммы предпросмотра {space}", "rulers": "Линейки", - "multiplayerCursors": "Курсоры участников" + "multiplayerCursors": "Курсоры участников", + "direction": "Направление", + "flow": "Поток", + "auto": "Авто", + "columns": "Столбцы", + "rows": "Строки", + "sizingFixed": "Фиксированный", + "sizingHug": "По содержимому", + "sizingFill": "Заполнить", + "sizingFillFr": "Заполнить (fr)", + "sizingFixedPx": "Фиксированный (px)" }, "pages": { "newPage": "Новая страница", diff --git a/packages/vue/src/shared/input/auto-layout.ts b/packages/vue/src/shared/input/auto-layout.ts index ecbde9a50..e98e02744 100644 --- a/packages/vue/src/shared/input/auto-layout.ts +++ b/packages/vue/src/shared/input/auto-layout.ts @@ -1,7 +1,19 @@ +import { resolveNodeLayoutDirection } from '@open-pencil/core' + import type { DragMove } from './types' import type { SceneNode, Vector } from '@open-pencil/core' import type { Editor } from '@open-pencil/core/editor' +function resolveLayoutDirection(parent: SceneNode, editor: Editor): 'LTR' | 'RTL' { + const ancestor = parent.parentId ? editor.graph.getNode(parent.parentId) : null + const inheritedDirection = ancestor ? resolveLayoutDirection(ancestor, editor) : 'LTR' + return resolveNodeLayoutDirection(parent, inheritedDirection) +} + +function isRtlRow(parent: SceneNode, isRow: boolean, editor: Editor) { + return isRow && resolveLayoutDirection(parent, editor) === 'RTL' +} + export function computeIndicatorPosition( children: SceneNode[], insertIndex: number, @@ -10,27 +22,45 @@ export function computeIndicatorPosition( isRow: boolean, editor: Editor ): number { + const rtlRow = isRtlRow(parent, isRow, editor) + if (children.length === 0) { - return isRow ? parentAbs.x + parent.paddingLeft : parentAbs.y + parent.paddingTop + if (isRow) { + return rtlRow + ? parentAbs.x + parent.width - parent.paddingRight + : parentAbs.x + parent.paddingLeft + } + return parentAbs.y + parent.paddingTop } if (insertIndex === 0) { const firstAbs = editor.graph.getAbsolutePosition(children[0].id) - return (isRow ? firstAbs.x : firstAbs.y) - parent.itemSpacing / 2 + if (isRow) { + return rtlRow + ? firstAbs.x + children[0].width + parent.itemSpacing / 2 + : firstAbs.x - parent.itemSpacing / 2 + } + return firstAbs.y - parent.itemSpacing / 2 } if (insertIndex >= children.length) { const last = children[children.length - 1] const lastAbs = editor.graph.getAbsolutePosition(last.id) - return isRow - ? lastAbs.x + last.width + parent.itemSpacing / 2 - : lastAbs.y + last.height + parent.itemSpacing / 2 + if (isRow) { + return rtlRow + ? lastAbs.x - parent.itemSpacing / 2 + : lastAbs.x + last.width + parent.itemSpacing / 2 + } + return lastAbs.y + last.height + parent.itemSpacing / 2 } const prev = children[insertIndex - 1] const next = children[insertIndex] const prevAbs = editor.graph.getAbsolutePosition(prev.id) const nextAbs = editor.graph.getAbsolutePosition(next.id) - return isRow - ? (prevAbs.x + prev.width + nextAbs.x) / 2 - : (prevAbs.y + prev.height + nextAbs.y) / 2 + if (isRow) { + return rtlRow + ? (prevAbs.x + nextAbs.x + next.width) / 2 + : (prevAbs.x + prev.width + nextAbs.x) / 2 + } + return (prevAbs.y + prev.height + nextAbs.y) / 2 } export function filteredToRealIndex(parentId: string, insertIndex: number, editor: Editor): number { @@ -62,12 +92,15 @@ export function computeAutoLayoutIndicatorForFrame( const parentAbs = editor.graph.getAbsolutePosition(parent.id) const isRow = parent.layoutMode === 'HORIZONTAL' + const rtlRow = isRtlRow(parent, isRow, editor) let insertIndex = children.length for (let i = 0; i < children.length; i++) { const childAbs = editor.graph.getAbsolutePosition(children[i].id) const mid = isRow ? childAbs.x + children[i].width / 2 : childAbs.y + children[i].height / 2 - if ((isRow ? cx : cy) < mid) { + const cursor = isRow ? cx : cy + const shouldInsertBefore = rtlRow ? cursor > mid : cursor < mid + if (shouldInsertBefore) { insertIndex = i break } diff --git a/packages/vue/src/shared/input/move.ts b/packages/vue/src/shared/input/move.ts index 9896d0b06..12d8bf3ad 100644 --- a/packages/vue/src/shared/input/move.ts +++ b/packages/vue/src/shared/input/move.ts @@ -6,6 +6,8 @@ import type { DragMove, DragState } from './types' import type { SceneNode } from '@open-pencil/core' import type { Editor } from '@open-pencil/core/editor' +const AUTO_LAYOUT_REORDER_CLICK_SLOP = 3 + export function detectAutoLayoutParent(editor: Editor): string | undefined { if (editor.state.selectedIds.size !== 1) return undefined const selectedId = [...editor.state.selectedIds][0] @@ -46,6 +48,8 @@ export function duplicateAndDrag( type: 'move', startX: cx, startY: cy, + currentX: cx, + currentY: cy, originals: newOriginals, duplicated: true } @@ -120,6 +124,9 @@ function applyMoveSnap( } export function handleMoveMove(d: DragMove, cx: number, cy: number, editor: Editor) { + d.currentX = cx + d.currentY = cy + let dx = cx - d.startX let dy = cy - d.startY @@ -162,6 +169,10 @@ export function handleMoveMove(d: DragMove, cx: number, cy: number, editor: Edit editor.setDropTarget(dropTarget?.id ?? null) } +function getMoveDistance(d: DragMove) { + return Math.hypot(d.currentX - d.startX, d.currentY - d.startY) +} + function reparentOutsideNodes(editor: Editor) { for (const id of editor.state.selectedIds) { const node = editor.graph.getNode(id) @@ -183,6 +194,10 @@ export function handleMoveUp(d: DragMove, editor: Editor) { editor.setSnapGuides([]) if (indicator) { + if (getMoveDistance(d) < AUTO_LAYOUT_REORDER_CLICK_SLOP) { + editor.setDropTarget(null) + return + } for (const id of editor.state.selectedIds) { editor.reorderInAutoLayout(id, indicator.parentId, indicator.index) } diff --git a/packages/vue/src/shared/input/select.ts b/packages/vue/src/shared/input/select.ts index c3312ad77..f2482cc98 100644 --- a/packages/vue/src/shared/input/select.ts +++ b/packages/vue/src/shared/input/select.ts @@ -410,6 +410,8 @@ export function handleSelectDown( type: 'move', startX: cx, startY: cy, + currentX: cx, + currentY: cy, originals, autoLayoutParentId: detectAutoLayoutParent(editor) }) diff --git a/packages/vue/src/shared/input/types.ts b/packages/vue/src/shared/input/types.ts index bbf6d2955..0aa953e91 100644 --- a/packages/vue/src/shared/input/types.ts +++ b/packages/vue/src/shared/input/types.ts @@ -17,6 +17,8 @@ export interface DragMove { type: 'move' startX: number startY: number + currentX: number + currentY: number originals: Map duplicated?: boolean autoLayoutParentId?: string diff --git a/public/NotoNaskhArabic-Regular.ttf b/public/NotoNaskhArabic-Regular.ttf new file mode 100644 index 000000000..1c522ff28 Binary files /dev/null and b/public/NotoNaskhArabic-Regular.ttf differ diff --git a/src/ai/system-prompt.md b/src/ai/system-prompt.md index e17080445..5870ef673 100644 --- a/src/ai/system-prompt.md +++ b/src/ai/system-prompt.md @@ -18,11 +18,11 @@ These are ALL available props. Nothing else exists. **Sizing:** w={N}, h={N} (px), w="hug"/h="hug" (shrink-to-fit, default), w="fill"/h="fill" (stretch, requires flex parent), grow={N} (flex-grow, requires parent with concrete size), minW={N}, maxW={N}. -**Layout:** flex="row"|"col" enables auto-layout. gap={N}, wrap, rowGap={N}. justify="start"|"end"|"center"|"between" ⚠ NO "evenly" — not supported. items="start"|"end"|"center"|"stretch". Padding: p={N}, px={N}, py={N}, pt/pr/pb/pl={N}. Grid: grid, columns="1fr 1fr", rows="1fr", columnGap={N}, rowGap={N}, colStart={N}, rowStart={N}, colSpan={N}, rowSpan={N}. ⚠ With `wrap`, always set `rowGap={N}`. +**Layout:** flex="row"|"col" enables auto-layout. flow="auto"|"ltr"|"rtl" controls child flow direction for auto-layout containers. gap={N}, wrap, rowGap={N}. justify="start"|"end"|"center"|"between" ⚠ NO "evenly" — not supported. items="start"|"end"|"center"|"stretch". Padding: p={N}, px={N}, py={N}, pt/pr/pb/pl={N}. Grid: grid, columns="1fr 1fr", rows="1fr", columnGap={N}, rowGap={N}, colStart={N}, rowStart={N}, colSpan={N}, rowSpan={N}. ⚠ With `wrap`, always set `rowGap={N}`. **Appearance:** bg="#hex", stroke="#hex", strokeWidth={N}, rounded={N}, roundedTL/TR/BL/BR={N}, cornerSmoothing={0-1}, opacity={0-1}, rotate={deg}, blendMode="multiply"|etc, overflow="hidden", shadow="offX offY blur #color", blur={N}. -**Text (only on ``):** size={N}, weight="bold"|"medium"|{N}, color="#hex", font="Family", textAlign="left"|"center"|"right"|"justified", lineHeight={N} (px), letterSpacing={N} (px), textDecoration="underline"|"strikethrough", textCase="upper"|"lower"|"title", maxLines={N}, truncate. ⚠ Text without `color` is invisible. +**Text (only on ``):** size={N}, weight="bold"|"medium"|{N}, color="#hex", font="Family", dir="auto"|"ltr"|"rtl", textAlign="left"|"center"|"right"|"justified", lineHeight={N} (px), letterSpacing={N} (px), textDecoration="underline"|"strikethrough", textCase="upper"|"lower"|"title", maxLines={N}, truncate. ⚠ Text without `color` is invisible. **Icon:** `` — fetches and renders vector icon inline. No need for separate search/fetch/insert calls. Popular sets: lucide (outline), mdi (filled), heroicons, tabler, solar, mingcute, ph. ⚠ Always set `color` — default is black. @@ -38,6 +38,8 @@ These are ALL available props. Nothing else exists. justify/items require flex. The value is "between", not "space-between". +Use `dir="rtl"` on Arabic/Hebrew text when direction should be explicit. Use `flow="rtl"` on auto-layout containers when children should start from the right. `flow="auto"` inherits from the parent container. + A hug parent shrinks to fit children. A fill child stretches to parent. Can't be circular — at least one child needs concrete size. Nested flex containers need w="fill" at EVERY level to stretch. `grow={1}` inside HUG parent = zero width. diff --git a/src/components/properties/LayoutSection.vue b/src/components/properties/LayoutSection.vue index c9b16ee94..3a38b84f8 100644 --- a/src/components/properties/LayoutSection.vue +++ b/src/components/properties/LayoutSection.vue @@ -109,6 +109,19 @@ const { panels } = useI18n() +
+ + +
+