feat: complete TASK-WP4-04 color and dynamic stickers
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
export interface AmapAdapter {
|
||||
reverseGeocode(coordinates: { latitude: number; longitude: number }): Promise<{ formattedValue: string; serviceMode: "mock" }>;
|
||||
}
|
||||
|
||||
export class MockAmapAdapter implements AmapAdapter {
|
||||
readonly calls: Array<{ latitude: number; longitude: number }> = [];
|
||||
|
||||
async reverseGeocode(coordinates: { latitude: number; longitude: number }) {
|
||||
this.calls.push({ ...coordinates });
|
||||
return {
|
||||
formattedValue: `模拟地点 ${coordinates.latitude.toFixed(4)}, ${coordinates.longitude.toFixed(4)}`,
|
||||
serviceMode: "mock" as const,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,8 @@ import {
|
||||
RecentAssetQuerySchema,
|
||||
RecentAssetRecordRequestSchema,
|
||||
RecentAssetRecordResponseSchema,
|
||||
ReverseGeocodeRequestSchema,
|
||||
ReverseGeocodeResponseSchema,
|
||||
RegistrationCompleteHeadersSchema,
|
||||
RegistrationCompleteRequestSchema,
|
||||
RegistrationCompleteResponseSchema,
|
||||
@@ -128,6 +130,7 @@ import {
|
||||
type ProjectStateSaveHeaders,
|
||||
type RecentAssetQuery,
|
||||
type RecentAssetRecordRequest,
|
||||
type ReverseGeocodeRequest,
|
||||
type RegistrationCompleteRequest,
|
||||
type RegistrationSendRequest,
|
||||
} from "@dada/shared-contracts";
|
||||
@@ -171,6 +174,7 @@ import {
|
||||
} from "./registration-errors.js";
|
||||
import type { RegistrationService } from "./registration.js";
|
||||
import type { RecentAssetService } from "./recent-assets.js";
|
||||
import type { AmapAdapter } from "./amap-adapter.js";
|
||||
import { ModelConfigurationError } from "./model-configuration.js";
|
||||
import type { ModelConfigurationService } from "./model-configuration.js";
|
||||
|
||||
@@ -187,6 +191,7 @@ const defaultBootstrap: BootstrapResponse = {
|
||||
};
|
||||
|
||||
export interface CreateAppOptions {
|
||||
amap?: AmapAdapter;
|
||||
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
|
||||
browserGate?: boolean;
|
||||
browserSupportRelease?: BrowserSupportRelease;
|
||||
@@ -742,6 +747,8 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
RecentAssetListResponseSchema,
|
||||
RecentAssetRecordRequestSchema,
|
||||
RecentAssetRecordResponseSchema,
|
||||
ReverseGeocodeRequestSchema,
|
||||
ReverseGeocodeResponseSchema,
|
||||
FailedEmptyTrashRequestSchema,
|
||||
FailedEmptyTrashResponseSchema,
|
||||
ModelIdSchema,
|
||||
@@ -896,6 +903,40 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/location/reverse-geocode",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
body: Type.Ref(ReverseGeocodeRequestSchema),
|
||||
headers: Type.Ref(CsrfHeadersSchema),
|
||||
operationId: "reverseGeocodeLocation",
|
||||
response: {
|
||||
200: Type.Ref(ReverseGeocodeResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Null(),
|
||||
},
|
||||
tags: ["Location"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) return reply.code(400).send(null);
|
||||
if (!options.registration || !options.amap) return reply.code(503).send(null);
|
||||
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
|
||||
const csrfToken = headerValue(request.headers["x-csrf-token"]);
|
||||
if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
try {
|
||||
options.registration.authorizeUserMutation({ csrfToken, sessionToken: token });
|
||||
const result = await options.amap.reverseGeocode(request.body as ReverseGeocodeRequest);
|
||||
return { formatted_value: result.formattedValue, service_mode: result.serviceMode, status: "resolved" as const };
|
||||
} catch (error) {
|
||||
if (error instanceof RegistrationError) return registrationFailure(reply, request.id, error);
|
||||
return reply.code(503).send(null);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/admin-auth/login/send",
|
||||
{
|
||||
|
||||
@@ -17,6 +17,7 @@ import { readSecureConfigCandidate } from "./secure-config.js";
|
||||
import { StructuredJsonlLogger } from "./structured-log.js";
|
||||
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
|
||||
import { ModelConfigurationService } from "./model-configuration.js";
|
||||
import { MockAmapAdapter } from "./amap-adapter.js";
|
||||
|
||||
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
|
||||
let registration: RegistrationService | undefined;
|
||||
@@ -70,6 +71,7 @@ if (credentialChannelEnabled) {
|
||||
|
||||
const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json"));
|
||||
const app = await createApp({
|
||||
amap: new MockAmapAdapter(),
|
||||
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
||||
...(credits ? { credits } : {}),
|
||||
...(latestExports ? { latestExports } : {}),
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { P0A_COLOR_CARDS, createColorCardElement, drawColorCard, type ColorCardDefinition } from "./palette-provider.js";
|
||||
|
||||
const previewPalette = ["#04D960", "#0ABF58", "#5FD994", "#A0F2C4", "#D5F2E2"] as const;
|
||||
const previewHalfSize = {
|
||||
style_01: { height: 76, width: 26 }, style_02: { height: 77, width: 18 },
|
||||
style_08: { height: 10, width: 73 }, style_16: { height: 9, width: 78 },
|
||||
} as const;
|
||||
|
||||
function ColorCardPreview({ definition }: { definition: ColorCardDefinition }) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
useEffect(() => {
|
||||
const canvas = ref.current;
|
||||
const context = canvas?.getContext("2d");
|
||||
if (!canvas || !context) return;
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = "#30343b";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
const half = previewHalfSize[definition.styleId];
|
||||
const scale = Math.min(1, 146 / (half.width * 2), 62 / (half.height * 2));
|
||||
context.translate(canvas.width / 2, canvas.height / 2);
|
||||
context.scale(scale, scale);
|
||||
drawColorCard(context, createColorCardElement(definition, previewPalette, {
|
||||
createdAt: "2026-08-03T00:00:00.000Z", elementId: "00000000-0000-4000-8000-000000000001",
|
||||
}, 0));
|
||||
}, [definition]);
|
||||
return <canvas aria-hidden="true" className="editor-source-preview-canvas" height={72} ref={ref} width={160} />;
|
||||
}
|
||||
|
||||
export function ColorCardPanel(props: { canAdd: boolean; hasBackground: boolean; onAdd: (definition: ColorCardDefinition) => void }) {
|
||||
return <section className="editor-provider-panel">
|
||||
<header><h2>色卡</h2><button aria-label="色卡说明" className="editor-info-button" title="色卡基于原始底图,更换底图时更新,不随裁剪、调色和滤镜变化" type="button">i</button></header>
|
||||
<p className="editor-provider-note">基于当前原始底图</p>
|
||||
<div className="editor-provider-grid">
|
||||
{P0A_COLOR_CARDS.map((definition) => <button
|
||||
aria-label={`添加色卡 ${definition.cardId} ${definition.displayName}`}
|
||||
disabled={!props.canAdd || !props.hasBackground}
|
||||
key={definition.cardId}
|
||||
onClick={() => props.onAdd(definition)}
|
||||
type="button"
|
||||
><ColorCardPreview definition={definition} /><strong>{definition.cardId}</strong><span>{definition.displayName}</span></button>)}
|
||||
</div>
|
||||
{!props.hasBackground ? <p className="editor-limit" role="status">需要先有原始底图才能提取色卡。</p> : null}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
export function DynamicInspector(props: { element: CanvasElement; onCommit: (value: string) => void }) {
|
||||
const [value, setValue] = useState(props.element.formatted_value ?? "");
|
||||
useEffect(() => setValue(props.element.formatted_value ?? ""), [props.element.element_id, props.element.formatted_value]);
|
||||
return <section className="editor-dynamic-inspector">
|
||||
{props.element.template_or_asset_id === "DYN012" ? <p className="editor-font-substitution" role="note">原 DIN_MediumAlternate.otf 缺失,当前明确使用 FONT081 · Lexend Deca 替代。</p> : null}
|
||||
<label>显示文字<textarea aria-label="动态贴纸显示文字" onChange={(event) => setValue(event.target.value)} value={value} /></label>
|
||||
<button className="editor-wide-command" disabled={!value.trim() || value === props.element.formatted_value} onClick={() => props.onCommit(value)} type="button">应用显示文字</button>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import { DYNAMIC_RESOURCE_VERSION } from "./dynamic-render-models.js";
|
||||
import type { CanvasElementIdentity } from "./editor-elements.js";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
export type DynamicCategory = "identity" | "location" | "other" | "time";
|
||||
export type DynamicTemplateId = "DYN001" | "DYN002" | "DYN003" | "DYN004" | "DYN007" | "DYN008" | "DYN011" | "DYN012" | "DYN015" | "DYN016";
|
||||
|
||||
export interface DynamicStickerDefinition {
|
||||
category: DynamicCategory;
|
||||
displayName: string;
|
||||
requiresLocationConsent: boolean;
|
||||
templateId: DynamicTemplateId;
|
||||
}
|
||||
|
||||
export interface DynamicProviderContext {
|
||||
location?: { formattedValue: string; latitude?: number; longitude?: number };
|
||||
now: Date;
|
||||
profile: { creatorName: string; socialId: string };
|
||||
}
|
||||
|
||||
// Declarative conversion of l_shijian7.prefab. The browser never executes the Android Prefab or Lua.
|
||||
export const DYN012_RENDER_LAYOUT = {
|
||||
background: "transparent",
|
||||
divider: { color: "#FFFFFF", height: 42, width: 7, x: 0, y: 0 },
|
||||
hour: { color: "#FFFFFF", fontId: "FONT081", fontSize: 100, x: -60, y: 0 },
|
||||
meridiem: { color: "#FFFFFF", fontId: "FONT081", fontSize: 20, x: 94, y: -65 },
|
||||
minute: { color: "#FFFFFF", fontId: "FONT081", fontSize: 100, x: 60, y: 0 },
|
||||
} as const;
|
||||
|
||||
export function dyn012DisplayParts(element: CanvasElement) {
|
||||
if (element.type !== "dynamic_sticker" || element.template_or_asset_id !== "DYN012") throw new Error("dyn012_element_required");
|
||||
const formatted = element.formatted_value?.match(/^(\d{1,2}):(\d{2})$/);
|
||||
const hour = formatted?.[1]?.padStart(2, "0") ?? String(element.dynamic_fields?.hour ?? "00").padStart(2, "0");
|
||||
const minute = formatted?.[2] ?? String(element.dynamic_fields?.minute ?? "00").padStart(2, "0");
|
||||
const hourNumber = Number(hour);
|
||||
const minuteNumber = Number(minute);
|
||||
return {
|
||||
hour,
|
||||
meridiem: hourNumber > 12 || (hourNumber === 12 && minuteNumber > 0) ? "PM" : "AM",
|
||||
minute,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export const P0A_DYNAMIC_STICKERS: readonly DynamicStickerDefinition[] = [
|
||||
{ category: "location", displayName: "地点标题", requiresLocationConsent: false, templateId: "DYN001" },
|
||||
{ category: "location", displayName: "英文地点", requiresLocationConsent: false, templateId: "DYN002" },
|
||||
{ category: "location", displayName: "城市地点", requiresLocationConsent: false, templateId: "DYN003" },
|
||||
{ category: "location", displayName: "经纬地点", requiresLocationConsent: true, templateId: "DYN004" },
|
||||
{ category: "other", displayName: "用户名组合", requiresLocationConsent: false, templateId: "DYN007" },
|
||||
{ category: "time", displayName: "月与时间", requiresLocationConsent: false, templateId: "DYN008" },
|
||||
{ category: "time", displayName: "完整日期", requiresLocationConsent: false, templateId: "DYN011" },
|
||||
{ category: "time", displayName: "数字时间", requiresLocationConsent: false, templateId: "DYN012" },
|
||||
{ category: "identity", displayName: "创作署名", requiresLocationConsent: false, templateId: "DYN015" },
|
||||
{ category: "identity", displayName: "社交 ID", requiresLocationConsent: false, templateId: "DYN016" },
|
||||
] as const;
|
||||
|
||||
function twoDigits(value: number) {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
export function normalizeSocialId(value: string) {
|
||||
return `@${value.trim().replace(/^@+/, "")}`;
|
||||
}
|
||||
|
||||
function snapshotFor(templateId: DynamicTemplateId, context: DynamicProviderContext) {
|
||||
const year = String(context.now.getFullYear());
|
||||
const month = twoDigits(context.now.getMonth() + 1);
|
||||
const day = twoDigits(context.now.getDate());
|
||||
const hour = twoDigits(context.now.getHours());
|
||||
const minute = twoDigits(context.now.getMinutes());
|
||||
if (templateId === "DYN001") return { fields: { title: context.location?.formattedValue ?? "输入地点" }, value: context.location?.formattedValue ?? "输入地点" };
|
||||
if (templateId === "DYN002") return { fields: { city_en: context.location?.formattedValue ?? "LOCATION", title: context.location?.formattedValue ?? "输入地点" }, value: (context.location?.formattedValue ?? "LOCATION").toUpperCase() };
|
||||
if (templateId === "DYN003") return { fields: { city: context.location?.formattedValue ?? "城市", title: context.location?.formattedValue ?? "输入地点" }, value: context.location?.formattedValue ?? "城市 · 输入地点" };
|
||||
if (templateId === "DYN004") {
|
||||
if (context.location?.latitude === undefined || context.location.longitude === undefined) throw new Error("dynamic_location_consent_required");
|
||||
return {
|
||||
fields: { city: context.location.formattedValue, latitude: context.location.latitude, longitude: context.location.longitude },
|
||||
value: `${context.location.formattedValue}\n${context.location.latitude.toFixed(4)}, ${context.location.longitude.toFixed(4)}`,
|
||||
};
|
||||
}
|
||||
if (templateId === "DYN007") {
|
||||
const nickname = normalizeSocialId(context.profile.socialId);
|
||||
return { fields: { nickname }, value: nickname };
|
||||
}
|
||||
if (templateId === "DYN008") return { fields: { hour, minute, month }, value: `${month}月 · ${hour}:${minute}` };
|
||||
if (templateId === "DYN011") return { fields: { day, month, year }, value: `${year}.${month}.${day}` };
|
||||
if (templateId === "DYN012") return { fields: { font_substitution: "FONT081", hour, minute }, value: `${hour}:${minute}` };
|
||||
if (templateId === "DYN015") return { fields: { nickname: context.profile.creatorName }, value: context.profile.creatorName };
|
||||
return { fields: { nickname: normalizeSocialId(context.profile.socialId) }, value: normalizeSocialId(context.profile.socialId) };
|
||||
}
|
||||
|
||||
export function createDynamicStickerElement(
|
||||
templateId: DynamicTemplateId,
|
||||
context: DynamicProviderContext,
|
||||
identity: CanvasElementIdentity,
|
||||
zIndex: number,
|
||||
): CanvasElement {
|
||||
if (!P0A_DYNAMIC_STICKERS.some((item) => item.templateId === templateId)) throw new Error("dynamic_template_unavailable");
|
||||
const snapshot = snapshotFor(templateId, context);
|
||||
return {
|
||||
created_at: identity.createdAt,
|
||||
dynamic_fields: snapshot.fields,
|
||||
element_id: identity.elementId,
|
||||
...(templateId === "DYN004" && context.location?.latitude !== undefined && context.location.longitude !== undefined
|
||||
? { coordinates: { latitude: context.location.latitude, longitude: context.location.longitude } }
|
||||
: {}),
|
||||
...(templateId === "DYN012" ? { font_override: "FONT081" } : {}),
|
||||
formatted_value: snapshot.value,
|
||||
opacity: 1,
|
||||
position: { x: 0.5, y: 0.5 },
|
||||
resource_version: DYNAMIC_RESOURCE_VERSION,
|
||||
rotation: 0,
|
||||
scale: { x: 1, y: 1 },
|
||||
style_parameters: {
|
||||
provider: "typescript-declarative-v1",
|
||||
...(templateId === "DYN012" ? { font_substitution_disclosure: "DIN_MediumAlternate.otf missing; FONT081 Lexend Deca substitute" } : {}),
|
||||
},
|
||||
template_or_asset_id: templateId,
|
||||
type: "dynamic_sticker",
|
||||
z_index: zIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export function overrideDynamicStickerValue(element: CanvasElement, formattedValue: string): CanvasElement {
|
||||
if (element.type !== "dynamic_sticker") throw new Error("dynamic_element_required");
|
||||
const value = formattedValue.trim();
|
||||
if (!value) throw new Error("dynamic_value_required");
|
||||
return {
|
||||
...structuredClone(element),
|
||||
dynamic_fields: { ...structuredClone(element.dynamic_fields ?? {}), display_override: value },
|
||||
formatted_value: value,
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocationResult {
|
||||
formattedValue: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export class LocationConsentGate {
|
||||
constructor(private readonly adapter: {
|
||||
geolocate: () => Promise<{ latitude: number; longitude: number }>;
|
||||
reverseGeocode: (coordinates: { latitude: number; longitude: number }) => Promise<string>;
|
||||
}) {}
|
||||
|
||||
reject() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async confirm(): Promise<LocationResult> {
|
||||
const coordinates = await this.adapter.geolocate();
|
||||
if (!Number.isFinite(coordinates.latitude) || coordinates.latitude < -90 || coordinates.latitude > 90
|
||||
|| !Number.isFinite(coordinates.longitude) || coordinates.longitude < -180 || coordinates.longitude > 180) {
|
||||
throw new Error("dynamic_location_invalid");
|
||||
}
|
||||
const formattedValue = (await this.adapter.reverseGeocode(coordinates)).trim();
|
||||
if (!formattedValue) throw new Error("dynamic_location_unavailable");
|
||||
return { ...coordinates, formattedValue };
|
||||
}
|
||||
}
|
||||
|
||||
export function browserGeolocate() {
|
||||
return new Promise<{ latitude: number; longitude: number }>((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error("dynamic_location_unsupported"));
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => resolve({ latitude: position.coords.latitude, longitude: position.coords.longitude }),
|
||||
() => reject(new Error("dynamic_location_denied")),
|
||||
{ enableHighAccuracy: false, maximumAge: 0, timeout: 10_000 },
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import { fontOption, type FontOption } from "./text-assets.js";
|
||||
import type { DynamicTemplateId } from "./dynamic-provider.js";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
export type DynamicTextValue =
|
||||
| "city"
|
||||
| "city_en"
|
||||
| "day"
|
||||
| "hour"
|
||||
| "latitude_dms"
|
||||
| "longitude_dms"
|
||||
| "meridiem"
|
||||
| "minute"
|
||||
| "month"
|
||||
| "month_en"
|
||||
| "nickname"
|
||||
| "time"
|
||||
| "title"
|
||||
| "year_short";
|
||||
|
||||
export interface DynamicImageLayer {
|
||||
assetId: string;
|
||||
height: number;
|
||||
width: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface DynamicTextLayer {
|
||||
align: "center" | "left";
|
||||
color: string;
|
||||
fontId: string;
|
||||
fontSize: number;
|
||||
value: DynamicTextValue;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface DynamicRenderModel {
|
||||
halfSize: { height: number; width: number };
|
||||
imageLayers: readonly DynamicImageLayer[];
|
||||
sourceCandidateId: string;
|
||||
textLayers: readonly DynamicTextLayer[];
|
||||
}
|
||||
|
||||
export const DYNAMIC_RESOURCE_VERSION = "wp4-dynamic-source-v1";
|
||||
|
||||
const dynamicFont = (fontId: string): FontOption => ({
|
||||
displayName: fontId,
|
||||
fontId,
|
||||
url: `/api/v1/assets/public/${DYNAMIC_RESOURCE_VERSION}/${fontId}`,
|
||||
});
|
||||
|
||||
export const DYNAMIC_FONT_OPTIONS: readonly FontOption[] = [
|
||||
dynamicFont("15974853bc3294ef68e7e6d58fe74fd7"),
|
||||
dynamicFont("46f8336813e4c48d06a1aef294fdccf6"),
|
||||
dynamicFont("53ca6b704728520da50c145eabb2e635"),
|
||||
dynamicFont("cca5efc0e02fb1bf62349bd68ef30fc1"),
|
||||
dynamicFont("dd25b35dcb7ba4476cbaa9a9592e39e2"),
|
||||
dynamicFont("e4210c9872f0c279b35273f230809821"),
|
||||
dynamicFont("f4bfd4132df2d6be97ceabadf3853505"),
|
||||
] as const;
|
||||
|
||||
export const DYNAMIC_RENDER_MODELS: Readonly<Record<DynamicTemplateId, DynamicRenderModel>> = {
|
||||
DYN001: {
|
||||
halfSize: { height: 42, width: 130 },
|
||||
imageLayers: [{ assetId: "DYN001-image28", height: 67, width: 219, x: -17.562, y: 2.203 }],
|
||||
sourceCandidateId: "l_POI01",
|
||||
textLayers: [{ align: "center", color: "#FFFFFF", fontId: "dd25b35dcb7ba4476cbaa9a9592e39e2", fontSize: 21, value: "title", x: 0, y: 0 }],
|
||||
},
|
||||
DYN002: {
|
||||
halfSize: { height: 48, width: 215 },
|
||||
imageLayers: [{ assetId: "DYN002-image29", height: 77, width: 232, x: 94.5, y: 0.5 }],
|
||||
sourceCandidateId: "l_POI02",
|
||||
textLayers: [
|
||||
{ align: "left", color: "#FFFFFF", fontId: "15974853bc3294ef68e7e6d58fe74fd7", fontSize: 19, value: "title", x: -94.5, y: 10.5 },
|
||||
{ align: "left", color: "#FFFFFF", fontId: "15974853bc3294ef68e7e6d58fe74fd7", fontSize: 10, value: "city_en", x: 0, y: 36 },
|
||||
],
|
||||
},
|
||||
DYN003: {
|
||||
halfSize: { height: 54, width: 215 },
|
||||
imageLayers: [{ assetId: "DYN003-image30", height: 85, width: 235, x: -93.857, y: -23.635 }],
|
||||
sourceCandidateId: "l_POI03",
|
||||
textLayers: [
|
||||
{ align: "left", color: "#FFFFFF", fontId: "dd25b35dcb7ba4476cbaa9a9592e39e2", fontSize: 18, value: "title", x: -54, y: 9 },
|
||||
{ align: "left", color: "#FFFFFF", fontId: "dd25b35dcb7ba4476cbaa9a9592e39e2", fontSize: 24, value: "city", x: -53, y: -16 },
|
||||
],
|
||||
},
|
||||
DYN004: {
|
||||
halfSize: { height: 48, width: 180 },
|
||||
imageLayers: [{ assetId: "DYN004-image32", height: 76, width: 39, x: -21.067, y: -1 }],
|
||||
sourceCandidateId: "l_POI04",
|
||||
textLayers: [
|
||||
{ align: "left", color: "#FFFFFF", fontId: "dd25b35dcb7ba4476cbaa9a9592e39e2", fontSize: 26, value: "city", x: -28, y: 0 },
|
||||
{ align: "left", color: "#FFFFFF", fontId: "15974853bc3294ef68e7e6d58fe74fd7", fontSize: 14, value: "latitude_dms", x: -29.844, y: 36 },
|
||||
{ align: "left", color: "#FFFFFF", fontId: "15974853bc3294ef68e7e6d58fe74fd7", fontSize: 14, value: "longitude_dms", x: 71.523, y: 36 },
|
||||
],
|
||||
},
|
||||
DYN007: {
|
||||
halfSize: { height: 34, width: 190 },
|
||||
imageLayers: [],
|
||||
sourceCandidateId: "diaoyu",
|
||||
textLayers: [{ align: "center", color: "#295E8B", fontId: "53ca6b704728520da50c145eabb2e635", fontSize: 50, value: "nickname", x: 0, y: 0 }],
|
||||
},
|
||||
DYN008: {
|
||||
halfSize: { height: 180, width: 140 },
|
||||
imageLayers: [{ assetId: "DYN008-backendui0", height: 185, width: 5, x: -125, y: 0 }],
|
||||
sourceCandidateId: "l_shijian2",
|
||||
textLayers: [
|
||||
{ align: "left", color: "#FFFFFF", fontId: "f4bfd4132df2d6be97ceabadf3853505", fontSize: 70, value: "month_en", x: -112, y: -32 },
|
||||
{ align: "left", color: "#FFFFFF", fontId: "f4bfd4132df2d6be97ceabadf3853505", fontSize: 130, value: "time", x: -114.024, y: 97 },
|
||||
],
|
||||
},
|
||||
DYN011: {
|
||||
halfSize: { height: 40, width: 130 },
|
||||
imageLayers: [{ assetId: "DYN011-backendui0", height: 12, width: 8, x: -120, y: -19 }],
|
||||
sourceCandidateId: "l_shijian6",
|
||||
textLayers: [
|
||||
{ align: "center", color: "#FF6D2F", fontId: "e4210c9872f0c279b35273f230809821", fontSize: 64, value: "year_short", x: -85, y: 0 },
|
||||
{ align: "center", color: "#FF6D2F", fontId: "e4210c9872f0c279b35273f230809821", fontSize: 64, value: "month", x: 0, y: 0 },
|
||||
{ align: "center", color: "#FF6D2F", fontId: "e4210c9872f0c279b35273f230809821", fontSize: 64, value: "day", x: 85, y: 0 },
|
||||
],
|
||||
},
|
||||
DYN012: {
|
||||
halfSize: { height: 78, width: 118 },
|
||||
imageLayers: [],
|
||||
sourceCandidateId: "l_shijian7",
|
||||
textLayers: [],
|
||||
},
|
||||
DYN015: {
|
||||
halfSize: { height: 42, width: 300 },
|
||||
imageLayers: [{ assetId: "DYN015-imager2", height: 60, width: 60, x: -140, y: 0 }],
|
||||
sourceCandidateId: "0721userna",
|
||||
textLayers: [{ align: "left", color: "#666666", fontId: "cca5efc0e02fb1bf62349bd68ef30fc1", fontSize: 40, value: "nickname", x: -100, y: 0 }],
|
||||
},
|
||||
DYN016: {
|
||||
halfSize: { height: 48, width: 160 },
|
||||
imageLayers: [{ assetId: "DYN016-image21", height: 59, width: 186, x: -61.842, y: 16.483 }],
|
||||
sourceCandidateId: "l_username00",
|
||||
textLayers: [{ align: "left", color: "#FFFFFF", fontId: "46f8336813e4c48d06a1aef294fdccf6", fontSize: 27, value: "nickname", x: 0, y: 0 }],
|
||||
},
|
||||
};
|
||||
|
||||
export function dynamicFontOptionsFor(templateId: string) {
|
||||
if (templateId === "DYN012") {
|
||||
const replacement = fontOption("FONT081");
|
||||
return replacement ? [replacement] : [];
|
||||
}
|
||||
const model = DYNAMIC_RENDER_MODELS[templateId as DynamicTemplateId];
|
||||
if (!model) return [];
|
||||
const ids = new Set(model.textLayers.map((layer) => layer.fontId));
|
||||
return DYNAMIC_FONT_OPTIONS.filter((option) => ids.has(option.fontId));
|
||||
}
|
||||
|
||||
export function dynamicImageUrl(resourceVersion: string, assetId: string) {
|
||||
return `/api/v1/assets/public/${encodeURIComponent(resourceVersion)}/${encodeURIComponent(assetId)}`;
|
||||
}
|
||||
|
||||
function numericField(element: CanvasElement, key: string) {
|
||||
const value = element.dynamic_fields?.[key];
|
||||
return typeof value === "number" ? value : Number(value);
|
||||
}
|
||||
|
||||
function coordinate(value: number, positive: string, negative: string) {
|
||||
if (!Number.isFinite(value)) return "";
|
||||
const absolute = Math.abs(value);
|
||||
const degrees = Math.floor(absolute);
|
||||
const minutesFloat = (absolute - degrees) * 60;
|
||||
const minutes = Math.floor(minutesFloat);
|
||||
const seconds = (minutesFloat - minutes) * 60;
|
||||
return `${degrees}°${String(minutes).padStart(2, "0")}'${seconds.toFixed(2).padStart(5, "0")}\"${value >= 0 ? positive : negative}`;
|
||||
}
|
||||
|
||||
export function dynamicTextValue(element: CanvasElement, value: DynamicTextValue) {
|
||||
const fields = element.dynamic_fields ?? {};
|
||||
const text = (key: string) => String(fields[key] ?? "");
|
||||
const override = fields.display_override;
|
||||
if (typeof override === "string" && (value === "nickname" || value === "title")) return element.formatted_value ?? override;
|
||||
if (value === "time") return `${text("hour")}:${text("minute")}`;
|
||||
if (value === "month_en") {
|
||||
const month = Math.max(1, Math.min(12, numericField(element, "month")));
|
||||
return `${["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][month - 1]}.`;
|
||||
}
|
||||
if (value === "year_short") return text("year").slice(-2);
|
||||
if (value === "latitude_dms") return coordinate(numericField(element, "latitude"), "N", "S");
|
||||
if (value === "longitude_dms") return coordinate(numericField(element, "longitude"), "E", "W");
|
||||
if (value === "meridiem") {
|
||||
const hour = numericField(element, "hour");
|
||||
const minute = numericField(element, "minute");
|
||||
return hour > 12 || (hour === 12 && minute > 0) ? "PM" : "AM";
|
||||
}
|
||||
return text(value);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { createDynamicStickerElement, P0A_DYNAMIC_STICKERS, type DynamicTemplateId } from "./dynamic-provider.js";
|
||||
import { DYNAMIC_RENDER_MODELS, dynamicImageUrl } from "./dynamic-render-models.js";
|
||||
import { drawDynamicSticker } from "./editor-stage.js";
|
||||
import type { ArchivedFontStatus } from "./text-font-loader.js";
|
||||
|
||||
const categoryLabels = { identity: "身份", location: "地点", other: "综合", time: "时间" } as const;
|
||||
|
||||
function loadImage(url: string) {
|
||||
return new Promise<HTMLImageElement | undefined>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => resolve(undefined);
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function DynamicPreview(props: { fontStatuses: Readonly<Record<string, ArchivedFontStatus>>; templateId: DynamicTemplateId }) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
const statusKey = Object.entries(props.fontStatuses).map(([id, status]) => `${id}:${status}`).join("|");
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const model = DYNAMIC_RENDER_MODELS[props.templateId];
|
||||
const element = createDynamicStickerElement(props.templateId, {
|
||||
location: { formattedValue: "温州", latitude: 27.9943, longitude: 120.6994 },
|
||||
now: new Date("2026-08-03T09:07:00+08:00"), profile: { creatorName: "Dada Creator", socialId: "@dada" },
|
||||
}, { createdAt: "2026-08-03T00:00:00.000Z", elementId: "00000000-0000-4000-8000-000000000002" }, 0);
|
||||
void Promise.all(model.imageLayers.map(async (layer) => [layer.assetId, await loadImage(dynamicImageUrl(element.resource_version, layer.assetId))] as const)).then((entries) => {
|
||||
if (!active) return;
|
||||
const canvas = ref.current;
|
||||
const context = canvas?.getContext("2d");
|
||||
if (!canvas || !context) return;
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = "#30343b";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
const scale = Math.min(1, 146 / (model.halfSize.width * 2), 62 / (model.halfSize.height * 2));
|
||||
context.translate(canvas.width / 2, canvas.height / 2);
|
||||
context.scale(scale, scale);
|
||||
const images = Object.fromEntries(entries.filter((entry): entry is readonly [string, HTMLImageElement] => entry[1] !== undefined));
|
||||
drawDynamicSticker(context, element, props.fontStatuses, images);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [props.templateId, statusKey]);
|
||||
return <canvas aria-hidden="true" className="editor-source-preview-canvas" height={72} ref={ref} width={160} />;
|
||||
}
|
||||
|
||||
export function DynamicStickerPanel(props: { canAdd: boolean; fontStatuses: Readonly<Record<string, ArchivedFontStatus>>; onAdd: (templateId: DynamicTemplateId) => void }) {
|
||||
return <section className="editor-provider-panel">
|
||||
<h2>动态贴纸</h2>
|
||||
<div className="editor-provider-groups">
|
||||
{(["location", "time", "identity", "other"] as const).map((category) => <section key={category}>
|
||||
<h3>{categoryLabels[category]}</h3>
|
||||
<div className="editor-provider-grid">
|
||||
{P0A_DYNAMIC_STICKERS.filter((item) => item.category === category).map((item) => <button
|
||||
aria-label={`添加动态贴纸 ${item.templateId} ${item.displayName}`}
|
||||
disabled={!props.canAdd}
|
||||
key={item.templateId}
|
||||
onClick={() => props.onAdd(item.templateId)}
|
||||
type="button"
|
||||
><DynamicPreview fontStatuses={props.fontStatuses} templateId={item.templateId} /><strong>{item.templateId}</strong><span>{item.displayName}</span>{item.templateId === "DYN012" ? <small>FONT081 替代字体</small> : null}</button>)}
|
||||
</div>
|
||||
</section>)}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import { DYNAMIC_RENDER_MODELS } from "./dynamic-render-models.js";
|
||||
import type { DynamicTemplateId } from "./dynamic-provider.js";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
export interface CanvasPoint {
|
||||
@@ -45,6 +48,22 @@ function textLineUnits(line: string) {
|
||||
}
|
||||
|
||||
export function elementHalfExtents(state: CanvasState, element: CanvasElement): CanvasPoint {
|
||||
if (element.type === "color_card") {
|
||||
const vertical = element.style_id === "style_01" || element.style_id === "style_02";
|
||||
return {
|
||||
x: Math.max(hitHalfExtent, (vertical ? 62 : 175) / state.pixel_width) * element.scale.x,
|
||||
y: Math.max(hitHalfExtent, (vertical ? 155 : 50) / state.pixel_height) * element.scale.y,
|
||||
};
|
||||
}
|
||||
if (element.type === "dynamic_sticker") {
|
||||
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
|
||||
const halfWidth = model?.halfSize.width ?? 170;
|
||||
const halfHeight = model?.halfSize.height ?? 62;
|
||||
return {
|
||||
x: Math.max(hitHalfExtent, halfWidth / state.pixel_width) * element.scale.x,
|
||||
y: Math.max(hitHalfExtent, halfHeight / state.pixel_height) * element.scale.y,
|
||||
};
|
||||
}
|
||||
if (element.type !== "text_template") {
|
||||
return { x: hitHalfExtent * element.scale.x, y: hitHalfExtent * element.scale.y };
|
||||
}
|
||||
|
||||
@@ -176,8 +176,7 @@
|
||||
|
||||
.editor-sticker-grid button:disabled { color: #62625d; background: #e8e8e5; cursor: not-allowed; }
|
||||
.editor-sticker-grid button > span:last-child { overflow-wrap: anywhere; font-size: 11px; }
|
||||
.editor-sticker-preview { display: block; width: 58px; height: 58px; border: 3px solid #111111; background: #f2f400; }
|
||||
.editor-sticker-preview.stk002 { border-radius: 50%; background: #1769aa; }
|
||||
.editor-sticker-preview { display: block; width: 58px; height: 58px; object-fit: contain; border: 1px solid #b9b9b3; background: #30343b; }
|
||||
.editor-limit { margin: 12px 0 0; padding: 8px; border-left: 3px solid #c92a24; background: #ffffff; color: #8f1d14; font-size: 12px; }
|
||||
|
||||
.editor-workspace {
|
||||
@@ -368,6 +367,46 @@
|
||||
.editor-template-mark.tag { background: #dbeafe; }
|
||||
.editor-template-mark.simple { background: #ffffff; }
|
||||
|
||||
.editor-provider-panel > header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.editor-provider-panel > header h2 { margin: 0; }
|
||||
.editor-info-button { width: 28px; height: 28px; border: 1px solid #111111; border-radius: 50%; background: #ffffff; font: 700 13px Georgia, serif; }
|
||||
.editor-provider-note { margin: 8px 0 12px; color: #62625d; font-size: 11px; }
|
||||
.editor-provider-groups { display: grid; gap: 18px; }
|
||||
.editor-provider-groups section { display: grid; gap: 8px; }
|
||||
.editor-provider-groups h3 { margin: 0; font-size: 12px; }
|
||||
.editor-provider-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
|
||||
.editor-provider-grid button {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 112px;
|
||||
grid-template-rows: 48px 16px minmax(18px, auto) auto;
|
||||
align-items: center;
|
||||
padding: 7px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #85857f;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.editor-provider-grid button:disabled { background: #e8e8e5; color: #62625d; cursor: not-allowed; }
|
||||
.editor-provider-grid strong { font-family: Consolas, monospace; font-size: 10px; }
|
||||
.editor-provider-grid small { color: #8f1d14; font-size: 9px; }
|
||||
.editor-color-card-preview { display: flex; width: 100%; height: 42px; align-items: stretch; border: 1px solid #111111; background: #ffffff; }
|
||||
.editor-color-card-preview i { flex: 1; }
|
||||
.editor-color-card-preview.style_01,
|
||||
.editor-color-card-preview.style_02 { width: 30px; height: 48px; flex-direction: column; justify-self: center; }
|
||||
.editor-dynamic-preview { display: grid; width: 100%; height: 44px; place-items: center; border: 1px solid #111111; background: #ffffff; font-size: 15px; font-weight: 800; }
|
||||
.editor-dynamic-preview.location { border-left: 6px solid #1769aa; }
|
||||
.editor-dynamic-preview.time { background: #FFE62C; }
|
||||
.editor-dynamic-preview.identity { background: #1769aa; color: #ffffff; }
|
||||
.editor-dynamic-preview.dyn012 { position: relative; display: flex; gap: 7px; justify-content: center; border-color: #111111; background: #30343b; color: #ffffff; font-family: Dada_FONT081, sans-serif; font-size: 17px; font-weight: 400; }
|
||||
.editor-dynamic-preview.dyn012 i { width: 2px; height: 15px; background: #ffffff; }
|
||||
.editor-dynamic-preview.dyn012 small { position: absolute; top: 3px; right: 6px; color: #ffffff; font-size: 6px; }
|
||||
.editor-source-preview-canvas { display: block; width: 100%; height: 72px; background: #30343b; }
|
||||
|
||||
.editor-text-inspector { display: grid; gap: 14px; margin-top: 14px; padding-block: 14px; border-block: 1px solid #b9b9b3; }
|
||||
.editor-text-inspector label { display: grid; gap: 5px; font-size: 12px; font-weight: 700; }
|
||||
.editor-text-inspector textarea,
|
||||
@@ -385,6 +424,11 @@
|
||||
.editor-color-swatches button { width: 32px; height: 32px; border: 1px solid #111111; border-radius: 0; }
|
||||
.editor-check { display: flex !important; grid-template-columns: 18px 1fr; align-items: center; }
|
||||
.editor-check input { width: 18px; height: 18px; margin: 0; }
|
||||
.editor-dynamic-inspector { display: grid; gap: 10px; margin-top: 14px; padding-block: 14px; border-block: 1px solid #b9b9b3; }
|
||||
.editor-dynamic-inspector label { display: grid; gap: 5px; font-size: 12px; font-weight: 700; }
|
||||
.editor-dynamic-inspector textarea { min-height: 82px; padding: 7px 8px; border: 1px solid #85857f; border-radius: 0; resize: vertical; font: inherit; }
|
||||
.editor-dynamic-inspector .editor-wide-command { margin-top: 0; }
|
||||
.editor-font-substitution { margin: 0; padding: 8px; border-left: 3px solid #c92a24; background: #ffffff; color: #8f1d14; font-size: 11px; }
|
||||
|
||||
.editor-statusbar {
|
||||
display: flex;
|
||||
@@ -415,6 +459,9 @@
|
||||
.editor-confirm p { margin: 0; }
|
||||
.editor-confirm > div { display: flex; gap: 8px; margin-top: 24px; }
|
||||
.editor-confirm button { min-height: 40px; padding: 8px 14px; border: 1px solid #111111; border-radius: 0; background: #ffffff; font: inherit; font-weight: 700; }
|
||||
.editor-location-consent label { display: grid; gap: 6px; margin-top: 18px; font-size: 12px; font-weight: 700; }
|
||||
.editor-location-consent input { min-height: 40px; padding: 7px 9px; border: 1px solid #85857f; border-radius: 0; font: inherit; }
|
||||
.editor-location-actions { display: grid !important; grid-template-columns: 1fr; }
|
||||
|
||||
.editor-loading { display: grid; min-height: 100vh; place-items: center; background: #e8e8e5; }
|
||||
|
||||
|
||||
+161
-24
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+118
-29
@@ -2,9 +2,13 @@ import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } fr
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import { cssFilterForBackground } from "./editor-canvas.js";
|
||||
import { DYN012_RENDER_LAYOUT, dyn012DisplayParts } from "./dynamic-provider.js";
|
||||
import type { DynamicTemplateId } from "./dynamic-provider.js";
|
||||
import { DYNAMIC_RENDER_MODELS, dynamicFontOptionsFor, dynamicImageUrl, dynamicTextValue } from "./dynamic-render-models.js";
|
||||
import type { CanvasPoint, CanvasRect } from "./editor-elements.js";
|
||||
import { fontFamilyName, type ArchivedFontStatus } from "./text-font-loader.js";
|
||||
import { fontIdForTextElement } from "./text-assets.js";
|
||||
import { drawColorCard } from "./palette-provider.js";
|
||||
|
||||
interface Gesture {
|
||||
append: boolean;
|
||||
@@ -116,11 +120,77 @@ function drawTextElement(context: CanvasRenderingContext2D, element: CanvasState
|
||||
});
|
||||
}
|
||||
|
||||
function drawDynamicUnavailable(context: CanvasRenderingContext2D, message: string) {
|
||||
context.fillStyle = "#e5e7eb";
|
||||
context.fillRect(-140, -42, 280, 84);
|
||||
context.fillStyle = "#9f1d1d";
|
||||
context.font = "600 18px Microsoft YaHei UI, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillText(message, 0, 0);
|
||||
}
|
||||
|
||||
export function drawDynamicSticker(
|
||||
context: CanvasRenderingContext2D,
|
||||
element: CanvasState["elements"][number],
|
||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
|
||||
resourceImages: Readonly<Record<string, HTMLImageElement>>,
|
||||
) {
|
||||
const id = element.template_or_asset_id as DynamicTemplateId;
|
||||
const model = DYNAMIC_RENDER_MODELS[id];
|
||||
if (!model) {
|
||||
drawDynamicUnavailable(context, "动态贴纸模型不可用");
|
||||
return;
|
||||
}
|
||||
if (dynamicFontOptionsFor(id).some((font) => fontStatuses[font.fontId] !== "ready")) {
|
||||
drawDynamicUnavailable(context, id === "DYN012" ? "FONT081 替代字体不可用" : "原版字体不可用");
|
||||
return;
|
||||
}
|
||||
if (model.imageLayers.some((layer) => !resourceImages[layer.assetId])) {
|
||||
drawDynamicUnavailable(context, "原版图片资源不可用");
|
||||
return;
|
||||
}
|
||||
context.textBaseline = "middle";
|
||||
for (const layer of model.imageLayers) {
|
||||
const image = resourceImages[layer.assetId]!;
|
||||
context.drawImage(image, layer.x - layer.width / 2, layer.y - layer.height / 2, layer.width, layer.height);
|
||||
}
|
||||
if (id === "DYN012") {
|
||||
const parts = dyn012DisplayParts(element);
|
||||
const layout = DYN012_RENDER_LAYOUT;
|
||||
context.fillStyle = layout.divider.color;
|
||||
context.fillRect(layout.divider.x - layout.divider.width / 2, layout.divider.y - layout.divider.height / 2, layout.divider.width, layout.divider.height);
|
||||
for (const [part, layer] of [[parts.hour, layout.hour], [parts.minute, layout.minute], [parts.meridiem, layout.meridiem]] as const) {
|
||||
context.fillStyle = layer.color;
|
||||
context.font = `${layer.fontSize}px "${fontFamilyName(layer.fontId)}"`;
|
||||
context.textAlign = "center";
|
||||
context.fillText(part, layer.x, layer.y);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const layer of model.textLayers) {
|
||||
context.fillStyle = layer.color;
|
||||
context.font = `${layer.fontSize}px "${fontFamilyName(layer.fontId)}"`;
|
||||
context.textAlign = layer.align;
|
||||
context.fillText(dynamicTextValue(element, layer.value), layer.x, layer.y);
|
||||
}
|
||||
}
|
||||
|
||||
function elementSelectionHalfSize(
|
||||
context: CanvasRenderingContext2D,
|
||||
element: CanvasState["elements"][number],
|
||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
|
||||
) {
|
||||
if (element.type === "color_card") {
|
||||
if (element.style_id === "style_01") return { height: 76 * element.scale.y, width: 26 * element.scale.x };
|
||||
if (element.style_id === "style_02") return { height: 77 * element.scale.y, width: 18 * element.scale.x };
|
||||
if (element.style_id === "style_08") return { height: 10 * element.scale.y, width: 73 * element.scale.x };
|
||||
return { height: 9 * element.scale.y, width: 78 * element.scale.x };
|
||||
}
|
||||
if (element.type === "dynamic_sticker") {
|
||||
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
|
||||
return { height: (model?.halfSize.height ?? 62) * element.scale.y, width: (model?.halfSize.width ?? 170) * element.scale.x };
|
||||
}
|
||||
if (element.type !== "text_template") return { height: 78 * element.scale.y, width: 78 * element.scale.x };
|
||||
const fontId = fontIdForTextElement(element);
|
||||
if (!fontId || fontStatuses[fontId] !== "ready") return { height: 34 * element.scale.y, width: 110 * element.scale.x };
|
||||
@@ -130,7 +200,14 @@ function elementSelectionHalfSize(
|
||||
return { height: geometry.height * element.scale.y / 2, width: geometry.width * element.scale.x / 2 };
|
||||
}
|
||||
|
||||
function drawElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], width: number, height: number, fontStatuses: Readonly<Record<string, ArchivedFontStatus>>) {
|
||||
function drawElement(
|
||||
context: CanvasRenderingContext2D,
|
||||
element: CanvasState["elements"][number],
|
||||
width: number,
|
||||
height: number,
|
||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
|
||||
resourceImages: Readonly<Record<string, HTMLImageElement>>,
|
||||
) {
|
||||
const x = element.position.x * width;
|
||||
const y = element.position.y * height;
|
||||
context.save();
|
||||
@@ -139,28 +216,30 @@ function drawElement(context: CanvasRenderingContext2D, element: CanvasState["el
|
||||
context.rotate((element.rotation * Math.PI) / 180);
|
||||
const flip = element.style_parameters?.flip_horizontal === true ? -1 : 1;
|
||||
context.scale(element.scale.x * flip, element.scale.y);
|
||||
if (element.type === "color_card") {
|
||||
const colors = element.colors ?? ["#111111", "#333333", "#555555", "#777777", "#999999"];
|
||||
colors.forEach((color, index) => {
|
||||
context.fillStyle = color;
|
||||
context.fillRect(index * 48 - 120, -28, 44, 56);
|
||||
});
|
||||
} else if (element.type === "static_sticker") {
|
||||
context.fillStyle = element.template_or_asset_id.endsWith("2") ? "#1769aa" : "#f2f400";
|
||||
context.strokeStyle = "#111111";
|
||||
context.lineWidth = 5;
|
||||
context.beginPath();
|
||||
context.roundRect(-62, -62, 124, 124, element.template_or_asset_id.endsWith("2") ? 62 : 8);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
context.fillStyle = "#111111";
|
||||
context.font = "700 22px Consolas, monospace";
|
||||
context.textAlign = "center";
|
||||
context.fillText(element.template_or_asset_id, 0, 8);
|
||||
if (element.type === "color_card") drawColorCard(context, element);
|
||||
else if (element.type === "static_sticker") {
|
||||
const image = resourceImages[element.template_or_asset_id];
|
||||
if (!image) drawDynamicUnavailable(context, "原版贴纸资源不可用");
|
||||
else {
|
||||
const scale = 156 / Math.max(image.naturalWidth, image.naturalHeight);
|
||||
const width = image.naturalWidth * scale;
|
||||
const height = image.naturalHeight * scale;
|
||||
context.drawImage(image, -width / 2, -height / 2, width, height);
|
||||
}
|
||||
} else if (element.type === "text_template") drawTextElement(context, element, fontStatuses);
|
||||
else if (element.type === "dynamic_sticker") drawDynamicSticker(context, element, fontStatuses, resourceImages);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function loadCanvasImage(url: string) {
|
||||
return new Promise<HTMLImageElement | undefined>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => resolve(undefined);
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export function EditorStage(props: EditorStageProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const gestureRef = useRef<Gesture | undefined>(undefined);
|
||||
@@ -168,20 +247,21 @@ export function EditorStage(props: EditorStageProps) {
|
||||
const [marquee, setMarquee] = useState<CanvasRect>();
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return undefined;
|
||||
canvas.width = props.canvasState.pixel_width;
|
||||
canvas.height = props.canvasState.pixel_height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return undefined;
|
||||
const render = (image?: HTMLImageElement) => {
|
||||
const render = (image: HTMLImageElement | undefined, resourceImages: Readonly<Record<string, HTMLImageElement>>) => {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.filter = cssFilterForBackground(props.canvasState.background.adjustments);
|
||||
if (image) context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
context.filter = "none";
|
||||
for (const element of [...props.canvasState.elements].sort((left, right) => left.z_index - right.z_index)) drawElement(context, element, canvas.width, canvas.height, props.fontStatuses);
|
||||
for (const element of [...props.canvasState.elements].sort((left, right) => left.z_index - right.z_index)) drawElement(context, element, canvas.width, canvas.height, props.fontStatuses, resourceImages);
|
||||
context.lineWidth = 4;
|
||||
context.strokeStyle = "#005fcc";
|
||||
for (const element of props.canvasState.elements.filter((entry) => props.selectedIds.includes(entry.element_id))) {
|
||||
@@ -201,15 +281,24 @@ export function EditorStage(props: EditorStageProps) {
|
||||
if (marquee) context.strokeRect(marquee.x * canvas.width, marquee.y * canvas.height, marquee.width * canvas.width, marquee.height * canvas.height);
|
||||
context.restore();
|
||||
};
|
||||
if (!props.assetId) {
|
||||
render();
|
||||
return undefined;
|
||||
const background = props.assetId
|
||||
? loadCanvasImage(`/api/v1/private-assets/projects/${encodeURIComponent(props.projectId)}/images/${encodeURIComponent(props.assetId)}`)
|
||||
: Promise.resolve(undefined);
|
||||
const imageReferences = new Map<string, string>();
|
||||
for (const element of props.canvasState.elements) {
|
||||
if (element.type === "static_sticker") imageReferences.set(element.template_or_asset_id, dynamicImageUrl(element.resource_version, element.template_or_asset_id));
|
||||
if (element.type === "dynamic_sticker") {
|
||||
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
|
||||
for (const layer of model?.imageLayers ?? []) imageReferences.set(layer.assetId, dynamicImageUrl(element.resource_version, layer.assetId));
|
||||
}
|
||||
}
|
||||
const image = new Image();
|
||||
image.onload = () => render(image);
|
||||
image.onerror = () => render();
|
||||
image.src = `/api/v1/private-assets/projects/${encodeURIComponent(props.projectId)}/images/${encodeURIComponent(props.assetId)}`;
|
||||
return () => { image.onload = null; image.onerror = null; };
|
||||
const dynamic = Promise.all([...imageReferences].map(async ([assetId, url]) => [assetId, await loadCanvasImage(url)] as const));
|
||||
void Promise.all([background, dynamic]).then(([image, loaded]) => {
|
||||
if (!active) return;
|
||||
const resourceImages = Object.fromEntries(loaded.filter((entry): entry is readonly [string, HTMLImageElement] => entry[1] !== undefined));
|
||||
render(image, resourceImages);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [marquee, props.assetId, props.canvasState, props.fontStatuses, props.guides, props.projectId, props.selectedIds]);
|
||||
|
||||
useEffect(() => () => { if (longPressRef.current) clearTimeout(longPressRef.current); }, []);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||
|
||||
import type { CreditAdjustmentResponse, CreditAdjustmentRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
|
||||
import type { CreditAdjustmentResponse, CreditAdjustmentRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
|
||||
|
||||
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
|
||||
|
||||
@@ -230,6 +230,15 @@ export async function restoreProject(options: ClientOptions = {}): Promise<Proje
|
||||
return response.json() as Promise<ProjectRestoreResponse>;
|
||||
}
|
||||
|
||||
export async function reverseGeocodeLocation(body: ReverseGeocodeRequest, options: ClientOptions = {}): Promise<ReverseGeocodeResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/location/reverse-geocode`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<ReverseGeocodeResponse>;
|
||||
}
|
||||
|
||||
export async function saveLatestExport(body: FormData, options: ClientOptions = {}): Promise<LatestExportSaveResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
|
||||
@@ -715,6 +715,17 @@ export type RegistrationSendResponse = {
|
||||
"status": "verification_sent";
|
||||
};
|
||||
|
||||
export type ReverseGeocodeRequest = {
|
||||
"latitude": number;
|
||||
"longitude": number;
|
||||
};
|
||||
|
||||
export type ReverseGeocodeResponse = {
|
||||
"formatted_value": string;
|
||||
"service_mode": "mock";
|
||||
"status": "resolved";
|
||||
};
|
||||
|
||||
export type SseEvent = {
|
||||
"entity_ref": string;
|
||||
"event_id": number;
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { MMCQ } from "@vibrant/quantizer-mmcq";
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import type { CanvasElementIdentity } from "./editor-elements.js";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
export interface ColorCardDefinition {
|
||||
cardId: "COLOR001" | "COLOR002" | "COLOR008" | "COLOR016";
|
||||
displayName: string;
|
||||
mappingStatus: "confirmed_native_mapping" | "stable_web_style_native_mapping_provisional";
|
||||
rendererName: "horizontal_line" | "ticket_strip" | "vertical_stack" | "vertical_ticket";
|
||||
styleId: "style_01" | "style_02" | "style_08" | "style_16";
|
||||
}
|
||||
|
||||
export interface PaletteColor {
|
||||
hex: string;
|
||||
population: number;
|
||||
rgbValue: number;
|
||||
}
|
||||
|
||||
export const P0A_COLOR_CARDS: readonly ColorCardDefinition[] = [
|
||||
{ cardId: "COLOR001", displayName: "纵向票据", mappingStatus: "confirmed_native_mapping", rendererName: "vertical_ticket", styleId: "style_01" },
|
||||
{ cardId: "COLOR002", displayName: "纵向色阶", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_stack", styleId: "style_02" },
|
||||
{ cardId: "COLOR008", displayName: "横向标尺", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "horizontal_line", styleId: "style_08" },
|
||||
{ cardId: "COLOR016", displayName: "横向票条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "ticket_strip", styleId: "style_16" },
|
||||
] as const;
|
||||
|
||||
// Coordinates are translated from the archived 320x320 renderer around its 160px center.
|
||||
export const COLOR_CARD_SOURCE_GEOMETRY = {
|
||||
style_01: {
|
||||
bounds: { bottom: 76, left: -26, right: 26, top: -74 },
|
||||
palette: { bottom: 37, left: -23, right: 23, top: -71 },
|
||||
},
|
||||
style_02: { bounds: { bottom: 69, left: -18, right: 18, top: -77 } },
|
||||
style_08: { bounds: { bottom: 10, left: -73, right: 73, top: -9 } },
|
||||
style_16: { bounds: { bottom: 9, left: -78, right: 78, top: -9 } },
|
||||
} as const;
|
||||
|
||||
function normalizedHex(value: string) {
|
||||
return value.toUpperCase();
|
||||
}
|
||||
|
||||
export function extractMmcqPalette(pixels: Uint8ClampedArray): PaletteColor[] {
|
||||
const swatches = MMCQ(pixels, { colorCount: 10 });
|
||||
if (swatches.length < 5) throw new Error("palette_five_colors_required");
|
||||
return swatches
|
||||
.map((swatch) => ({
|
||||
hex: normalizedHex(swatch.hex),
|
||||
population: swatch.population,
|
||||
rgbValue: (Math.round(swatch.r) << 16) + (Math.round(swatch.g) << 8) + Math.round(swatch.b),
|
||||
}))
|
||||
.sort((left, right) => right.population - left.population || left.rgbValue - right.rgbValue)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
export function extractPaletteFromImage(image: CanvasImageSource & { height: number; width: number }) {
|
||||
const longestEdge = Math.max(image.width, image.height);
|
||||
if (longestEdge <= 0) throw new Error("palette_image_invalid");
|
||||
const scale = Math.min(1, 256 / longestEdge);
|
||||
const width = Math.max(1, Math.round(image.width * scale));
|
||||
const height = Math.max(1, Math.round(image.height * scale));
|
||||
const surface = document.createElement("canvas");
|
||||
surface.width = width;
|
||||
surface.height = height;
|
||||
const context = surface.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("palette_canvas_unavailable");
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
return extractMmcqPalette(context.getImageData(0, 0, width, height).data).map((entry) => entry.hex);
|
||||
}
|
||||
|
||||
function validatePalette(palette: readonly string[]) {
|
||||
if (palette.length !== 5 || palette.some((color) => !/^#[0-9A-Fa-f]{6}$/.test(color))) throw new Error("canvas_palette_invalid");
|
||||
return palette.map(normalizedHex);
|
||||
}
|
||||
|
||||
export function createColorCardElement(
|
||||
definition: ColorCardDefinition,
|
||||
palette: readonly string[],
|
||||
identity: CanvasElementIdentity,
|
||||
zIndex: number,
|
||||
): CanvasElement {
|
||||
return {
|
||||
colors: validatePalette(palette),
|
||||
created_at: identity.createdAt,
|
||||
element_id: identity.elementId,
|
||||
opacity: 1,
|
||||
position: { x: 0.5, y: 0.5 },
|
||||
resource_version: "wp4-provider-v1",
|
||||
rotation: 0,
|
||||
scale: { x: 1, y: 1 },
|
||||
style_id: definition.styleId,
|
||||
style_parameters: {
|
||||
mapping_status: definition.mappingStatus,
|
||||
palette_algorithm_version: "mmcq-v1",
|
||||
renderer_name: definition.rendererName,
|
||||
},
|
||||
template_or_asset_id: definition.cardId,
|
||||
type: "color_card",
|
||||
z_index: zIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export function refreshColorCards(state: CanvasState, palette: readonly string[]): CanvasState {
|
||||
const colors = validatePalette(palette);
|
||||
const next = structuredClone(state);
|
||||
next.elements = next.elements.map((element) => element.type === "color_card" ? { ...element, colors: [...colors] } : element);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function drawColorCard(context: CanvasRenderingContext2D, element: CanvasElement) {
|
||||
const colors = element.colors ?? ["#111111", "#333333", "#555555", "#777777", "#999999"];
|
||||
if (element.style_id === "style_01") {
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(-26, -74, 52, 145);
|
||||
colors.forEach((color, index) => {
|
||||
const top = -71 + index * 21.6;
|
||||
context.fillStyle = color;
|
||||
context.fillRect(-23, top, 46, index === colors.length - 1 ? 37 - top : 21.6);
|
||||
});
|
||||
context.fillStyle = "#222222";
|
||||
context.font = "700 5px Arial, sans-serif";
|
||||
context.textAlign = "left";
|
||||
context.textBaseline = "top";
|
||||
context.fillText("COLOR", -20, 44);
|
||||
context.fillStyle = "#979797";
|
||||
context.fillRect(-20, 54, 24, 1);
|
||||
context.fillStyle = "#ffffff";
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const left = -26 + index * 6.5;
|
||||
context.beginPath();
|
||||
context.moveTo(left, 71);
|
||||
context.lineTo(left + 3.25, 76);
|
||||
context.lineTo(left + 6.5, 71);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (element.style_id === "style_02") {
|
||||
context.font = "4px Arial, sans-serif";
|
||||
context.textAlign = "left";
|
||||
context.textBaseline = "top";
|
||||
context.fillStyle = "#222222";
|
||||
context.fillText("C O L O R", -17, -77);
|
||||
colors.forEach((color, index) => {
|
||||
context.fillStyle = color;
|
||||
context.fillRect(-18, -71 + index * 28, 36, 27);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (element.style_id === "style_08") {
|
||||
colors.forEach((color, index) => {
|
||||
const left = -73 + index * 29.2;
|
||||
const center = -59 + index * 29;
|
||||
context.fillStyle = color;
|
||||
context.fillRect(left, -2, index === colors.length - 1 ? 73 - left : 29.2, 4);
|
||||
context.beginPath();
|
||||
context.arc(center, -7, 2, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.strokeStyle = "#ffffff";
|
||||
context.lineWidth = 1;
|
||||
context.stroke();
|
||||
context.fillStyle = "#222222";
|
||||
context.font = "4px Arial, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillText(`0${index + 1}`, center, 8);
|
||||
});
|
||||
return;
|
||||
}
|
||||
context.fillStyle = "#ffffff";
|
||||
context.beginPath();
|
||||
context.moveTo(-78, -9);
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
const y = -9 + index * 4.5;
|
||||
context.lineTo(-74, y + 2.25);
|
||||
context.lineTo(-78, y + 4.5);
|
||||
}
|
||||
context.lineTo(-70, 9);
|
||||
context.lineTo(-70, -9);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
context.fillStyle = "#222222";
|
||||
context.fillRect(-72, -9, 17, 18);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.font = "700 5px Arial, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillText("C", -63.5, 0);
|
||||
const stripColors = [...colors, "#f2f2f2"];
|
||||
stripColors.forEach((color, index) => {
|
||||
const left = -55 + index * (133 / stripColors.length);
|
||||
context.fillStyle = color;
|
||||
context.fillRect(left, -9, index === stripColors.length - 1 ? 78 - left : 133 / stripColors.length, 18);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user