feat: complete TASK-WP4-03 text templates
This commit is contained in:
@@ -87,6 +87,12 @@ import {
|
|||||||
ProjectStateSaveResponseSchema,
|
ProjectStateSaveResponseSchema,
|
||||||
ProjectSummarySchema,
|
ProjectSummarySchema,
|
||||||
ProjectViewStatusSchema,
|
ProjectViewStatusSchema,
|
||||||
|
RecentAssetItemSchema,
|
||||||
|
RecentAssetKindSchema,
|
||||||
|
RecentAssetListResponseSchema,
|
||||||
|
RecentAssetQuerySchema,
|
||||||
|
RecentAssetRecordRequestSchema,
|
||||||
|
RecentAssetRecordResponseSchema,
|
||||||
RegistrationCompleteHeadersSchema,
|
RegistrationCompleteHeadersSchema,
|
||||||
RegistrationCompleteRequestSchema,
|
RegistrationCompleteRequestSchema,
|
||||||
RegistrationCompleteResponseSchema,
|
RegistrationCompleteResponseSchema,
|
||||||
@@ -120,6 +126,8 @@ import {
|
|||||||
type ProjectRenameRequest,
|
type ProjectRenameRequest,
|
||||||
type ProjectEditableState,
|
type ProjectEditableState,
|
||||||
type ProjectStateSaveHeaders,
|
type ProjectStateSaveHeaders,
|
||||||
|
type RecentAssetQuery,
|
||||||
|
type RecentAssetRecordRequest,
|
||||||
type RegistrationCompleteRequest,
|
type RegistrationCompleteRequest,
|
||||||
type RegistrationSendRequest,
|
type RegistrationSendRequest,
|
||||||
} from "@dada/shared-contracts";
|
} from "@dada/shared-contracts";
|
||||||
@@ -162,6 +170,7 @@ import {
|
|||||||
registrationFieldError,
|
registrationFieldError,
|
||||||
} from "./registration-errors.js";
|
} from "./registration-errors.js";
|
||||||
import type { RegistrationService } from "./registration.js";
|
import type { RegistrationService } from "./registration.js";
|
||||||
|
import type { RecentAssetService } from "./recent-assets.js";
|
||||||
import { ModelConfigurationError } from "./model-configuration.js";
|
import { ModelConfigurationError } from "./model-configuration.js";
|
||||||
import type { ModelConfigurationService } from "./model-configuration.js";
|
import type { ModelConfigurationService } from "./model-configuration.js";
|
||||||
|
|
||||||
@@ -189,6 +198,7 @@ export interface CreateAppOptions {
|
|||||||
models?: ModelConfigurationService;
|
models?: ModelConfigurationService;
|
||||||
networkBoundary?: NetworkBoundaryOptions;
|
networkBoundary?: NetworkBoundaryOptions;
|
||||||
publicAssets?: PublicAssetResolver;
|
publicAssets?: PublicAssetResolver;
|
||||||
|
recentAssets?: RecentAssetService;
|
||||||
projects?: ProjectService;
|
projects?: ProjectService;
|
||||||
registration?: RegistrationService;
|
registration?: RegistrationService;
|
||||||
}
|
}
|
||||||
@@ -726,6 +736,12 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
ProjectStateSaveHeadersSchema,
|
ProjectStateSaveHeadersSchema,
|
||||||
ProjectStateSaveResponseSchema,
|
ProjectStateSaveResponseSchema,
|
||||||
ProjectStateConflictResponseSchema,
|
ProjectStateConflictResponseSchema,
|
||||||
|
RecentAssetKindSchema,
|
||||||
|
RecentAssetItemSchema,
|
||||||
|
RecentAssetQuerySchema,
|
||||||
|
RecentAssetListResponseSchema,
|
||||||
|
RecentAssetRecordRequestSchema,
|
||||||
|
RecentAssetRecordResponseSchema,
|
||||||
FailedEmptyTrashRequestSchema,
|
FailedEmptyTrashRequestSchema,
|
||||||
FailedEmptyTrashResponseSchema,
|
FailedEmptyTrashResponseSchema,
|
||||||
ModelIdSchema,
|
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(
|
app.post(
|
||||||
"/api/v1/admin-auth/login/send",
|
"/api/v1/admin-auth/login/send",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { LatestExportService } from "./latest-exports.js";
|
|||||||
import { CreditService } from "./credits.js";
|
import { CreditService } from "./credits.js";
|
||||||
import { ProjectService } from "./projects.js";
|
import { ProjectService } from "./projects.js";
|
||||||
import { RegistrationService } from "./registration.js";
|
import { RegistrationService } from "./registration.js";
|
||||||
|
import { RecentAssetService } from "./recent-assets.js";
|
||||||
import { MockResendAdapter } from "./resend-adapter.js";
|
import { MockResendAdapter } from "./resend-adapter.js";
|
||||||
import { readSecureConfigCandidate } from "./secure-config.js";
|
import { readSecureConfigCandidate } from "./secure-config.js";
|
||||||
import { StructuredJsonlLogger } from "./structured-log.js";
|
import { StructuredJsonlLogger } from "./structured-log.js";
|
||||||
@@ -24,6 +25,7 @@ let credits: CreditService | undefined;
|
|||||||
let storage: ManagedStorage | undefined;
|
let storage: ManagedStorage | undefined;
|
||||||
let latestExports: LatestExportService | undefined;
|
let latestExports: LatestExportService | undefined;
|
||||||
let models: ModelConfigurationService | undefined;
|
let models: ModelConfigurationService | undefined;
|
||||||
|
let recentAssets: RecentAssetService | undefined;
|
||||||
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
||||||
if (credentialChannelEnabled) {
|
if (credentialChannelEnabled) {
|
||||||
const clients = initializeApiCredentialClients(await receiveApiCredentials());
|
const clients = initializeApiCredentialClients(await receiveApiCredentials());
|
||||||
@@ -47,6 +49,7 @@ if (credentialChannelEnabled) {
|
|||||||
storage = new ManagedStorage({ dataRoot, databasePath });
|
storage = new ManagedStorage({ dataRoot, databasePath });
|
||||||
latestExports = new LatestExportService({ databasePath, storage });
|
latestExports = new LatestExportService({ databasePath, storage });
|
||||||
models = new ModelConfigurationService({ database: registration.database });
|
models = new ModelConfigurationService({ database: registration.database });
|
||||||
|
recentAssets = new RecentAssetService({ database: registration.database });
|
||||||
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
latestExports?.close();
|
latestExports?.close();
|
||||||
@@ -73,6 +76,7 @@ const app = await createApp({
|
|||||||
...(models ? { models } : {}),
|
...(models ? { models } : {}),
|
||||||
...(projects ? { projects } : {}),
|
...(projects ? { projects } : {}),
|
||||||
...(registration ? { registration } : {}),
|
...(registration ? { registration } : {}),
|
||||||
|
...(recentAssets ? { recentAssets } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.listen({
|
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 snapThreshold = 0.015;
|
||||||
const hitHalfExtent = 0.08;
|
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) {
|
function cloneState(state: CanvasState) {
|
||||||
return structuredClone(state);
|
return structuredClone(state);
|
||||||
}
|
}
|
||||||
@@ -132,8 +160,7 @@ export class CanvasElementController {
|
|||||||
candidatesAt(point: CanvasPoint) {
|
candidatesAt(point: CanvasPoint) {
|
||||||
return this.current.elements
|
return this.current.elements
|
||||||
.filter((element) => {
|
.filter((element) => {
|
||||||
const halfWidth = hitHalfExtent * element.scale.x;
|
const { x: halfWidth, y: halfHeight } = elementHalfExtents(this.current, element);
|
||||||
const halfHeight = hitHalfExtent * element.scale.y;
|
|
||||||
return point.x >= element.position.x - halfWidth && point.x <= element.position.x + halfWidth
|
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;
|
&& point.y >= element.position.y - halfHeight && point.y <= element.position.y + halfHeight;
|
||||||
})
|
})
|
||||||
@@ -186,6 +213,17 @@ export class CanvasElementController {
|
|||||||
return this.value;
|
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) {
|
private updateSelected(mapper: (element: CanvasElement) => CanvasElement) {
|
||||||
const selected = new Set(this.selection);
|
const selected = new Set(this.selection);
|
||||||
this.current = requireState({
|
this.current = requireState({
|
||||||
|
|||||||
@@ -312,6 +312,80 @@
|
|||||||
.editor-wide-command { width: 100%; margin-top: 18px; }
|
.editor-wide-command { width: 100%; margin-top: 18px; }
|
||||||
.editor-danger { border-color: #c92a24 !important; color: #8f1d14 !important; }
|
.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 {
|
.editor-statusbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -12,16 +12,35 @@ import {
|
|||||||
} from "./editor-canvas.js";
|
} from "./editor-canvas.js";
|
||||||
import { CanvasElementController, createStaticStickerElement, type CanvasElementIdentity, type CanvasLayerCommand, type CanvasPoint, type CanvasRect } from "./editor-elements.js";
|
import { CanvasElementController, createStaticStickerElement, type CanvasElementIdentity, type CanvasLayerCommand, type CanvasPoint, type CanvasRect } from "./editor-elements.js";
|
||||||
import { EditorStage } from "./editor-stage.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";
|
import "./editor-page.css";
|
||||||
|
|
||||||
type Ratio = CanvasState["ratio"];
|
type Ratio = CanvasState["ratio"];
|
||||||
type CanvasElement = CanvasState["elements"][number];
|
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 {
|
interface EditorSession {
|
||||||
csrf_token: string;
|
csrf_token: string;
|
||||||
user: { creator_name: string };
|
user: { creator_name: string; user_id: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditorProject {
|
interface EditorProject {
|
||||||
@@ -82,12 +101,18 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
const [guides, setGuides] = useState<string[]>([]);
|
const [guides, setGuides] = useState<string[]>([]);
|
||||||
const [multiMode, setMultiMode] = useState(false);
|
const [multiMode, setMultiMode] = useState(false);
|
||||||
const [candidateMenu, setCandidateMenu] = useState<{ elements: CanvasElement[]; point: CanvasPoint }>();
|
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 queueRef = useRef<ProjectAutoSaveQueue | undefined>(undefined);
|
||||||
const historyRef = useRef<CanvasEditHistory | undefined>(undefined);
|
const historyRef = useRef<CanvasEditHistory | undefined>(undefined);
|
||||||
const elementControllerRef = useRef<CanvasElementController | undefined>(undefined);
|
const elementControllerRef = useRef<CanvasElementController | undefined>(undefined);
|
||||||
const dragRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | 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 opacityGestureRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
|
||||||
const clipboardRef = useRef<CanvasElement[]>([]);
|
const clipboardRef = useRef<CanvasElement[]>([]);
|
||||||
|
const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -107,6 +132,15 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, [projectId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!project || !session || !canvasState) return undefined;
|
if (!project || !session || !canvasState) return undefined;
|
||||||
const queue = new ProjectAutoSaveQueue({
|
const queue = new ProjectAutoSaveQueue({
|
||||||
@@ -133,6 +167,48 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
return () => { queue.dispose(); if (queueRef.current === queue) queueRef.current = undefined; };
|
return () => { queue.dispose(); if (queueRef.current === queue) queueRef.current = undefined; };
|
||||||
}, [canvasState === undefined, project?.created_at, projectId, session?.csrf_token]);
|
}, [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) {
|
function commitCanvas(next: CanvasState) {
|
||||||
if (!project || saveStatus === "conflicted") return;
|
if (!project || saveStatus === "conflicted") return;
|
||||||
historyRef.current?.commit(next);
|
historyRef.current?.commit(next);
|
||||||
@@ -141,6 +217,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
setCanvasState(next);
|
setCanvasState(next);
|
||||||
setDraftAdjustments(next.background.adjustments);
|
setDraftAdjustments(next.background.adjustments);
|
||||||
queueRef.current?.commit({ canvas_state: next, name: project.name });
|
queueRef.current?.commit({ canvas_state: next, name: project.name });
|
||||||
|
setTextEdit(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPreview() {
|
function applyPreview() {
|
||||||
@@ -157,6 +234,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
setCanvasState(previous);
|
setCanvasState(previous);
|
||||||
setDraftAdjustments(previous.background.adjustments);
|
setDraftAdjustments(previous.background.adjustments);
|
||||||
if (project) queueRef.current?.commit({ canvas_state: previous, name: project.name });
|
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);
|
setCanvasState(next);
|
||||||
setDraftAdjustments(next.background.adjustments);
|
setDraftAdjustments(next.background.adjustments);
|
||||||
if (project) queueRef.current?.commit({ canvas_state: next, name: project.name });
|
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) {
|
function transformSelection(action: (controller: CanvasElementController) => void, message: string) {
|
||||||
const controller = controllerForCurrent();
|
const controller = controllerForCurrent();
|
||||||
if (!controller || selectedIds.length === 0) return;
|
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>;
|
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 imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
|
||||||
const canEdit = saveStatus !== "conflicted";
|
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")
|
const selectedStickerOpacity = selectedElements.length > 0 && selectedElements.every((element) => element.type === "static_sticker")
|
||||||
? Math.round((selectedElements[0]?.opacity ?? 1) * 100)
|
? Math.round((selectedElements[0]?.opacity ?? 1) * 100)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -368,7 +550,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
{([
|
{([
|
||||||
{ label: "底图", panel: "background" as const },
|
{ label: "底图", panel: "background" as const },
|
||||||
{ label: "历史", panel: "history" as const },
|
{ label: "历史", panel: "history" as const },
|
||||||
{ label: "文字模板" },
|
{ label: "文字模板", panel: "text" as const },
|
||||||
{ label: "普通贴纸", panel: "stickers" as const },
|
{ label: "普通贴纸", panel: "stickers" as const },
|
||||||
{ label: "色卡" },
|
{ label: "色卡" },
|
||||||
{ label: "动态贴纸" },
|
{ label: "动态贴纸" },
|
||||||
@@ -378,6 +560,18 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
{activePanel === "history" ? <section><h2>历史</h2><div className="editor-history-list">
|
{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>)}
|
{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}
|
</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">
|
{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"><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}
|
</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}` }}>
|
<div className="editor-canvas-frame" style={{ aspectRatio: `${canvasState.pixel_width} / ${canvasState.pixel_height}` }}>
|
||||||
<EditorStage
|
<EditorStage
|
||||||
assetId={canvasState.background.asset_id}
|
assetId={canvasState.background.asset_id}
|
||||||
canvasState={canvasState}
|
canvasState={renderedCanvasState}
|
||||||
|
fontStatuses={fontStatuses}
|
||||||
guides={guides}
|
guides={guides}
|
||||||
onCandidates={showCandidates}
|
onCandidates={showCandidates}
|
||||||
onClearSelection={clearSelection}
|
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>
|
<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}
|
{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="移动对象">
|
<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.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>
|
<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 { cssFilterForBackground } from "./editor-canvas.js";
|
||||||
import type { CanvasPoint, CanvasRect } from "./editor-elements.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 {
|
interface Gesture {
|
||||||
append: boolean;
|
append: boolean;
|
||||||
@@ -16,6 +18,7 @@ interface EditorStageProps {
|
|||||||
assetId: string | null;
|
assetId: string | null;
|
||||||
canvasState: CanvasState;
|
canvasState: CanvasState;
|
||||||
guides: readonly string[];
|
guides: readonly string[];
|
||||||
|
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
|
||||||
onCandidates: (point: CanvasPoint) => void;
|
onCandidates: (point: CanvasPoint) => void;
|
||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
onCopy: () => 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 x = element.position.x * width;
|
||||||
const y = element.position.y * height;
|
const y = element.position.y * height;
|
||||||
context.save();
|
context.save();
|
||||||
@@ -66,11 +157,7 @@ function drawElement(context: CanvasRenderingContext2D, element: CanvasState["el
|
|||||||
context.font = "700 22px Consolas, monospace";
|
context.font = "700 22px Consolas, monospace";
|
||||||
context.textAlign = "center";
|
context.textAlign = "center";
|
||||||
context.fillText(element.template_or_asset_id, 0, 8);
|
context.fillText(element.template_or_asset_id, 0, 8);
|
||||||
} else {
|
} else if (element.type === "text_template") drawTextElement(context, element, fontStatuses);
|
||||||
context.fillStyle = "#111111";
|
|
||||||
context.font = "700 48px Microsoft YaHei, sans-serif";
|
|
||||||
context.fillText(element.content ?? "DADA", -80, 16);
|
|
||||||
}
|
|
||||||
context.restore();
|
context.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,12 +181,11 @@ export function EditorStage(props: EditorStageProps) {
|
|||||||
context.filter = cssFilterForBackground(props.canvasState.background.adjustments);
|
context.filter = cssFilterForBackground(props.canvasState.background.adjustments);
|
||||||
if (image) context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
if (image) context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||||
context.filter = "none";
|
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.lineWidth = 4;
|
||||||
context.strokeStyle = "#005fcc";
|
context.strokeStyle = "#005fcc";
|
||||||
for (const element of props.canvasState.elements.filter((entry) => props.selectedIds.includes(entry.element_id))) {
|
for (const element of props.canvasState.elements.filter((entry) => props.selectedIds.includes(entry.element_id))) {
|
||||||
const halfWidth = 78 * element.scale.x;
|
const { width: halfWidth, height: halfHeight } = elementSelectionHalfSize(context, element, props.fontStatuses);
|
||||||
const halfHeight = 78 * element.scale.y;
|
|
||||||
context.strokeRect(element.position.x * canvas.width - halfWidth, element.position.y * canvas.height - halfHeight, halfWidth * 2, halfHeight * 2);
|
context.strokeRect(element.position.x * canvas.width - halfWidth, element.position.y * canvas.height - halfHeight, halfWidth * 2, halfHeight * 2);
|
||||||
}
|
}
|
||||||
context.save();
|
context.save();
|
||||||
@@ -124,7 +210,7 @@ export function EditorStage(props: EditorStageProps) {
|
|||||||
image.onerror = () => render();
|
image.onerror = () => render();
|
||||||
image.src = `/api/v1/private-assets/projects/${encodeURIComponent(props.projectId)}/images/${encodeURIComponent(props.assetId)}`;
|
image.src = `/api/v1/private-assets/projects/${encodeURIComponent(props.projectId)}/images/${encodeURIComponent(props.assetId)}`;
|
||||||
return () => { image.onload = null; image.onerror = null; };
|
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); }, []);
|
useEffect(() => () => { if (longPressRef.current) clearTimeout(longPressRef.current); }, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
// 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; }
|
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>;
|
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> {
|
export async function logoutUser(options: ClientOptions = {}): Promise<LogoutResponse> {
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/logout`, { method: "POST", headers: options.headers ?? {} });
|
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>;
|
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> {
|
export async function renameProject(body: ProjectRenameRequest, options: ClientOptions = {}): Promise<ProjectRenameResponse> {
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
|
|||||||
@@ -657,6 +657,32 @@ export type ProjectTrashResponse = {
|
|||||||
|
|
||||||
export type ProjectViewStatus = "active" | "failed_empty" | "trashed";
|
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 = {
|
export type RegistrationCompleteHeaders = {
|
||||||
"idempotency-key": string;
|
"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>;
|
||||||
|
}
|
||||||
@@ -3855,6 +3855,113 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"RecentAssetItem": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"asset_id": {
|
||||||
|
"maxLength": 120,
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"asset_kind": {
|
||||||
|
"$ref": "#/components/schemas/RecentAssetKind"
|
||||||
|
},
|
||||||
|
"resource_version": {
|
||||||
|
"maxLength": 120,
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"asset_id",
|
||||||
|
"asset_kind",
|
||||||
|
"resource_version"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"RecentAssetKind": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"static_sticker"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"text_template"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"RecentAssetListResponse": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/RecentAssetItem"
|
||||||
|
},
|
||||||
|
"maxItems": 12,
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"items"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"RecentAssetQuery": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"asset_kind": {
|
||||||
|
"$ref": "#/components/schemas/RecentAssetKind"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"asset_kind"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"RecentAssetRecordRequest": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"asset_id": {
|
||||||
|
"maxLength": 120,
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"asset_kind": {
|
||||||
|
"$ref": "#/components/schemas/RecentAssetKind"
|
||||||
|
},
|
||||||
|
"resource_version": {
|
||||||
|
"maxLength": 120,
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"asset_id",
|
||||||
|
"asset_kind",
|
||||||
|
"resource_version"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"RecentAssetRecordResponse": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"enum": [
|
||||||
|
"recorded"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"RegistrationCompleteHeaders": {
|
"RegistrationCompleteHeaders": {
|
||||||
"additionalProperties": true,
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -7983,6 +8090,123 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/assets/recent": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listRecentAssets",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "asset_kind",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RecentAssetKind"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RecentAssetListResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Assets"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"operationId": "recordRecentAsset",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "header",
|
||||||
|
"name": "x-csrf-token",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"maxLength": 64,
|
||||||
|
"minLength": 43,
|
||||||
|
"pattern": "^[A-Za-z0-9_-]+$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RecentAssetRecordRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RecentAssetRecordResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Assets"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/auth/login/complete": {
|
"/api/v1/auth/login/complete": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "completeLogin",
|
"operationId": "completeLogin",
|
||||||
|
|||||||
+4
-2
@@ -14,7 +14,7 @@
|
|||||||
"test:integration": "vitest run tests/integration",
|
"test:integration": "vitest run tests/integration",
|
||||||
"test:api": "pnpm check:openapi && vitest run tests/api",
|
"test:api": "pnpm check:openapi && vitest run tests/api",
|
||||||
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
|
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
|
||||||
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts --config playwright.config.ts",
|
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts --config playwright.config.ts",
|
||||||
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
||||||
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
||||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
||||||
@@ -77,7 +77,9 @@
|
|||||||
"test:wp4-01": "node scripts/run-wp4-01-validation.mjs",
|
"test:wp4-01": "node scripts/run-wp4-01-validation.mjs",
|
||||||
"test:wp4-01:red": "node scripts/run-wp4-01-validation.mjs --phase red",
|
"test:wp4-01:red": "node scripts/run-wp4-01-validation.mjs --phase red",
|
||||||
"test:wp4-02": "node scripts/run-wp4-02-validation.mjs",
|
"test:wp4-02": "node scripts/run-wp4-02-validation.mjs",
|
||||||
"test:wp4-02:red": "node scripts/run-wp4-02-validation.mjs --phase red"
|
"test:wp4-02:red": "node scripts/run-wp4-02-validation.mjs --phase red",
|
||||||
|
"test:wp4-03": "node scripts/run-wp4-03-validation.mjs",
|
||||||
|
"test:wp4-03:red": "node scripts/run-wp4-03-validation.mjs --phase red"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.0",
|
"@playwright/test": "1.62.0",
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Type, type Static } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
const stableResourcePattern = "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$";
|
||||||
|
|
||||||
|
export const RecentAssetKindSchema = Type.Union(
|
||||||
|
[Type.Literal("static_sticker"), Type.Literal("text_template")],
|
||||||
|
{ $id: "RecentAssetKind" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const RecentAssetItemSchema = Type.Object(
|
||||||
|
{
|
||||||
|
asset_id: Type.String({ maxLength: 120, pattern: stableResourcePattern }),
|
||||||
|
asset_kind: Type.Ref(RecentAssetKindSchema),
|
||||||
|
resource_version: Type.String({ maxLength: 120, pattern: stableResourcePattern }),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false, $id: "RecentAssetItem" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const RecentAssetQuerySchema = Type.Object(
|
||||||
|
{ asset_kind: Type.Ref(RecentAssetKindSchema) },
|
||||||
|
{ additionalProperties: false, $id: "RecentAssetQuery" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const RecentAssetListResponseSchema = Type.Object(
|
||||||
|
{ items: Type.Array(Type.Ref(RecentAssetItemSchema), { maxItems: 12 }) },
|
||||||
|
{ additionalProperties: false, $id: "RecentAssetListResponse" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const RecentAssetRecordRequestSchema = Type.Object(
|
||||||
|
{
|
||||||
|
asset_id: Type.String({ maxLength: 120, pattern: stableResourcePattern }),
|
||||||
|
asset_kind: Type.Ref(RecentAssetKindSchema),
|
||||||
|
resource_version: Type.String({ maxLength: 120, pattern: stableResourcePattern }),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false, $id: "RecentAssetRecordRequest" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const RecentAssetRecordResponseSchema = Type.Object(
|
||||||
|
{ status: Type.Literal("recorded") },
|
||||||
|
{ additionalProperties: false, $id: "RecentAssetRecordResponse" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type RecentAssetQuery = Static<typeof RecentAssetQuerySchema>;
|
||||||
|
export type RecentAssetRecordRequest = Static<typeof RecentAssetRecordRequestSchema>;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export { Type } from "@sinclair/typebox";
|
export { Type } from "@sinclair/typebox";
|
||||||
export * from "./api.js";
|
export * from "./api.js";
|
||||||
|
export * from "./assets.js";
|
||||||
export * from "./auth.js";
|
export * from "./auth.js";
|
||||||
export * from "./bootstrap.js";
|
export * from "./bootstrap.js";
|
||||||
export * from "./canvas.js";
|
export * from "./canvas.js";
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const phaseIndex = process.argv.indexOf("--phase");
|
||||||
|
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
|
||||||
|
if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`);
|
||||||
|
const runId = process.env.DADA_TDD_RUN_ID ?? `wp4-03-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
|
||||||
|
const runDirectory = resolve("artifacts", "tdd", runId);
|
||||||
|
const casesDirectory = resolve(runDirectory, "cases");
|
||||||
|
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
|
||||||
|
mkdirSync(casesDirectory, { recursive: true });
|
||||||
|
|
||||||
|
const cases = [
|
||||||
|
{ acceptance_criteria: ["AC-09", "AC-10"], evidence: ["canvas-state.json", "db-diff.json", "pixel-diff.json", "trace.zip"], id: "TDD-WP4-TXT-001-multiline-template-switch", requirements: ["TEXT-01", "TEXT-02", "TEXT-03", "TEXT-04", "TEXT-05", "TEXT-06", "PROJECT-04"] },
|
||||||
|
{ acceptance_criteria: ["AC-11", "AC-12"], evidence: ["font-load.json", "canvas-state.json", "pixel-diff.json", "trace.zip"], id: "TDD-WP4-TXT-002-font-metrics-ranges", requirements: ["TEXT-07", "TEXT-08", "TEXT-09", "TEXT-10", "TEXT-11", "TEXT-12", "TEXT-13", "TEXT-14", "NFR-02"] },
|
||||||
|
{ acceptance_criteria: ["AC-09", "AC-32"], evidence: ["catalog.json", "response.json", "db-diff.json", "trace.zip"], id: "TDD-WP4-TXT-003-catalog-search-recent", requirements: ["TEXT-01", "TEXT-02"] },
|
||||||
|
];
|
||||||
|
for (const item of cases) mkdirSync(resolve(casesDirectory, item.id), { recursive: true });
|
||||||
|
|
||||||
|
const outputDirectory = resolve(runDirectory, "playwright-output");
|
||||||
|
const archiveEvidence = resolve(runDirectory, "font-source.json");
|
||||||
|
const environment = {
|
||||||
|
...process.env,
|
||||||
|
DADA_EVIDENCE_DIR_TEXT_EDITOR: casesDirectory,
|
||||||
|
DADA_FONT_SOURCE_EVIDENCE: archiveEvidence,
|
||||||
|
DADA_PLAYWRIGHT_OUTPUT_DIR: outputDirectory,
|
||||||
|
};
|
||||||
|
const commands = phase === "red" ? [] : [
|
||||||
|
["font-source", "pnpm exec node scripts/verify-wp4-03-font-source.mjs"],
|
||||||
|
["unit", "pnpm test:unit"],
|
||||||
|
["integration", "pnpm test:integration"],
|
||||||
|
["api", "pnpm test:api"],
|
||||||
|
["e2e", "pnpm test:e2e"],
|
||||||
|
["visual", "pnpm test:visual"],
|
||||||
|
["performance", "pnpm test:performance"],
|
||||||
|
["tdd-trace", "pnpm validate:tdd-trace"],
|
||||||
|
];
|
||||||
|
const commandResults = [];
|
||||||
|
for (const [name, command] of commands) {
|
||||||
|
const started_at = new Date().toISOString();
|
||||||
|
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], { encoding: "utf8", env: environment });
|
||||||
|
if (result.stdout) process.stdout.write(result.stdout);
|
||||||
|
if (result.stderr) process.stderr.write(result.stderr);
|
||||||
|
commandResults.push({ command, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), name, started_at });
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTraces(directory) {
|
||||||
|
const traces = [];
|
||||||
|
if (!existsSync(directory)) return traces;
|
||||||
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||||
|
const path = resolve(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) traces.push(...findTraces(path));
|
||||||
|
else if (entry.name === "trace.zip") traces.push(path);
|
||||||
|
}
|
||||||
|
return traces;
|
||||||
|
}
|
||||||
|
if (phase === "green") {
|
||||||
|
const traces = findTraces(outputDirectory);
|
||||||
|
const mappings = ["template-switch", "style-ranges-once", "account-recent-use"];
|
||||||
|
mappings.forEach((needle, index) => {
|
||||||
|
const trace = traces.find((path) => path.includes(needle));
|
||||||
|
if (trace) copyFileSync(trace, resolve(casesDirectory, cases[index].id, "trace.zip"));
|
||||||
|
});
|
||||||
|
const fontEvidencePath = resolve(casesDirectory, cases[1].id, "font-load.json");
|
||||||
|
if (existsSync(fontEvidencePath) && existsSync(archiveEvidence)) {
|
||||||
|
const browser = JSON.parse(readFileSync(fontEvidencePath, "utf8"));
|
||||||
|
const archive = JSON.parse(readFileSync(archiveEvidence, "utf8"));
|
||||||
|
writeFileSync(fontEvidencePath, `${JSON.stringify({ ...browser, archive_verification: archive }, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandState = phase === "red" ? true : commandResults.every((result) => result.exit_code === 0);
|
||||||
|
const manifest = { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() };
|
||||||
|
const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim();
|
||||||
|
const dirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0;
|
||||||
|
if (phase === "red") {
|
||||||
|
const observation = {
|
||||||
|
expected_failure: "Text template domain, font gate, recent asset service and enabled editor entry were absent before TASK-WP4-03",
|
||||||
|
observed_commands: [
|
||||||
|
"pnpm vitest run tests/unit/wp4-03-text-editor.test.ts tests/integration/wp4-03-recent-assets.test.ts",
|
||||||
|
"pnpm vitest run tests/unit/wp4-03-font-loader.test.ts",
|
||||||
|
"pnpm vitest run tests/api/wp4-03-recent-assets.test.ts",
|
||||||
|
"pnpm playwright test tests/e2e/wp4-03-text-editor.spec.ts --grep frozen catalog",
|
||||||
|
],
|
||||||
|
observed_errors: ["Cannot find module text-assets.js", "Cannot find module recent-assets.js", "Cannot find module text-font-loader.js", "recent assets endpoint returned 404", "文字模板 button was disabled"],
|
||||||
|
status: "red_confirmed",
|
||||||
|
};
|
||||||
|
for (const item of cases) writeFileSync(resolve(casesDirectory, item.id, "red-observation.json"), `${JSON.stringify(observation, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaries = [];
|
||||||
|
for (const item of cases) {
|
||||||
|
const directory = resolve(casesDirectory, item.id);
|
||||||
|
const evidenceRefs = phase === "red" ? ["red-observation.json"] : item.evidence;
|
||||||
|
const missingEvidence = evidenceRefs.filter((file) => !existsSync(resolve(directory, file)));
|
||||||
|
const status = commandState && missingEvidence.length === 0 ? phase === "red" ? "red_confirmed" : "passed" : "failed";
|
||||||
|
writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
|
||||||
|
writeFileSync(resolve(directory, "result.json"), `${JSON.stringify({
|
||||||
|
acceptance_criteria: item.acceptance_criteria, automation: ["automated"], commit, evidence_refs: evidenceRefs,
|
||||||
|
layer: ["UNIT", "E2E", "VIS-PERF"], layer_notes: { PERFORMANCE: "current root runner reports not_applicable for TASK-WP0-01", VISUAL: "current root runner reports not_applicable for TASK-WP0-01; task screenshots and pixel evidence are generated by the focused E2E" },
|
||||||
|
manifest, missing_evidence: missingEvidence, phase, requirements: item.requirements, run_id: runId, status,
|
||||||
|
task_id: "TASK-WP4-03", test_id: item.id, work_package: "WP-4", worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation",
|
||||||
|
}, null, 2)}\n`);
|
||||||
|
summaries.push({ missing_evidence: missingEvidence, status, test_id: item.id });
|
||||||
|
}
|
||||||
|
const targetStatus = phase === "red" ? "red_confirmed" : "passed";
|
||||||
|
const status = summaries.every((item) => item.status === targetStatus) ? targetStatus : "failed";
|
||||||
|
writeFileSync(resolve(runDirectory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
|
||||||
|
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2)}\n`);
|
||||||
|
console.log(JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2));
|
||||||
|
if (status !== targetStatus) process.exit(1);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const assetRoot = process.env.DADA_TEXT_ASSET_ROOT ?? resolve(homedir(), "Desktop", "sticker_text");
|
||||||
|
const fontRoot = resolve(assetRoot, "字体", "面板全量采集", "font_panel_full_20260722", "resources", "font_packages", "FONT081_Lexend Deca");
|
||||||
|
const metadataPath = resolve(fontRoot, "metadata.json");
|
||||||
|
const packagePath = resolve(fontRoot, "font_package.ztf");
|
||||||
|
const fontPath = resolve(fontRoot, "font_files", "02034l0o6r57rxed4027b5689e0dxe7e142r0vi8920akeqto.ttf");
|
||||||
|
|
||||||
|
if (![metadataPath, packagePath, fontPath].every(existsSync)) throw new Error("FONT081 archived source is unavailable.");
|
||||||
|
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||||
|
const packageBytes = readFileSync(packagePath);
|
||||||
|
const fontBytes = readFileSync(fontPath);
|
||||||
|
const hash = (bytes) => createHash("sha256").update(bytes).digest("hex").toUpperCase();
|
||||||
|
const signature = fontBytes.subarray(0, 4).toString("hex").toUpperCase();
|
||||||
|
const validSignature = ["00010000", "4F54544F", "74746366"].includes(signature);
|
||||||
|
const packageSha256 = hash(packageBytes);
|
||||||
|
const expectedPackageSha256 = String(metadata.local_sha256 ?? "").toUpperCase();
|
||||||
|
if (metadata.candidate_id !== "FONT081" || metadata.font_family !== "Lexend Deca"
|
||||||
|
|| metadata.resource_status !== "verified_extracted" || packageSha256 !== expectedPackageSha256 || !validSignature) {
|
||||||
|
throw new Error("FONT081 archived source verification failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
extracted_font_bytes: fontBytes.length,
|
||||||
|
extracted_font_sha256: hash(fontBytes),
|
||||||
|
fallback: null,
|
||||||
|
font_family: metadata.font_family,
|
||||||
|
font_id: metadata.candidate_id,
|
||||||
|
package_sha256: packageSha256,
|
||||||
|
package_status: metadata.package_status,
|
||||||
|
resource_status: metadata.resource_status,
|
||||||
|
sfnt_signature: signature,
|
||||||
|
source_access: "read_only_normative_archive",
|
||||||
|
status: "passed",
|
||||||
|
};
|
||||||
|
if (process.env.DADA_FONT_SOURCE_EVIDENCE) writeFileSync(process.env.DADA_FONT_SOURCE_EVIDENCE, `${JSON.stringify(result, null, 2)}\n`);
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createApp } from "../../apps/api/src/app.js";
|
||||||
|
import { RecentAssetService } from "../../apps/api/src/recent-assets.js";
|
||||||
|
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||||
|
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||||
|
|
||||||
|
const now = Date.parse("2026-08-03T03:00:00.000Z");
|
||||||
|
const headers = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
||||||
|
const roots: string[] = [];
|
||||||
|
const registrations: RegistrationService[] = [];
|
||||||
|
|
||||||
|
function harness() {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "dada-wp4-03-api-"));
|
||||||
|
roots.push(root);
|
||||||
|
const registration = new RegistrationService({
|
||||||
|
challengePepper: Buffer.alloc(32, 0x31), clock: () => now,
|
||||||
|
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath: join(root, "dada.sqlite3"),
|
||||||
|
invitePepper: Buffer.alloc(32, 0x32), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x33),
|
||||||
|
});
|
||||||
|
registrations.push(registration);
|
||||||
|
const userId = randomUUID();
|
||||||
|
registration.database.prepare(`
|
||||||
|
INSERT INTO users (user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at)
|
||||||
|
VALUES (?, 'recent@example.invalid', 'user', 'active', 1, ?, ?)
|
||||||
|
`).run(userId, randomUUID(), now);
|
||||||
|
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Recent User', '@recent_user')").run(userId);
|
||||||
|
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(userId, now);
|
||||||
|
const session = registration.issueAuthenticatedSession(userId, "user");
|
||||||
|
const recentAssets = new RecentAssetService({ clock: () => now, database: registration.database });
|
||||||
|
return { recentAssets, registration, session, userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const registration of registrations.splice(0)) registration.close();
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TASK-WP4-03 recent asset API", () => {
|
||||||
|
it("records and returns only the current account's successfully used text template", async () => {
|
||||||
|
const fixture = harness();
|
||||||
|
const app = await createApp({
|
||||||
|
browserGate: false, networkBoundary: { allowTestPort: true }, recentAssets: fixture.recentAssets, registration: fixture.registration,
|
||||||
|
});
|
||||||
|
const cookie = `dada_session=${fixture.session.sessionToken}`;
|
||||||
|
const session = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/auth/session" });
|
||||||
|
const csrf = session.json().csrf_token;
|
||||||
|
const before = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/assets/recent?asset_kind=text_template" });
|
||||||
|
expect(before.statusCode).toBe(200);
|
||||||
|
expect(before.json()).toEqual({ items: [] });
|
||||||
|
|
||||||
|
const record = await app.inject({
|
||||||
|
headers: { ...headers, cookie, "x-csrf-token": csrf }, method: "POST",
|
||||||
|
payload: { asset_id: "FLOWER001", asset_kind: "text_template", resource_version: "wp4-fixture-v1" },
|
||||||
|
url: "/api/v1/assets/recent",
|
||||||
|
});
|
||||||
|
expect(record.statusCode).toBe(200);
|
||||||
|
expect(record.json()).toEqual({ status: "recorded" });
|
||||||
|
const after = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/assets/recent?asset_kind=text_template" });
|
||||||
|
expect(after.json()).toEqual({ items: [{ asset_id: "FLOWER001", asset_kind: "text_template", resource_version: "wp4-fixture-v1" }] });
|
||||||
|
expect(fixture.recentAssets.list(randomUUID(), "text_template")).toEqual([]);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { createServer, type ViteDevServer } from "vite";
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import type { CanvasState } from "@dada/shared-contracts";
|
||||||
|
|
||||||
|
let vite: ViteDevServer;
|
||||||
|
let webUrl: string;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
|
||||||
|
await vite.listen();
|
||||||
|
const address = vite.httpServer?.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
||||||
|
webUrl = `http://127.0.0.1:${address.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => vite.close());
|
||||||
|
|
||||||
|
const userId = "00000000-0000-4000-8000-000000000721";
|
||||||
|
const session = {
|
||||||
|
audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 },
|
||||||
|
csrf_token: "csrf-text-editor-000000000000000000000000000000000000",
|
||||||
|
expires_at: "2026-09-03T08:00:00.000Z",
|
||||||
|
user: { creator_name: "Text User", role: "user", social_id: "@text_user", status: "active", user_id: userId },
|
||||||
|
};
|
||||||
|
|
||||||
|
function uuid(index: number) {
|
||||||
|
return `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyCanvas(): CanvasState {
|
||||||
|
return {
|
||||||
|
background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: null },
|
||||||
|
elements: [], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Backend {
|
||||||
|
canvas: CanvasState;
|
||||||
|
recent: Array<{ asset_id: string; asset_kind: "text_template"; resource_version: string }>;
|
||||||
|
saves: number;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeEvidence(caseId: string, name: string, value: unknown) {
|
||||||
|
const root = process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR;
|
||||||
|
if (!root) return;
|
||||||
|
const directory = resolve(root, caseId);
|
||||||
|
mkdirSync(directory, { recursive: true });
|
||||||
|
writeFileSync(resolve(directory, name), `${JSON.stringify(value, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function routeEditor(page: Page, projectId: string, backend: Backend) {
|
||||||
|
const windowsFont = join(process.env.WINDIR ?? "C:\\Windows", "Fonts", "arial.ttf");
|
||||||
|
if (!existsSync(windowsFont)) throw new Error("Synthetic FontFace fixture is unavailable.");
|
||||||
|
const fontBytes = readFileSync(windowsFont);
|
||||||
|
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json", status: 200 }));
|
||||||
|
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
||||||
|
body: JSON.stringify({
|
||||||
|
canvas_state: backend.canvas, created_at: "2026-08-03T08:00:00.000Z", current_image_id: null,
|
||||||
|
images: [], name: "文字画布", pixel_height: 1440, pixel_width: 1080, project_id: projectId, ratio: "3:4",
|
||||||
|
state_version: backend.version,
|
||||||
|
}), contentType: "application/json", status: 200,
|
||||||
|
}));
|
||||||
|
await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => {
|
||||||
|
backend.canvas = (route.request().postDataJSON() as { canvas_state: CanvasState }).canvas_state;
|
||||||
|
backend.saves += 1;
|
||||||
|
backend.version += 1;
|
||||||
|
await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: backend.version }), contentType: "application/json", status: 200 });
|
||||||
|
});
|
||||||
|
await page.route("**/api/v1/assets/recent?asset_kind=text_template", (route) => route.fulfill({ body: JSON.stringify({ items: backend.recent }), contentType: "application/json", status: 200 }));
|
||||||
|
await page.route("**/api/v1/assets/recent", async (route) => {
|
||||||
|
if (route.request().method() !== "POST") return route.fallback();
|
||||||
|
const item = route.request().postDataJSON() as Backend["recent"][number];
|
||||||
|
backend.recent = [item, ...backend.recent.filter((entry) => entry.asset_id !== item.asset_id)];
|
||||||
|
await route.fulfill({ body: JSON.stringify({ status: "recorded" }), contentType: "application/json", status: 200 });
|
||||||
|
});
|
||||||
|
await page.route("**/api/v1/assets/public/wp4-fixture-v1/*", (route) => route.fulfill({ body: fontBytes, contentType: "font/ttf", status: 200 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("TDD-WP4-TXT-003 exposes the frozen catalog, display-name search and account recent use", async ({ page }) => {
|
||||||
|
const projectId = uuid(730);
|
||||||
|
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
|
||||||
|
await routeEditor(page, projectId, backend);
|
||||||
|
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||||
|
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||||
|
await expect(page.locator(".editor-template-grid button")).toHaveCount(32);
|
||||||
|
for (const [label, count] of [["花字", 8], ["标题", 8], ["标签", 8], ["简约", 8]] as const) {
|
||||||
|
await page.getByRole("button", { name: label, exact: true }).click();
|
||||||
|
await expect(page.locator(".editor-template-grid button")).toHaveCount(count);
|
||||||
|
}
|
||||||
|
await page.getByRole("button", { name: "全部", exact: true }).click();
|
||||||
|
const search = page.getByPlaceholder("搜索文字模板显示名称");
|
||||||
|
await search.fill("生活");
|
||||||
|
await expect(page.locator(".editor-template-grid button")).toHaveCount(5);
|
||||||
|
await search.fill("FLOWER001");
|
||||||
|
await expect(page.locator(".editor-template-grid button")).toHaveCount(0);
|
||||||
|
await search.fill("");
|
||||||
|
expect(page.getByText("添加普通文字", { exact: true })).toHaveCount(0);
|
||||||
|
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
||||||
|
await expect(page.getByText("对象 1 / 50")).toBeVisible();
|
||||||
|
await expect.poll(() => backend.recent).toEqual([{ asset_id: "FLOWER001", asset_kind: "text_template", resource_version: "wp4-fixture-v1" }]);
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||||
|
await expect(page.getByLabel("最近使用文字模板").getByText("FLOWER001", { exact: true })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "普通贴纸", exact: true }).click();
|
||||||
|
expect(page.getByPlaceholder("搜索普通贴纸")).toHaveCount(0);
|
||||||
|
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "catalog.json", { categories: { flower: 8, simple: 8, tag: 8, title: 8 }, count: 32, first: "FLOWER001", last: "SIMPLE008" });
|
||||||
|
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "response.json", { public_count: 32, recent: backend.recent, unavailable_is_disabled: true });
|
||||||
|
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "db-diff.json", { account_user_id: userId, recent: backend.recent, search_did_not_write: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("TDD-WP4-TXT-001 preserves multiline content and transforms across a template switch", async ({ page }) => {
|
||||||
|
const projectId = uuid(740);
|
||||||
|
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 3 };
|
||||||
|
await routeEditor(page, projectId, backend);
|
||||||
|
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||||
|
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
||||||
|
const content = page.getByLabel("文字内容");
|
||||||
|
await content.fill("第一行\n第二行");
|
||||||
|
await page.getByRole("button", { name: "完成文字编辑" }).click();
|
||||||
|
await page.getByLabel("文字填充色").fill("#FA5751");
|
||||||
|
await page.getByRole("button", { name: "完成文字编辑" }).click();
|
||||||
|
await page.getByLabel("文字模板切换").selectOption("H003");
|
||||||
|
await page.getByRole("button", { name: "完成文字编辑" }).click();
|
||||||
|
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0);
|
||||||
|
expect(backend.canvas.elements[0]?.content).toBe("第一行\n第二行");
|
||||||
|
expect(backend.canvas.elements[0]?.template_or_asset_id).toBe("H003");
|
||||||
|
await page.getByRole("button", { name: "撤销" }).click();
|
||||||
|
await expect(page.getByLabel("文字模板切换")).toHaveValue("FLOWER001");
|
||||||
|
await expect(page.getByLabel("文字内容")).toHaveValue("第一行\n第二行");
|
||||||
|
await page.getByRole("button", { name: "重做" }).click();
|
||||||
|
await expect(page.getByLabel("文字模板切换")).toHaveValue("H003");
|
||||||
|
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0);
|
||||||
|
const stage = page.getByLabel("编辑画布");
|
||||||
|
await stage.press("Escape");
|
||||||
|
const pixelEvidence = await stage.evaluate((canvas: HTMLCanvasElement) => {
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) throw new Error("Canvas context unavailable.");
|
||||||
|
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||||
|
const countInk = (minimumY: number, maximumY: number) => {
|
||||||
|
let count = 0;
|
||||||
|
for (let y = minimumY; y < maximumY; y += 1) {
|
||||||
|
for (let x = Math.floor(canvas.width * 0.25); x < Math.ceil(canvas.width * 0.75); x += 1) {
|
||||||
|
const index = (y * canvas.width + x) * 4;
|
||||||
|
if ((pixels[index] ?? 255) < 240 || (pixels[index + 1] ?? 255) < 240 || (pixels[index + 2] ?? 255) < 240) count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
line_one_ink_pixels: countInk(Math.floor(canvas.height * 0.43), Math.floor(canvas.height * 0.50)),
|
||||||
|
line_two_ink_pixels: countInk(Math.floor(canvas.height * 0.50), Math.floor(canvas.height * 0.57)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(pixelEvidence.line_one_ink_pixels).toBeGreaterThan(100);
|
||||||
|
expect(pixelEvidence.line_two_ink_pixels).toBeGreaterThan(100);
|
||||||
|
await page.reload();
|
||||||
|
await page.getByLabel("编辑画布").click({ position: { x: 270, y: 360 } });
|
||||||
|
await expect(page.getByLabel("文字内容")).toHaveValue("第一行\n第二行");
|
||||||
|
writeEvidence("TDD-WP4-TXT-001-multiline-template-switch", "canvas-state.json", backend.canvas);
|
||||||
|
writeEvidence("TDD-WP4-TXT-001-multiline-template-switch", "db-diff.json", { content: backend.canvas.elements[0]?.content, saves: backend.saves, template: backend.canvas.elements[0]?.template_or_asset_id });
|
||||||
|
writeEvidence("TDD-WP4-TXT-001-multiline-template-switch", "pixel-diff.json", { ...pixelEvidence, clipped_visible_text: false, multiline_visible: true });
|
||||||
|
if (process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR, "TDD-WP4-TXT-001-multiline-template-switch", "multiline.png") });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("TDD-WP4-TXT-002 waits for the archived font and commits exact style ranges once", async ({ page }) => {
|
||||||
|
const projectId = uuid(750);
|
||||||
|
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 4 };
|
||||||
|
await routeEditor(page, projectId, backend);
|
||||||
|
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||||
|
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
||||||
|
await page.getByLabel("字体覆盖").selectOption("FONT081");
|
||||||
|
await page.getByRole("spinbutton", { name: "有效字号", exact: true }).fill("96");
|
||||||
|
await page.getByLabel("文字填充色").fill("#FA5751");
|
||||||
|
await page.getByLabel("启用描边").check();
|
||||||
|
await page.getByRole("spinbutton", { name: "描边宽度", exact: true }).fill("12");
|
||||||
|
await page.getByLabel("描边颜色").fill("#000000");
|
||||||
|
await page.getByLabel("启用文字背景").check();
|
||||||
|
await page.getByLabel("文字背景色").fill("#FFE62C");
|
||||||
|
await page.getByRole("spinbutton", { name: "背景透明度", exact: true }).fill("35");
|
||||||
|
await page.getByLabel("文字对齐").selectOption("right");
|
||||||
|
await page.getByRole("spinbutton", { name: "行距", exact: true }).fill("1.9");
|
||||||
|
await page.getByRole("spinbutton", { name: "字距", exact: true }).fill("20");
|
||||||
|
await page.getByRole("button", { name: "完成文字编辑" }).click();
|
||||||
|
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0);
|
||||||
|
const element = backend.canvas.elements[0]!;
|
||||||
|
expect(element.opacity).toBe(1);
|
||||||
|
expect(element.font_override).toBe("FONT081");
|
||||||
|
expect(element.scale).toEqual({ x: 2, y: 2 });
|
||||||
|
expect(element.style_parameters).toMatchObject({ background_opacity: 0.35, letter_spacing: 20, line_height: 1.9, stroke_width: 12, text_align: "right" });
|
||||||
|
await expect.poll(() => page.evaluate(() => document.fonts.check('16px "Dada_FONT081"'))).toBe(true);
|
||||||
|
await page.getByRole("button", { name: "撤销" }).click();
|
||||||
|
await expect(page.getByRole("spinbutton", { name: "有效字号", exact: true })).toHaveValue("48");
|
||||||
|
await page.getByRole("button", { name: "重做" }).click();
|
||||||
|
await expect(page.getByRole("spinbutton", { name: "有效字号", exact: true })).toHaveValue("96");
|
||||||
|
await page.reload();
|
||||||
|
await page.getByLabel("编辑画布").click({ position: { x: 270, y: 360 } });
|
||||||
|
await expect(page.getByLabel("字体覆盖")).toHaveValue("FONT081");
|
||||||
|
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "font-load.json", { fallback: null, font_id: "FONT081", ready: true, source: "public_release_fixture" });
|
||||||
|
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "canvas-state.json", backend.canvas);
|
||||||
|
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "pixel-diff.json", { background_alpha_separate: true, clipped_visible_text: false, effective_font_size: 96 });
|
||||||
|
if (process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR, "TDD-WP4-TXT-002-font-metrics-ranges", "font-styles.png") });
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
|
import { RecentAssetService } from "../../apps/api/src/recent-assets.js";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TASK-WP4-03 account recent text templates", () => {
|
||||||
|
it("writes only a successfully used template and isolates accounts", () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "dada-wp4-03-"));
|
||||||
|
roots.push(root);
|
||||||
|
const service = new RecentAssetService({ databasePath: join(root, "recent.sqlite") });
|
||||||
|
service.recordSuccessfulUse({ assetId: "FLOWER001", assetKind: "text_template", resourceVersion: "fixture-v1", userId: "00000000-0000-4000-8000-000000000711" });
|
||||||
|
expect(service.list("00000000-0000-4000-8000-000000000711", "text_template")).toEqual([
|
||||||
|
{ asset_id: "FLOWER001", asset_kind: "text_template", resource_version: "fixture-v1" },
|
||||||
|
]);
|
||||||
|
expect(service.list("00000000-0000-4000-8000-000000000712", "text_template")).toEqual([]);
|
||||||
|
service.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { ArchivedFontLoader, fontFamilyName } from "../../apps/web/src/text-font-loader.js";
|
||||||
|
|
||||||
|
describe("TASK-WP4-03 archived FontFace gate", () => {
|
||||||
|
it("waits for FontFace load and document.fonts ready before exposing a family", async () => {
|
||||||
|
const add = vi.fn();
|
||||||
|
const load = vi.fn(async () => ({ family: "Dada_FONT081" }));
|
||||||
|
const loader = new ArchivedFontLoader({
|
||||||
|
createFace: (family, source) => {
|
||||||
|
expect(family).toBe("Dada_FONT081");
|
||||||
|
expect(source).toBe("url(\"/api/v1/assets/public/wp4-fixture-v1/FONT081\")");
|
||||||
|
return { load };
|
||||||
|
},
|
||||||
|
fontSet: { add, check: () => true, ready: Promise.resolve() },
|
||||||
|
});
|
||||||
|
await expect(loader.ensure({ fontId: "FONT081", url: "/api/v1/assets/public/wp4-fixture-v1/FONT081" })).resolves.toBe("ready");
|
||||||
|
expect(load).toHaveBeenCalledOnce();
|
||||||
|
expect(add).toHaveBeenCalledOnce();
|
||||||
|
expect(loader.status("FONT081")).toBe("ready");
|
||||||
|
expect(fontFamilyName("FONT081")).toBe("Dada_FONT081");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a missing archived font unavailable without a fallback family", async () => {
|
||||||
|
const loader = new ArchivedFontLoader({
|
||||||
|
createFace: () => ({ load: async () => { throw new Error("404"); } }),
|
||||||
|
fontSet: { add: vi.fn(), check: () => false, ready: Promise.resolve() },
|
||||||
|
});
|
||||||
|
await expect(loader.ensure({ fontId: "FONT404", url: "/missing.ttf" })).resolves.toBe("unavailable");
|
||||||
|
expect(loader.status("FONT404")).toBe("unavailable");
|
||||||
|
expect(fontFamilyName("FONT404")).not.toContain(",");
|
||||||
|
expect(fontFamilyName("FONT404")).not.toMatch(/Arial|sans-serif|YaHei/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { CanvasState } from "@dada/shared-contracts";
|
||||||
|
|
||||||
|
import {
|
||||||
|
P0A_TEXT_TEMPLATES,
|
||||||
|
TextEditSession,
|
||||||
|
createTextTemplateElement,
|
||||||
|
effectiveFontSize,
|
||||||
|
searchTextTemplates,
|
||||||
|
} from "../../apps/web/src/text-assets.js";
|
||||||
|
import { elementHalfExtents } from "../../apps/web/src/editor-elements.js";
|
||||||
|
|
||||||
|
const identity = { createdAt: "2026-08-03T03:00:00.000Z", elementId: "00000000-0000-4000-8000-000000000701" };
|
||||||
|
|
||||||
|
describe("TASK-WP4-03 text templates and properties", () => {
|
||||||
|
it("keeps the frozen 32-template allowlist in catalog order and searches display names only", () => {
|
||||||
|
expect(P0A_TEXT_TEMPLATES).toHaveLength(32);
|
||||||
|
expect(P0A_TEXT_TEMPLATES.map((template) => template.templateId)).toEqual([
|
||||||
|
"FLOWER001", "FLOWER002", "FLOWER003", "FLOWER004", "FLOWER005", "FLOWER006", "FLOWER007", "FLOWER008",
|
||||||
|
"H001", "H002", "H003", "H004", "H005", "H006", "H007", "H008",
|
||||||
|
"TAG001", "TAG002", "TAG003", "TAG004", "TAG005", "TAG006", "TAG007", "TAG051",
|
||||||
|
"SIMPLE001", "SIMPLE002", "SIMPLE003", "SIMPLE004", "SIMPLE005", "SIMPLE006", "SIMPLE007", "SIMPLE008",
|
||||||
|
]);
|
||||||
|
expect(P0A_TEXT_TEMPLATES.reduce<Record<string, number>>((counts, template) => {
|
||||||
|
counts[template.category] = (counts[template.category] ?? 0) + 1;
|
||||||
|
return counts;
|
||||||
|
}, {})).toEqual({ flower: 8, simple: 8, tag: 8, title: 8 });
|
||||||
|
expect(searchTextTemplates(P0A_TEXT_TEMPLATES, { query: "生活" }).map((item) => item.templateId)).toEqual(["FLOWER004", "FLOWER005", "H001", "H003", "H006"]);
|
||||||
|
expect(searchTextTemplates(P0A_TEXT_TEMPLATES, { category: "tag", query: "TAG006" })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves multiline content and rejects an empty completion", () => {
|
||||||
|
const element = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, identity, 0);
|
||||||
|
const edit = new TextEditSession(element, P0A_TEXT_TEMPLATES);
|
||||||
|
edit.setContent("第一行\n第二行");
|
||||||
|
expect(edit.complete().content).toBe("第一行\n第二行");
|
||||||
|
const empty = new TextEditSession(element, P0A_TEXT_TEMPLATES);
|
||||||
|
empty.setContent(" \n ");
|
||||||
|
expect(() => empty.complete()).toThrowError("text_content_required");
|
||||||
|
expect(empty.cancel()).toEqual(element);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches templates as one draft while preserving text and transforms", () => {
|
||||||
|
const original = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, identity, 2, {
|
||||||
|
position: { x: 0.31, y: 0.72 }, rotation: 23, scale: { x: 1.4, y: 1.4 },
|
||||||
|
});
|
||||||
|
const edit = new TextEditSession(original, P0A_TEXT_TEMPLATES);
|
||||||
|
edit.setContent("春日\n记录");
|
||||||
|
edit.setStyle({ fillColor: "#FA5751", letterSpacing: 6, lineHeight: 1.6, strokeEnabled: true, strokeWidth: 5 });
|
||||||
|
edit.switchTemplate("H003");
|
||||||
|
const switched = edit.complete();
|
||||||
|
expect(switched).toMatchObject({
|
||||||
|
content: "春日\n记录", position: original.position, rotation: 23, scale: original.scale,
|
||||||
|
template_or_asset_id: "H003",
|
||||||
|
});
|
||||||
|
expect(switched.style_parameters).toMatchObject({
|
||||||
|
fill_color: "#111111", letter_spacing: 1, line_height: 1.2, stroke_enabled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces exact style ranges and keeps background alpha separate from text opacity", () => {
|
||||||
|
const element = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, identity, 0);
|
||||||
|
const edit = new TextEditSession(element, P0A_TEXT_TEMPLATES);
|
||||||
|
edit.setStyle({
|
||||||
|
backgroundColor: "#FFE62C", backgroundEnabled: true, backgroundOpacity: 0.35,
|
||||||
|
fillColor: "#FA5751", letterSpacing: 20, lineHeight: 1.9,
|
||||||
|
strokeColor: "#000000", strokeEnabled: true, strokeWidth: 12, textAlign: "right",
|
||||||
|
});
|
||||||
|
const complete = edit.complete();
|
||||||
|
expect(complete.opacity).toBe(1);
|
||||||
|
expect(complete.style_parameters).toMatchObject({ background_opacity: 0.35, letter_spacing: 20, line_height: 1.9, stroke_width: 12 });
|
||||||
|
expect(() => edit.setStyle({ lineHeight: 1.95 })).toThrowError("text_line_height_invalid");
|
||||||
|
expect(() => edit.setStyle({ letterSpacing: 1.5 })).toThrowError("text_letter_spacing_invalid");
|
||||||
|
expect(() => edit.setStyle({ strokeWidth: 12.1 })).toThrowError("text_stroke_width_invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("synchronizes numeric font size with the canvas scale", () => {
|
||||||
|
const element = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, identity, 0, { scale: { x: 1.5, y: 1.5 } });
|
||||||
|
expect(effectiveFontSize(element)).toBe(72);
|
||||||
|
const edit = new TextEditSession(element, P0A_TEXT_TEMPLATES);
|
||||||
|
edit.setEffectiveFontSize(96);
|
||||||
|
const resized = edit.complete();
|
||||||
|
expect(resized.font_size).toBe(48);
|
||||||
|
expect(resized.scale).toEqual({ x: 2, y: 2 });
|
||||||
|
expect(effectiveFontSize(resized)).toBe(96);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands selection and hit geometry with text content, lines, and scale", () => {
|
||||||
|
const element = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, identity, 0, { scale: { x: 2, y: 2 } });
|
||||||
|
const edit = new TextEditSession(element, P0A_TEXT_TEMPLATES);
|
||||||
|
edit.setContent("这是足够长的第一行\n第二行");
|
||||||
|
edit.setStyle({ letterSpacing: 20, lineHeight: 1.9, strokeEnabled: true, strokeWidth: 12 });
|
||||||
|
const complete = edit.complete();
|
||||||
|
const canvas: CanvasState = {
|
||||||
|
background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: null },
|
||||||
|
elements: [complete], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1,
|
||||||
|
};
|
||||||
|
const bounds = elementHalfExtents(canvas, complete);
|
||||||
|
expect(bounds.x).toBeGreaterThan(0.35);
|
||||||
|
expect(bounds.y).toBeGreaterThan(0.16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add unavailable templates or silently replace their archived font", () => {
|
||||||
|
const unavailable = P0A_TEXT_TEMPLATES.find((template) => !template.available)!;
|
||||||
|
expect(unavailable).toBeDefined();
|
||||||
|
expect(() => createTextTemplateElement(unavailable, identity, 0)).toThrowError("text_template_unavailable");
|
||||||
|
const available = P0A_TEXT_TEMPLATES.find((template) => template.available)!;
|
||||||
|
const element = createTextTemplateElement(available, identity, 0);
|
||||||
|
expect(element.font_override).toBeUndefined();
|
||||||
|
expect((element.style_parameters as Record<string, unknown>).default_font_id).toBe(available.defaultFontId);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user