feat: complete TASK-WP4-03 text templates
This commit is contained in:
@@ -87,6 +87,12 @@ import {
|
||||
ProjectStateSaveResponseSchema,
|
||||
ProjectSummarySchema,
|
||||
ProjectViewStatusSchema,
|
||||
RecentAssetItemSchema,
|
||||
RecentAssetKindSchema,
|
||||
RecentAssetListResponseSchema,
|
||||
RecentAssetQuerySchema,
|
||||
RecentAssetRecordRequestSchema,
|
||||
RecentAssetRecordResponseSchema,
|
||||
RegistrationCompleteHeadersSchema,
|
||||
RegistrationCompleteRequestSchema,
|
||||
RegistrationCompleteResponseSchema,
|
||||
@@ -120,6 +126,8 @@ import {
|
||||
type ProjectRenameRequest,
|
||||
type ProjectEditableState,
|
||||
type ProjectStateSaveHeaders,
|
||||
type RecentAssetQuery,
|
||||
type RecentAssetRecordRequest,
|
||||
type RegistrationCompleteRequest,
|
||||
type RegistrationSendRequest,
|
||||
} from "@dada/shared-contracts";
|
||||
@@ -162,6 +170,7 @@ import {
|
||||
registrationFieldError,
|
||||
} from "./registration-errors.js";
|
||||
import type { RegistrationService } from "./registration.js";
|
||||
import type { RecentAssetService } from "./recent-assets.js";
|
||||
import { ModelConfigurationError } from "./model-configuration.js";
|
||||
import type { ModelConfigurationService } from "./model-configuration.js";
|
||||
|
||||
@@ -189,6 +198,7 @@ export interface CreateAppOptions {
|
||||
models?: ModelConfigurationService;
|
||||
networkBoundary?: NetworkBoundaryOptions;
|
||||
publicAssets?: PublicAssetResolver;
|
||||
recentAssets?: RecentAssetService;
|
||||
projects?: ProjectService;
|
||||
registration?: RegistrationService;
|
||||
}
|
||||
@@ -726,6 +736,12 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
ProjectStateSaveHeadersSchema,
|
||||
ProjectStateSaveResponseSchema,
|
||||
ProjectStateConflictResponseSchema,
|
||||
RecentAssetKindSchema,
|
||||
RecentAssetItemSchema,
|
||||
RecentAssetQuerySchema,
|
||||
RecentAssetListResponseSchema,
|
||||
RecentAssetRecordRequestSchema,
|
||||
RecentAssetRecordResponseSchema,
|
||||
FailedEmptyTrashRequestSchema,
|
||||
FailedEmptyTrashResponseSchema,
|
||||
ModelIdSchema,
|
||||
@@ -810,6 +826,76 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/assets/recent",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
operationId: "listRecentAssets",
|
||||
querystring: Type.Ref(RecentAssetQuerySchema),
|
||||
response: {
|
||||
200: Type.Ref(RecentAssetListResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Assets"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) return reply.code(400).send(null);
|
||||
if (!options.registration || !options.recentAssets) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
|
||||
const session = token ? options.registration.readUserSession(token) : undefined;
|
||||
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
const query = request.query as RecentAssetQuery;
|
||||
return { items: options.recentAssets.list(session.userId, query.asset_kind) };
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/assets/recent",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
body: Type.Ref(RecentAssetRecordRequestSchema),
|
||||
headers: Type.Ref(CsrfHeadersSchema),
|
||||
operationId: "recordRecentAsset",
|
||||
response: {
|
||||
200: Type.Ref(RecentAssetRecordResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Assets"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) return reply.code(400).send(null);
|
||||
if (!options.registration || !options.recentAssets) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
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 {
|
||||
const owner = options.registration.authorizeUserMutation({ csrfToken, sessionToken: token });
|
||||
const body = request.body as RecentAssetRecordRequest;
|
||||
options.recentAssets.recordSuccessfulUse({
|
||||
assetId: body.asset_id,
|
||||
assetKind: body.asset_kind,
|
||||
resourceVersion: body.resource_version,
|
||||
userId: owner.userId,
|
||||
});
|
||||
return { status: "recorded" as const };
|
||||
} catch (error) {
|
||||
return registrationFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/admin-auth/login/send",
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ import { LatestExportService } from "./latest-exports.js";
|
||||
import { CreditService } from "./credits.js";
|
||||
import { ProjectService } from "./projects.js";
|
||||
import { RegistrationService } from "./registration.js";
|
||||
import { RecentAssetService } from "./recent-assets.js";
|
||||
import { MockResendAdapter } from "./resend-adapter.js";
|
||||
import { readSecureConfigCandidate } from "./secure-config.js";
|
||||
import { StructuredJsonlLogger } from "./structured-log.js";
|
||||
@@ -24,6 +25,7 @@ let credits: CreditService | undefined;
|
||||
let storage: ManagedStorage | undefined;
|
||||
let latestExports: LatestExportService | undefined;
|
||||
let models: ModelConfigurationService | undefined;
|
||||
let recentAssets: RecentAssetService | undefined;
|
||||
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
||||
if (credentialChannelEnabled) {
|
||||
const clients = initializeApiCredentialClients(await receiveApiCredentials());
|
||||
@@ -47,6 +49,7 @@ if (credentialChannelEnabled) {
|
||||
storage = new ManagedStorage({ dataRoot, databasePath });
|
||||
latestExports = new LatestExportService({ databasePath, storage });
|
||||
models = new ModelConfigurationService({ database: registration.database });
|
||||
recentAssets = new RecentAssetService({ database: registration.database });
|
||||
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
||||
} catch (error) {
|
||||
latestExports?.close();
|
||||
@@ -73,6 +76,7 @@ const app = await createApp({
|
||||
...(models ? { models } : {}),
|
||||
...(projects ? { projects } : {}),
|
||||
...(registration ? { registration } : {}),
|
||||
...(recentAssets ? { recentAssets } : {}),
|
||||
});
|
||||
|
||||
await app.listen({
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createRequire } from "node:module";
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const Database = require("better-sqlite3") as typeof BetterSqlite3;
|
||||
|
||||
export type RecentAssetKind = "static_sticker" | "text_template";
|
||||
|
||||
export interface RecentAssetItem {
|
||||
asset_id: string;
|
||||
asset_kind: RecentAssetKind;
|
||||
resource_version: string;
|
||||
}
|
||||
|
||||
export class RecentAssetService {
|
||||
readonly database: BetterSqlite3.Database;
|
||||
private readonly ownsDatabase: boolean;
|
||||
private readonly clock: () => number;
|
||||
|
||||
constructor(input: { clock?: () => number; database?: BetterSqlite3.Database; databasePath?: string }) {
|
||||
if (!input.database && !input.databasePath) throw new Error("recent_asset_database_required");
|
||||
this.database = input.database ?? new Database(input.databasePath!);
|
||||
this.ownsDatabase = !input.database;
|
||||
this.clock = input.clock ?? Date.now;
|
||||
this.database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS recent_assets (
|
||||
user_id TEXT NOT NULL,
|
||||
asset_kind TEXT NOT NULL CHECK (asset_kind IN ('text_template', 'static_sticker')),
|
||||
asset_id TEXT NOT NULL,
|
||||
resource_version TEXT NOT NULL,
|
||||
used_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, asset_kind, asset_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS recent_assets_user_kind_used
|
||||
ON recent_assets (user_id, asset_kind, used_at DESC, asset_id ASC);
|
||||
`);
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.ownsDatabase) this.database.close();
|
||||
}
|
||||
|
||||
list(userId: string, assetKind: RecentAssetKind, limit = 12): RecentAssetItem[] {
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new Error("recent_asset_limit_invalid");
|
||||
return this.database.prepare(`
|
||||
SELECT asset_id, asset_kind, resource_version
|
||||
FROM recent_assets
|
||||
WHERE user_id = ? AND asset_kind = ?
|
||||
ORDER BY used_at DESC, asset_id ASC
|
||||
LIMIT ?
|
||||
`).all(userId, assetKind, limit) as RecentAssetItem[];
|
||||
}
|
||||
|
||||
recordSuccessfulUse(input: { assetId: string; assetKind: RecentAssetKind; resourceVersion: string; userId: string }) {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/.test(input.assetId)
|
||||
|| !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/.test(input.resourceVersion)) {
|
||||
throw new Error("recent_asset_reference_invalid");
|
||||
}
|
||||
this.database.prepare(`
|
||||
INSERT INTO recent_assets (user_id, asset_kind, asset_id, resource_version, used_at)
|
||||
VALUES (@userId, @assetKind, @assetId, @resourceVersion, @usedAt)
|
||||
ON CONFLICT (user_id, asset_kind, asset_id) DO UPDATE SET
|
||||
resource_version = excluded.resource_version,
|
||||
used_at = excluded.used_at
|
||||
`).run({ ...input, usedAt: this.clock() });
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,34 @@ interface StaticStickerInput {
|
||||
const snapThreshold = 0.015;
|
||||
const hitHalfExtent = 0.08;
|
||||
|
||||
function styleNumber(element: CanvasElement, key: string, fallback: number) {
|
||||
const value = element.style_parameters?.[key];
|
||||
return typeof value === "number" ? value : fallback;
|
||||
}
|
||||
|
||||
function textLineUnits(line: string) {
|
||||
return Array.from(line).reduce((total, character) => total + (/^[\x00-\x7F]$/.test(character) ? 0.62 : 1), 0);
|
||||
}
|
||||
|
||||
export function elementHalfExtents(state: CanvasState, element: CanvasElement): CanvasPoint {
|
||||
if (element.type !== "text_template") {
|
||||
return { x: hitHalfExtent * element.scale.x, y: hitHalfExtent * element.scale.y };
|
||||
}
|
||||
const lines = (element.content ?? "").split("\n");
|
||||
const fontSize = element.font_size ?? 48;
|
||||
const letterSpacing = styleNumber(element, "letter_spacing", 1);
|
||||
const lineHeight = styleNumber(element, "line_height", 1.2);
|
||||
const strokeWidth = styleNumber(element, "stroke_width", 0);
|
||||
const longestLine = Math.max(1, ...lines.map((line) => textLineUnits(line)));
|
||||
const longestCharacterCount = Math.max(1, ...lines.map((line) => Array.from(line).length));
|
||||
const widthPixels = longestLine * fontSize + (longestCharacterCount - 1) * letterSpacing + 32 + strokeWidth * 2;
|
||||
const heightPixels = Math.max(1, lines.length) * fontSize * lineHeight + 32 + strokeWidth * 2;
|
||||
return {
|
||||
x: Math.max(hitHalfExtent, widthPixels / state.pixel_width / 2) * element.scale.x,
|
||||
y: Math.max(hitHalfExtent, heightPixels / state.pixel_height / 2) * element.scale.y,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneState(state: CanvasState) {
|
||||
return structuredClone(state);
|
||||
}
|
||||
@@ -132,8 +160,7 @@ export class CanvasElementController {
|
||||
candidatesAt(point: CanvasPoint) {
|
||||
return this.current.elements
|
||||
.filter((element) => {
|
||||
const halfWidth = hitHalfExtent * element.scale.x;
|
||||
const halfHeight = hitHalfExtent * element.scale.y;
|
||||
const { x: halfWidth, y: halfHeight } = elementHalfExtents(this.current, element);
|
||||
return point.x >= element.position.x - halfWidth && point.x <= element.position.x + halfWidth
|
||||
&& point.y >= element.position.y - halfHeight && point.y <= element.position.y + halfHeight;
|
||||
})
|
||||
@@ -186,6 +213,17 @@ export class CanvasElementController {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
replaceElement(element: CanvasElement) {
|
||||
const index = this.current.elements.findIndex((candidate) => candidate.element_id === element.element_id);
|
||||
if (index < 0) throw new Error("canvas_element_not_found");
|
||||
const elements = [...this.current.elements];
|
||||
elements[index] = structuredClone(element);
|
||||
this.current = requireState({ ...this.current, elements });
|
||||
this.selection = [element.element_id];
|
||||
this.pointerMoved();
|
||||
return this.value;
|
||||
}
|
||||
|
||||
private updateSelected(mapper: (element: CanvasElement) => CanvasElement) {
|
||||
const selected = new Set(this.selection);
|
||||
this.current = requireState({
|
||||
|
||||
@@ -312,6 +312,80 @@
|
||||
.editor-wide-command { width: 100%; margin-top: 18px; }
|
||||
.editor-danger { border-color: #c92a24 !important; color: #8f1d14 !important; }
|
||||
|
||||
.editor-text-assets { min-width: 0; }
|
||||
.editor-template-search {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #85857f;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
font: inherit;
|
||||
}
|
||||
.editor-template-categories { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 2px; margin-bottom: 12px; }
|
||||
.editor-template-categories button {
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
padding: 4px 2px;
|
||||
border: 1px solid #85857f;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
}
|
||||
.editor-template-categories button.active { border-color: #005fcc; box-shadow: inset 0 -3px #005fcc; font-weight: 700; }
|
||||
.editor-template-retry { width: 100%; min-height: 36px; margin-bottom: 10px; border: 1px solid #8f1d14; border-radius: 0; background: #ffffff; color: #8f1d14; font: inherit; font-weight: 700; }
|
||||
.editor-template-recent { margin-bottom: 12px; padding: 10px 0; border-block: 1px solid #b9b9b3; }
|
||||
.editor-template-recent h3 { margin: 0 0 8px; font-size: 12px; }
|
||||
.editor-template-recent > div { display: flex; gap: 6px; overflow-x: auto; }
|
||||
.editor-template-recent span { display: grid; flex: 0 0 116px; min-height: 48px; align-content: center; padding: 5px 7px; border-left: 4px solid #f2f400; background: #ffffff; font-size: 10px; }
|
||||
.editor-template-recent strong { font-family: Consolas, monospace; font-size: 10px; }
|
||||
.editor-template-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
|
||||
.editor-template-grid button {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 116px;
|
||||
grid-template-rows: 48px 16px minmax(18px, auto) 16px;
|
||||
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-template-grid button:disabled { border-style: dashed; background: #e8e8e5; color: #62625d; cursor: not-allowed; }
|
||||
.editor-template-grid strong { overflow: hidden; font-family: Consolas, monospace; font-size: 10px; text-overflow: ellipsis; }
|
||||
.editor-template-grid small { color: #8f1d14; font-size: 9px; }
|
||||
.editor-template-mark { display: grid; width: 100%; height: 44px; place-items: center; border: 1px solid #111111; background: #f2f400; font-size: 18px; font-weight: 800; }
|
||||
.editor-template-mark.title { background: #111111; color: #ffffff; }
|
||||
.editor-template-mark.tag { background: #dbeafe; }
|
||||
.editor-template-mark.simple { background: #ffffff; }
|
||||
|
||||
.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,
|
||||
.editor-text-inspector input[type="number"],
|
||||
.editor-text-inspector select { width: 100%; min-height: 40px; padding: 7px 8px; border: 1px solid #85857f; border-radius: 0; background: #ffffff; color: #111111; font: inherit; }
|
||||
.editor-text-inspector textarea { min-height: 96px; resize: vertical; }
|
||||
.editor-text-number { grid-template-columns: minmax(0, 1fr) 68px; }
|
||||
.editor-text-number > span { grid-column: 1 / -1; }
|
||||
.editor-text-number input[type="range"] { align-self: center; }
|
||||
.editor-text-number input[type="number"] { min-width: 0; }
|
||||
.editor-color-fields { display: grid; gap: 8px; margin: 0; padding: 10px; border: 1px solid #b9b9b3; }
|
||||
.editor-color-fields legend { padding-inline: 4px; font-size: 12px; font-weight: 700; }
|
||||
.editor-text-inspector input[type="color"] { width: 100%; height: 40px; padding: 2px; border: 1px solid #85857f; border-radius: 0; background: #ffffff; }
|
||||
.editor-color-swatches { display: flex; gap: 6px; }
|
||||
.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-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -12,16 +12,35 @@ import {
|
||||
} from "./editor-canvas.js";
|
||||
import { CanvasElementController, createStaticStickerElement, type CanvasElementIdentity, type CanvasLayerCommand, type CanvasPoint, type CanvasRect } from "./editor-elements.js";
|
||||
import { EditorStage } from "./editor-stage.js";
|
||||
import { TextInspector } from "./text-inspector.js";
|
||||
import { createBrowserArchivedFontLoader, type ArchivedFontLoader, type ArchivedFontStatus } from "./text-font-loader.js";
|
||||
import {
|
||||
fontIdForTextElement,
|
||||
fontOption,
|
||||
P0A_TEXT_TEMPLATES,
|
||||
TextEditSession,
|
||||
createTextTemplateElement,
|
||||
type TextStylePatch,
|
||||
type TextTemplateCategory,
|
||||
type TextTemplateDefinition,
|
||||
} from "./text-assets.js";
|
||||
import { TextTemplatePanel } from "./text-template-panel.js";
|
||||
|
||||
import "./editor-page.css";
|
||||
|
||||
type Ratio = CanvasState["ratio"];
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
type EditorAssetPanel = "background" | "history" | "stickers";
|
||||
type EditorAssetPanel = "background" | "history" | "stickers" | "text";
|
||||
|
||||
interface TextEditState {
|
||||
draft: CanvasElement;
|
||||
elementId: string;
|
||||
originalTemplateId: string;
|
||||
}
|
||||
|
||||
interface EditorSession {
|
||||
csrf_token: string;
|
||||
user: { creator_name: string };
|
||||
user: { creator_name: string; user_id: string };
|
||||
}
|
||||
|
||||
interface EditorProject {
|
||||
@@ -82,12 +101,18 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
const [guides, setGuides] = useState<string[]>([]);
|
||||
const [multiMode, setMultiMode] = useState(false);
|
||||
const [candidateMenu, setCandidateMenu] = useState<{ elements: CanvasElement[]; point: CanvasPoint }>();
|
||||
const [templateCategory, setTemplateCategory] = useState<TextTemplateCategory>();
|
||||
const [templateQuery, setTemplateQuery] = useState("");
|
||||
const [recentTextIds, setRecentTextIds] = useState<string[]>([]);
|
||||
const [fontStatuses, setFontStatuses] = useState<Record<string, ArchivedFontStatus>>({});
|
||||
const [textEdit, setTextEdit] = useState<TextEditState>();
|
||||
const queueRef = useRef<ProjectAutoSaveQueue | undefined>(undefined);
|
||||
const historyRef = useRef<CanvasEditHistory | undefined>(undefined);
|
||||
const elementControllerRef = useRef<CanvasElementController | undefined>(undefined);
|
||||
const dragRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
|
||||
const opacityGestureRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
|
||||
const clipboardRef = useRef<CanvasElement[]>([]);
|
||||
const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -107,6 +132,15 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
return () => { active = false; };
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
let active = true;
|
||||
readEditorJson<{ items: Array<{ asset_id: string }> }>("/api/v1/assets/recent?asset_kind=text_template")
|
||||
.then((response) => { if (active) setRecentTextIds(response.items.map((item) => item.asset_id)); })
|
||||
.catch(() => { if (active) setRecentTextIds([]); });
|
||||
return () => { active = false; };
|
||||
}, [session?.user.user_id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project || !session || !canvasState) return undefined;
|
||||
const queue = new ProjectAutoSaveQueue({
|
||||
@@ -133,6 +167,48 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
return () => { queue.dispose(); if (queueRef.current === queue) queueRef.current = undefined; };
|
||||
}, [canvasState === undefined, project?.created_at, projectId, session?.csrf_token]);
|
||||
|
||||
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" }));
|
||||
}
|
||||
}, [canvasState?.elements.map((element) => `${element.element_id}:${element.font_override ?? ""}:${element.template_or_asset_id}`).join("|")]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasState || selectedIds.length !== 1) {
|
||||
setTextEdit(undefined);
|
||||
return;
|
||||
}
|
||||
const element = canvasState.elements.find((candidate) => candidate.element_id === selectedIds[0]);
|
||||
if (!element || element.type !== "text_template") {
|
||||
setTextEdit(undefined);
|
||||
return;
|
||||
}
|
||||
setTextEdit((current) => current?.elementId === element.element_id ? current : {
|
||||
draft: structuredClone(element), elementId: element.element_id, originalTemplateId: element.template_or_asset_id,
|
||||
});
|
||||
}, [canvasState, selectedIds.join("|")]);
|
||||
|
||||
async function ensureFont(fontId: string, url: string, retry = false) {
|
||||
const current = fontStatuses[fontId];
|
||||
if (current === "ready" || (current === "unavailable" && !retry)) return current;
|
||||
const loader = fontLoaderRef.current ?? createBrowserArchivedFontLoader();
|
||||
fontLoaderRef.current = loader;
|
||||
setFontStatuses((statuses) => ({ ...statuses, [fontId]: "loading" }));
|
||||
const status = retry ? await loader.retry({ fontId, url }) : await loader.ensure({ fontId, url });
|
||||
setFontStatuses((statuses) => ({ ...statuses, [fontId]: status }));
|
||||
return status;
|
||||
}
|
||||
|
||||
async function retryTextFonts() {
|
||||
const available = P0A_TEXT_TEMPLATES.filter((template) => template.available && template.fontUrl);
|
||||
await Promise.all(available.map((template) => ensureFont(template.defaultFontId, template.fontUrl!, true)));
|
||||
}
|
||||
|
||||
function commitCanvas(next: CanvasState) {
|
||||
if (!project || saveStatus === "conflicted") return;
|
||||
historyRef.current?.commit(next);
|
||||
@@ -141,6 +217,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
setCanvasState(next);
|
||||
setDraftAdjustments(next.background.adjustments);
|
||||
queueRef.current?.commit({ canvas_state: next, name: project.name });
|
||||
setTextEdit(undefined);
|
||||
}
|
||||
|
||||
function applyPreview() {
|
||||
@@ -157,6 +234,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
setCanvasState(previous);
|
||||
setDraftAdjustments(previous.background.adjustments);
|
||||
if (project) queueRef.current?.commit({ canvas_state: previous, name: project.name });
|
||||
setTextEdit(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +246,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
setCanvasState(next);
|
||||
setDraftAdjustments(next.background.adjustments);
|
||||
if (project) queueRef.current?.commit({ canvas_state: next, name: project.name });
|
||||
setTextEdit(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +291,105 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function recordRecentTextTemplate(templateId: string, resourceVersion: string) {
|
||||
if (!session) return;
|
||||
try {
|
||||
const response = await fetch("/api/v1/assets/recent", {
|
||||
body: JSON.stringify({ asset_id: templateId, asset_kind: "text_template", resource_version: resourceVersion }),
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": session.csrf_token },
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) return;
|
||||
setRecentTextIds((current) => [templateId, ...current.filter((id) => id !== templateId)].slice(0, 12));
|
||||
} catch {
|
||||
// Recent-use metadata does not roll back an otherwise successful canvas edit.
|
||||
}
|
||||
}
|
||||
|
||||
async function addTextTemplate(template: TextTemplateDefinition) {
|
||||
if (!template.fontUrl || !canvasState) return;
|
||||
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
||||
if (status !== "ready") {
|
||||
setNotice("素材暂不可用,未使用系统字体替代。");
|
||||
return;
|
||||
}
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller) return;
|
||||
try {
|
||||
controller.add(createTextTemplateElement(template, newElementIdentity(), canvasState.elements.length));
|
||||
commitElementOperation(controller, "文字模板已加入画布");
|
||||
void recordRecentTextTemplate(template.templateId, template.resourceVersion);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else setNotice("文字模板未能加入画布");
|
||||
}
|
||||
}
|
||||
|
||||
function updateTextDraft(action: (session: TextEditSession) => void) {
|
||||
setTextEdit((current) => {
|
||||
if (!current) return current;
|
||||
try {
|
||||
const edit = new TextEditSession(current.draft, P0A_TEXT_TEMPLATES);
|
||||
action(edit);
|
||||
return { ...current, draft: edit.value };
|
||||
} catch {
|
||||
setNotice("文字参数不在允许范围内");
|
||||
return current;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function changeTextTemplate(templateId: string) {
|
||||
const template = P0A_TEXT_TEMPLATES.find((candidate) => candidate.templateId === templateId);
|
||||
if (!template?.fontUrl) return;
|
||||
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
||||
if (status !== "ready") {
|
||||
setNotice("素材暂不可用,未使用系统字体替代。");
|
||||
return;
|
||||
}
|
||||
updateTextDraft((edit) => edit.switchTemplate(templateId));
|
||||
}
|
||||
|
||||
async function changeTextFont(fontId: string | null) {
|
||||
if (!fontId) {
|
||||
updateTextDraft((edit) => edit.setStyle({ fontOverride: null }));
|
||||
return;
|
||||
}
|
||||
const option = fontOption(fontId);
|
||||
if (!option || await ensureFont(option.fontId, option.url) !== "ready") {
|
||||
setNotice("字体素材暂不可用,未使用系统字体替代。");
|
||||
return;
|
||||
}
|
||||
updateTextDraft((edit) => edit.setStyle({ fontOverride: fontId }));
|
||||
}
|
||||
|
||||
function completeTextEdit() {
|
||||
if (!textEdit) return;
|
||||
try {
|
||||
const edit = new TextEditSession(textEdit.draft, P0A_TEXT_TEMPLATES);
|
||||
const complete = edit.complete();
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller) return;
|
||||
controller.replaceElement(complete);
|
||||
commitElementOperation(controller, "文字编辑已完成");
|
||||
if (complete.template_or_asset_id !== textEdit.originalTemplateId) {
|
||||
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "text_content_required") setNotice("请输入文字内容或删除该元素。");
|
||||
else setNotice("文字编辑未能完成");
|
||||
}
|
||||
}
|
||||
|
||||
function cancelTextEdit() {
|
||||
if (canvasState && textEdit) {
|
||||
const current = canvasState.elements.find((element) => element.element_id === textEdit.elementId);
|
||||
if (current) setTextEdit({ draft: structuredClone(current), elementId: current.element_id, originalTemplateId: current.template_or_asset_id });
|
||||
}
|
||||
setNotice("已取消未提交的文字修改");
|
||||
}
|
||||
|
||||
function transformSelection(action: (controller: CanvasElementController) => void, message: string) {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller || selectedIds.length === 0) return;
|
||||
@@ -342,9 +520,13 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
}
|
||||
|
||||
if (!project || !canvasState) return <main className="editor-loading" aria-live="polite">正在加载编辑器</main>;
|
||||
const renderedCanvasState = textEdit ? {
|
||||
...canvasState,
|
||||
elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
|
||||
} : canvasState;
|
||||
const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
|
||||
const canEdit = saveStatus !== "conflicted";
|
||||
const selectedElements = canvasState.elements.filter((element) => selectedIds.includes(element.element_id));
|
||||
const selectedElements = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id));
|
||||
const selectedStickerOpacity = selectedElements.length > 0 && selectedElements.every((element) => element.type === "static_sticker")
|
||||
? Math.round((selectedElements[0]?.opacity ?? 1) * 100)
|
||||
: undefined;
|
||||
@@ -368,7 +550,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
{([
|
||||
{ label: "底图", panel: "background" as const },
|
||||
{ label: "历史", panel: "history" as const },
|
||||
{ label: "文字模板" },
|
||||
{ label: "文字模板", panel: "text" as const },
|
||||
{ label: "普通贴纸", panel: "stickers" as const },
|
||||
{ label: "色卡" },
|
||||
{ label: "动态贴纸" },
|
||||
@@ -378,6 +560,18 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
{activePanel === "history" ? <section><h2>历史</h2><div className="editor-history-list">
|
||||
{project.images.length === 0 ? <p className="editor-muted">暂无其他成功图</p> : project.images.map((image) => <button className="editor-source" key={image.image_id} onClick={() => setPendingBackground(image.image_id)} type="button"><span className="editor-thumb" style={{ backgroundImage: `url(/api/v1/private-assets/projects/${projectId}/images/${image.image_id})` }} /><span>生成图 · {formatDate(image.created_at)}</span></button>)}
|
||||
</div></section> : null}
|
||||
{activePanel === "text" ? <TextTemplatePanel
|
||||
canAdd={canEdit && canvasState.elements.length < 50}
|
||||
fontStatuses={fontStatuses}
|
||||
onAdd={(template) => { void addTextTemplate(template); }}
|
||||
onCategory={setTemplateCategory}
|
||||
onQuery={setTemplateQuery}
|
||||
onRetry={() => { void retryTextFonts(); }}
|
||||
query={templateQuery}
|
||||
recentIds={recentTextIds}
|
||||
templates={P0A_TEXT_TEMPLATES}
|
||||
{...(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>)}
|
||||
</div>{canvasState.elements.length >= 50 ? <p className="editor-limit" role="status">画布最多 50 个元素,请先删除现有元素。</p> : null}</section> : null}
|
||||
@@ -392,7 +586,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
<div className="editor-canvas-frame" style={{ aspectRatio: `${canvasState.pixel_width} / ${canvasState.pixel_height}` }}>
|
||||
<EditorStage
|
||||
assetId={canvasState.background.asset_id}
|
||||
canvasState={canvasState}
|
||||
canvasState={renderedCanvasState}
|
||||
fontStatuses={fontStatuses}
|
||||
guides={guides}
|
||||
onCandidates={showCandidates}
|
||||
onClearSelection={clearSelection}
|
||||
@@ -424,6 +619,17 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
</> : <>
|
||||
<header><p>{selectedElements.length > 1 ? "MULTI SELECT" : "OBJECT"}</p><h2>{selectedElements.length > 1 ? `已选 ${selectedElements.length} 个对象` : "对象参数"}</h2></header>
|
||||
{selectedElements.length === 1 ? <p className="editor-object-id">{selectedElements[0]?.template_or_asset_id}</p> : null}
|
||||
{textEdit && selectedElements.length === 1 ? canEdit ? <TextInspector
|
||||
draft={textEdit.draft}
|
||||
onCancel={cancelTextEdit}
|
||||
onComplete={completeTextEdit}
|
||||
onContent={(content) => updateTextDraft((edit) => edit.setContent(content))}
|
||||
onFont={(fontId) => { void changeTextFont(fontId); }}
|
||||
onFontSize={(value) => updateTextDraft((edit) => edit.setEffectiveFontSize(value))}
|
||||
onStyle={(style: TextStylePatch) => updateTextDraft((edit) => edit.setStyle(style))}
|
||||
onTemplate={(templateId) => { void changeTextTemplate(templateId); }}
|
||||
templates={P0A_TEXT_TEMPLATES}
|
||||
/> : <p className="editor-muted">版本冲突,文字属性只读</p> : 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>
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import { cssFilterForBackground } from "./editor-canvas.js";
|
||||
import type { CanvasPoint, CanvasRect } from "./editor-elements.js";
|
||||
import { fontFamilyName, type ArchivedFontStatus } from "./text-font-loader.js";
|
||||
import { fontIdForTextElement } from "./text-assets.js";
|
||||
|
||||
interface Gesture {
|
||||
append: boolean;
|
||||
@@ -16,6 +18,7 @@ interface EditorStageProps {
|
||||
assetId: string | null;
|
||||
canvasState: CanvasState;
|
||||
guides: readonly string[];
|
||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
|
||||
onCandidates: (point: CanvasPoint) => void;
|
||||
onClearSelection: () => void;
|
||||
onCopy: () => void;
|
||||
@@ -39,7 +42,95 @@ function pointFromEvent(event: PointerEvent<HTMLCanvasElement>): CanvasPoint {
|
||||
};
|
||||
}
|
||||
|
||||
function drawElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], width: number, height: number) {
|
||||
function styleValue<T extends string | number | boolean>(element: CanvasState["elements"][number], key: string, fallback: T): T {
|
||||
const value = element.style_parameters?.[key];
|
||||
return typeof value === typeof fallback ? value as T : fallback;
|
||||
}
|
||||
|
||||
interface TextPixelGeometry {
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
function prepareTextContext(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontId: string) {
|
||||
const fontSize = element.font_size ?? 48;
|
||||
const letterSpacing = styleValue(element, "letter_spacing", 1);
|
||||
context.font = `${fontSize}px "${fontFamilyName(fontId)}"`;
|
||||
(context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
|
||||
}
|
||||
|
||||
function measureTextElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontId: string): TextPixelGeometry {
|
||||
prepareTextContext(context, element, fontId);
|
||||
const fontSize = element.font_size ?? 48;
|
||||
const lineHeight = styleValue(element, "line_height", 1.2);
|
||||
const letterSpacing = styleValue(element, "letter_spacing", 1);
|
||||
const strokeWidth = styleValue(element, "stroke_width", 0);
|
||||
const lines = (element.content ?? "").split("\n");
|
||||
const widths = lines.map((line) => context.measureText(line).width + Math.max(0, Array.from(line).length - 1) * letterSpacing);
|
||||
return {
|
||||
height: Math.max(fontSize * lineHeight, lines.length * fontSize * lineHeight) + 32 + strokeWidth * 2,
|
||||
width: Math.max(1, ...widths) + 32 + strokeWidth * 2,
|
||||
};
|
||||
}
|
||||
|
||||
function drawTextElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontStatuses: Readonly<Record<string, ArchivedFontStatus>>) {
|
||||
const fontId = fontIdForTextElement(element);
|
||||
if (!fontId || fontStatuses[fontId] !== "ready") {
|
||||
context.fillStyle = "#e5e7eb";
|
||||
context.fillRect(-110, -34, 220, 68);
|
||||
context.fillStyle = "#9f1d1d";
|
||||
context.font = "600 22px Microsoft YaHei UI, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.fillText("字体不可用", 0, 8);
|
||||
return;
|
||||
}
|
||||
const fontSize = element.font_size ?? 48;
|
||||
const lineHeight = styleValue(element, "line_height", 1.2);
|
||||
const letterSpacing = styleValue(element, "letter_spacing", 1);
|
||||
const align = styleValue(element, "text_align", "center") as CanvasTextAlign;
|
||||
const lines = (element.content ?? "").split("\n");
|
||||
prepareTextContext(context, element, fontId);
|
||||
context.textAlign = align;
|
||||
context.textBaseline = "middle";
|
||||
const widths = lines.map((line) => context.measureText(line).width + Math.max(0, line.length - 1) * letterSpacing);
|
||||
const textWidth = Math.max(1, ...widths);
|
||||
const textHeight = Math.max(fontSize * lineHeight, lines.length * fontSize * lineHeight);
|
||||
const padding = 16;
|
||||
const backgroundEnabled = styleValue(element, "background_enabled", false);
|
||||
if (backgroundEnabled) {
|
||||
const elementOpacity = context.globalAlpha;
|
||||
context.globalAlpha = elementOpacity * styleValue(element, "background_opacity", 1);
|
||||
context.fillStyle = styleValue(element, "background_color", "#FFE62C");
|
||||
context.fillRect(-textWidth / 2 - padding, -textHeight / 2 - padding, textWidth + padding * 2, textHeight + padding * 2);
|
||||
context.globalAlpha = elementOpacity;
|
||||
}
|
||||
const firstY = -((lines.length - 1) * fontSize * lineHeight) / 2;
|
||||
const anchorX = align === "left" ? -textWidth / 2 : align === "right" ? textWidth / 2 : 0;
|
||||
context.fillStyle = styleValue(element, "fill_color", "#111111");
|
||||
context.strokeStyle = styleValue(element, "stroke_color", "#000000");
|
||||
context.lineWidth = styleValue(element, "stroke_width", 0);
|
||||
lines.forEach((line, index) => {
|
||||
const y = firstY + index * fontSize * lineHeight;
|
||||
if (styleValue(element, "stroke_enabled", false) && context.lineWidth > 0) context.strokeText(line, anchorX, y);
|
||||
context.fillText(line, anchorX, y);
|
||||
});
|
||||
}
|
||||
|
||||
function elementSelectionHalfSize(
|
||||
context: CanvasRenderingContext2D,
|
||||
element: CanvasState["elements"][number],
|
||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
|
||||
) {
|
||||
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 };
|
||||
context.save();
|
||||
const geometry = measureTextElement(context, element, fontId);
|
||||
context.restore();
|
||||
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>>) {
|
||||
const x = element.position.x * width;
|
||||
const y = element.position.y * height;
|
||||
context.save();
|
||||
@@ -66,11 +157,7 @@ function drawElement(context: CanvasRenderingContext2D, element: CanvasState["el
|
||||
context.font = "700 22px Consolas, monospace";
|
||||
context.textAlign = "center";
|
||||
context.fillText(element.template_or_asset_id, 0, 8);
|
||||
} else {
|
||||
context.fillStyle = "#111111";
|
||||
context.font = "700 48px Microsoft YaHei, sans-serif";
|
||||
context.fillText(element.content ?? "DADA", -80, 16);
|
||||
}
|
||||
} else if (element.type === "text_template") drawTextElement(context, element, fontStatuses);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
@@ -94,12 +181,11 @@ export function EditorStage(props: EditorStageProps) {
|
||||
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);
|
||||
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);
|
||||
context.lineWidth = 4;
|
||||
context.strokeStyle = "#005fcc";
|
||||
for (const element of props.canvasState.elements.filter((entry) => props.selectedIds.includes(entry.element_id))) {
|
||||
const halfWidth = 78 * element.scale.x;
|
||||
const halfHeight = 78 * element.scale.y;
|
||||
const { width: halfWidth, height: halfHeight } = elementSelectionHalfSize(context, element, props.fontStatuses);
|
||||
context.strokeRect(element.position.x * canvas.width - halfWidth, element.position.y * canvas.height - halfHeight, halfWidth * 2, halfHeight * 2);
|
||||
}
|
||||
context.save();
|
||||
@@ -124,7 +210,7 @@ export function EditorStage(props: EditorStageProps) {
|
||||
image.onerror = () => render();
|
||||
image.src = `/api/v1/private-assets/projects/${encodeURIComponent(props.projectId)}/images/${encodeURIComponent(props.assetId)}`;
|
||||
return () => { image.onload = null; image.onerror = null; };
|
||||
}, [marquee, props.assetId, props.canvasState, props.guides, props.projectId, props.selectedIds]);
|
||||
}, [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, LogoutResponse, ProjectPurgeResponse, 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, 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; }
|
||||
|
||||
@@ -175,6 +175,13 @@ export async function listProjects(options: ClientOptions = {}): Promise<Project
|
||||
return response.json() as Promise<ProjectListResponse>;
|
||||
}
|
||||
|
||||
export async function listRecentAssets(options: ClientOptions = {}): Promise<RecentAssetListResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/assets/recent`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<RecentAssetListResponse>;
|
||||
}
|
||||
|
||||
export async function logoutUser(options: ClientOptions = {}): Promise<LogoutResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/logout`, { method: "POST", headers: options.headers ?? {} });
|
||||
@@ -189,6 +196,15 @@ export async function purgeProject(options: ClientOptions = {}): Promise<Project
|
||||
return response.json() as Promise<ProjectPurgeResponse>;
|
||||
}
|
||||
|
||||
export async function recordRecentAsset(body: RecentAssetRecordRequest, options: ClientOptions = {}): Promise<RecentAssetRecordResponse> {
|
||||
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/assets/recent`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<RecentAssetRecordResponse>;
|
||||
}
|
||||
|
||||
export async function renameProject(body: ProjectRenameRequest, options: ClientOptions = {}): Promise<ProjectRenameResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
|
||||
@@ -657,6 +657,32 @@ export type ProjectTrashResponse = {
|
||||
|
||||
export type ProjectViewStatus = "active" | "failed_empty" | "trashed";
|
||||
|
||||
export type RecentAssetItem = {
|
||||
"asset_id": string;
|
||||
"asset_kind": RecentAssetKind;
|
||||
"resource_version": string;
|
||||
};
|
||||
|
||||
export type RecentAssetKind = "static_sticker" | "text_template";
|
||||
|
||||
export type RecentAssetListResponse = {
|
||||
"items": Array<RecentAssetItem>;
|
||||
};
|
||||
|
||||
export type RecentAssetQuery = {
|
||||
"asset_kind": RecentAssetKind;
|
||||
};
|
||||
|
||||
export type RecentAssetRecordRequest = {
|
||||
"asset_id": string;
|
||||
"asset_kind": RecentAssetKind;
|
||||
"resource_version": string;
|
||||
};
|
||||
|
||||
export type RecentAssetRecordResponse = {
|
||||
"status": "recorded";
|
||||
};
|
||||
|
||||
export type RegistrationCompleteHeaders = {
|
||||
"idempotency-key": string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import type { CanvasElementIdentity } from "./editor-elements.js";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
export type TextTemplateCategory = "flower" | "simple" | "tag" | "title";
|
||||
export type TextAlign = "center" | "left" | "right";
|
||||
|
||||
export interface TextTemplateDefinition {
|
||||
available: boolean;
|
||||
catalogOrder: number;
|
||||
category: TextTemplateCategory;
|
||||
defaultFontId: string;
|
||||
defaultFontSize: number;
|
||||
defaultText: string;
|
||||
displayName: string;
|
||||
fontUrl?: string;
|
||||
resourceClass: "parameter_only" | "zip_template";
|
||||
resourceVersion: string;
|
||||
templateId: string;
|
||||
}
|
||||
|
||||
export interface FontOption {
|
||||
displayName: string;
|
||||
fontId: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface TextStylePatch {
|
||||
backgroundColor?: string;
|
||||
backgroundEnabled?: boolean;
|
||||
backgroundOpacity?: number;
|
||||
fillColor?: string;
|
||||
fontOverride?: string | null;
|
||||
letterSpacing?: number;
|
||||
lineHeight?: number;
|
||||
strokeColor?: string;
|
||||
strokeEnabled?: boolean;
|
||||
strokeWidth?: number;
|
||||
textAlign?: TextAlign;
|
||||
}
|
||||
|
||||
const fixtureVersion = "wp4-fixture-v1";
|
||||
const defaults = {
|
||||
background_color: "#FFE62C",
|
||||
background_enabled: false,
|
||||
background_opacity: 1,
|
||||
fill_color: "#111111",
|
||||
letter_spacing: 1,
|
||||
line_height: 1.2,
|
||||
stroke_color: "#000000",
|
||||
stroke_enabled: false,
|
||||
stroke_width: 0,
|
||||
text_align: "center",
|
||||
} as const;
|
||||
|
||||
type CatalogSeed = [id: string, category: TextTemplateCategory, displayName: string, defaultText: string, defaultFontId: string, available?: boolean, resourceClass?: "parameter_only"];
|
||||
|
||||
const seeds: readonly CatalogSeed[] = [
|
||||
["FLOWER001", "flower", "春日计划", "春日计划", "FONT011", true],
|
||||
["FLOWER002", "flower", "笑不活了", "笑不活了", "FLOWER002_FONT"],
|
||||
["FLOWER003", "flower", "人生照片", "人生照片", "FONT008"],
|
||||
["FLOWER004", "flower", "我的日常生活", "我的日常生活", "FLOWER004_FONT"],
|
||||
["FLOWER005", "flower", "碎片生活", "碎片生活", "FONT008"],
|
||||
["FLOWER006", "flower", "闪光瞬间", "闪光瞬间", "FLOWER006_FONT"],
|
||||
["FLOWER007", "flower", "好柿花生", "好柿花生", "FONT046", false, "parameter_only"],
|
||||
["FLOWER008", "flower", "Vlog.", "Vlog.", "FONT005"],
|
||||
["H001", "title", "电影生活记录", "电影生活记录", "H001_FONT"],
|
||||
["H002", "title", "30°C", "30°C", "H002_FONT"],
|
||||
["H003", "title", "生活分享家", "生活分享家", "FONT039", true],
|
||||
["H004", "title", "快乐充值成功", "快乐充值成功", "FONT046"],
|
||||
["H005", "title", "日常的镜头", "日常的镜头", "H005_FONT"],
|
||||
["H006", "title", "慢生活指南", "慢生活指南", "FONT052"],
|
||||
["H007", "title", "做个有闲人", "做个有闲人", "H007_FONT"],
|
||||
["H008", "title", "海滩日记", "海滩日记", "H008_FONT"],
|
||||
["TAG001", "tag", "自定义标签", "自定义标签", "FONT027"],
|
||||
["TAG002", "tag", "自定义标签", "自定义标签", "FONT043"],
|
||||
["TAG003", "tag", "打卡x1", "打卡x1", "FONT043"],
|
||||
["TAG004", "tag", "自定义标签", "自定义标签", "TAG004_FONT"],
|
||||
["TAG005", "tag", "自定义标签", "自定义标签", "FONT008"],
|
||||
["TAG006", "tag", "City Walk", "City Walk", "TAG006_FONT"],
|
||||
["TAG007", "tag", "打卡x1", "打卡x1", "FONT043"],
|
||||
["TAG051", "tag", "自定义标签", "自定义标签", "FONT022"],
|
||||
["SIMPLE001", "simple", "碎片回忆录", "碎片回忆录", "SIMPLE001_FONT"],
|
||||
["SIMPLE002", "simple", "秋天的信笺", "秋天的信笺", "SIMPLE002_FONT"],
|
||||
["SIMPLE003", "simple", "返航时海鸟追着船盘旋", "返航时海鸟追着船盘旋", "SIMPLE003_FONT"],
|
||||
["SIMPLE004", "simple", "下段旅程,幸福丰盛。", "下段旅程,幸福丰盛。", "SIMPLE002_FONT"],
|
||||
["SIMPLE005", "simple", "见信好。", "见信好。", "SIMPLE005_FONT"],
|
||||
["SIMPLE006", "simple", "万物回春", "万物回春", "SIMPLE001_FONT"],
|
||||
["SIMPLE007", "simple", "周而复始。", "周而复始。", "SIMPLE007_FONT"],
|
||||
["SIMPLE008", "simple", "周五愉快", "周五愉快", "SIMPLE008_FONT"],
|
||||
];
|
||||
|
||||
export const P0A_TEXT_TEMPLATES: readonly TextTemplateDefinition[] = seeds.map((seed, catalogOrder) => ({
|
||||
available: seed[5] === true,
|
||||
catalogOrder,
|
||||
category: seed[1],
|
||||
defaultFontId: seed[4],
|
||||
defaultFontSize: 48,
|
||||
defaultText: seed[3],
|
||||
displayName: seed[2],
|
||||
...(seed[5] === true ? { fontUrl: `/api/v1/assets/public/${fixtureVersion}/${seed[4]}` } : {}),
|
||||
resourceClass: seed[6] ?? "zip_template",
|
||||
resourceVersion: fixtureVersion,
|
||||
templateId: seed[0],
|
||||
}));
|
||||
|
||||
export const P0A_FONT_OPTIONS: readonly FontOption[] = [
|
||||
{ displayName: "默陌手写", fontId: "FONT011", url: `/api/v1/assets/public/${fixtureVersion}/FONT011` },
|
||||
{ displayName: "字由油漆", fontId: "FONT039", url: `/api/v1/assets/public/${fixtureVersion}/FONT039` },
|
||||
{ displayName: "Lexend Deca", fontId: "FONT081", url: `/api/v1/assets/public/${fixtureVersion}/FONT081` },
|
||||
];
|
||||
|
||||
export function fontOption(fontId: string) {
|
||||
return P0A_FONT_OPTIONS.find((option) => option.fontId === fontId);
|
||||
}
|
||||
|
||||
export function fontIdForTextElement(element: CanvasElement) {
|
||||
const defaultFontId = element.style_parameters?.default_font_id;
|
||||
return element.font_override ?? (typeof defaultFontId === "string" ? defaultFontId : undefined);
|
||||
}
|
||||
|
||||
function cloneElement(element: CanvasElement): CanvasElement {
|
||||
return structuredClone(element);
|
||||
}
|
||||
|
||||
function templateById(templates: readonly TextTemplateDefinition[], templateId: string) {
|
||||
const template = templates.find((candidate) => candidate.templateId === templateId);
|
||||
if (!template) throw new Error("text_template_not_found");
|
||||
return template;
|
||||
}
|
||||
|
||||
function checkedColor(value: string) {
|
||||
if (!/^#[0-9A-F]{6}$/i.test(value)) throw new Error("text_color_invalid");
|
||||
return value.toUpperCase();
|
||||
}
|
||||
|
||||
function isStep(value: number, minimum: number, step: number) {
|
||||
return Math.abs((value - minimum) / step - Math.round((value - minimum) / step)) < 1e-8;
|
||||
}
|
||||
|
||||
function templateStyle(template: TextTemplateDefinition): Record<string, string | number | boolean | null> {
|
||||
return { ...defaults, default_font_id: template.defaultFontId };
|
||||
}
|
||||
|
||||
export function searchTextTemplates(
|
||||
templates: readonly TextTemplateDefinition[],
|
||||
input: { category?: TextTemplateCategory; query?: string },
|
||||
) {
|
||||
const query = input.query?.trim().toLocaleLowerCase("zh-CN") ?? "";
|
||||
return templates
|
||||
.filter((template) => !input.category || template.category === input.category)
|
||||
.filter((template) => !query || template.displayName.toLocaleLowerCase("zh-CN").includes(query))
|
||||
.toSorted((left, right) => left.catalogOrder - right.catalogOrder);
|
||||
}
|
||||
|
||||
export function createTextTemplateElement(
|
||||
template: TextTemplateDefinition,
|
||||
identity: CanvasElementIdentity,
|
||||
zIndex: number,
|
||||
transform: Partial<Pick<CanvasElement, "position" | "rotation" | "scale">> = {},
|
||||
): CanvasElement {
|
||||
if (!template.available || !template.fontUrl) throw new Error("text_template_unavailable");
|
||||
return {
|
||||
content: template.defaultText,
|
||||
created_at: identity.createdAt,
|
||||
element_id: identity.elementId,
|
||||
font_size: template.defaultFontSize,
|
||||
opacity: 1,
|
||||
position: transform.position ?? { x: 0.5, y: 0.5 },
|
||||
resource_version: template.resourceVersion,
|
||||
rotation: transform.rotation ?? 0,
|
||||
scale: transform.scale ?? { x: 1, y: 1 },
|
||||
style_parameters: templateStyle(template),
|
||||
template_or_asset_id: template.templateId,
|
||||
type: "text_template",
|
||||
z_index: zIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export function effectiveFontSize(element: CanvasElement) {
|
||||
return (element.font_size ?? 48) * element.scale.x;
|
||||
}
|
||||
|
||||
export class TextEditSession {
|
||||
readonly original: CanvasElement;
|
||||
private draft: CanvasElement;
|
||||
private readonly templates: readonly TextTemplateDefinition[];
|
||||
|
||||
constructor(element: CanvasElement, templates: readonly TextTemplateDefinition[]) {
|
||||
if (element.type !== "text_template") throw new Error("text_element_required");
|
||||
this.original = cloneElement(element);
|
||||
this.draft = cloneElement(element);
|
||||
this.templates = templates;
|
||||
}
|
||||
|
||||
get value() { return cloneElement(this.draft); }
|
||||
|
||||
cancel() { return cloneElement(this.original); }
|
||||
|
||||
complete() {
|
||||
if (!(this.draft.content ?? "").trim()) throw new Error("text_content_required");
|
||||
return cloneElement(this.draft);
|
||||
}
|
||||
|
||||
setContent(content: string) {
|
||||
this.draft.content = content;
|
||||
}
|
||||
|
||||
setEffectiveFontSize(value: number) {
|
||||
if (!Number.isFinite(value) || value < 1 || value > 512) throw new Error("text_font_size_invalid");
|
||||
const base = this.draft.font_size ?? 48;
|
||||
const factor = value / base;
|
||||
this.draft.scale = { x: factor, y: factor };
|
||||
}
|
||||
|
||||
setStyle(patch: TextStylePatch) {
|
||||
const style = { ...(this.draft.style_parameters ?? {}) };
|
||||
if (patch.fillColor !== undefined) style.fill_color = checkedColor(patch.fillColor);
|
||||
if (patch.strokeColor !== undefined) style.stroke_color = checkedColor(patch.strokeColor);
|
||||
if (patch.backgroundColor !== undefined) style.background_color = checkedColor(patch.backgroundColor);
|
||||
if (patch.strokeEnabled !== undefined) style.stroke_enabled = patch.strokeEnabled;
|
||||
if (patch.backgroundEnabled !== undefined) style.background_enabled = patch.backgroundEnabled;
|
||||
if (patch.strokeWidth !== undefined) {
|
||||
if (!Number.isFinite(patch.strokeWidth) || patch.strokeWidth < 0 || patch.strokeWidth > 12) throw new Error("text_stroke_width_invalid");
|
||||
style.stroke_width = patch.strokeWidth;
|
||||
}
|
||||
if (patch.backgroundOpacity !== undefined) {
|
||||
if (!Number.isFinite(patch.backgroundOpacity) || patch.backgroundOpacity < 0 || patch.backgroundOpacity > 1) throw new Error("text_background_opacity_invalid");
|
||||
style.background_opacity = patch.backgroundOpacity;
|
||||
}
|
||||
if (patch.textAlign !== undefined) {
|
||||
if (!["center", "left", "right"].includes(patch.textAlign)) throw new Error("text_align_invalid");
|
||||
style.text_align = patch.textAlign;
|
||||
}
|
||||
if (patch.lineHeight !== undefined) {
|
||||
if (patch.lineHeight < 1 || patch.lineHeight > 1.9 || !isStep(patch.lineHeight, 1, 0.1)) throw new Error("text_line_height_invalid");
|
||||
style.line_height = patch.lineHeight;
|
||||
}
|
||||
if (patch.letterSpacing !== undefined) {
|
||||
if (patch.letterSpacing < 1 || patch.letterSpacing > 20 || !isStep(patch.letterSpacing, 1, 1)) throw new Error("text_letter_spacing_invalid");
|
||||
style.letter_spacing = patch.letterSpacing;
|
||||
}
|
||||
if (patch.fontOverride !== undefined) {
|
||||
if (patch.fontOverride === null) delete this.draft.font_override;
|
||||
else this.draft.font_override = patch.fontOverride;
|
||||
}
|
||||
this.draft.style_parameters = style;
|
||||
}
|
||||
|
||||
switchTemplate(templateId: string) {
|
||||
const template = templateById(this.templates, templateId);
|
||||
if (!template.available || !template.fontUrl) throw new Error("text_template_unavailable");
|
||||
const content = this.draft.content;
|
||||
this.draft = {
|
||||
...this.draft,
|
||||
font_size: template.defaultFontSize,
|
||||
resource_version: template.resourceVersion,
|
||||
style_parameters: templateStyle(template),
|
||||
template_or_asset_id: template.templateId,
|
||||
};
|
||||
if (content !== undefined) this.draft.content = content;
|
||||
delete this.draft.font_override;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
export type ArchivedFontStatus = "idle" | "loading" | "ready" | "unavailable";
|
||||
|
||||
interface LoadableFontFace {
|
||||
load: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface FontSetPort {
|
||||
add: (face: unknown) => unknown;
|
||||
check: (font: string) => boolean;
|
||||
ready: Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface ArchivedFontReference {
|
||||
fontId: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function fontFamilyName(fontId: string) {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/.test(fontId)) throw new Error("font_id_invalid");
|
||||
return `Dada_${fontId.replaceAll(/[^A-Za-z0-9_]/g, "_")}`;
|
||||
}
|
||||
|
||||
export class ArchivedFontLoader {
|
||||
private readonly createFace: (family: string, source: string) => LoadableFontFace;
|
||||
private readonly fontSet: FontSetPort;
|
||||
private readonly pending = new Map<string, Promise<ArchivedFontStatus>>();
|
||||
private readonly statuses = new Map<string, ArchivedFontStatus>();
|
||||
|
||||
constructor(input: { createFace: (family: string, source: string) => LoadableFontFace; fontSet: FontSetPort }) {
|
||||
this.createFace = input.createFace;
|
||||
this.fontSet = input.fontSet;
|
||||
}
|
||||
|
||||
status(fontId: string): ArchivedFontStatus {
|
||||
return this.statuses.get(fontId) ?? "idle";
|
||||
}
|
||||
|
||||
ensure(reference: ArchivedFontReference): Promise<ArchivedFontStatus> {
|
||||
const existing = this.pending.get(reference.fontId);
|
||||
if (existing) return existing;
|
||||
const operation = this.load(reference);
|
||||
this.pending.set(reference.fontId, operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
retry(reference: ArchivedFontReference) {
|
||||
this.pending.delete(reference.fontId);
|
||||
this.statuses.delete(reference.fontId);
|
||||
return this.ensure(reference);
|
||||
}
|
||||
|
||||
private async load(reference: ArchivedFontReference): Promise<ArchivedFontStatus> {
|
||||
const family = fontFamilyName(reference.fontId);
|
||||
this.statuses.set(reference.fontId, "loading");
|
||||
try {
|
||||
const face = this.createFace(family, `url("${reference.url}")`);
|
||||
const loaded = await face.load();
|
||||
this.fontSet.add(loaded);
|
||||
await this.fontSet.ready;
|
||||
if (!this.fontSet.check(`16px "${family}"`)) throw new Error("font_not_ready");
|
||||
this.statuses.set(reference.fontId, "ready");
|
||||
return "ready";
|
||||
} catch {
|
||||
this.statuses.set(reference.fontId, "unavailable");
|
||||
return "unavailable";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createBrowserArchivedFontLoader() {
|
||||
return new ArchivedFontLoader({
|
||||
createFace: (family, source) => new FontFace(family, source),
|
||||
fontSet: document.fonts as unknown as FontSetPort,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import { effectiveFontSize, P0A_FONT_OPTIONS, type TextStylePatch, type TextTemplateDefinition } from "./text-assets.js";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
function scalar<T extends string | number | boolean>(element: CanvasElement, key: string, fallback: T): T {
|
||||
const value = element.style_parameters?.[key];
|
||||
return typeof value === typeof fallback ? value as T : fallback;
|
||||
}
|
||||
|
||||
function NumberAndRange(props: { label: string; max: number; min: number; onChange: (value: number) => void; step: number; value: number }) {
|
||||
return <label className="editor-range editor-text-number"><span>{props.label}<output>{props.value}</output></span><input aria-label={`${props.label}滑杆`} max={props.max} min={props.min} onChange={(event) => props.onChange(Number(event.target.value))} step={props.step} type="range" value={props.value} /><input aria-label={props.label} max={props.max} min={props.min} onChange={(event) => props.onChange(Number(event.target.value))} step={props.step} type="number" value={props.value} /></label>;
|
||||
}
|
||||
|
||||
export function TextInspector(props: {
|
||||
draft: CanvasElement;
|
||||
onCancel: () => void;
|
||||
onComplete: () => void;
|
||||
onContent: (content: string) => void;
|
||||
onFont: (fontId: string | null) => void;
|
||||
onFontSize: (value: number) => void;
|
||||
onStyle: (patch: TextStylePatch) => void;
|
||||
onTemplate: (templateId: string) => void;
|
||||
templates: readonly TextTemplateDefinition[];
|
||||
}) {
|
||||
const style = props.draft.style_parameters ?? {};
|
||||
const backgroundOpacity = Math.round(scalar(props.draft, "background_opacity", 1) * 100);
|
||||
const strokeEnabled = scalar(props.draft, "stroke_enabled", false);
|
||||
const backgroundEnabled = scalar(props.draft, "background_enabled", false);
|
||||
return <div className="editor-text-inspector">
|
||||
<label>文字模板切换<select aria-label="文字模板切换" onChange={(event) => props.onTemplate(event.target.value)} value={props.draft.template_or_asset_id}>
|
||||
{props.templates.map((template) => <option disabled={!template.available} key={template.templateId} value={template.templateId}>{template.templateId} · {template.displayName}{template.available ? "" : "(素材暂不可用)"}</option>)}
|
||||
</select></label>
|
||||
<label>文字内容<textarea aria-label="文字内容" onChange={(event) => props.onContent(event.target.value)} rows={4} value={props.draft.content ?? ""} /></label>
|
||||
<label>字体覆盖<select aria-label="字体覆盖" onChange={(event) => props.onFont(event.target.value || null)} value={props.draft.font_override ?? ""}><option value="">模板默认字体</option>{P0A_FONT_OPTIONS.map((font) => <option key={font.fontId} value={font.fontId}>{font.fontId} · {font.displayName}</option>)}</select></label>
|
||||
<NumberAndRange label="有效字号" max={256} min={1} onChange={props.onFontSize} step={1} value={Math.round(effectiveFontSize(props.draft))} />
|
||||
<fieldset className="editor-color-fields"><legend>文字颜色</legend>
|
||||
<label>文字填充色<input aria-label="文字填充色" onChange={(event) => props.onStyle({ fillColor: event.target.value })} type="color" value={scalar(props.draft, "fill_color", "#111111")} /></label>
|
||||
<div className="editor-color-swatches" role="group" aria-label="文字参考色"><button aria-label="红色填充" onClick={() => props.onStyle({ fillColor: "#FA5751" })} style={{ background: "#FA5751" }} type="button" /><button aria-label="黑色填充" onClick={() => props.onStyle({ fillColor: "#000000" })} style={{ background: "#000000" }} type="button" /></div>
|
||||
</fieldset>
|
||||
<label className="editor-check"><input aria-label="启用描边" checked={strokeEnabled} onChange={(event) => props.onStyle({ strokeEnabled: event.target.checked })} type="checkbox" />启用描边</label>
|
||||
<NumberAndRange label="描边宽度" max={12} min={0} onChange={(value) => props.onStyle({ strokeWidth: value })} step={1} value={scalar(props.draft, "stroke_width", 0)} />
|
||||
<label>描边颜色<input aria-label="描边颜色" onChange={(event) => props.onStyle({ strokeColor: event.target.value })} type="color" value={scalar(props.draft, "stroke_color", "#000000")} /></label>
|
||||
<label className="editor-check"><input aria-label="启用文字背景" checked={backgroundEnabled} onChange={(event) => props.onStyle({ backgroundEnabled: event.target.checked })} type="checkbox" />启用文字背景</label>
|
||||
<label>文字背景色<input aria-label="文字背景色" onChange={(event) => props.onStyle({ backgroundColor: event.target.value })} type="color" value={scalar(props.draft, "background_color", "#FFE62C")} /></label>
|
||||
<NumberAndRange label="背景透明度" max={100} min={0} onChange={(value) => props.onStyle({ backgroundOpacity: value / 100 })} step={1} value={backgroundOpacity} />
|
||||
<label>文字对齐<select aria-label="文字对齐" onChange={(event) => props.onStyle({ textAlign: event.target.value as "center" | "left" | "right" })} value={scalar(props.draft, "text_align", "center")}><option value="left">左对齐</option><option value="center">居中</option><option value="right">右对齐</option></select></label>
|
||||
<NumberAndRange label="行距" max={1.9} min={1} onChange={(value) => props.onStyle({ lineHeight: value })} step={0.1} value={scalar(props.draft, "line_height", 1.2)} />
|
||||
<NumberAndRange label="字距" max={20} min={1} onChange={(value) => props.onStyle({ letterSpacing: value })} step={1} value={scalar(props.draft, "letter_spacing", 1)} />
|
||||
<div className="editor-inspector-actions"><button onClick={props.onCancel} type="button">取消</button><button aria-label="完成文字编辑" className="editor-primary" onClick={props.onComplete} type="button">完成</button></div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ArchivedFontStatus } from "./text-font-loader.js";
|
||||
import { searchTextTemplates, type TextTemplateCategory, type TextTemplateDefinition } from "./text-assets.js";
|
||||
|
||||
const categories: Array<{ id?: TextTemplateCategory; label: string }> = [
|
||||
{ label: "全部" }, { id: "flower", label: "花字" }, { id: "title", label: "标题" }, { id: "tag", label: "标签" }, { id: "simple", label: "简约" },
|
||||
];
|
||||
|
||||
export function TextTemplatePanel(props: {
|
||||
canAdd: boolean;
|
||||
category?: TextTemplateCategory;
|
||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
|
||||
onAdd: (template: TextTemplateDefinition) => void;
|
||||
onCategory: (category?: TextTemplateCategory) => void;
|
||||
onQuery: (query: string) => void;
|
||||
onRetry: () => void;
|
||||
query: string;
|
||||
recentIds: readonly string[];
|
||||
templates: readonly TextTemplateDefinition[];
|
||||
}) {
|
||||
const visible = searchTextTemplates(props.templates, { ...(props.category ? { category: props.category } : {}), query: props.query });
|
||||
const recent = props.recentIds.map((id) => props.templates.find((template) => template.templateId === id)).filter((item): item is TextTemplateDefinition => Boolean(item));
|
||||
const loadFailed = props.templates.some((template) => template.available && props.fontStatuses[template.defaultFontId] === "unavailable");
|
||||
return <section className="editor-text-assets">
|
||||
<h2>文字模板</h2>
|
||||
<input aria-label="搜索文字模板显示名称" className="editor-template-search" onChange={(event) => props.onQuery(event.target.value)} placeholder="搜索文字模板显示名称" type="search" value={props.query} />
|
||||
<div aria-label="文字模板分类" className="editor-template-categories" role="group">
|
||||
{categories.map((item) => <button aria-pressed={item.id === props.category || (!item.id && !props.category)} className={item.id === props.category || (!item.id && !props.category) ? "active" : ""} key={item.label} onClick={() => props.onCategory(item.id)} type="button">{item.label}</button>)}
|
||||
</div>
|
||||
{loadFailed ? <button className="editor-template-retry" onClick={props.onRetry} type="button">重试文字素材</button> : null}
|
||||
{recent.length > 0 ? <div aria-label="最近使用文字模板" className="editor-template-recent"><h3>最近使用</h3><div>{recent.map((template) => <span key={template.templateId}><strong>{template.templateId}</strong>{template.displayName}</span>)}</div></div> : null}
|
||||
<div className="editor-template-grid">
|
||||
{visible.map((template) => {
|
||||
const status = props.fontStatuses[template.defaultFontId] ?? "idle";
|
||||
const unavailable = !template.available || status === "unavailable";
|
||||
return <button aria-label={`${template.templateId} ${template.displayName}${unavailable ? " 素材暂不可用" : ""}`} disabled={!props.canAdd || unavailable || status === "loading"} key={template.templateId} onClick={() => props.onAdd(template)} type="button">
|
||||
<span className={`editor-template-mark ${template.category}`}>{template.displayName.slice(0, 2)}</span>
|
||||
<strong>{template.templateId}</strong>
|
||||
<span>{template.displayName}</span>
|
||||
{unavailable ? <small>素材暂不可用</small> : status === "loading" ? <small>正在加载字体</small> : null}
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
{visible.length === 0 ? <p className="editor-muted">没有匹配的文字模板</p> : null}
|
||||
</section>;
|
||||
}
|
||||
Reference in New Issue
Block a user