Add Arabic and RTL support
# Conflicts: # packages/core/src/renderer/text.ts
This commit is contained in:
commit
fd0124b6f8
|
|
@ -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
|
||||
|
||||
|
|
|
|||
52
packages/core/src/direction.ts
Normal file
52
packages/core/src/direction.ts
Normal 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')
|
||||
)
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) + '…'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ const QUERYABLE_ATTRS = [
|
|||
'fontSize',
|
||||
'fontFamily',
|
||||
'fontWeight',
|
||||
'textDirection',
|
||||
'layoutMode',
|
||||
'layoutDirection',
|
||||
'itemSpacing',
|
||||
'paddingTop',
|
||||
'paddingBottom',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Новая страница",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -410,6 +410,8 @@ export function handleSelectDown(
|
|||
type: 'move',
|
||||
startX: cx,
|
||||
startY: cy,
|
||||
currentX: cx,
|
||||
currentY: cy,
|
||||
originals,
|
||||
autoLayoutParentId: detectAutoLayoutParent(editor)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
BIN
public/NotoNaskhArabic-Regular.ttf
Normal file
BIN
public/NotoNaskhArabic-Regular.ttf
Normal file
Binary file not shown.
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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), {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
85
tests/engine/rtl-auto-layout-input.test.ts
Normal file
85
tests/engine/rtl-auto-layout-input.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
|
|
@ -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), {
|
||||
|
|
|
|||
3
tests/fixtures/fonts/NotoNaskhArabic-Regular.ttf
vendored
Normal file
3
tests/fixtures/fonts/NotoNaskhArabic-Regular.ttf
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c34fdbd98af4dbc45ca192a23d2eeb77032add83086f5fe32957d23b3f36b221
|
||||
size 158580
|
||||
Loading…
Reference in a new issue