Add Arabic and RTL support

# Conflicts:
#	packages/core/src/renderer/text.ts
This commit is contained in:
Danila Poyarkov 2026-03-29 21:06:29 +03:00
commit fd0124b6f8
50 changed files with 968 additions and 95 deletions

View file

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

View file

@ -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<TextDirection, 'AUTO'> {
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<TextDirection, 'AUTO'> {
return direction === 'AUTO' ? detectTextDirection(text) : direction
}
export function resolveNodeTextDirection(node: Pick<SceneNode, 'textDirection' | 'text'>): 'LTR' | 'RTL' {
return resolveTextDirection(node.textDirection, node.text)
}
export function resolveNodeLayoutDirection(
node: { layoutDirection?: LayoutDirection },
inheritedDirection: Exclude<LayoutDirection, 'AUTO'> = 'LTR'
): Exclude<LayoutDirection, 'AUTO'> {
return !node.layoutDirection || node.layoutDirection === 'AUTO'
? inheritedDirection
: node.layoutDirection
}
export function isLogicalTextAlignStart(
node: Pick<SceneNode, 'textAlignHorizontal' | 'textDirection' | 'text'>
): boolean {
const direction = resolveNodeTextDirection(node)
return (
(direction === 'LTR' && node.textAlignHorizontal === 'LEFT') ||
(direction === 'RTL' && node.textAlignHorizontal === 'RIGHT')
)
}
export function isLogicalTextAlignEnd(
node: Pick<SceneNode, 'textAlignHorizontal' | 'textDirection' | 'text'>
): boolean {
const direction = resolveNodeTextDirection(node)
return (
(direction === 'LTR' && node.textAlignHorizontal === 'RIGHT') ||
(direction === 'RTL' && node.textAlignHorizontal === 'LEFT')
)
}

View file

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

View file

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

View file

@ -58,7 +58,8 @@ export async function listFamilies(): Promise<string[]> {
}
const BUNDLED_FONTS: Record<string, string> = {
'Inter|Regular': '/Inter-Regular.ttf'
'Inter|Regular': '/Inter-Regular.ttf',
'Noto Naskh Arabic|Regular': '/NotoNaskhArabic-Regular.ttf'
}
const googleFontsCache = new Map<string, Record<string, string>>()
@ -196,7 +197,7 @@ export async function loadFont(family: string, style = 'Regular'): Promise<Array
if (bundledUrl) {
try {
const buffer = await fetchBundledFont(bundledUrl)
if (buffer) return registerAndCache(family, style, buffer)
if (buffer && !isVariableFont(buffer)) return registerAndCache(family, style, buffer)
} catch (e) {
console.warn(`Bundled font load failed for "${family}" ${style}:`, e)
}
@ -303,6 +304,8 @@ export function collectFontKeys(graph: SceneGraph, nodeIds: string[]): Array<[st
const cjkFallbackFamilies: string[] = []
let cjkFallbackPromise: Promise<string[]> | null = null
const arabicFallbackFamilies: string[] = []
let arabicFallbackPromise: Promise<string[]> | 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<string[]> {
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<number, string> = {
100: 'Thin',
200: 'Extra Light',

View file

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

View file

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

View file

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

View file

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

View file

@ -12,6 +12,17 @@ import type { Color, GUID, Matrix } from '../types'
import type { NodeChange, Paint, VariableConsumptionEntry } from './codec'
const fontDigestCache = new Map<string, Uint8Array>()
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<Uint8Array> {
if (typeof crypto !== 'undefined') {
@ -319,6 +330,7 @@ function serializeTextProps(
graph: SceneGraph,
fontDigestMap?: Map<string, Uint8Array>
): 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

View file

@ -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<SceneNode, 'layoutDirection' | 'parentId'>
): '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<string>()
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<SceneNode['layoutDirection'], 'AUTO'>
): 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<SceneNode['layoutDirection'], 'AUTO'>
): 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)
}

View file

@ -57,6 +57,17 @@ const TEXT_AUTO_RESIZE_MAP: Record<string, SceneNode['textAutoResize']> = {
height: 'HEIGHT'
}
const DIRECTION_MAP: Record<string, SceneNode['textDirection']> = {
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)
}

View file

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

View file

@ -429,7 +429,8 @@ export class SkiaRenderer {
async loadFonts(): Promise<void> {
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()
})
}
/**

View file

@ -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<SceneNode, 'textAlignHorizontal' | 'textDirection' | 'text'>
) {
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,

View file

@ -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<NodeArgs, NodeResult | { error: string }> =
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) + '…'

View file

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

View file

@ -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<SceneNode> = {}):
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',

View file

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

View file

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

View file

@ -118,6 +118,7 @@ export_svg ids=[<icon_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

View file

@ -20,7 +20,9 @@ const QUERYABLE_ATTRS = [
'fontSize',
'fontFamily',
'fontWeight',
'textDirection',
'layoutMode',
'layoutDirection',
'itemSpacing',
'paddingTop',
'paddingBottom',

View file

@ -8,6 +8,7 @@ const ctx = useLayout()
<slot
:editor="ctx.editor"
:node="ctx.node.value"
:layout-direction="ctx.layoutDirection.value"
:is-in-auto-layout="ctx.isInAutoLayout.value"
:is-grid="ctx.isGrid.value"
:is-flex="ctx.isFlex.value"
@ -26,6 +27,7 @@ const ctx = useLayout()
:set-uniform-padding="ctx.setUniformPadding"
:commit-uniform-padding="ctx.commitUniformPadding"
:set-alignment="ctx.setAlignment"
:set-layout-direction="ctx.setLayoutDirection"
:update-grid-track="ctx.updateGridTrack"
:add-track="ctx.addTrack"
:remove-track="ctx.removeTrack"

View file

@ -27,6 +27,7 @@ function onFormattingChange(val: AcceptableValue | AcceptableValue[]) {
:active-formatting="ctx.activeFormatting"
:set-family="ctx.setFamily"
:set-weight="ctx.setWeight"
:set-direction="ctx.setDirection"
:update-prop="ctx.updateProp"
:commit-prop="ctx.commitProp"
:on-align-change="onAlignChange"

View file

@ -2,15 +2,9 @@ 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 type {
SceneNode,
LayoutSizing,
LayoutAlign,
LayoutCounterAlign,
GridTrack,
GridTrackSizing
} from '@open-pencil/core'
import type { SceneNode, LayoutSizing, LayoutAlign, LayoutCounterAlign, GridTrack } from '@open-pencil/core'
type AlignCell = { primary: LayoutAlign; counter: LayoutCounterAlign }
@ -38,11 +32,13 @@ const ALIGN_VERTICAL: AlignCell[] = [
{ primary: 'MAX', counter: 'MAX' }
]
const TRACK_SIZING_OPTIONS: { value: GridTrackSizing; label: string }[] = [
{ value: 'FR', label: 'Fill (fr)' },
{ value: 'FIXED', label: 'Fixed (px)' },
{ value: 'AUTO', label: 'Auto' }
]
function createTrackSizingOptions(panels: ReturnType<typeof useI18n>['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<SceneNode | null>(() => editor.getSelectedNode() ?? null)
const layoutDirection = computed<SceneNode['layoutDirection']>(() => 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,

View file

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

View file

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

View file

@ -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<T>(fn: () => T): ComputedRef<T> {
return computed(fn)
const editor = useEditor()
return computed(() => {
void editor.state.sceneVersion
void editor.state.selectedIds
void editor.state.currentPageId
return fn()
})
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": "Новая страница",

View file

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

View file

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

View file

@ -410,6 +410,8 @@ export function handleSelectDown(
type: 'move',
startX: cx,
startY: cy,
currentX: cx,
currentY: cy,
originals,
autoLayoutParentId: detectAutoLayoutParent(editor)
})

View file

@ -17,6 +17,8 @@ export interface DragMove {
type: 'move'
startX: number
startY: number
currentX: number
currentY: number
originals: Map<string, { x: number; y: number; parentId: string }>
duplicated?: boolean
autoLayoutParentId?: string

Binary file not shown.

View file

@ -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 `<Text>`):** 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 `<Text>`):** 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:** `<Icon name="lucide:heart" size={20} color="#FFF" />` — 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.

View file

@ -109,6 +109,19 @@ const { panels } = useI18n()
</button>
</div>
<div v-if="ctx.isFlex" class="mt-2">
<label class="mb-1 block text-[11px] text-muted">{{ panels.flow }}</label>
<AppSelect
:model-value="ctx.layoutDirection"
:options="[
{ value: 'AUTO', label: panels.auto },
{ value: 'LTR', label: 'LTR' },
{ value: 'RTL', label: 'RTL' }
]"
@update:model-value="ctx.setLayoutDirection($event as 'AUTO' | 'LTR' | 'RTL')"
/>
</div>
<template v-if="ctx.isGrid">
<div
v-for="trackProp in ['gridTemplateColumns', 'gridTemplateRows'] as const"
@ -117,7 +130,7 @@ const { panels } = useI18n()
>
<div class="mb-1 flex items-center justify-between">
<label class="text-[11px] text-muted">{{
trackProp === 'gridTemplateColumns' ? 'Columns' : 'Rows'
trackProp === 'gridTemplateColumns' ? panels.columns : panels.rows
}}</label>
<button
class="cursor-pointer rounded border-none bg-transparent px-1 text-xs leading-none text-muted hover:bg-hover hover:text-surface"

View file

@ -83,6 +83,19 @@ const { panels } = useI18n()
</ScrubInput>
</div>
<div class="mb-1.5">
<label class="mb-1 block text-[11px] text-muted">{{ panels.direction }}</label>
<AppSelect
:model-value="ctx.node.value.textDirection"
:options="[
{ value: 'AUTO', label: panels.auto },
{ value: 'LTR', label: 'LTR' },
{ value: 'RTL', label: 'RTL' }
]"
@update:model-value="ctx.setDirection($event as 'AUTO' | 'LTR' | 'RTL')"
/>
</div>
<div class="flex items-center gap-3">
<ToggleGroupRoot
type="single"

View file

@ -63,6 +63,19 @@ describe('sceneNodeToJSX', () => {
expect(jsx).toContain('>Hello World</Text>')
})
test('rtl text node', () => {
const graph = makeGraph()
const node = graph.createNode('TEXT', pageId(graph), {
name: 'Arabic Title',
width: 200,
height: 24,
text: 'مرحبا',
textDirection: 'RTL'
})
const jsx = sceneNodeToJSX(node.id, graph)
expect(jsx).toContain('dir="rtl"')
})
test('frame with auto-layout', () => {
const graph = makeGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
@ -85,6 +98,19 @@ describe('sceneNodeToJSX', () => {
expect(jsx).toContain('w={400}')
})
test('frame with rtl auto-layout', () => {
const graph = makeGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
name: 'Row RTL',
width: 400,
height: 100,
layoutMode: 'HORIZONTAL',
layoutDirection: 'RTL'
})
const jsx = sceneNodeToJSX(frame.id, graph)
expect(jsx).toContain('dir="rtl"')
})
test('frame with children', () => {
const graph = makeGraph()
const frame = graph.createNode('FRAME', pageId(graph), {

View file

@ -237,6 +237,13 @@ describe('FigmaAPI', () => {
expect(text.textAlignHorizontal).toBe('CENTER')
})
test('textDirection', () => {
const api = createAPI()
const text = api.createText()
text.textDirection = 'RTL'
expect(text.textDirection).toBe('RTL')
})
test('textAutoResize', () => {
const api = createAPI()
const text = api.createText()
@ -253,6 +260,24 @@ describe('FigmaAPI', () => {
expect(frame.layoutMode).toBe('VERTICAL')
})
test('layoutDirection', () => {
const api = createAPI()
const frame = api.createFrame()
expect(frame.layoutDirection).toBe('AUTO')
frame.layoutDirection = 'RTL'
expect(frame.layoutDirection).toBe('RTL')
})
test('layoutDirection falls back to AUTO for legacy nodes with no stored value', () => {
const api = createAPI()
const frame = api.createFrame()
const raw = api.graph.getNode(frame.id)
expect(raw).toBeDefined()
if (!raw) return
Reflect.deleteProperty(raw as object, 'layoutDirection')
expect(frame.layoutDirection).toBe('AUTO')
})
test('itemSpacing', () => {
const api = createAPI()
const frame = api.createFrame()

View file

@ -580,3 +580,30 @@ describe('Integration: auto-layout component with all fixes', () => {
expect(titleNode.lineHeight).toBe(20)
})
})
describe('Directionality plugin fallback', () => {
test('text and layout direction roundtrip through export/parse', async () => {
const graph = new SceneGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
name: 'RTL Row',
layoutMode: 'HORIZONTAL',
layoutDirection: 'RTL',
width: 240,
height: 80
})
const text = graph.createNode('TEXT', frame.id, {
text: 'مرحبا',
textDirection: 'RTL',
width: 120,
height: 24
})
const bytes = await exportFigFile(graph)
const parsed = await parseFigFile(bytes.buffer as ArrayBuffer)
const parsedFrame = [...parsed.getAllNodes()].find((node) => node.name === 'RTL Row')
const parsedText = [...parsed.getAllNodes()].find((node) => node.type === 'TEXT')
expect(parsedFrame?.layoutDirection).toBe('RTL')
expect(parsedText?.textDirection).toBe('RTL')
})
})

View file

@ -1,6 +1,6 @@
import { describe, test, expect } from 'bun:test'
import { SceneGraph, type SceneNode, type GridTrack, computeLayout, computeAllLayouts, setTextMeasurer, FigmaAPI, readFigFile } from '@open-pencil/core'
import { SceneGraph, type SceneNode, type GridTrack, computeLayout, computeAllLayouts, createEditor, setTextMeasurer, FigmaAPI, readFigFile } from '@open-pencil/core'
import { createEditorStore } from '@/stores/editor'
@ -112,6 +112,101 @@ describe('Auto Layout', () => {
expect(children[1].x).toBe(80)
expect(children[1].y).toBe(15)
})
test('positions children right-to-left when layoutDirection is RTL', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 300,
height: 80,
layoutDirection: 'RTL',
paddingLeft: 20,
paddingRight: 30,
itemSpacing: 10
})
rect(graph, frame.id, 50, 30)
rect(graph, frame.id, 60, 30)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].x).toBe(220)
expect(children[1].x).toBe(150)
})
test('inherits RTL flow when layoutDirection is AUTO inside an RTL parent', () => {
const graph = new SceneGraph()
const outer = autoFrame(graph, pageId(graph), {
width: 320,
height: 120,
layoutDirection: 'RTL'
})
const inner = autoFrame(graph, outer.id, {
width: 240,
height: 80,
layoutDirection: 'AUTO'
})
rect(graph, inner.id, 50, 30)
rect(graph, inner.id, 60, 30)
computeAllLayouts(graph, outer.id)
const children = graph.getChildren(inner.id)
expect(children[0].x).toBe(190)
expect(children[1].x).toBe(130)
})
test('allows nested frame to override parent flow direction', () => {
const graph = new SceneGraph()
const outer = autoFrame(graph, pageId(graph), {
width: 320,
height: 120,
layoutDirection: 'LTR'
})
const inner = autoFrame(graph, outer.id, {
width: 240,
height: 80,
layoutDirection: 'RTL'
})
rect(graph, inner.id, 50, 30)
rect(graph, inner.id, 60, 30)
computeAllLayouts(graph, outer.id)
const children = graph.getChildren(inner.id)
expect(children[0].x).toBe(190)
expect(children[1].x).toBe(130)
})
test('recomputes deep auto descendants when parent flow changes', () => {
const graph = new SceneGraph()
const outer = graph.createNode('FRAME', pageId(graph), {
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
width: 320,
height: 200,
layoutDirection: 'LTR'
})
const wrapper = graph.createNode('FRAME', outer.id, {
layoutMode: 'NONE',
width: 280,
height: 120
})
const inner = autoFrame(graph, wrapper.id, {
width: 240,
height: 80,
layoutDirection: 'AUTO'
})
rect(graph, inner.id, 50, 30)
rect(graph, inner.id, 60, 30)
const editor = createEditor({ graph })
editor.updateNodeWithUndo(outer.id, { layoutDirection: 'RTL' }, 'Change layout direction')
const children = graph.getChildren(inner.id)
expect(children[0].x).toBe(190)
expect(children[1].x).toBe(130)
})
})
describe('vertical basic', () => {

View file

@ -4,7 +4,13 @@ import type { SceneNode } from '../../packages/core/src/scene-graph'
import type { SkiaRenderer } from '../../packages/core/src/renderer/renderer'
import { initCanvasKit } from '../../packages/cli/src/headless'
import { SceneGraph, SkiaRenderer as SkiaRendererClass } from '@open-pencil/core'
import { initFontService, setCJKFallbackFamily, markFontLoaded } from '../../packages/core/src/fonts'
import {
initFontService,
setArabicFallbackFamily,
setCJKFallbackFamily,
markFontLoaded
} from '../../packages/core/src/fonts'
import { detectTextDirection, resolveTextDirection } from '@open-pencil/core'
function createMockCanvas() {
return {
@ -112,6 +118,13 @@ describe('renderText', () => {
})
describe('renderText headless visual', () => {
test('detects base direction for Arabic and mixed text', () => {
expect(detectTextDirection('مرحبا')).toBe('RTL')
expect(resolveTextDirection('AUTO', 'مرحبا world')).toBe('RTL')
expect(resolveTextDirection('AUTO', 'Hello مرحبا')).toBe('LTR')
expect(resolveTextDirection('RTL', 'Hello')).toBe('RTL')
})
test('renders CJK text via fallback font through paragraph shaper', async () => {
const ck = await initCanvasKit()
const fontProvider = ck.TypefaceFontProvider.Make()
@ -178,4 +191,71 @@ describe('renderText headless visual', () => {
// Tofu boxes would have far fewer (just outlines)
expect(darkPixels).toBeGreaterThan(500)
})
test('renders Arabic text via fallback font through paragraph shaper', async () => {
const ck = await initCanvasKit()
const fontProvider = ck.TypefaceFontProvider.Make()
initFontService(ck, fontProvider)
const interData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()
fontProvider.registerFont(interData, 'Inter')
markFontLoaded('Inter', 'Regular', interData)
const arabicPath = new URL('../fixtures/fonts/NotoNaskhArabic-Regular.ttf', import.meta.url)
.pathname
const arabicData = await Bun.file(arabicPath).arrayBuffer()
fontProvider.registerFont(arabicData, 'Noto Naskh Arabic')
setArabicFallbackFamily('Noto Naskh Arabic')
const graph = new SceneGraph()
const page = graph.getPages()[0]
const node = graph.createNode('TEXT', page.id, {
text: 'مرحبا بالعالم',
textDirection: 'AUTO',
fontFamily: 'Inter',
fontSize: 32,
fontWeight: 400,
width: 220,
height: 60,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const surface = ck.MakeSurface(220, 60)!
const renderer = new SkiaRendererClass(ck, surface)
renderer.viewportWidth = 220
renderer.viewportHeight = 60
renderer.dpr = 1
renderer.fontsLoaded = true
;(renderer as unknown as Record<string, unknown>).fontProvider = fontProvider
const canvas = surface.getCanvas()
canvas.clear(ck.WHITE)
renderText(renderer, canvas, graph.getNode(node.id)!)
surface.flush()
const image = surface.makeImageSnapshot()
const encoded = image.encodeToBytes(ck.ImageFormat.PNG, 100)!
image.delete()
surface.delete()
expect(encoded.length).toBeGreaterThan(200)
const decodedImage = ck.MakeImageFromEncoded(encoded)!
const pixels = decodedImage.readPixels(0, 0, {
width: 220,
height: 60,
colorType: ck.ColorType.RGBA_8888,
alphaType: ck.AlphaType.Unpremul,
colorSpace: ck.ColorSpace.SRGB
})!
decodedImage.delete()
let darkPixels = 0
for (let i = 0; i < pixels.length; i += 4) {
if (pixels[i] < 128 && pixels[i + 1] < 128 && pixels[i + 2] < 128) {
darkPixels++
}
}
expect(darkPixels).toBeGreaterThan(450)
})
})

View file

@ -0,0 +1,85 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph, computeLayout } from '@open-pencil/core'
import { createEditor } from '@open-pencil/core/editor'
import { computeAutoLayoutIndicatorForFrame } from '../../packages/vue/src/shared/input/auto-layout'
import { handleMoveUp } from '../../packages/vue/src/shared/input/move'
import type { DragMove } from '../../packages/vue/src/shared/input/types'
function pageId(graph: SceneGraph) {
return graph.getPages()[0].id
}
describe('RTL auto-layout input', () => {
test('computes insertion index by RTL visual order', () => {
const graph = new SceneGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
layoutMode: 'HORIZONTAL',
layoutDirection: 'RTL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
width: 300,
height: 80,
paddingRight: 30,
itemSpacing: 10
})
graph.createNode('RECTANGLE', frame.id, { width: 50, height: 30, name: 'A' })
graph.createNode('RECTANGLE', frame.id, { width: 60, height: 30, name: 'B' })
computeLayout(graph, frame.id)
const editor = createEditor({ graph })
computeAutoLayoutIndicatorForFrame(frame, 215, 40, editor)
expect(editor.state.layoutInsertIndicator?.index).toBe(1)
expect(editor.state.layoutInsertIndicator?.x).toBe(215)
})
test('does not reorder on click-sized movement inside auto-layout', () => {
const graph = new SceneGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
layoutMode: 'HORIZONTAL',
layoutDirection: 'RTL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
width: 300,
height: 80,
paddingRight: 30,
itemSpacing: 10
})
const first = graph.createNode('RECTANGLE', frame.id, { width: 50, height: 30, name: 'First' })
const second = graph.createNode('RECTANGLE', frame.id, {
width: 60,
height: 30,
name: 'Second'
})
computeLayout(graph, frame.id)
const editor = createEditor({ graph })
editor.select([second.id])
editor.setLayoutInsertIndicator({
parentId: frame.id,
index: 0,
x: 0,
y: 0,
length: 0,
direction: 'VERTICAL'
})
const drag: DragMove = {
type: 'move',
startX: 100,
startY: 40,
currentX: 100,
currentY: 40,
originals: new Map([[second.id, { x: second.x, y: second.y, parentId: frame.id }]]),
autoLayoutParentId: frame.id
}
handleMoveUp(drag, editor)
expect(graph.getChildren(frame.id).map((child) => child.id)).toEqual([first.id, second.id])
})
})

View file

@ -358,6 +358,23 @@ describe('renderNodesToSVG()', () => {
expect(result).toContain('>Hello World</text>')
})
test('rtl text node exports direction and logical anchor', () => {
const graph = makeGraph()
const node = graph.createNode('TEXT', pageId(graph), {
width: 180,
height: 24,
text: 'مرحبا',
fontSize: 18,
textDirection: 'RTL',
textAlignHorizontal: 'LEFT',
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('direction="rtl"')
expect(result).toContain('text-anchor="end"')
expect(result).toContain('x="180"')
})
test('text with style runs', () => {
const graph = makeGraph()
const node = graph.createNode('TEXT', pageId(graph), {

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c34fdbd98af4dbc45ca192a23d2eeb77032add83086f5fe32957d23b3f36b221
size 158580