Merge branch 'open-pencil:master' into master

This commit is contained in:
Sadko 2026-07-17 15:44:35 -04:00 committed by GitHub
commit 1a82f84ba5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 639 additions and 41 deletions

View file

@ -21,6 +21,7 @@
- Model imported fill, stroke, text, effect, and grid styles with reusable SDK/app selectors, automatic detach-on-edit, undo, and `.fig` definition roundtrips.
- Add variant, text, boolean, and nested instance-swap component property controls with mixed-selection undo and typed `.fig` metadata roundtrips.
- Add fill and effect blend-mode controls with shared-style detachment and mixed-selection undo.
- Add text case, justification, vertical alignment, truncation/max-lines, and common OpenType controls with CanvasKit rendering and undo.
- Standardize Pages, Layers/Assets navigation, and document tab states across light and dark themes.
- Standardize Vue SDK and app override type names on the `UI` acronym, including `FontPickerUI`.
- Add a headless Vue SDK NumberField with pointer scrubbing, keyboard stepping, safe arithmetic expressions, and mixed/bound states; remove the superseded ScrubInput API.

View file

@ -6,6 +6,7 @@ import { computeDescendantVisualBounds } from '@open-pencil/scene-graph/geometry
import type { Color } from '@open-pencil/scene-graph/primitives'
import { DROP_HIGHLIGHT_ALPHA, DROP_HIGHLIGHT_STROKE, SECTION_CORNER_RADIUS } from '#core/constants'
import { transformTextCase } from '#core/text/case'
import { fontManager } from '#core/text/fonts'
import { vectorNetworkToCenterlinePath } from '#core/vector'
@ -605,24 +606,39 @@ function shouldRenderTextAsOutline(fill?: Fill): boolean {
return fill !== undefined && fill.type !== 'SOLID'
}
function drawOutlinedText(r: SkiaRenderer, canvas: Canvas, node: SceneNode): boolean {
const path = textNodeToOutlinePath(r, node)
if (!path) return false
canvas.drawPath(path, r.fillPaint)
path.delete()
return true
export function textVerticalOffset(node: SceneNode, contentHeight: number): number {
const available = Math.max(0, node.height - contentHeight)
if (node.textAlignVertical === 'CENTER') return available / 2
if (node.textAlignVertical === 'BOTTOM') return available
return 0
}
function drawGradientText(
function drawOutlinedText(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
paragraphY: number
): boolean {
const outlineNode =
node.textCase === 'ORIGINAL'
? node
: { ...node, text: transformTextCase(node.text, node.textCase), styleRuns: [] }
const path = textNodeToOutlinePath(r, outlineNode)
if (!path) return false
canvas.save()
canvas.translate(0, paragraphY)
canvas.drawPath(path, r.fillPaint)
canvas.restore()
path.delete()
return true
}
function drawGradientText(r: SkiaRenderer, canvas: Canvas, node: SceneNode): boolean {
if (!r.fontsLoaded || !r.fontProvider) return false
const paragraph = r.buildParagraph(node, r.ck.Color4f(0, 0, 0, 1))
try {
const paragraphY = textVerticalOffset(node, paragraph.getHeight())
r.effectLayerPaint.setImageFilter(null)
r.effectLayerPaint.setColorFilter(null)
r.effectLayerPaint.setBlendMode(r.ck.BlendMode.SrcOver)
@ -654,7 +670,6 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode, fil
canvas.clipRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.ck.ClipOp.Intersect, false)
}
const paragraphY = 0
const fontReadiness = r.nodeFontReadiness(node)
if (fontReadiness !== 'ready') {
if (fontReadiness === 'exhausted') {
@ -675,20 +690,37 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode, fil
canvas.restore()
return
}
if (shouldRenderTextAsOutline(fill) && drawOutlinedText(r, canvas, node)) {
canvas.restore()
return
if (shouldRenderTextAsOutline(fill)) {
let paragraphY = 0
if (node.textAlignVertical !== 'TOP') {
const paragraph = r.buildParagraph(node, r.ck.Color4f(0, 0, 0, 1))
paragraphY = textVerticalOffset(node, paragraph.getHeight())
paragraph.delete()
}
if (drawOutlinedText(r, canvas, node, paragraphY)) {
canvas.restore()
return
}
}
if (isGradientFill(fill) && drawGradientText(r, canvas, node, paragraphY)) {
if (isGradientFill(fill) && drawGradientText(r, canvas, node)) {
canvas.restore()
return
}
if (r.fontsLoaded && r.fontProvider) {
const paragraph = r.buildParagraph(node, r.fillPaint.getColor())
const paragraphY = textVerticalOffset(node, paragraph.getHeight())
canvas.drawParagraph(paragraph, 0, paragraphY)
paragraph.delete()
} else if (r.textFont) {
canvas.drawText(text, 0, node.fontSize || r.DEFAULT_FONT_SIZE, r.fillPaint, r.textFont)
const fontSize = node.fontSize || r.DEFAULT_FONT_SIZE
const paragraphY = textVerticalOffset(node, fontSize)
canvas.drawText(
transformTextCase(text, node.textCase),
0,
paragraphY + fontSize,
r.fillPaint,
r.textFont
)
}
canvas.restore()

View file

@ -12,6 +12,7 @@ import type { SceneNode } from '@open-pencil/scene-graph'
import { resolveRGBAForPreview } from '#core/color/management'
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from '#core/constants'
import { transformTextCase } from '#core/text/case'
import { fontFallbackScriptForCharacter } from '#core/text/coverage'
import { resolveNodeTextDirection } from '#core/text/direction'
import type { FontFallbackScript } from '#core/text/fallbacks'
@ -403,6 +404,14 @@ function pushStyleRun(
)
}
function addParagraphText(
builder: ReturnType<CanvasKit['ParagraphBuilder']['MakeFromFontProvider']>,
node: SceneNode,
text: string
): void {
builder.addText(transformTextCase(text, node.textCase))
}
function addStyledRuns(
r: TextRenderer,
builder: ReturnType<CanvasKit['ParagraphBuilder']['MakeFromFontProvider']>,
@ -416,14 +425,14 @@ function addStyledRuns(
let pos = 0
for (const run of node.styleRuns) {
if (pos < run.start) builder.addText(text.slice(pos, run.start))
if (pos < run.start) addParagraphText(builder, node, text.slice(pos, run.start))
pushStyleRun(r, builder, node, run, baseColor, baseFontSize, fontFamilies, halfLeading)
builder.addText(text.slice(run.start, run.start + run.length))
addParagraphText(builder, node, text.slice(run.start, run.start + run.length))
builder.pop()
pos = run.start + run.length
}
if (pos < text.length) builder.addText(text.slice(pos))
if (pos < text.length) addParagraphText(builder, node, text.slice(pos))
}
export function buildParagraph(
@ -483,7 +492,7 @@ export function buildParagraph(
const builder = ck.ParagraphBuilder.MakeFromFontProvider(paraStyle, r.fontProvider)
if (node.styleRuns.length === 0) {
builder.addText(node.text)
addParagraphText(builder, node, node.text)
} else {
addStyledRuns(r, builder, node, baseColor, baseFontSize, fontFamilies, halfLeading)
}

View file

@ -309,6 +309,8 @@ function serializeTextProps(
nc.fontVersion = ''
nc.emojiImageSet = 'APPLE'
if (node.textCase !== 'ORIGINAL') nc.textCase = node.textCase
if (node.textTruncation === 'ENDING') nc.textTruncation = 'ENDING'
if (node.maxLines != null) nc.maxLines = node.maxLines
if (fontDigestMap) {
nc.derivedTextData = buildDerivedTextData(node, fontDigestMap, blobs, glyphBlobMap ?? new Map())
}

View file

@ -0,0 +1,13 @@
import type { TextCase } from '@open-pencil/scene-graph'
export function transformTextCase(text: string, textCase: TextCase): string {
if (textCase === 'UPPER') return text.toLocaleUpperCase()
if (textCase === 'LOWER') return text.toLocaleLowerCase()
if (textCase === 'TITLE') {
return text.replace(
/[\p{L}\p{N}][\p{L}\p{M}\p{N}]*/gu,
(word) => word.charAt(0).toLocaleUpperCase() + word.slice(1).toLocaleLowerCase()
)
}
return text
}

View file

@ -1,6 +1,7 @@
export * from './editor'
export * from './style-runs'
export * from './direction'
export * from './case'
export * from './coverage'
export * from './fonts'
export * from './fallbacks'

View file

@ -1,6 +1,7 @@
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { DEFAULT_FONT_FAMILY } from '#core/constants'
import { transformTextCase } from '#core/text/case'
import { cjkFallbackScriptForLanguage, type FontFallbackScript } from '#core/text/fallbacks'
import { weightToStyle } from '#core/text/font-style'
@ -63,7 +64,7 @@ export function collectGraphFontRequirements(
nodes.push(node)
if (node.type === 'TEXT') {
let index = 0
for (const character of node.text) {
for (const character of transformTextCase(node.text, node.textCase)) {
characters.add(character)
const script = fallbackScriptForCharacter(character, textLanguageAt(node, index))
if (script) scripts.add(script)

View file

@ -122,7 +122,7 @@ Figma's design documentation groups features into these areas:
| Ellipses / arcs | ✅ | ✅ | ◐ | ✅ | ✅ | `arcData` renders/exports; no full inspector controls. |
| Lines | ✅ | ✅ | ✅ | ✅ | ✅ | Stroke caps/joins render but are not fully exposed in UI. |
| Polygons / stars | ✅ | ✅ | ◐ | ✅ | ✅ | `pointCount` and `starInnerRadius` modeled. |
| Text | ✅ | ✅ | ◐ | ✅ | ✅ | Derived Figma glyphs improve fidelity; advanced typography is partial. |
| Text | ✅ | ✅ | ✅ | ✅ | ✅ | Case, justification, vertical alignment, truncation/max lines, common OpenType features, and derived Figma glyph fallback are supported; uncommon typography metadata remains round-trip only. |
| Vectors / vector networks | ✅ | ✅ | ◐ | ✅ | ✅ | Vector edit support exists; Figma Draw tools are not fully replicated. |
| Boolean operations | ✅ | ✅ | ◐ | ✅ | ✅ | Figma `BOOLEAN_OPERATION` nodes import/export as boolean operations; inspector editing remains limited. |
| Components | ✅ | ✅ | ◐ | ✅ | ✅ | Component metadata, descriptions, links, and publish fields mostly round-trip. |

View file

@ -1,6 +1,6 @@
---
title: useTypography
description: Read and update font family, weight, size, alignment, and formatting for text nodes.
description: Read and update font, alignment, case, truncation, and OpenType features for text nodes.
---
# useTypography
@ -14,6 +14,10 @@ It exposes:
- font size
- formatting state
- missing-font status
- horizontal and vertical alignment, including justification
- text case and direction
- ending truncation and maximum lines
- OpenType feature toggles
- helpers for changing family, weight, alignment, and decorations
## Usage
@ -35,6 +39,10 @@ const {
setFamily,
setWeight,
setAlign,
setVerticalAlign,
setTextCase,
setTruncation,
setFontFeature,
} = useTypography()
```
@ -44,8 +52,10 @@ const {
```ts
const typography = useTypography({
loadFont: async (family, style) => {
await myFontLoader(family, style)
fontLoader: {
load: async (family, style) => {
await myFontLoader(family, style)
},
},
})
```
@ -56,6 +66,10 @@ const typography = useTypography({
typography.toggleBold()
typography.toggleItalic()
typography.toggleDecoration('UNDERLINE')
typography.setTextCase('UPPER')
typography.setVerticalAlign('CENTER')
typography.setTruncation('ENDING')
typography.setFontFeature('LIGA', false)
```
## Related APIs

View file

@ -24,7 +24,8 @@ const TEXT_STYLE_KEYS = new Set<keyof SceneNode>([
'lineHeight',
'letterSpacing',
'textDecoration',
'textCase'
'textCase',
'fontFeatures'
])
export function sharedStyleRefKey(kind: SharedStyleKind): (typeof STYLE_REF_KEYS)[SharedStyleKind] {

View file

@ -9,8 +9,11 @@ import type { UseTypographyOptions } from '#vue/controls/typography/use'
import { useSceneComputed } from '#vue/internal/scene-computed/use'
import { useNodeFontStatus } from '#vue/shared/font-status/use'
type TextAlign = 'LEFT' | 'CENTER' | 'RIGHT'
type TextAlign = SceneNode['textAlignHorizontal']
type TextDirection = SceneNode['textDirection']
type TextVerticalAlign = SceneNode['textAlignVertical']
type TextCase = SceneNode['textCase']
type TextTruncation = SceneNode['textTruncation']
export const TYPOGRAPHY_WEIGHTS = Object.entries(FONT_WEIGHT_NAMES).map(([value, label]) => ({
value: Number(value),
@ -64,7 +67,9 @@ export function createTypographyActions({
activeFormatting,
options
}: TypographyActionOptions) {
let textStyleBeforePreview: string | null | undefined
let propBeforePreview:
| { key: keyof SceneNode; value: SceneNode[keyof SceneNode]; textStyleId: string | null }
| undefined
async function doLoadFont(family: string, style: string) {
await options.fontLoader?.load(family, style)
@ -98,6 +103,32 @@ export function createTypographyActions({
editor.updateNodeWithUndo(node.value.id, { textDirection: direction }, 'Change text direction')
}
function setVerticalAlign(align: TextVerticalAlign) {
if (!node.value) return
editor.updateNodeWithUndo(
node.value.id,
{ textAlignVertical: align },
'Change vertical text alignment'
)
}
function setTextCase(textCase: TextCase) {
if (!node.value) return
editor.updateNodeWithUndo(node.value.id, { textCase }, 'Change text case')
}
function setTruncation(textTruncation: TextTruncation) {
if (!node.value) return
editor.updateNodeWithUndo(node.value.id, { textTruncation }, 'Change text truncation')
}
function setFontFeature(tag: string, enabled: boolean) {
if (!node.value) return
const fontFeatures = node.value.fontFeatures.filter((feature) => feature.tag !== tag)
fontFeatures.push({ tag, enabled })
editor.updateNodeWithUndo(node.value.id, { fontFeatures }, `Change ${tag} feature`)
}
function toggleBold() {
if (!node.value) return
void setWeight(node.value.fontWeight >= 700 ? 400 : 700)
@ -131,23 +162,34 @@ export function createTypographyActions({
}
}
function updateProp(key: string, value: number | string) {
function updateProp(key: keyof SceneNode, value: number | string | null) {
if (!node.value) return
if (textStyleBeforePreview === undefined) textStyleBeforePreview = node.value.textStyleId
editor.updateNode(node.value.id, { [key]: value, textStyleId: null })
if (!propBeforePreview || propBeforePreview.key !== key) {
propBeforePreview = {
key,
value: node.value[key],
textStyleId: node.value.textStyleId
}
}
editor.updateNode(node.value.id, { [key]: value } as Partial<SceneNode>)
}
function commitProp(key: string, _value: number | string, previous: number | string) {
function commitProp(
key: keyof SceneNode,
_value: number | string | null,
previous: number | string | null
) {
if (!node.value) return
const snapshot = propBeforePreview?.key === key ? propBeforePreview : undefined
editor.commitNodeUpdate(
node.value.id,
{
[key]: previous,
...(textStyleBeforePreview !== undefined ? { textStyleId: textStyleBeforePreview } : {})
[key]: snapshot ? snapshot.value : previous,
...(snapshot ? { textStyleId: snapshot.textStyleId } : {})
} as Partial<SceneNode>,
`Change ${key}`
`Change ${String(key)}`
)
textStyleBeforePreview = undefined
propBeforePreview = undefined
}
return {
@ -155,6 +197,10 @@ export function createTypographyActions({
setWeight,
setAlign,
setDirection,
setVerticalAlign,
setTextCase,
setTruncation,
setFontFeature,
toggleBold,
toggleItalic,
toggleDecoration,

View file

@ -22,6 +22,20 @@
"lineHeight": "Zeilenhöhe",
"letterSpacing": "Zeichenabstand",
"textAlignment": "Textausrichtung",
"verticalTextAlignment": "Vertikale Textausrichtung",
"textCase": "Groß-/Kleinschreibung",
"textCaseOriginal": "Original",
"textCaseUpper": "Großbuchstaben",
"textCaseLower": "Kleinbuchstaben",
"textCaseTitle": "Titelschreibweise",
"truncation": "Kürzung",
"truncationDisabled": "Deaktiviert",
"truncationEnding": "Auslassung am Ende",
"maxLines": "Maximale Zeilen",
"openTypeFeatures": "OpenType-Funktionen",
"standardLigatures": "Standardligaturen",
"contextualAlternates": "Kontextvarianten",
"kerning": "Kerning",
"textFormatting": "Textformatierung",
"pageBackground": "Seitenhintergrund",
"variables": "Variablen",

View file

@ -33,6 +33,20 @@
"lineHeight": "Altura de línea",
"letterSpacing": "Espaciado entre letras",
"textAlignment": "Alineación del texto",
"verticalTextAlignment": "Alineación vertical del texto",
"textCase": "Mayúsculas y minúsculas",
"textCaseOriginal": "Original",
"textCaseUpper": "Mayúsculas",
"textCaseLower": "Minúsculas",
"textCaseTitle": "Tipo título",
"truncation": "Truncamiento",
"truncationDisabled": "Desactivado",
"truncationEnding": "Puntos suspensivos finales",
"maxLines": "Líneas máximas",
"openTypeFeatures": "Funciones OpenType",
"standardLigatures": "Ligaduras estándar",
"contextualAlternates": "Alternativas contextuales",
"kerning": "Kerning",
"textFormatting": "Formato de texto",
"pageBackground": "Fondo de página",
"variables": "Variables",

View file

@ -22,6 +22,20 @@
"lineHeight": "Hauteur de ligne",
"letterSpacing": "Espacement des lettres",
"textAlignment": "Alignement du texte",
"verticalTextAlignment": "Alignement vertical du texte",
"textCase": "Casse du texte",
"textCaseOriginal": "Originale",
"textCaseUpper": "Majuscules",
"textCaseLower": "Minuscules",
"textCaseTitle": "Casse de titre",
"truncation": "Troncature",
"truncationDisabled": "Désactivée",
"truncationEnding": "Points de suspension finaux",
"maxLines": "Lignes maximales",
"openTypeFeatures": "Fonctions OpenType",
"standardLigatures": "Ligatures standard",
"contextualAlternates": "Variantes contextuelles",
"kerning": "Crénage",
"textFormatting": "Mise en forme du texte",
"pageBackground": "Arrière-plan de la page",
"variables": "Variables",

View file

@ -22,6 +22,20 @@
"lineHeight": "Altezza riga",
"letterSpacing": "Spaziatura lettere",
"textAlignment": "Allineamento testo",
"verticalTextAlignment": "Allineamento verticale del testo",
"textCase": "Maiuscole/minuscole",
"textCaseOriginal": "Originale",
"textCaseUpper": "Maiuscolo",
"textCaseLower": "Minuscolo",
"textCaseTitle": "Iniziali maiuscole",
"truncation": "Troncamento",
"truncationDisabled": "Disattivato",
"truncationEnding": "Puntini finali",
"maxLines": "Righe massime",
"openTypeFeatures": "Funzioni OpenType",
"standardLigatures": "Legature standard",
"contextualAlternates": "Alternative contestuali",
"kerning": "Crenatura",
"textFormatting": "Formattazione testo",
"pageBackground": "Sfondo pagina",
"variables": "Variabili",

View file

@ -33,6 +33,20 @@
"lineHeight": "行の高さ",
"letterSpacing": "文字間隔",
"textAlignment": "テキストの配置",
"verticalTextAlignment": "テキストの垂直配置",
"textCase": "大文字・小文字",
"textCaseOriginal": "元のまま",
"textCaseUpper": "大文字",
"textCaseLower": "小文字",
"textCaseTitle": "タイトルケース",
"truncation": "切り詰め",
"truncationDisabled": "無効",
"truncationEnding": "末尾を省略",
"maxLines": "最大行数",
"openTypeFeatures": "OpenType 機能",
"standardLigatures": "標準合字",
"contextualAlternates": "文脈依存字形",
"kerning": "カーニング",
"textFormatting": "テキストの書式",
"pageBackground": "ページの背景",
"variables": "変数",

View file

@ -22,6 +22,20 @@
"lineHeight": "Wysokość wiersza",
"letterSpacing": "Odstępy między literami",
"textAlignment": "Wyrównanie tekstu",
"verticalTextAlignment": "Pionowe wyrównanie tekstu",
"textCase": "Wielkość liter",
"textCaseOriginal": "Oryginalna",
"textCaseUpper": "Wielkie litery",
"textCaseLower": "Małe litery",
"textCaseTitle": "Jak w tytule",
"truncation": "Skracanie",
"truncationDisabled": "Wyłączone",
"truncationEnding": "Wielokropek na końcu",
"maxLines": "Maksymalna liczba wierszy",
"openTypeFeatures": "Funkcje OpenType",
"standardLigatures": "Ligatury standardowe",
"contextualAlternates": "Alternatywy kontekstowe",
"kerning": "Kerning",
"textFormatting": "Formatowanie tekstu",
"pageBackground": "Tło strony",
"variables": "Zmienne",

View file

@ -22,6 +22,20 @@
"lineHeight": "Высота строки",
"letterSpacing": "Межбуквенный интервал",
"textAlignment": "Выравнивание текста",
"verticalTextAlignment": "Вертикальное выравнивание текста",
"textCase": "Регистр текста",
"textCaseOriginal": "Исходный",
"textCaseUpper": "Верхний регистр",
"textCaseLower": "Нижний регистр",
"textCaseTitle": "Как в заголовке",
"truncation": "Обрезка",
"truncationDisabled": "Отключена",
"truncationEnding": "Многоточие в конце",
"maxLines": "Максимум строк",
"openTypeFeatures": "Функции OpenType",
"standardLigatures": "Стандартные лигатуры",
"contextualAlternates": "Контекстные варианты",
"kerning": "Кернинг",
"textFormatting": "Форматирование текста",
"pageBackground": "Фон страницы",
"variables": "Переменные",

View file

@ -22,6 +22,20 @@
"lineHeight": "行高",
"letterSpacing": "字间距",
"textAlignment": "文本对齐",
"verticalTextAlignment": "文本垂直对齐",
"textCase": "文本大小写",
"textCaseOriginal": "原始",
"textCaseUpper": "大写",
"textCaseLower": "小写",
"textCaseTitle": "标题式大小写",
"truncation": "截断",
"truncationDisabled": "禁用",
"truncationEnding": "末尾省略号",
"maxLines": "最大行数",
"openTypeFeatures": "OpenType 功能",
"standardLigatures": "标准连字",
"contextualAlternates": "上下文替代字形",
"kerning": "字距调整",
"textFormatting": "文本格式",
"pageBackground": "页面背景",
"variables": "变量",

View file

@ -54,6 +54,20 @@ export const panelMessageDefaults = {
lineHeight: 'Line height',
letterSpacing: 'Letter spacing',
textAlignment: 'Text alignment',
verticalTextAlignment: 'Vertical text alignment',
textCase: 'Text case',
textCaseOriginal: 'Original',
textCaseUpper: 'Uppercase',
textCaseLower: 'Lowercase',
textCaseTitle: 'Title case',
truncation: 'Truncation',
truncationDisabled: 'Disabled',
truncationEnding: 'Ending ellipsis',
maxLines: 'Maximum lines',
openTypeFeatures: 'OpenType features',
standardLigatures: 'Standard ligatures',
contextualAlternates: 'Contextual alternates',
kerning: 'Kerning',
textFormatting: 'Text formatting',
pageBackground: 'Page background',
variables: 'Variables',

View file

@ -11,7 +11,7 @@ const { fontLoader } = defineProps<{
const ctx = useTypography({ fontLoader })
function onAlignChange(val: AcceptableValue) {
if (val) ctx.setAlign(val as 'LEFT' | 'CENTER' | 'RIGHT')
if (val) ctx.setAlign(val as 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED')
}
function onFormattingChange(val: AcceptableValue | AcceptableValue[]) {
@ -22,6 +22,10 @@ const actions = {
setFamily: ctx.setFamily,
setWeight: ctx.setWeight,
setDirection: ctx.setDirection,
setVerticalAlign: ctx.setVerticalAlign,
setTextCase: ctx.setTextCase,
setTruncation: ctx.setTruncation,
setFontFeature: ctx.setFontFeature,
updateProp: ctx.updateProp,
commitProp: ctx.commitProp,
align: onAlignChange,

View file

@ -5,9 +5,11 @@ import { TypographyControlsRoot, useI18n } from '@open-pencil/vue'
import FontPicker from '@/components/font-picker/FontPicker.vue'
import FontSettingsPopover from '@/components/FontSettings/FontSettingsPopover.vue'
import NumberField from '@/components/inputs/NumberField.vue'
import SharedStyleField from '@/components/properties/shared-style/SharedStyleField.vue'
import VariableNumberField from '@/components/properties/VariableNumberField.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import AppSwitch from '@/components/ui/AppSwitch.vue'
import IconButton from '@/components/ui/IconButton.vue'
import PanelFieldGroup from '@/components/ui/panel/PanelFieldGroup.vue'
import PanelGrid from '@/components/ui/panel/PanelGrid.vue'
@ -22,8 +24,33 @@ const fontLoader = { load: loadFont }
const alignmentOptions = computed(() => [
{ value: 'LEFT', label: panels.value.alignLeft },
{ value: 'CENTER', label: panels.value.alignCenterHorizontally },
{ value: 'RIGHT', label: panels.value.alignRight }
{ value: 'RIGHT', label: panels.value.alignRight },
{ value: 'JUSTIFIED', label: panels.value.textAlignment }
])
const verticalAlignmentOptions = computed(() => [
{ value: 'TOP', label: panels.value.alignTop },
{ value: 'CENTER', label: panels.value.alignCenterVertically },
{ value: 'BOTTOM', label: panels.value.alignBottom }
])
const textCaseOptions = computed(() => [
{ value: 'ORIGINAL', label: panels.value.textCaseOriginal },
{ value: 'UPPER', label: panels.value.textCaseUpper },
{ value: 'LOWER', label: panels.value.textCaseLower },
{ value: 'TITLE', label: panels.value.textCaseTitle }
])
const truncationOptions = computed(() => [
{ value: 'DISABLED', label: panels.value.truncationDisabled },
{ value: 'ENDING', label: panels.value.truncationEnding }
])
const commonFeatures = computed(() => [
{ tag: 'LIGA', label: panels.value.standardLigatures },
{ tag: 'CALT', label: panels.value.contextualAlternates },
{ tag: 'KERN', label: panels.value.kerning }
])
function featureEnabled(features: Array<{ tag: string; enabled: boolean }>, tag: string) {
return features.find((feature) => feature.tag === tag)?.enabled ?? true
}
</script>
<template>
@ -137,16 +164,95 @@ const alignmentOptions = computed(() => [
:model-value="ctx.node.value.textAlignHorizontal"
:options="alignmentOptions"
:label="panels.textAlignment"
@change="ctx.actions.align($event as 'LEFT' | 'CENTER' | 'RIGHT')"
@change="ctx.actions.align($event as 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED')"
>
<template #option="{ option }">
<icon-lucide-align-left v-if="option.value === 'LEFT'" class="size-3.5" />
<icon-lucide-align-center v-else-if="option.value === 'CENTER'" class="size-3.5" />
<icon-lucide-align-right v-else class="size-3.5" />
<icon-lucide-align-right v-else-if="option.value === 'RIGHT'" class="size-3.5" />
<icon-lucide-align-justify v-else class="size-3.5" />
</template>
</SegmentedControl>
</PanelFieldGroup>
<PanelFieldGroup :label="panels.verticalTextAlignment" class="mb-panel">
<SegmentedControl
:model-value="ctx.node.value.textAlignVertical"
:options="verticalAlignmentOptions"
:label="panels.verticalTextAlignment"
@change="ctx.actions.setVerticalAlign($event as 'TOP' | 'CENTER' | 'BOTTOM')"
>
<template #option="{ option }">
<icon-lucide-align-vertical-justify-start
v-if="option.value === 'TOP'"
class="size-3.5"
/>
<icon-lucide-align-vertical-justify-center
v-else-if="option.value === 'CENTER'"
class="size-3.5"
/>
<icon-lucide-align-vertical-justify-end v-else class="size-3.5" />
</template>
</SegmentedControl>
</PanelFieldGroup>
<PanelGrid columns="two" class="mb-panel">
<PanelFieldGroup :label="panels.textCase">
<AppSelect
:label="panels.textCase"
:model-value="ctx.node.value.textCase"
:options="textCaseOptions"
@update:model-value="
ctx.actions.setTextCase($event as 'ORIGINAL' | 'UPPER' | 'LOWER' | 'TITLE')
"
/>
</PanelFieldGroup>
<PanelFieldGroup :label="panels.truncation">
<AppSelect
:label="panels.truncation"
:model-value="ctx.node.value.textTruncation"
:options="truncationOptions"
@update:model-value="ctx.actions.setTruncation($event as 'DISABLED' | 'ENDING')"
/>
</PanelFieldGroup>
</PanelGrid>
<PanelFieldGroup
v-if="ctx.node.value.textTruncation === 'ENDING'"
:label="panels.maxLines"
class="mb-panel"
>
<NumberField
:model-value="ctx.node.value.maxLines ?? 1"
:aria-label="panels.maxLines"
:min="1"
:step="1"
data-property="max-lines"
@update:model-value="ctx.actions.updateProp('maxLines', Math.max(1, Math.round($event)))"
@commit="
(value: number, previous: number) => ctx.actions.commitProp('maxLines', value, previous)
"
/>
</PanelFieldGroup>
<PanelFieldGroup :label="panels.openTypeFeatures" class="mb-panel">
<div class="grid gap-1.5">
<label
v-for="feature in commonFeatures"
:key="feature.tag"
class="flex items-center justify-between gap-panel text-[11px] text-muted"
>
<span>{{ feature.label }}</span>
<AppSwitch
:model-value="featureEnabled(ctx.node.value.fontFeatures, feature.tag)"
:label="feature.label"
:data-property="`font-feature-${feature.tag.toLowerCase()}`"
@update:model-value="ctx.actions.setFontFeature(feature.tag, $event)"
/>
</label>
</div>
</PanelFieldGroup>
<PanelFieldGroup :label="panels.textFormatting" :ui="{ container: 'flex-row gap-panel' }">
<div class="flex items-center gap-panel" role="toolbar" :aria-label="panels.textFormatting">
<IconButton

View file

@ -0,0 +1,77 @@
import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures'
const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers')
test('text case vertical alignment and ending truncation', async () => {
await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const pageId = store.state.currentPageId
const cases = [
{ label: 'uppercase text', textCase: 'UPPER' as const, vertical: 'TOP' as const },
{ label: 'centered Title', textCase: 'TITLE' as const, vertical: 'CENTER' as const },
{ label: 'bottom text', textCase: 'LOWER' as const, vertical: 'BOTTOM' as const }
]
for (const [index, item] of cases.entries()) {
const x = 70 + index * 240
store.graph.createNode('RECTANGLE', pageId, {
x,
y: 90,
width: 200,
height: 130,
fills: [
{
type: 'SOLID',
color: { r: 0.92, g: 0.94, b: 0.98, a: 1 },
opacity: 1,
visible: true
}
]
})
store.graph.createNode('TEXT', pageId, {
x: x + 12,
y: 102,
width: 176,
height: 106,
text: item.label,
fontSize: 20,
textCase: item.textCase,
textAlignVertical: item.vertical
})
}
store.graph.createNode('RECTANGLE', pageId, {
x: 70,
y: 270,
width: 300,
height: 80,
fills: [
{
type: 'SOLID',
color: { r: 0.98, g: 0.91, b: 0.82, a: 1 },
opacity: 1,
visible: true
}
]
})
store.graph.createNode('TEXT', pageId, {
x: 82,
y: 280,
width: 276,
height: 60,
text: 'This paragraph demonstrates a deterministic two-line ending truncation with an ellipsis.',
fontSize: 17,
lineHeight: 22,
textTruncation: 'ENDING',
maxLines: 2,
textAlignHorizontal: 'JUSTIFIED'
})
store.clearSelection()
store.requestRender()
})
await editor.canvas.waitForRender()
await editor.page.waitForTimeout(300)
editor.canvas.assertNoErrors()
expect(await editor.canvas.canvas.screenshot()).toMatchSnapshot('typography-depth.png')
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View file

@ -57,6 +57,9 @@ test('typography controls use compact labeled anatomy', async () => {
await expect(typography.getByRole('spinbutton', { name: 'Line height' })).toBeVisible()
await expect(typography.getByRole('spinbutton', { name: 'Letter spacing' })).toBeVisible()
await expect(typography.getByRole('combobox', { name: 'Direction' })).toBeVisible()
await expect(typography.getByRole('combobox', { name: 'Text case' })).toBeVisible()
await expect(typography.getByRole('combobox', { name: 'Truncation' })).toBeVisible()
await expect(typography.getByRole('switch', { name: 'Standard ligatures' })).toBeVisible()
const alignment = typography.getByRole('group', { name: 'Text alignment' })
await expect(alignment.getByRole('button', { name: 'Align left' })).toHaveAttribute(
@ -72,6 +75,49 @@ test('typography controls use compact labeled anatomy', async () => {
await expect(typography).toHaveScreenshot('typography-panel.png')
})
test('advanced typography controls update case, alignment, truncation, and features', async () => {
const typography = page.getByRole('region', { name: 'Typography' })
const nodeId = expectDefined(await getSelectedNode(page), 'selected text node').id
const vertical = typography.getByRole('group', { name: 'Vertical text alignment' })
await vertical.getByRole('button', { name: 'Align bottom' }).click()
await typography.getByRole('combobox', { name: 'Text case' }).click()
await page.getByRole('option', { name: 'Uppercase' }).click()
await typography.getByRole('combobox', { name: 'Truncation' }).click()
await page.getByRole('option', { name: 'Ending ellipsis' }).click()
const maxLines = typography.getByRole('spinbutton', { name: 'Maximum lines' })
await maxLines.click()
await page.keyboard.press('Meta+a')
await page.keyboard.type('2')
await page.keyboard.press('Enter')
await typography.getByRole('switch', { name: 'Standard ligatures' }).click()
await canvas.waitForRender()
const node = await page.evaluate((id) => {
const store = window.openPencil?.getStore?.()
const current = store?.graph.getNode(id)
return current
? {
textAlignVertical: current.textAlignVertical,
textCase: current.textCase,
textTruncation: current.textTruncation,
maxLines: current.maxLines,
fontFeatures: current.fontFeatures
}
: null
}, nodeId)
expect(node).toMatchObject({
textAlignVertical: 'BOTTOM',
textCase: 'UPPER',
textTruncation: 'ENDING',
maxLines: 2
})
expect(node?.fontFeatures).toContainEqual({ tag: 'LIGA', enabled: false })
})
test('bold button toggles fontWeight to 700 then back to 400', async () => {
await canvas.pressKey('Escape')
await canvas.waitForRender()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -42,6 +42,36 @@ describe('text node export', () => {
expect(textNode.fontSize).toBe(16)
})
test('roundtrips advanced typography fields', async () => {
await initCodec()
const graph = new SceneGraph()
graph.createNode('TEXT', graph.getPages()[0].id, {
name: 'Advanced type',
text: 'Advanced',
textAlignHorizontal: 'JUSTIFIED',
textAlignVertical: 'BOTTOM',
textCase: 'UPPER',
textTruncation: 'ENDING',
maxLines: 2,
fontFeatures: [
{ tag: 'liga', enabled: false },
{ tag: 'kern', enabled: true }
]
})
const reimported = await parseFigFile((await exportFigFile(graph)).buffer as ArrayBuffer)
const textNode = reimported.getAllNodes().find((node) => node.name === 'Advanced type')
expect(textNode).toMatchObject({
textAlignHorizontal: 'JUSTIFIED',
textAlignVertical: 'BOTTOM',
textCase: 'UPPER',
textTruncation: 'ENDING',
maxLines: 2
})
expect(textNode?.fontFeatures).toContainEqual({ tag: 'LIGA', enabled: false })
})
test('keeps OpenPencil text language hints out of the .fig schema', async () => {
await initCodec()

View file

@ -10,8 +10,9 @@ import type { SceneNode } from '@open-pencil/scene-graph'
import { initCanvasKit } from '#cli/headless'
import type { SkiaRenderer } from '#core/canvas/renderer'
import { renderText } from '#core/canvas/scene'
import { renderText, textVerticalOffset } from '#core/canvas/scene'
import { buildParagraph, isNodeFontLoaded } from '#core/canvas/text'
import { transformTextCase } from '#core/text/case'
import { fontManager } from '#core/text/fonts'
import { missingGlyphCharacters } from '#core/text/resolver'
@ -28,12 +29,13 @@ function createMockCanvas() {
save: mock(() => undefined),
saveLayer: mock(() => undefined),
restore: mock(() => undefined),
clipRect: mock(() => undefined)
clipRect: mock(() => undefined),
translate: mock(() => undefined)
}
}
function createMockParagraph() {
return { delete: mock(() => undefined) }
return { delete: mock(() => undefined), getHeight: mock(() => 20) }
}
function createMockPicture() {
@ -104,6 +106,22 @@ async function createTextRenderer() {
return { renderer, surface }
}
describe('text case and vertical alignment', () => {
test('transforms display text without changing the source', () => {
expect(transformTextCase('hello WORLD', 'ORIGINAL')).toBe('hello WORLD')
expect(transformTextCase('hello World', 'UPPER')).toBe('HELLO WORLD')
expect(transformTextCase('Hello WORLD', 'LOWER')).toBe('hello world')
expect(transformTextCase('hello WORLD 42nd', 'TITLE')).toBe('Hello World 42nd')
})
test('computes top, center, and bottom paragraph offsets', () => {
expect(textVerticalOffset(textNode({ height: 100, textAlignVertical: 'TOP' }), 20)).toBe(0)
expect(textVerticalOffset(textNode({ height: 100, textAlignVertical: 'CENTER' }), 20)).toBe(40)
expect(textVerticalOffset(textNode({ height: 100, textAlignVertical: 'BOTTOM' }), 20)).toBe(80)
expect(textVerticalOffset(textNode({ height: 10, textAlignVertical: 'BOTTOM' }), 20)).toBe(0)
})
})
describe('renderText', () => {
test('uses buildParagraph when fonts are loaded and node font is available', () => {
const r = createMockRenderer()

View file

@ -1,13 +1,23 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/core'
import { fontManager, textNeededFallbackScripts } from '@open-pencil/core/text'
import {
collectGraphFontRequirements,
fontManager,
textNeededFallbackScripts
} from '@open-pencil/core/text'
function pageId(graph: SceneGraph): string {
return graph.getPages()[0].id
}
describe('font fallback coverage indexing', () => {
test('collects glyphs after applying visual text case', () => {
const graph = new SceneGraph()
const node = graph.createNode('TEXT', pageId(graph), { text: 'abc', textCase: 'UPPER' })
expect(collectGraphFontRequirements(graph, [node.id]).characters).toBe('ABC')
})
test('detects supplementary-plane Han code points', async () => {
const family = `SupplementaryHan_${Date.now()}`
const data = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()

View file

@ -0,0 +1,61 @@
import { describe, expect, test } from 'bun:test'
import { computed } from 'vue'
import { createEditor } from '@open-pencil/core/editor'
import { createTypographyActions } from '#vue/controls/typography/actions'
describe('typography depth actions', () => {
test('restores nullable max lines and text style after preview undo', () => {
const editor = createEditor()
const text = editor.graph.createNode('TEXT', editor.state.currentPageId, {
maxLines: null,
textStyleId: '1:20'
})
const node = computed(() => editor.graph.getNode(text.id) ?? null)
const actions = createTypographyActions({
editor,
node,
currentWeightLabel: computed(() => 'Regular'),
activeFormatting: computed(() => []),
options: {}
})
actions.updateProp('maxLines', 3)
actions.commitProp('maxLines', 3, 1)
expect(editor.graph.getNode(text.id)).toMatchObject({ maxLines: 3, textStyleId: '1:20' })
editor.undo.undo()
expect(editor.graph.getNode(text.id)).toMatchObject({ maxLines: null, textStyleId: '1:20' })
})
test('updates OpenType features and detaches text styles in one undo step', () => {
const editor = createEditor()
const text = editor.graph.createNode('TEXT', editor.state.currentPageId, {
fontFeatures: [{ tag: 'kern', enabled: true }],
textStyleId: '1:21'
})
const actions = createTypographyActions({
editor,
node: computed(() => editor.graph.getNode(text.id) ?? null),
currentWeightLabel: computed(() => 'Regular'),
activeFormatting: computed(() => []),
options: {}
})
actions.setFontFeature('LIGA', false)
expect(editor.graph.getNode(text.id)).toMatchObject({
fontFeatures: [
{ tag: 'kern', enabled: true },
{ tag: 'LIGA', enabled: false }
],
textStyleId: null
})
editor.undo.undo()
expect(editor.graph.getNode(text.id)).toMatchObject({
fontFeatures: [{ tag: 'kern', enabled: true }],
textStyleId: '1:21'
})
})
})