openpencil/src/components/panels/text-section.tsx
Kayshen Xu ebe1346d24
V0.4.1 (#45)
* feat(canvas): enhance font handling and text measurement

- Introduced a new `cssFontFamily` utility to ensure proper quoting of font family names in canvas text rendering.
- Updated text measurement functions to utilize `cssFontFamily`, improving compatibility with various font formats.
- Added a `systemFontFamilies` set in `SkiaFontManager` to manage system fonts that cannot be loaded into CanvasKit, ensuring fallback to bitmap rendering.
- Implemented a hook to enumerate system fonts, enhancing the font selection experience in the UI.
- Added a new `FontPicker` component for improved font selection, integrating bundled and system fonts with search functionality.
- Updated translations for new font-related UI elements across multiple languages.

This update significantly improves text rendering accuracy and user experience when selecting fonts.

* feat(figma): enhance clipboard data processing and font handling

- Introduced a new mechanism to identify and handle known non-Google fonts, preventing unnecessary requests to the Google Fonts CDN for system and proprietary fonts.
- Improved the Figma clipboard data extraction process by simplifying error handling and removing excessive debug logs, enhancing performance and readability.
- Added functionality to convert unresolved image references in Figma clipboard data into placeholder rectangles, ensuring better visual fidelity when images are missing.
- Enhanced instance conversion logic to merge symbol properties into instances, ensuring that clipboard data retains necessary layout and visual properties.

This update significantly improves the handling of Figma clipboard data and font management, leading to a more robust user experience.

* refactor(figma): enhance clipboard data processing and style resolution

- Improved the Figma clipboard data extraction by adding optional HTML parsing to enrich nodes with style hints, ensuring better fidelity in text rendering.
- Updated the `figmaClipboardToNodes` function to log conversion results and handle unresolved image references more effectively.
- Refactored style resolution logic to ensure inline properties are correctly applied from style references, enhancing the overall rendering accuracy of Figma imports.

This update significantly enhances the handling of Figma clipboard data and style management, leading to a more robust user experience.

* feat(canvas): add image fill support and enhancements to fill section

- Introduced support for image fills in the SkiaRenderer, allowing for dynamic image rendering with various fit modes and adjustments.
- Added a new ImageFillPopover component for managing image fill properties, including exposure, contrast, and saturation adjustments.
- Updated the FillSection component to include an option for image fills, enhancing the user interface for fill selection.
- Improved localization for new image-related features across multiple languages.

This update significantly enhances the capabilities of the canvas rendering system, providing users with more options for fill types and customization.

* feat(canvas): enhance text shadow rendering and fill section UI

- Implemented a new method for drawing text shadows as blurred copies of glyphs, aligning with Figma's drop-shadow behavior.
- Updated the `drawText` method to incorporate shadow effects for text nodes, improving visual fidelity.
- Adjusted the FillSection component to use a fixed width for better layout consistency.

This update significantly enhances text rendering capabilities and improves the user interface for fill options.

* refactor(figma): simplify arc ellipse conversion logic

- Removed unnecessary position adjustments for flipped nodes, as the extractPosition function already computes the correct visual top-left.
- Cleaned up comments to clarify the handling of arc properties without rotation adjustments, streamlining the conversion process for arc ellipses.

This update enhances the clarity and efficiency of the arc ellipse conversion logic in the Figma node converters.

* fix(canvas): use drawImageRect for image fill modes instead of broken shader scaling

CanvasKit's Image.makeShaderOptions() fails to render when the localMatrix
contains scaling factors with Clamp/Decal tile modes. Only Repeat mode
works reliably with shader scaling.

- Tile mode: keep shader + TileMode.Repeat (works correctly)
- Fill/Fit/Crop/Stretch: use drawImageRect with canvas clipping
- Add drawImageFillRect() for non-tile image fill rendering
- makeFillPaint() returns optional imageFillDraw info for drawRect

* fix(panels): fix image adjustment reset button not working

The reset button called onAdjustmentChange in a loop, but each call
spread from the same stale fill reference, so only the last adjustment
was actually reset. Added onResetAdjustments callback that resets all
adjustment values in a single atomic update.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
2026-03-16 09:33:57 +08:00

210 lines
6.2 KiB
TypeScript

import NumberInput from '@/components/shared/number-input'
import SectionHeader from '@/components/shared/section-header'
import FontPicker from '@/components/shared/font-picker'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
AlignLeft,
AlignCenter,
AlignRight,
AlignJustify,
AlignVerticalJustifyStart,
AlignVerticalJustifyCenter,
AlignVerticalJustifyEnd,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import type { PenNode, TextNode } from '@/types/pen'
interface TextSectionProps {
node: TextNode
onUpdate: (updates: Partial<PenNode>) => void
}
const WEIGHT_OPTIONS = [
{ value: '100', labelKey: 'text.weight.thin' },
{ value: '300', labelKey: 'text.weight.light' },
{ value: '400', labelKey: 'text.weight.regular' },
{ value: '500', labelKey: 'text.weight.medium' },
{ value: '600', labelKey: 'text.weight.semibold' },
{ value: '700', labelKey: 'text.weight.bold' },
{ value: '900', labelKey: 'text.weight.black' },
]
const H_ALIGN_OPTIONS = [
{ value: 'left', icon: AlignLeft, labelKey: 'text.alignLeft' },
{ value: 'center', icon: AlignCenter, labelKey: 'text.alignCenter' },
{ value: 'right', icon: AlignRight, labelKey: 'text.alignRight' },
{ value: 'justify', icon: AlignJustify, labelKey: 'text.justify' },
]
const V_ALIGN_OPTIONS = [
{ value: 'top', icon: AlignVerticalJustifyStart, labelKey: 'text.top' },
{ value: 'middle', icon: AlignVerticalJustifyCenter, labelKey: 'text.middle' },
{ value: 'bottom', icon: AlignVerticalJustifyEnd, labelKey: 'text.bottom' },
]
const LineHeightIcon = (
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
<line x1="1" y1="2" x2="1" y2="10" />
<polyline points="3,4 1,2 -1,4" transform="translate(0,0)" />
<polyline points="3,8 1,10 -1,8" transform="translate(0,0)" />
<line x1="5" y1="6" x2="11" y2="6" />
<line x1="5" y1="3" x2="9" y2="3" />
<line x1="5" y1="9" x2="9" y2="9" />
</svg>
)
function AlignButton({
active,
onClick,
icon: Icon,
label,
}: {
active: boolean
onClick: () => void
icon: React.ComponentType<{ className?: string }>
label: string
}) {
return (
<button
type="button"
aria-label={label}
onClick={onClick}
className={cn(
'h-6 w-6 flex items-center justify-center rounded transition-colors',
active
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50',
)}
>
<Icon className="w-3.5 h-3.5" />
</button>
)
}
export default function TextSection({
node,
onUpdate,
}: TextSectionProps) {
const { t } = useTranslation()
const fontFamily = node.fontFamily ?? 'Inter, sans-serif'
const fontSize = node.fontSize ?? 16
const fontWeight = String(node.fontWeight ?? '400')
const lineHeight = node.lineHeight ?? 1.2
const letterSpacing = node.letterSpacing ?? 0
const textAlign = node.textAlign ?? 'left'
const textAlignVertical = node.textAlignVertical ?? 'top'
return (
<div className="space-y-1.5">
<SectionHeader title={t('text.typography')} />
{/* Font family */}
<FontPicker
value={fontFamily}
onChange={(v) => onUpdate({ fontFamily: v } as Partial<PenNode>)}
/>
{/* Weight + Size */}
<div className="grid grid-cols-2 gap-1">
<Select
value={fontWeight}
onValueChange={(v) =>
onUpdate({ fontWeight: Number(v) } as Partial<PenNode>)
}
>
<SelectTrigger className="h-6 text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{WEIGHT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{t(opt.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
<NumberInput
label="S"
value={fontSize}
onChange={(v) =>
onUpdate({ fontSize: v } as Partial<PenNode>)
}
min={1}
max={999}
/>
</div>
{/* Line height + Letter spacing */}
<div className="flex items-center justify-between text-[9px] text-muted-foreground px-0.5">
<span>{t('text.lineHeight')}</span>
<span>{t('text.letterSpacing')}</span>
</div>
<div className="grid grid-cols-2 gap-1">
<NumberInput
icon={LineHeightIcon}
value={Math.round(lineHeight * 100)}
onChange={(v) =>
onUpdate({ lineHeight: v / 100 } as Partial<PenNode>)
}
min={50}
max={400}
suffix="%"
/>
<NumberInput
label="|A|"
value={letterSpacing}
onChange={(v) =>
onUpdate({ letterSpacing: v } as Partial<PenNode>)
}
/>
</div>
{/* Horizontal alignment */}
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground">{t('text.horizontal')}</span>
<div className="flex items-center gap-0.5">
{H_ALIGN_OPTIONS.map(({ value, icon, labelKey }) => (
<AlignButton
key={value}
active={textAlign === value}
onClick={() =>
onUpdate({
textAlign: value as TextNode['textAlign'],
} as Partial<PenNode>)
}
icon={icon}
label={t(labelKey)}
/>
))}
</div>
</div>
{/* Vertical alignment */}
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground">{t('text.vertical')}</span>
<div className="flex items-center gap-0.5">
{V_ALIGN_OPTIONS.map(({ value, icon, labelKey }) => (
<AlignButton
key={value}
active={textAlignVertical === value}
onClick={() =>
onUpdate({
textAlignVertical: value as TextNode['textAlignVertical'],
} as Partial<PenNode>)
}
icon={icon}
label={t(labelKey)}
/>
))}
</div>
</div>
</div>
)
}