Merge branch 'open-pencil:master' into master
This commit is contained in:
commit
1a82f84ba5
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
13
packages/core/src/text/case.ts
Normal file
13
packages/core/src/text/case.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "変数",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Переменные",
|
||||
|
|
|
|||
|
|
@ -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": "变量",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
77
tests/e2e/canvas/typography-depth-visual.spec.ts
Normal file
77
tests/e2e/canvas/typography-depth-visual.spec.ts
Normal 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 |
|
|
@ -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 |
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
61
tests/engine/vue/controls/typography-depth.test.ts
Normal file
61
tests/engine/vue/controls/typography-depth.test.ts
Normal 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue