Files
RUWIO/src/components/SvgLayerPanel.vue
T
thandClaude Opus 4.7 4c2b7c7c35 1.2.5: text font picker + 7 bundled Google fonts + default text fill black
- 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>
2026-04-26 00:32:42 +02:00

456 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>