Merge branch 't2-fonts' into integration

This commit is contained in:
2026-09-04 16:48:50 +08:00
3 changed files with 745 additions and 24 deletions
+287 -7
View File
@@ -1,21 +1,301 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { Shadow, Textbox } from "fabric";
import { useEditor } from "@/editor/useEditor";
import {
ensureFontLoaded,
fonts,
listFonts,
loadingFonts,
styleOfSubfamily,
} from "@/fonts/useFonts";
/**
* 【T2 待实现 — 文字面板】
* 【T2 — 文字面板】选中 Textbox 时显示。
*
* 选中 Textbox 时显示。要有:字体下拉(来自 useFonts)、字号、字重/颜色、
* 对齐、行高、描边与阴影(现有封面靠这两样保证白字在图上读得清)。
* 字体下拉列出本机字体(family + subfamily),选中后把 fontFamily / fontWeight /
* fontStyle 应用到当前文本,再交给 ensureFontLoaded 走「先本机、后 webfont」;
* 加载失败要在这里给出可见提示,不能静默。
*
* 拿当前选中对象用 `useEditor().activeObject`,改完记得 `canvas.requestRenderAll()`。
* 画布选中对象是 Fabric 实例,不是响应式的,所以用一批本地 ref 做 UI 状态:
* 选中对象变化时从对象同步进来,用户改动时写回对象并 `requestRenderAll()`。
*/
const { activeObject, getCanvas } = useEditor();
const textObj = computed<Textbox | null>(() =>
activeObject.value instanceof Textbox ? activeObject.value : null,
);
// 本地 UI 状态。
const fontSize = ref(40);
const lineHeight = ref(1.16);
const fill = ref("#ffffff");
const textAlign = ref("center");
const stroke = ref("");
const strokeWidth = ref(0);
const shadowEnabled = ref(false);
const shadowColor = ref("#000000");
const shadowBlur = ref(0);
const shadowOffsetX = ref(0);
const shadowOffsetY = ref(0);
// 字体选择状态。
const selectedId = ref("");
const fontError = ref("");
/** 把 Fabric 的 fontWeight("normal" / "bold" / 数字)归一成数字字符串再比对。 */
function normWeight(w: string | number | null | undefined): string {
if (w === "normal" || w === null || w === undefined || w === "") return "400";
if (w === "bold") return "700";
return String(w);
}
/** 用对象上的 fontFamily + weight/style 反查出下拉里对应的字体 id。 */
function matchEntryId(): string {
const obj = textObj.value;
if (!obj) return "";
const fam = obj.fontFamily;
if (!fam) return "";
const candidates = fonts.value.filter((f) => f.family === fam);
if (candidates.length === 0) return "";
if (candidates.length === 1) return candidates[0].id;
const w = normWeight(obj.fontWeight);
const st = obj.fontStyle ?? "normal";
const exact = candidates.find((f) => {
const s = styleOfSubfamily(f.subfamily);
return String(s.fontWeight) === w && s.fontStyle === st;
});
return exact?.id ?? candidates[0].id;
}
/** 选中对象变化 → 把对象上的值同步进 UI。 */
function syncFromObject() {
const obj = textObj.value;
if (!obj) return;
fontSize.value = typeof obj.fontSize === "number" ? obj.fontSize : 40;
lineHeight.value = typeof obj.lineHeight === "number" ? obj.lineHeight : 1.16;
fill.value = typeof obj.fill === "string" ? obj.fill : "#ffffff";
textAlign.value = obj.textAlign ?? "left";
stroke.value = typeof obj.stroke === "string" ? obj.stroke : "";
strokeWidth.value = typeof obj.strokeWidth === "number" ? obj.strokeWidth : 0;
const s = obj.shadow;
shadowEnabled.value = !!s;
shadowColor.value = typeof s?.color === "string" ? s.color : "#000000";
shadowBlur.value = typeof s?.blur === "number" ? s.blur : 0;
shadowOffsetX.value = typeof s?.offsetX === "number" ? s.offsetX : 0;
shadowOffsetY.value = typeof s?.offsetY === "number" ? s.offsetY : 0;
selectedId.value = matchEntryId();
}
watch(textObj, syncFromObject, { immediate: true });
watch(fonts, () => {
selectedId.value = matchEntryId();
});
/** 用户改动 → 写回对象并重绘。 */
function applyToObject() {
const obj = textObj.value;
if (!obj) return;
const patch: Record<string, unknown> = {
fill: fill.value,
textAlign: textAlign.value,
stroke: stroke.value || null,
};
const fs = Number(fontSize.value);
if (Number.isFinite(fs) && fs > 0) patch.fontSize = fs;
const lh = Number(lineHeight.value);
if (Number.isFinite(lh) && lh > 0) patch.lineHeight = lh;
const sw = Number(strokeWidth.value);
if (Number.isFinite(sw) && sw >= 0) patch.strokeWidth = sw;
let shadow: Shadow | null = null;
if (shadowEnabled.value) {
const blur = Number(shadowBlur.value);
const ox = Number(shadowOffsetX.value);
const oy = Number(shadowOffsetY.value);
shadow = new Shadow({
color: shadowColor.value || "#000000",
blur: Number.isFinite(blur) && blur >= 0 ? blur : 0,
offsetX: Number.isFinite(ox) ? ox : 0,
offsetY: Number.isFinite(oy) ? oy : 0,
});
}
patch.shadow = shadow;
obj.set(patch);
getCanvas().requestRenderAll();
}
watch(
[
fontSize,
lineHeight,
fill,
textAlign,
stroke,
strokeWidth,
shadowEnabled,
shadowColor,
shadowBlur,
shadowOffsetX,
shadowOffsetY,
],
applyToObject,
);
async function pickFont(id: string) {
const entry = fonts.value.find((f) => f.id === id);
const obj = textObj.value;
if (!entry || !obj) return;
selectedId.value = id;
const { fontWeight, fontStyle } = styleOfSubfamily(entry.subfamily);
obj.set({ fontFamily: entry.family, fontWeight, fontStyle });
getCanvas().requestRenderAll();
try {
await ensureFontLoaded(entry);
fontError.value = "";
} catch {
fontError.value = "这款字体用不了:本机解析不到,浏览器也加载不了,文字暂时用默认字体。";
}
}
listFonts();
</script>
<template>
<p class="todo">T2 待实现:字体选择与文字样式</p>
<div class="text-panel">
<div class="field">
<label>字体</label>
<select :value="selectedId" @change="pickFont(($event.target as HTMLSelectElement).value)">
<option value="" disabled>— 选择本机字体 —</option>
<option v-for="f in fonts" :key="f.id" :value="f.id" :disabled="f.needsExtraction">
{{ f.family }}{{ f.subfamily ? " " + f.subfamily : "" }}{{ f.needsExtraction ? "(.ttc 合集,暂不支持)" : "" }}
</option>
</select>
<p v-if="fontError" class="meta error">{{ fontError }}</p>
<p v-else-if="loadingFonts" class="meta">正在读取本机字体…</p>
<p v-else-if="fonts.length === 0" class="meta">未找到可用字体(本机需装 .ttf / .otf 字体)。</p>
</div>
<div class="field row">
<div class="grow">
<label>字号</label>
<input type="number" v-model.number="fontSize" min="1" step="1" />
</div>
<div class="grow">
<label>行高</label>
<input type="number" v-model.number="lineHeight" min="0.1" step="0.1" />
</div>
</div>
<div class="field row">
<div class="grow">
<label>颜色</label>
<input type="color" v-model="fill" />
</div>
<div class="grow">
<label>对齐</label>
<select v-model="textAlign">
<option value="left">左对齐</option>
<option value="center">居中</option>
<option value="right">右对齐</option>
<option value="justify">两端对齐</option>
</select>
</div>
</div>
<fieldset class="group">
<legend>描边</legend>
<div class="field row">
<div class="grow">
<label>颜色</label>
<input type="color" v-model="stroke" />
</div>
<div class="grow">
<label>宽度</label>
<input type="number" v-model.number="strokeWidth" min="0" step="1" />
</div>
</div>
</fieldset>
<fieldset class="group">
<legend>阴影</legend>
<label class="check">
<input type="checkbox" v-model="shadowEnabled" />
启用阴影
</label>
<template v-if="shadowEnabled">
<div class="field">
<label>颜色</label>
<input type="color" v-model="shadowColor" />
</div>
<div class="field row">
<div class="grow">
<label>模糊</label>
<input type="number" v-model.number="shadowBlur" min="0" step="1" />
</div>
<div class="grow">
<label>水平偏移</label>
<input type="number" v-model.number="shadowOffsetX" step="1" />
</div>
<div class="grow">
<label>垂直偏移</label>
<input type="number" v-model.number="shadowOffsetY" step="1" />
</div>
</div>
</template>
</fieldset>
</div>
</template>
<style scoped>
.todo {
.text-panel {
display: flex;
flex-direction: column;
}
.grow {
flex: 1;
min-width: 0;
}
.meta {
color: var(--fg-dim);
margin: 0;
margin: 6px 0 0;
line-height: 1.6;
}
.meta.error {
color: var(--accent);
}
.group {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 8px 10px 4px;
margin: 0 0 10px;
}
.group legend {
padding: 0 4px;
color: var(--fg-dim);
font-size: 12px;
}
.check {
display: flex;
align-items: center;
gap: 6px;
color: var(--fg);
cursor: pointer;
margin-bottom: 8px;
}
.check input {
width: auto;
}
</style>