feat: complete TASK-WP4-04 color and dynamic stickers

This commit is contained in:
suyx
2026-08-03 14:11:40 +08:00
parent dd7a23a281
commit 457e1147e4
29 changed files with 2234 additions and 61 deletions
+161 -24
View File
@@ -25,12 +25,29 @@ import {
type TextTemplateDefinition,
} from "./text-assets.js";
import { TextTemplatePanel } from "./text-template-panel.js";
import { ColorCardPanel } from "./color-card-panel.js";
import { DynamicInspector } from "./dynamic-inspector.js";
import { DynamicStickerPanel } from "./dynamic-sticker-panel.js";
import {
LocationConsentGate,
P0A_DYNAMIC_STICKERS,
browserGeolocate,
createDynamicStickerElement,
overrideDynamicStickerValue,
type DynamicTemplateId,
} from "./dynamic-provider.js";
import { dynamicFontOptionsFor } from "./dynamic-render-models.js";
import {
createColorCardElement,
extractPaletteFromImage,
type ColorCardDefinition,
} from "./palette-provider.js";
import "./editor-page.css";
type Ratio = CanvasState["ratio"];
type CanvasElement = CanvasState["elements"][number];
type EditorAssetPanel = "background" | "history" | "stickers" | "text";
type EditorAssetPanel = "background" | "color" | "dynamic" | "history" | "stickers" | "text";
interface TextEditState {
draft: CanvasElement;
@@ -40,7 +57,13 @@ interface TextEditState {
interface EditorSession {
csrf_token: string;
user: { creator_name: string; user_id: string };
user: { creator_name: string; social_id: string; user_id: string };
}
interface LocationDialogState {
error?: string;
manualValue: string;
pending: boolean;
}
interface EditorProject {
@@ -70,14 +93,9 @@ function formatDate(value: string) {
return new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(value));
}
function paletteForAsset(assetId: string) {
const seed = assetId.replaceAll("-", "");
return [0, 1, 2, 3, 4].map((index) => `#${seed.slice(index * 6, index * 6 + 6).padEnd(6, "0")}`);
}
const stickerFixtures = [
{ assetId: "STK001", label: "方形标记" },
{ assetId: "STK002", label: "圆形标记" },
{ assetId: "STK001", label: "part1 原版草莓" },
{ assetId: "STK002", label: "part2 原版小狗" },
] as const;
function newElementIdentity(): CanvasElementIdentity {
@@ -106,6 +124,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
const [recentTextIds, setRecentTextIds] = useState<string[]>([]);
const [fontStatuses, setFontStatuses] = useState<Record<string, ArchivedFontStatus>>({});
const [textEdit, setTextEdit] = useState<TextEditState>();
const [locationDialog, setLocationDialog] = useState<LocationDialogState>();
const queueRef = useRef<ProjectAutoSaveQueue | undefined>(undefined);
const historyRef = useRef<CanvasEditHistory | undefined>(undefined);
const elementControllerRef = useRef<CanvasElementController | undefined>(undefined);
@@ -170,14 +189,19 @@ export function EditorPage({ projectId }: { projectId: string }) {
useEffect(() => {
if (!canvasState) return;
for (const element of canvasState.elements) {
if (element.type !== "text_template") continue;
const fontId = fontIdForTextElement(element);
const option = fontId ? fontOption(fontId) : undefined;
if (fontId && option) void ensureFont(option.fontId, option.url);
else if (fontId) setFontStatuses((current) => ({ ...current, [fontId]: "unavailable" }));
const options = element.type === "text_template"
? [fontIdForTextElement(element)].map((fontId) => fontId ? fontOption(fontId) : undefined).filter((option) => option !== undefined)
: element.type === "dynamic_sticker" ? dynamicFontOptionsFor(element.template_or_asset_id) : [];
for (const option of options) void ensureFont(option.fontId, option.url);
}
}, [canvasState?.elements.map((element) => `${element.element_id}:${element.font_override ?? ""}:${element.template_or_asset_id}`).join("|")]);
useEffect(() => {
if (activePanel !== "dynamic") return;
const options = P0A_DYNAMIC_STICKERS.flatMap((definition) => dynamicFontOptionsFor(definition.templateId));
for (const option of options) void ensureFont(option.fontId, option.url);
}, [activePanel]);
useEffect(() => {
if (!canvasState || selectedIds.length !== 1) {
setTextEdit(undefined);
@@ -250,11 +274,26 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
}
function confirmBackground() {
async function paletteForAsset(assetId: string) {
const image = new Image();
const loaded = new Promise<HTMLImageElement>((resolve, reject) => {
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("palette_image_unavailable"));
});
image.src = `/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(assetId)}`;
return extractPaletteFromImage(await loaded);
}
async function confirmBackground() {
if (!canvasState || !pendingBackground) return;
commitCanvas(switchBackground(canvasState, pendingBackground, paletteForAsset(pendingBackground)));
setPendingBackground(undefined);
setNotice("已更换底图,覆盖元素保留,底图处理已重置");
try {
const palette = await paletteForAsset(pendingBackground);
commitCanvas(switchBackground(canvasState, pendingBackground, palette));
setPendingBackground(undefined);
setNotice("已更换底图,覆盖元素保留,底图处理已重置");
} catch {
setNotice("新底图无法读取,未更换底图或刷新色卡");
}
}
function controllerForCurrent() {
@@ -291,6 +330,92 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
}
async function addColorCard(definition: ColorCardDefinition) {
const assetId = canvasState?.background.asset_id;
const controller = controllerForCurrent();
if (!assetId || !canvasState || !controller) return;
try {
const palette = await paletteForAsset(assetId);
controller.add(createColorCardElement(definition, palette, newElementIdentity(), canvasState.elements.length));
commitElementOperation(controller, "色卡已按原始底图加入画布");
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("无法从原始底图稳定提取五色,色卡未加入画布");
}
}
async function addDynamicSticker(templateId: DynamicTemplateId, location?: { formattedValue: string; latitude?: number; longitude?: number }) {
const controller = controllerForCurrent();
if (!canvasState || !controller || !session) return;
if (templateId === "DYN012") {
const font = fontOption("FONT081");
if (!font || await ensureFont(font.fontId, font.url) !== "ready") {
setNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。");
return;
}
}
try {
controller.add(createDynamicStickerElement(templateId, {
...(location ? { location } : {}),
now: new Date(),
profile: { creatorName: session.user.creator_name, socialId: session.user.social_id },
}, newElementIdentity(), canvasState.elements.length));
commitElementOperation(controller, "动态值已确认并加入画布");
setLocationDialog(undefined);
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("动态贴纸未能加入画布");
}
}
function chooseDynamicSticker(templateId: DynamicTemplateId) {
if (templateId === "DYN004") {
setLocationDialog({ manualValue: "", pending: false });
return;
}
void addDynamicSticker(templateId);
}
async function confirmAutomaticLocation() {
if (!session) return;
setLocationDialog((current) => {
if (!current) return current;
const { error: _error, ...rest } = current;
return { ...rest, pending: true };
});
const gate = new LocationConsentGate({
geolocate: browserGeolocate,
reverseGeocode: async (coordinates) => {
const response = await fetch("/api/v1/location/reverse-geocode", {
body: JSON.stringify(coordinates),
credentials: "same-origin",
headers: { "Content-Type": "application/json", "X-CSRF-Token": session.csrf_token },
method: "POST",
});
if (!response.ok) throw new Error("dynamic_location_service_unavailable");
const payload = await response.json() as { formatted_value: string };
return payload.formatted_value;
},
});
try {
const result = await gate.confirm();
await addDynamicSticker("DYN004", result);
} catch {
setLocationDialog((current) => current ? { ...current, error: "自动定位不可用,请改用手动地点贴纸。", pending: false } : current);
}
}
function commitDynamicOverride(element: CanvasElement, value: string) {
const controller = controllerForCurrent();
if (!controller) return;
try {
controller.replaceElement(overrideDynamicStickerValue(element, value));
commitElementOperation(controller, "动态贴纸显示文字已更新");
} catch {
setNotice("动态贴纸显示文字不能为空");
}
}
async function recordRecentTextTemplate(templateId: string, resourceVersion: string) {
if (!session) return;
try {
@@ -527,7 +652,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
const canEdit = saveStatus !== "conflicted";
const selectedElements = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id));
const selectedStickerOpacity = selectedElements.length > 0 && selectedElements.every((element) => element.type === "static_sticker")
const selectedStickerOpacity = selectedElements.length > 0 && selectedElements.every((element) => element.type !== "text_template")
? Math.round((selectedElements[0]?.opacity ?? 1) * 100)
: undefined;
return (
@@ -552,9 +677,9 @@ export function EditorPage({ projectId }: { projectId: string }) {
{ label: "历史", panel: "history" as const },
{ label: "文字模板", panel: "text" as const },
{ label: "普通贴纸", panel: "stickers" as const },
{ label: "色卡" },
{ label: "动态贴纸" },
]).map((item) => <button aria-current={item.panel === activePanel ? "page" : undefined} disabled={!item.panel} key={item.label} onClick={() => { if (item.panel) setActivePanel(item.panel); }} type="button">{item.label}</button>)}
{ label: "色卡", panel: "color" as const },
{ label: "动态贴纸", panel: "dynamic" as const },
]).map((item) => <button aria-current={item.panel === activePanel ? "page" : undefined} key={item.label} onClick={() => setActivePanel(item.panel)} type="button">{item.label}</button>)}
</nav>
{activePanel === "background" ? <section><h2></h2><button className="editor-source active" type="button"><span className="editor-thumb" style={{ backgroundImage: `url(${imageUrl})` }} /><span></span></button></section> : null}
{activePanel === "history" ? <section><h2></h2><div className="editor-history-list">
@@ -573,8 +698,10 @@ export function EditorPage({ projectId }: { projectId: string }) {
{...(templateCategory ? { category: templateCategory } : {})}
/> : null}
{activePanel === "stickers" ? <section><h2></h2><div className="editor-sticker-grid">
{stickerFixtures.map((sticker) => <button aria-label={`添加贴纸 ${sticker.assetId}`} disabled={!canEdit || canvasState.elements.length >= 50} key={sticker.assetId} onClick={() => addSticker(sticker.assetId)} type="button"><span className={`editor-sticker-preview ${sticker.assetId.toLowerCase()}`} /><strong>{sticker.assetId}</strong><span>{sticker.label}</span></button>)}
{stickerFixtures.map((sticker) => <button aria-label={`添加贴纸 ${sticker.assetId}`} disabled={!canEdit || canvasState.elements.length >= 50} key={sticker.assetId} onClick={() => addSticker(sticker.assetId)} type="button"><img alt="" className="editor-sticker-preview" src={`/api/v1/assets/public/fixture-v1/${sticker.assetId}`} /><strong>{sticker.assetId}</strong><span>{sticker.label}</span></button>)}
</div>{canvasState.elements.length >= 50 ? <p className="editor-limit" role="status"> 50 </p> : null}</section> : null}
{activePanel === "color" ? <ColorCardPanel canAdd={canEdit && canvasState.elements.length < 50} hasBackground={Boolean(canvasState.background.asset_id)} onAdd={(definition) => { void addColorCard(definition); }} /> : null}
{activePanel === "dynamic" ? <DynamicStickerPanel canAdd={canEdit && canvasState.elements.length < 50} fontStatuses={fontStatuses} onAdd={chooseDynamicSticker} /> : null}
</aside>
<section aria-label="画布工作区" className="editor-workspace">
<div className="editor-canvas-tools" role="toolbar" aria-label="画布选择工具">
@@ -630,6 +757,9 @@ export function EditorPage({ projectId }: { projectId: string }) {
onTemplate={(templateId) => { void changeTextTemplate(templateId); }}
templates={P0A_TEXT_TEMPLATES}
/> : <p className="editor-muted"></p> : null}
{selectedElements.length === 1 && selectedElements[0]?.type === "dynamic_sticker" && canEdit
? <DynamicInspector element={selectedElements[0]} onCommit={(value) => commitDynamicOverride(selectedElements[0]!, value)} />
: null}
<div className="editor-object-moves" role="group" aria-label="移动对象">
<button aria-label="向左移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: -0.01, y: 0 }, { snap: false }); }, "对象位置已提交")} title="向左移动" type="button"></button>
<button aria-label="向上移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: 0, y: -0.01 }, { snap: false }); }, "对象位置已提交")} title="向上移动" type="button"></button>
@@ -657,7 +787,14 @@ export function EditorPage({ projectId }: { projectId: string }) {
</aside>
</main>
<footer className="editor-statusbar"><span> {canvasState.pixel_width} × {canvasState.pixel_height}</span><span> {canvasState.elements.length} / 50</span><span> 100%</span><span> · state version {project.state_version}</span></footer>
{pendingBackground ? <div className="editor-dialog-backdrop"><section aria-labelledby="editor-background-confirm" aria-modal="true" className="editor-confirm" role="dialog"><p>CHANGE BACKGROUND</p><h2 id="editor-background-confirm"></h2><p></p><div><button className="editor-primary" onClick={confirmBackground} type="button"></button><button onClick={() => setPendingBackground(undefined)} type="button"></button></div></section></div> : null}
{pendingBackground ? <div className="editor-dialog-backdrop"><section aria-labelledby="editor-background-confirm" aria-modal="true" className="editor-confirm" role="dialog"><p>CHANGE BACKGROUND</p><h2 id="editor-background-confirm"></h2><p></p><div><button className="editor-primary" onClick={() => { void confirmBackground(); }} type="button"></button><button onClick={() => setPendingBackground(undefined)} type="button"></button></div></section></div> : null}
{locationDialog ? <div className="editor-dialog-backdrop"><section aria-labelledby="editor-location-consent" aria-modal="true" className="editor-confirm editor-location-consent" role="dialog">
<p>LOCATION PRIVACY</p><h2 id="editor-location-consent">使</h2>
<p></p>
{locationDialog.error ? <p className="editor-limit" role="alert">{locationDialog.error}</p> : null}
<label><input aria-label="手动地点文字" disabled={locationDialog.pending} onChange={(event) => setLocationDialog((current) => current ? { ...current, manualValue: event.target.value } : current)} value={locationDialog.manualValue} /></label>
<div className="editor-location-actions"><button className="editor-primary" disabled={locationDialog.pending} onClick={() => { void confirmAutomaticLocation(); }} type="button">{locationDialog.pending ? "正在定位" : "同意并自动定位"}</button><button disabled={!locationDialog.manualValue.trim() || locationDialog.pending} onClick={() => { void addDynamicSticker("DYN001", { formattedValue: locationDialog.manualValue.trim() }); }} type="button"></button><button disabled={locationDialog.pending} onClick={() => setLocationDialog(undefined)} type="button"></button></div>
</section></div> : null}
</div>
);
}