- New src/lib/fonts.ts: curated set (Inter, Roboto, Lora, Playfair Display, Bebas Neue, Source Code Pro, Permanent Marker) shipped as local .woff2 in public/fonts/. loadFonts() registers them via FontFace at app start; fontsReady ref re-triggers Konva text configs once fonts resolve so the first paint isn't stuck on the fallback. - TextAsset gets optional fontFamily (id; undefined = system sans). PropertiesPanel adds a font dropdown next to font size. - All five render sites read the family: CanvasStage textConfig + clipTextConfig (Konva), drawLayer + drawMaskShape (canvas2d), exportPlayer engine (uses an embedded FONT_FAMILIES map and fontFam helper; runtime version 27 → 28). - Example HTML export now lists used bundled fonts and prints the @font-face CSS the user has to paste alongside the .woff2 files — fonts themselves are NOT bundled into the export to keep it small. - Default text fill changed from yellow #f5d76e to black #000000 in both the main sidebar and the SVG-scope add-text path. - 8 new tests under "text font picker — model + render + export". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
456 lines
11 KiB
Vue
456 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref } from 'vue'
|
||
import {
|
||
PhCircle,
|
||
PhDotsSixVertical,
|
||
PhEye,
|
||
PhEyeSlash,
|
||
PhImage,
|
||
PhLock,
|
||
PhLockOpen,
|
||
PhPencilSimple,
|
||
PhPolygon,
|
||
PhSquare,
|
||
PhTextT,
|
||
PhTrash,
|
||
} from '@phosphor-icons/vue'
|
||
import { useSceneStore } from '@/stores/scene'
|
||
import { promptDialog } from '@/components/ui'
|
||
import { t } from '@/i18n'
|
||
import type { Asset, Layer } from '@/types/scene'
|
||
|
||
const store = useSceneStore()
|
||
|
||
// The active SVG asset is the source of truth. When the user exits
|
||
// the scope this panel unmounts — App.vue swaps to AssetSidebar.
|
||
const layers = computed<Layer[]>(() => store.activeSvgAsset?.layers ?? [])
|
||
|
||
// Find the asset this layer renders. Used for the row icon and to
|
||
// keep the layer-list visually aligned with the main asset library.
|
||
const assetById = computed(() => {
|
||
const map = new Map<string, Asset>()
|
||
for (const a of store.scene.assets) map.set(a.id, a)
|
||
return map
|
||
})
|
||
|
||
function iconFor(layer: Layer) {
|
||
const asset = assetById.value.get(layer.assetId)
|
||
if (!asset) return PhSquare
|
||
switch (asset.kind) {
|
||
case 'rect': return PhSquare
|
||
case 'circle': return PhCircle
|
||
case 'text': return PhTextT
|
||
case 'polygon': return PhPolygon
|
||
case 'image': return PhImage
|
||
default: return PhSquare
|
||
}
|
||
}
|
||
|
||
// -- Shape creators (mirror AssetSidebar; the active scope routes
|
||
// them into the SVG asset's layer list automatically). --------
|
||
|
||
function addRect() {
|
||
const asset = store.addAsset({
|
||
kind: 'rect', name: 'Rect', width: 160, height: 100, fill: '#6ea8ff',
|
||
})
|
||
store.addLayer(asset.id)
|
||
}
|
||
|
||
function addCircle() {
|
||
const asset = store.addAsset({
|
||
kind: 'circle', name: 'Circle', radius: 60, fill: '#ff7a59',
|
||
})
|
||
store.addLayer(asset.id)
|
||
}
|
||
|
||
async function addText() {
|
||
const input = await promptDialog({
|
||
title: t('sidebar.newTextDialog.title'),
|
||
message: t('sidebar.newTextDialog.message'),
|
||
defaultValue: t('sidebar.newTextDialog.default'),
|
||
confirmLabel: t('sidebar.newTextDialog.confirm'),
|
||
})
|
||
if (input === null) return
|
||
const text = input.trim() || t('sidebar.newTextDialog.fallback')
|
||
const asset = store.addAsset({
|
||
kind: 'text',
|
||
name: text.length > 24 ? text.slice(0, 24) + '…' : text,
|
||
text,
|
||
fontSize: 48,
|
||
fill: '#000000',
|
||
})
|
||
store.addLayer(asset.id)
|
||
}
|
||
|
||
async function addPolygon() {
|
||
const input = await promptDialog({
|
||
title: t('sidebar.newPolygonDialog.title'),
|
||
message: t('sidebar.newPolygonDialog.message'),
|
||
defaultValue: '6',
|
||
confirmLabel: t('sidebar.newPolygonDialog.confirm'),
|
||
})
|
||
if (input === null) return
|
||
const parsed = parseInt(input.trim(), 10)
|
||
const sides = Number.isFinite(parsed) ? Math.max(3, Math.min(24, parsed)) : 6
|
||
const radius = 60
|
||
const points: { x: number; y: number }[] = []
|
||
for (let i = 0; i < sides; i++) {
|
||
const a = (i / sides) * Math.PI * 2 - Math.PI / 2
|
||
points.push({ x: Math.cos(a) * radius, y: Math.sin(a) * radius })
|
||
}
|
||
const asset = store.addAsset({
|
||
kind: 'polygon', name: `Polygon ${sides}`, points, fill: '#9b7ede',
|
||
})
|
||
store.addLayer(asset.id)
|
||
}
|
||
|
||
function startFreeform() {
|
||
store.startFreeformDrawing()
|
||
}
|
||
|
||
// -- Per-layer interactions ---------------------------------------
|
||
|
||
function selectLayer(layer: Layer, e: MouseEvent) {
|
||
if (e.shiftKey) {
|
||
store.toggleLayerSelection(layer.id)
|
||
} else {
|
||
store.selectLayer(layer.id)
|
||
}
|
||
}
|
||
|
||
function isSelected(layer: Layer): boolean {
|
||
return store.selectedLayerIds.includes(layer.id)
|
||
}
|
||
|
||
function toggleVisible(layer: Layer) {
|
||
store.setLayerEditorHidden(layer.id, !layer.editorHidden)
|
||
}
|
||
|
||
function toggleLocked(layer: Layer) {
|
||
store.setTrackLocked(layer.id, !layer.locked)
|
||
}
|
||
|
||
async function renameLayer(layer: Layer) {
|
||
const input = await promptDialog({
|
||
title: t('svgPanel.renameLayer'),
|
||
defaultValue: layer.name,
|
||
confirmLabel: t('svgPanel.renameConfirm'),
|
||
})
|
||
if (input === null) return
|
||
const trimmed = input.trim()
|
||
if (trimmed) store.setLayerName(layer.id, trimmed)
|
||
}
|
||
|
||
function removeLayer(layer: Layer) {
|
||
store.removeLayer(layer.id)
|
||
}
|
||
|
||
// -- Drag-to-reorder. Top of list = front of stack (same as the
|
||
// timeline's top-down semantics). -------------------------------
|
||
|
||
const dragIndex = ref<number | null>(null)
|
||
const dragOverIndex = ref<number | null>(null)
|
||
|
||
function onDragStart(e: DragEvent, index: number) {
|
||
dragIndex.value = index
|
||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
|
||
}
|
||
|
||
function onDragOver(e: DragEvent, index: number) {
|
||
if (dragIndex.value === null) return
|
||
e.preventDefault()
|
||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
|
||
dragOverIndex.value = index
|
||
}
|
||
|
||
function onDrop(e: DragEvent, index: number) {
|
||
e.preventDefault()
|
||
const from = dragIndex.value
|
||
dragIndex.value = null
|
||
dragOverIndex.value = null
|
||
if (from === null || from === index) return
|
||
store.moveLayer(from, index)
|
||
}
|
||
|
||
function onDragEnd() {
|
||
dragIndex.value = null
|
||
dragOverIndex.value = null
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="svg-panel">
|
||
<div class="toolbar">
|
||
<button
|
||
class="tool-btn"
|
||
v-tooltip="t('sidebar.shape.rect')"
|
||
@click="addRect"
|
||
>
|
||
<PhSquare :size="16" />
|
||
</button>
|
||
<button
|
||
class="tool-btn"
|
||
v-tooltip="t('sidebar.shape.circle')"
|
||
@click="addCircle"
|
||
>
|
||
<PhCircle :size="16" />
|
||
</button>
|
||
<button
|
||
class="tool-btn"
|
||
v-tooltip="t('sidebar.shape.text')"
|
||
@click="addText"
|
||
>
|
||
<PhTextT :size="16" />
|
||
</button>
|
||
<button
|
||
class="tool-btn"
|
||
v-tooltip="t('sidebar.shape.polygon')"
|
||
@click="addPolygon"
|
||
>
|
||
<PhPolygon :size="16" />
|
||
</button>
|
||
<button
|
||
class="tool-btn"
|
||
:class="{ active: store.drawingMode === 'freeform' }"
|
||
v-tooltip="t('sidebar.shape.freeform')"
|
||
@click="startFreeform"
|
||
>
|
||
<PhPencilSimple :size="16" />
|
||
</button>
|
||
</div>
|
||
|
||
<div class="section-head-row">
|
||
<span class="section-title">{{ t('svgPanel.layers') }}</span>
|
||
<span class="section-count">{{ layers.length }}</span>
|
||
</div>
|
||
<ul class="layer-list">
|
||
<li
|
||
v-for="(layer, idx) in layers"
|
||
:key="layer.id"
|
||
class="layer-row"
|
||
:class="{
|
||
selected: isSelected(layer),
|
||
'drop-target': dragOverIndex === idx,
|
||
dragging: dragIndex === idx,
|
||
}"
|
||
draggable="true"
|
||
@click="selectLayer(layer, $event)"
|
||
@dragstart="onDragStart($event, idx)"
|
||
@dragover="onDragOver($event, idx)"
|
||
@drop="onDrop($event, idx)"
|
||
@dragend="onDragEnd"
|
||
>
|
||
<PhDotsSixVertical :size="12" class="drag-grip" />
|
||
<component :is="iconFor(layer)" :size="13" class="layer-icon" />
|
||
<span class="layer-name" v-tooltip="layer.name">{{ layer.name }}</span>
|
||
<button
|
||
class="row-btn"
|
||
:class="{ off: layer.editorHidden }"
|
||
v-tooltip="layer.editorHidden ? t('timeline.showInEditor') : t('timeline.hideInEditor')"
|
||
@click.stop="toggleVisible(layer)"
|
||
>
|
||
<component :is="layer.editorHidden ? PhEyeSlash : PhEye" :size="12" />
|
||
</button>
|
||
<button
|
||
class="row-btn"
|
||
:class="{ on: layer.locked }"
|
||
v-tooltip="layer.locked ? t('timeline.unlock') : t('timeline.lock')"
|
||
@click.stop="toggleLocked(layer)"
|
||
>
|
||
<component :is="layer.locked ? PhLock : PhLockOpen" :size="12" />
|
||
</button>
|
||
<button
|
||
class="row-btn"
|
||
v-tooltip="t('svgPanel.renameLayer')"
|
||
@click.stop="renameLayer(layer)"
|
||
>
|
||
<PhPencilSimple :size="12" />
|
||
</button>
|
||
<button
|
||
class="row-btn danger"
|
||
v-tooltip="t('sidebar.deleteTooltip')"
|
||
@click.stop="removeLayer(layer)"
|
||
>
|
||
<PhTrash :size="12" />
|
||
</button>
|
||
</li>
|
||
<li v-if="!layers.length" class="empty">{{ t('svgPanel.empty') }}</li>
|
||
</ul>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.svg-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 10px 8px;
|
||
gap: 8px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.toolbar {
|
||
display: flex;
|
||
gap: 4px;
|
||
padding: 6px;
|
||
background: var(--panel-alt);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.tool-btn {
|
||
flex: 1 1 0;
|
||
min-width: 0;
|
||
height: 28px;
|
||
padding: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: transparent;
|
||
border: 1px solid transparent;
|
||
border-radius: var(--radius-sm);
|
||
color: var(--text-dim);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.tool-btn:hover {
|
||
background: var(--panel);
|
||
color: var(--text);
|
||
}
|
||
|
||
.tool-btn.active {
|
||
background: var(--accent-soft);
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.section-head-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 0 4px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.section-title {
|
||
flex: 1 1 auto;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: var(--text-dim);
|
||
}
|
||
|
||
.section-count {
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.layer-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.layer-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 6px;
|
||
background: var(--panel-alt);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
}
|
||
|
||
.layer-row:hover {
|
||
border-color: var(--border-strong);
|
||
}
|
||
|
||
.layer-row.selected {
|
||
background: var(--accent-soft);
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.layer-row.dragging {
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.layer-row.drop-target {
|
||
border-color: var(--accent);
|
||
outline: 1px dashed var(--accent);
|
||
outline-offset: -2px;
|
||
}
|
||
|
||
.drag-grip {
|
||
color: var(--text-dim);
|
||
cursor: grab;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.layer-icon {
|
||
color: var(--text-dim);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.layer-name {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
color: var(--text);
|
||
}
|
||
|
||
.row-btn {
|
||
width: 22px;
|
||
height: 22px;
|
||
/* Override the global `button { padding: 5px 10px }` reset —
|
||
with box-sizing:border-box that padding eats the entire 22×22
|
||
box and clips the icon out of view ("icons missing"). */
|
||
padding: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: transparent;
|
||
border: 1px solid transparent;
|
||
border-radius: var(--radius-sm);
|
||
color: var(--text-dim);
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.row-btn:hover {
|
||
background: var(--panel);
|
||
color: var(--text);
|
||
border-color: var(--border);
|
||
}
|
||
|
||
.row-btn.off {
|
||
color: var(--text-dim);
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.row-btn.on {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.row-btn.danger:hover {
|
||
color: var(--danger);
|
||
border-color: var(--danger);
|
||
}
|
||
|
||
.empty {
|
||
padding: 16px 8px;
|
||
text-align: center;
|
||
color: var(--text-dim);
|
||
font-style: italic;
|
||
font-size: 11px;
|
||
}
|
||
</style>
|