Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42f993378f | ||
|
|
e9fd15e7b6 |
+2
-1
@@ -3,7 +3,8 @@
|
|||||||
"browsers": [
|
"browsers": [
|
||||||
{
|
{
|
||||||
"brand": "Google Chrome",
|
"brand": "Google Chrome",
|
||||||
"fullVersion": "150.0.7871.187"
|
"fullVersion": "150.0.7871.187",
|
||||||
|
"supportedMajorVersions": [150, 151]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"brand": "Microsoft Edge",
|
"brand": "Microsoft Edge",
|
||||||
|
|||||||
@@ -151,10 +151,13 @@ export function createAdminDiagnosticsProvider(input: {
|
|||||||
const system: AdminDiagnosticsResponse["system"] = {
|
const system: AdminDiagnosticsResponse["system"] = {
|
||||||
api_status: "ready",
|
api_status: "ready",
|
||||||
app_version: input.appVersion ?? input.browserSupportRelease?.appVersion ?? "0.0.0",
|
app_version: input.appVersion ?? input.browserSupportRelease?.appVersion ?? "0.0.0",
|
||||||
browser_support: (input.browserSupportRelease?.browsers ?? []).map((browser) => ({
|
browser_support: (input.browserSupportRelease?.browsers ?? []).flatMap((browser) => {
|
||||||
brand: browser.brand,
|
const majors = browser.supportedMajorVersions
|
||||||
major: Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10),
|
?? [Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10)];
|
||||||
})).filter((browser) => Number.isSafeInteger(browser.major) && browser.major > 0),
|
return majors
|
||||||
|
.map((major) => ({ brand: browser.brand, major }))
|
||||||
|
.filter((entry) => Number.isSafeInteger(entry.major) && entry.major > 0);
|
||||||
|
}),
|
||||||
worker_status: services.services.find((service) => service.service_id === "worker")?.status === "active"
|
worker_status: services.services.find((service) => service.service_id === "worker")?.status === "active"
|
||||||
? "ready"
|
? "ready"
|
||||||
: services.services.find((service) => service.service_id === "worker")?.status === "unavailable"
|
: services.services.find((service) => service.service_id === "worker")?.status === "unavailable"
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const BrowserSupportSuccessSchema = Type.Object(
|
|||||||
app_version: Type.String({ maxLength: 80 }),
|
app_version: Type.String({ maxLength: 80 }),
|
||||||
browser: SupportedBrowserSummarySchema,
|
browser: SupportedBrowserSummarySchema,
|
||||||
status: Type.Literal("supported"),
|
status: Type.Literal("supported"),
|
||||||
supported_browsers: Type.Array(SupportedBrowserSummarySchema, { maxItems: 2 }),
|
supported_browsers: Type.Array(SupportedBrowserSummarySchema, { maxItems: 8 }),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false, $id: "BrowserSupportSuccess" },
|
{ additionalProperties: false, $id: "BrowserSupportSuccess" },
|
||||||
);
|
);
|
||||||
@@ -57,6 +57,7 @@ export interface BrowserSupportRelease {
|
|||||||
browsers: ReadonlyArray<{
|
browsers: ReadonlyArray<{
|
||||||
brand: SupportedBrand;
|
brand: SupportedBrand;
|
||||||
fullVersion: string;
|
fullVersion: string;
|
||||||
|
supportedMajorVersions?: ReadonlyArray<number>;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +116,14 @@ function supportedIdentity(entries: Array<{ brand: string; version: string }>) {
|
|||||||
|
|
||||||
export function supportedBrowserSummary(release: BrowserSupportRelease | undefined) {
|
export function supportedBrowserSummary(release: BrowserSupportRelease | undefined) {
|
||||||
if (!release) return [];
|
if (!release) return [];
|
||||||
return release.browsers.map(({ brand, fullVersion }) => ({ brand, major: major(fullVersion)! }));
|
return release.browsers.flatMap(({ brand, fullVersion, supportedMajorVersions }) => {
|
||||||
|
const majors = supportedMajorVersions ?? [major(fullVersion)!];
|
||||||
|
return majors.map((supportedMajor) => ({ brand, major: supportedMajor }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptedMajorVersions(browser: BrowserSupportRelease["browsers"][number]) {
|
||||||
|
return browser.supportedMajorVersions ?? [major(browser.fullVersion)!];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateBrowserSupportRelease(value: unknown): value is BrowserSupportRelease {
|
export function validateBrowserSupportRelease(value: unknown): value is BrowserSupportRelease {
|
||||||
@@ -126,13 +134,26 @@ export function validateBrowserSupportRelease(value: unknown): value is BrowserS
|
|||||||
}
|
}
|
||||||
if (!Array.isArray(release.browsers) || release.browsers.length !== 2) return false;
|
if (!Array.isArray(release.browsers) || release.browsers.length !== 2) return false;
|
||||||
const brands = new Set(release.browsers.map(({ brand }) => brand));
|
const brands = new Set(release.browsers.map(({ brand }) => brand));
|
||||||
|
const supportedMajorCount = release.browsers.reduce(
|
||||||
|
(count, browser) => count + (browser.supportedMajorVersions?.length ?? 1),
|
||||||
|
0,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
brands.size === 2 &&
|
brands.size === 2 &&
|
||||||
brands.has("Google Chrome") &&
|
brands.has("Google Chrome") &&
|
||||||
brands.has("Microsoft Edge") &&
|
brands.has("Microsoft Edge") &&
|
||||||
release.browsers.every(
|
supportedMajorCount <= 8 &&
|
||||||
({ brand, fullVersion }) => supportedBrands.has(brand) && fullVersionPattern.test(fullVersion),
|
release.browsers.every(({ brand, fullVersion, supportedMajorVersions }) => {
|
||||||
)
|
if (!supportedBrands.has(brand) || !fullVersionPattern.test(fullVersion)) return false;
|
||||||
|
const baselineMajor = major(fullVersion);
|
||||||
|
if (!baselineMajor) return false;
|
||||||
|
if (supportedMajorVersions === undefined) return true;
|
||||||
|
return supportedMajorVersions.length > 0
|
||||||
|
&& supportedMajorVersions.length <= 8
|
||||||
|
&& supportedMajorVersions.every((value: number) => Number.isSafeInteger(value) && value >= 1)
|
||||||
|
&& new Set(supportedMajorVersions).size === supportedMajorVersions.length
|
||||||
|
&& supportedMajorVersions.includes(baselineMajor);
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +209,7 @@ export function checkBrowserSupport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const supported = release?.browsers.find(({ brand }) => brand === fullIdentity.brand);
|
const supported = release?.browsers.find(({ brand }) => brand === fullIdentity.brand);
|
||||||
if (!supported || major(supported.fullVersion) !== fullIdentity.major) {
|
if (!supported || !acceptedMajorVersions(supported).includes(fullIdentity.major)) {
|
||||||
return { reason: "version_unsupported", supported: false };
|
return { reason: "version_unsupported", supported: false };
|
||||||
}
|
}
|
||||||
return { identity: fullIdentity, supported: true };
|
return { identity: fullIdentity, supported: true };
|
||||||
@@ -268,7 +289,7 @@ export function verifyBrowserSupportCookie(input: {
|
|||||||
return { reason: "identity_unavailable" as const, supported: false as const };
|
return { reason: "identity_unavailable" as const, supported: false as const };
|
||||||
}
|
}
|
||||||
const supported = input.release.browsers.find(({ brand }) => brand === currentIdentity.brand);
|
const supported = input.release.browsers.find(({ brand }) => brand === currentIdentity.brand);
|
||||||
if (currentIdentity.major !== payload.major || major(supported?.fullVersion ?? "") !== currentIdentity.major) {
|
if (!supported || currentIdentity.major !== payload.major || !acceptedMajorVersions(supported).includes(currentIdentity.major)) {
|
||||||
return { reason: "version_unsupported" as const, supported: false as const };
|
return { reason: "version_unsupported" as const, supported: false as const };
|
||||||
}
|
}
|
||||||
return { identity: currentIdentity, supported: true as const };
|
return { identity: currentIdentity, supported: true as const };
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
.editor-page-shell {
|
.editor-page-shell {
|
||||||
height: 100vh;
|
min-height: 100vh;
|
||||||
height: 100dvh;
|
|
||||||
min-height: 0;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 56px minmax(0, 1fr) 32px;
|
grid-template-rows: 56px minmax(0, 1fr) 32px;
|
||||||
background: #e8e8e5;
|
background: #e8e8e5;
|
||||||
color: #111111;
|
color: #111111;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-page-shell :focus-visible {
|
.editor-page-shell :focus-visible {
|
||||||
@@ -127,7 +124,6 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 280px minmax(0, 1fr) 320px;
|
grid-template-columns: 280px minmax(0, 1fr) 320px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-assets-panel,
|
.editor-assets-panel,
|
||||||
@@ -546,13 +542,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.editor-page-shell { height: auto; min-height: 100dvh; grid-template-rows: auto minmax(0, 1fr) auto; overflow: visible; }
|
.editor-page-shell { grid-template-rows: auto minmax(0, 1fr) auto; }
|
||||||
.editor-toolbar { display: flex; min-height: 56px; flex-wrap: wrap; gap: 8px; padding: 8px 10px; }
|
.editor-toolbar { display: flex; min-height: 56px; flex-wrap: wrap; gap: 8px; padding: 8px 10px; }
|
||||||
.editor-title { min-width: 0; flex: 1 1 calc(100% - 56px); }
|
.editor-title { min-width: 0; flex: 1 1 calc(100% - 56px); }
|
||||||
.editor-history-actions { order: 3; }
|
.editor-history-actions { order: 3; }
|
||||||
.editor-save-status { order: 4; flex: 1 1 128px; }
|
.editor-save-status { order: 4; flex: 1 1 128px; }
|
||||||
.editor-toolbar-controls > button { display: block; order: 5; }
|
.editor-toolbar-controls > button { display: block; order: 5; }
|
||||||
.editor-layout { grid-template-columns: 1fr; overflow: visible; }
|
.editor-layout { grid-template-columns: 1fr; }
|
||||||
.editor-assets-panel, .editor-inspector { border: 0; }
|
.editor-assets-panel, .editor-inspector { border: 0; }
|
||||||
.editor-assets-panel { order: 2; }
|
.editor-assets-panel { order: 2; }
|
||||||
.editor-inspector { order: 3; }
|
.editor-inspector { order: 3; }
|
||||||
|
|||||||
+40
-128
@@ -86,14 +86,6 @@ interface EditorExportResult {
|
|||||||
status: ExportFlowStatus;
|
status: ExportFlowStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
function withTextDraft(canvasState: CanvasState, textEdit: TextEditState | undefined) {
|
|
||||||
if (!textEdit) return canvasState;
|
|
||||||
return {
|
|
||||||
...canvasState,
|
|
||||||
elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EditorProject {
|
interface EditorProject {
|
||||||
canvas_state?: CanvasState;
|
canvas_state?: CanvasState;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -163,26 +155,11 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
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 textHistoryRef = useRef<{ base: CanvasState; elementId: string; last: CanvasState } | undefined>(undefined);
|
|
||||||
const clipboardRef = useRef<CanvasElement[]>([]);
|
const clipboardRef = useRef<CanvasElement[]>([]);
|
||||||
const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined);
|
const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined);
|
||||||
const conflictExportGuardRef = useRef(new ConflictExportGuard());
|
const conflictExportGuardRef = useRef(new ConflictExportGuard());
|
||||||
const candidateMenuRef = useRef<HTMLDivElement | null>(null);
|
const candidateMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const candidateTriggerRef = useRef<HTMLButtonElement | null>(null);
|
const candidateTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const noticeTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
||||||
|
|
||||||
function showNotice(message: string) {
|
|
||||||
if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
|
|
||||||
setNotice(message);
|
|
||||||
noticeTimerRef.current = setTimeout(() => {
|
|
||||||
setNotice("");
|
|
||||||
noticeTimerRef.current = undefined;
|
|
||||||
}, 3_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => () => {
|
|
||||||
if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -198,7 +175,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
setDraftAdjustments(initial.background.adjustments);
|
setDraftAdjustments(initial.background.adjustments);
|
||||||
historyRef.current = new CanvasEditHistory(initial);
|
historyRef.current = new CanvasEditHistory(initial);
|
||||||
elementControllerRef.current = new CanvasElementController(initial);
|
elementControllerRef.current = new CanvasElementController(initial);
|
||||||
}).catch(() => { if (active) showNotice("编辑器暂时无法读取项目"); });
|
}).catch(() => { if (active) setNotice("编辑器暂时无法读取项目"); });
|
||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
@@ -284,10 +261,6 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
});
|
});
|
||||||
}, [canvasState, selectedIds.join("|")]);
|
}, [canvasState, selectedIds.join("|")]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (textEdit) commitTextDraftAutomatically(textEdit);
|
|
||||||
}, [textEdit?.draft]);
|
|
||||||
|
|
||||||
async function ensureFont(fontId: string, url: string, retry = false) {
|
async function ensureFont(fontId: string, url: string, retry = false) {
|
||||||
const current = fontStatuses[fontId];
|
const current = fontStatuses[fontId];
|
||||||
if (current === "ready" || (current === "unavailable" && !retry)) return current;
|
if (current === "ready" || (current === "unavailable" && !retry)) return current;
|
||||||
@@ -304,34 +277,24 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
await Promise.all(available.map((template) => ensureFont(template.defaultFontId, template.fontUrl!, true)));
|
await Promise.all(available.map((template) => ensureFont(template.defaultFontId, template.fontUrl!, true)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function finalizeTextHistory() {
|
function commitCanvas(next: CanvasState) {
|
||||||
const pending = textHistoryRef.current;
|
|
||||||
if (!pending) return undefined;
|
|
||||||
textHistoryRef.current = undefined;
|
|
||||||
historyRef.current?.commit(pending.last);
|
|
||||||
return pending.last;
|
|
||||||
}
|
|
||||||
|
|
||||||
function commitCanvas(next: CanvasState, options: { preserveTextEdit?: boolean } = {}) {
|
|
||||||
if (!project || saveStatus === "conflicted") return;
|
if (!project || saveStatus === "conflicted") return;
|
||||||
const finalizedText = finalizeTextHistory();
|
historyRef.current?.commit(next);
|
||||||
if (!finalizedText || JSON.stringify(finalizedText) !== JSON.stringify(next)) historyRef.current?.commit(next);
|
|
||||||
elementControllerRef.current?.replaceState(next);
|
elementControllerRef.current?.replaceState(next);
|
||||||
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
|
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
|
||||||
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 });
|
||||||
if (!options.preserveTextEdit) setTextEdit(undefined);
|
setTextEdit(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPreview() {
|
function applyPreview() {
|
||||||
if (!canvasState) return;
|
if (!canvasState) return;
|
||||||
commitCanvas(updateBackgroundAdjustments(canvasState, draftAdjustments));
|
commitCanvas(updateBackgroundAdjustments(canvasState, draftAdjustments));
|
||||||
showNotice("底图调整已提交");
|
setNotice("底图调整已提交");
|
||||||
}
|
}
|
||||||
|
|
||||||
function undo() {
|
function undo() {
|
||||||
finalizeTextHistory();
|
|
||||||
const previous = historyRef.current?.undo();
|
const previous = historyRef.current?.undo();
|
||||||
if (previous) {
|
if (previous) {
|
||||||
elementControllerRef.current?.replaceState(previous);
|
elementControllerRef.current?.replaceState(previous);
|
||||||
@@ -344,7 +307,6 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function redo() {
|
function redo() {
|
||||||
finalizeTextHistory();
|
|
||||||
const next = historyRef.current?.redo();
|
const next = historyRef.current?.redo();
|
||||||
if (next) {
|
if (next) {
|
||||||
elementControllerRef.current?.replaceState(next);
|
elementControllerRef.current?.replaceState(next);
|
||||||
@@ -372,9 +334,9 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
const palette = await paletteForAsset(pendingBackground);
|
const palette = await paletteForAsset(pendingBackground);
|
||||||
commitCanvas(switchBackground(canvasState, pendingBackground, palette));
|
commitCanvas(switchBackground(canvasState, pendingBackground, palette));
|
||||||
setPendingBackground(undefined);
|
setPendingBackground(undefined);
|
||||||
showNotice("已更换底图,覆盖元素保留,底图处理已重置");
|
setNotice("已更换底图,覆盖元素保留,底图处理已重置");
|
||||||
} catch {
|
} catch {
|
||||||
showNotice("新底图无法读取,未更换底图或刷新色卡");
|
setNotice("新底图无法读取,未更换底图或刷新色卡");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,7 +353,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
commitCanvas(next);
|
commitCanvas(next);
|
||||||
setGuides([]);
|
setGuides([]);
|
||||||
setCandidateMenu(undefined);
|
setCandidateMenu(undefined);
|
||||||
showNotice(message);
|
setNotice(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addSticker(sticker: StaticStickerCatalogItem) {
|
function addSticker(sticker: StaticStickerCatalogItem) {
|
||||||
@@ -407,8 +369,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
}));
|
}));
|
||||||
commitElementOperation(controller, "贴纸已加入画布");
|
commitElementOperation(controller, "贴纸已加入画布");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
else showNotice("贴纸未能加入画布");
|
else setNotice("贴纸未能加入画布");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,8 +383,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
controller.add(createColorCardElement(definition, palette, newElementIdentity(), canvasState.elements.length));
|
controller.add(createColorCardElement(definition, palette, newElementIdentity(), canvasState.elements.length));
|
||||||
commitElementOperation(controller, "色卡已按原始底图加入画布");
|
commitElementOperation(controller, "色卡已按原始底图加入画布");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
else showNotice("无法从原始底图稳定提取五色,色卡未加入画布");
|
else setNotice("无法从原始底图稳定提取五色,色卡未加入画布");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,7 +394,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
if (templateId === "DYN012") {
|
if (templateId === "DYN012") {
|
||||||
const font = fontOption("FONT081");
|
const font = fontOption("FONT081");
|
||||||
if (!font || await ensureFont(font.fontId, font.url) !== "ready") {
|
if (!font || await ensureFont(font.fontId, font.url) !== "ready") {
|
||||||
showNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。");
|
setNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -445,8 +407,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
commitElementOperation(controller, "动态值已确认并加入画布");
|
commitElementOperation(controller, "动态值已确认并加入画布");
|
||||||
setLocationDialog(undefined);
|
setLocationDialog(undefined);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
else showNotice("动态贴纸未能加入画布");
|
else setNotice("动态贴纸未能加入画布");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,7 +456,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
controller.replaceElement(overrideDynamicStickerValue(element, value));
|
controller.replaceElement(overrideDynamicStickerValue(element, value));
|
||||||
commitElementOperation(controller, "动态贴纸显示文字已更新");
|
commitElementOperation(controller, "动态贴纸显示文字已更新");
|
||||||
} catch {
|
} catch {
|
||||||
showNotice("动态贴纸显示文字不能为空");
|
setNotice("动态贴纸显示文字不能为空");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,7 +480,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
if (!template.fontUrl || !canvasState) return;
|
if (!template.fontUrl || !canvasState) return;
|
||||||
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
||||||
if (status !== "ready") {
|
if (status !== "ready") {
|
||||||
showNotice("素材暂不可用,未使用系统字体替代。");
|
setNotice("素材暂不可用,未使用系统字体替代。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const controller = controllerForCurrent();
|
const controller = controllerForCurrent();
|
||||||
@@ -528,8 +490,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
commitElementOperation(controller, "文字模板已加入画布");
|
commitElementOperation(controller, "文字模板已加入画布");
|
||||||
void recordRecentTextTemplate(template.templateId, template.resourceVersion);
|
void recordRecentTextTemplate(template.templateId, template.resourceVersion);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
else showNotice("文字模板未能加入画布");
|
else setNotice("文字模板未能加入画布");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,49 +503,18 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
action(edit);
|
action(edit);
|
||||||
return { ...current, draft: edit.value };
|
return { ...current, draft: edit.value };
|
||||||
} catch {
|
} catch {
|
||||||
showNotice("文字参数不在允许范围内");
|
setNotice("文字参数不在允许范围内");
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitTextDraftAutomatically(editState: TextEditState) {
|
|
||||||
if (!canvasState || !project || saveStatus === "conflicted") return;
|
|
||||||
const index = canvasState.elements.findIndex((element) => element.element_id === editState.elementId);
|
|
||||||
if (index < 0 || JSON.stringify(canvasState.elements[index]) === JSON.stringify(editState.draft)) return;
|
|
||||||
try {
|
|
||||||
const complete = new TextEditSession(editState.draft, P0A_TEXT_TEMPLATES).complete();
|
|
||||||
const next = structuredClone(canvasState);
|
|
||||||
next.elements[index] = complete;
|
|
||||||
const history = textHistoryRef.current;
|
|
||||||
if (!history || history.elementId !== editState.elementId) {
|
|
||||||
if (history) finalizeTextHistory();
|
|
||||||
textHistoryRef.current = { base: canvasState, elementId: editState.elementId, last: next };
|
|
||||||
} else {
|
|
||||||
history.last = next;
|
|
||||||
}
|
|
||||||
elementControllerRef.current?.replaceState(next);
|
|
||||||
setCanvasState(next);
|
|
||||||
queueRef.current?.commit({ canvas_state: next, name: project.name });
|
|
||||||
if (complete.template_or_asset_id !== editState.originalTemplateId) {
|
|
||||||
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
|
|
||||||
}
|
|
||||||
setTextEdit((current) => current?.elementId === editState.elementId ? {
|
|
||||||
...current,
|
|
||||||
draft: complete,
|
|
||||||
originalTemplateId: complete.template_or_asset_id,
|
|
||||||
} : current);
|
|
||||||
} catch (error) {
|
|
||||||
if (!(error instanceof Error && error.message === "text_content_required")) showNotice("文字编辑未能自动保存");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function changeTextTemplate(templateId: string) {
|
async function changeTextTemplate(templateId: string) {
|
||||||
const template = P0A_TEXT_TEMPLATES.find((candidate) => candidate.templateId === templateId);
|
const template = P0A_TEXT_TEMPLATES.find((candidate) => candidate.templateId === templateId);
|
||||||
if (!template?.fontUrl) return;
|
if (!template?.fontUrl) return;
|
||||||
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
||||||
if (status !== "ready") {
|
if (status !== "ready") {
|
||||||
showNotice("素材暂不可用,未使用系统字体替代。");
|
setNotice("素材暂不可用,未使用系统字体替代。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateTextDraft((edit) => edit.switchTemplate(templateId));
|
updateTextDraft((edit) => edit.switchTemplate(templateId));
|
||||||
@@ -596,7 +527,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
const option = fontOption(fontId);
|
const option = fontOption(fontId);
|
||||||
if (!option || await ensureFont(option.fontId, option.url) !== "ready") {
|
if (!option || await ensureFont(option.fontId, option.url) !== "ready") {
|
||||||
showNotice("字体素材暂不可用,未使用系统字体替代。");
|
setNotice("字体素材暂不可用,未使用系统字体替代。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateTextDraft((edit) => edit.setStyle({ fontOverride: fontId }));
|
updateTextDraft((edit) => edit.setStyle({ fontOverride: fontId }));
|
||||||
@@ -604,11 +535,6 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
function completeTextEdit() {
|
function completeTextEdit() {
|
||||||
if (!textEdit) return;
|
if (!textEdit) return;
|
||||||
if (!pendingTextDraft()) {
|
|
||||||
finalizeTextHistory();
|
|
||||||
showNotice("文字修改已进入自动保存");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const edit = new TextEditSession(textEdit.draft, P0A_TEXT_TEMPLATES);
|
const edit = new TextEditSession(textEdit.draft, P0A_TEXT_TEMPLATES);
|
||||||
const complete = edit.complete();
|
const complete = edit.complete();
|
||||||
@@ -620,25 +546,17 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
|
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "text_content_required") showNotice("请输入文字内容或删除该元素。");
|
if (error instanceof Error && error.message === "text_content_required") setNotice("请输入文字内容或删除该元素。");
|
||||||
else showNotice("文字编辑未能完成");
|
else setNotice("文字编辑未能完成");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelTextEdit() {
|
function cancelTextEdit() {
|
||||||
const pendingHistory = textHistoryRef.current;
|
|
||||||
if (pendingHistory && project) {
|
|
||||||
textHistoryRef.current = undefined;
|
|
||||||
elementControllerRef.current?.replaceState(pendingHistory.base);
|
|
||||||
setCanvasState(pendingHistory.base);
|
|
||||||
queueRef.current?.commit({ canvas_state: pendingHistory.base, name: project.name });
|
|
||||||
}
|
|
||||||
if (canvasState && textEdit) {
|
if (canvasState && textEdit) {
|
||||||
const source = pendingHistory?.base ?? canvasState;
|
const current = canvasState.elements.find((element) => element.element_id === textEdit.elementId);
|
||||||
const current = source.elements.find((element) => element.element_id === textEdit.elementId);
|
|
||||||
if (current) setTextEdit({ draft: structuredClone(current), elementId: current.element_id, originalTemplateId: current.template_or_asset_id });
|
if (current) setTextEdit({ draft: structuredClone(current), elementId: current.element_id, originalTemplateId: current.template_or_asset_id });
|
||||||
}
|
}
|
||||||
showNotice("已取消未提交的文字修改");
|
setNotice("已取消未提交的文字修改");
|
||||||
}
|
}
|
||||||
|
|
||||||
function pendingTextDraft() {
|
function pendingTextDraft() {
|
||||||
@@ -706,7 +624,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ran = await conflictExportGuardRef.current.run(() => executeExport(options));
|
const ran = await conflictExportGuardRef.current.run(() => executeExport(options));
|
||||||
if (!ran) showNotice("版本冲突时仅允许导出本页版本一次");
|
if (!ran) setNotice("版本冲突时仅允许导出本页版本一次");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function retryExportDownload() {
|
async function retryExportDownload() {
|
||||||
@@ -721,8 +639,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
action(controller);
|
action(controller);
|
||||||
commitElementOperation(controller, message);
|
commitElementOperation(controller, message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
else showNotice("对象操作未完成");
|
else setNotice("对象操作未完成");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,7 +668,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
opacityGestureRef.current = undefined;
|
opacityGestureRef.current = undefined;
|
||||||
if (gesture.last === gesture.base) return;
|
if (gesture.last === gesture.base) return;
|
||||||
commitCanvas(gesture.last);
|
commitCanvas(gesture.last);
|
||||||
showNotice("贴纸透明度已提交");
|
setNotice("贴纸透明度已提交");
|
||||||
}
|
}
|
||||||
|
|
||||||
function duplicateSelection() {
|
function duplicateSelection() {
|
||||||
@@ -770,7 +688,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
const controller = controllerForCurrent();
|
const controller = controllerForCurrent();
|
||||||
if (!controller || selectedIds.length === 0) return;
|
if (!controller || selectedIds.length === 0) return;
|
||||||
clipboardRef.current = controller.copySelected();
|
clipboardRef.current = controller.copySelected();
|
||||||
showNotice("已复制到画布剪贴板");
|
setNotice("已复制到画布剪贴板");
|
||||||
}
|
}
|
||||||
|
|
||||||
function pasteSelection() {
|
function pasteSelection() {
|
||||||
@@ -780,20 +698,18 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
controller.pasteElements(clipboardRef.current, () => newElementIdentity());
|
controller.pasteElements(clipboardRef.current, () => newElementIdentity());
|
||||||
commitElementOperation(controller, "已粘贴画布对象");
|
commitElementOperation(controller, "已粘贴画布对象");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectAt(point: CanvasPoint, append: boolean) {
|
function selectAt(point: CanvasPoint, append: boolean) {
|
||||||
finalizeTextHistory();
|
|
||||||
const controller = controllerForCurrent();
|
const controller = controllerForCurrent();
|
||||||
if (!controller || !canvasState) return false;
|
if (!controller || !canvasState) return false;
|
||||||
const candidates = controller.candidatesAt(point);
|
const candidates = controller.candidatesAt(point);
|
||||||
const selection = controller.selectAt(point, { append: append || multiMode });
|
const selection = controller.selectAt(point, { append: append || multiMode });
|
||||||
setSelectedIds(selection);
|
setSelectedIds(selection);
|
||||||
setCandidateMenu(undefined);
|
setCandidateMenu(undefined);
|
||||||
const dragBase = withTextDraft(canvasState, textEdit);
|
dragRef.current = { base: canvasState, last: canvasState, selectedIds: selection };
|
||||||
dragRef.current = { base: dragBase, last: dragBase, selectedIds: selection };
|
|
||||||
return candidates.length > 0;
|
return candidates.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -805,25 +721,19 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
const preview = previewController.moveSelected(delta);
|
const preview = previewController.moveSelected(delta);
|
||||||
drag.last = preview.state;
|
drag.last = preview.state;
|
||||||
setCanvasState(preview.state);
|
setCanvasState(preview.state);
|
||||||
setTextEdit((current) => {
|
|
||||||
if (!current || !drag.selectedIds.includes(current.elementId)) return current;
|
|
||||||
const movedDraft = preview.state.elements.find((element) => element.element_id === current.elementId);
|
|
||||||
return movedDraft ? { ...current, draft: movedDraft } : current;
|
|
||||||
});
|
|
||||||
setGuides(preview.guides);
|
setGuides(preview.guides);
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitMove() {
|
function commitMove() {
|
||||||
const drag = dragRef.current;
|
const drag = dragRef.current;
|
||||||
if (!drag) return;
|
if (!drag) return;
|
||||||
commitCanvas(drag.last, { preserveTextEdit: true });
|
commitCanvas(drag.last);
|
||||||
showNotice("对象位置已提交");
|
setNotice("对象位置已提交");
|
||||||
setGuides([]);
|
setGuides([]);
|
||||||
dragRef.current = undefined;
|
dragRef.current = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function marqueeSelect(rectangle: CanvasRect, append: boolean) {
|
function marqueeSelect(rectangle: CanvasRect, append: boolean) {
|
||||||
finalizeTextHistory();
|
|
||||||
const controller = controllerForCurrent();
|
const controller = controllerForCurrent();
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
setSelectedIds(controller.marqueeSelect(rectangle, append || multiMode));
|
setSelectedIds(controller.marqueeSelect(rectangle, append || multiMode));
|
||||||
@@ -872,7 +782,6 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
finalizeTextHistory();
|
|
||||||
elementControllerRef.current?.clearSelection();
|
elementControllerRef.current?.clearSelection();
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
setCandidateMenu(undefined);
|
setCandidateMenu(undefined);
|
||||||
@@ -880,7 +789,10 @@ 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 = withTextDraft(canvasState, textEdit);
|
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 = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id));
|
const selectedElements = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id));
|
||||||
@@ -957,6 +869,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
maxWidth: `min(720px, calc(${(canvasState.pixel_width / canvasState.pixel_height * 100).toFixed(4)}vh - ${(canvasState.pixel_width / canvasState.pixel_height * 168).toFixed(4)}px))`,
|
maxWidth: `min(720px, calc(${(canvasState.pixel_width / canvasState.pixel_height * 100).toFixed(4)}vh - ${(canvasState.pixel_width / canvasState.pixel_height * 168).toFixed(4)}px))`,
|
||||||
}}>
|
}}>
|
||||||
<EditorStage
|
<EditorStage
|
||||||
|
assetId={canvasState.background.asset_id}
|
||||||
canvasState={renderedCanvasState}
|
canvasState={renderedCanvasState}
|
||||||
fontStatuses={fontStatuses}
|
fontStatuses={fontStatuses}
|
||||||
guides={guides}
|
guides={guides}
|
||||||
@@ -964,7 +877,6 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
onClearSelection={clearSelection}
|
onClearSelection={clearSelection}
|
||||||
onCopy={copySelection}
|
onCopy={copySelection}
|
||||||
onDelete={deleteSelection}
|
onDelete={deleteSelection}
|
||||||
onDragStart={() => setCandidateMenu(undefined)}
|
|
||||||
onMarquee={marqueeSelect}
|
onMarquee={marqueeSelect}
|
||||||
onMoveCommit={commitMove}
|
onMoveCommit={commitMove}
|
||||||
onMovePreview={previewMove}
|
onMovePreview={previewMove}
|
||||||
|
|||||||
@@ -12,18 +12,14 @@ import { drawColorCard } from "./palette-provider.js";
|
|||||||
|
|
||||||
interface Gesture {
|
interface Gesture {
|
||||||
append: boolean;
|
append: boolean;
|
||||||
bounds: DOMRect;
|
|
||||||
hit: boolean;
|
hit: boolean;
|
||||||
longPressOpened: boolean;
|
longPressOpened: boolean;
|
||||||
moved: boolean;
|
|
||||||
pointerId: number;
|
pointerId: number;
|
||||||
start: CanvasPoint;
|
start: CanvasPoint;
|
||||||
startClient: CanvasPoint;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DRAG_THRESHOLD_PX = 4;
|
|
||||||
|
|
||||||
interface EditorStageProps {
|
interface EditorStageProps {
|
||||||
|
assetId: string | null;
|
||||||
canvasState: CanvasState;
|
canvasState: CanvasState;
|
||||||
guides: readonly string[];
|
guides: readonly string[];
|
||||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
|
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
|
||||||
@@ -31,7 +27,6 @@ interface EditorStageProps {
|
|||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
onCopy: () => void;
|
onCopy: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onDragStart: () => void;
|
|
||||||
onMarquee: (rectangle: CanvasRect, append: boolean) => void;
|
onMarquee: (rectangle: CanvasRect, append: boolean) => void;
|
||||||
onMoveCommit: () => void;
|
onMoveCommit: () => void;
|
||||||
onMovePreview: (delta: CanvasPoint) => void;
|
onMovePreview: (delta: CanvasPoint) => void;
|
||||||
@@ -43,10 +38,11 @@ interface EditorStageProps {
|
|||||||
selectedIds: readonly string[];
|
selectedIds: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function pointFromClient(clientX: number, clientY: number, bounds: DOMRect): CanvasPoint {
|
function pointFromEvent(event: PointerEvent<HTMLCanvasElement>): CanvasPoint {
|
||||||
|
const bounds = event.currentTarget.getBoundingClientRect();
|
||||||
return {
|
return {
|
||||||
x: Math.max(0, Math.min(1, (clientX - bounds.left) / bounds.width)),
|
x: Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)),
|
||||||
y: Math.max(0, Math.min(1, (clientY - bounds.top) / bounds.height)),
|
y: Math.max(0, Math.min(1, (event.clientY - bounds.top) / bounds.height)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,27 +240,6 @@ function loadCanvasImage(url: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type CanvasImageLoader = (url: string) => Promise<HTMLImageElement | undefined>;
|
|
||||||
|
|
||||||
interface SceneResources {
|
|
||||||
background: HTMLImageElement | undefined;
|
|
||||||
resourceImages: Readonly<Record<string, HTMLImageElement>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createCachedCanvasImageLoader(): CanvasImageLoader {
|
|
||||||
const cache = new Map<string, Promise<HTMLImageElement | undefined>>();
|
|
||||||
return (url) => {
|
|
||||||
const cached = cache.get(url);
|
|
||||||
if (cached) return cached;
|
|
||||||
const pending = loadCanvasImage(url).then((image) => {
|
|
||||||
if (!image) cache.delete(url);
|
|
||||||
return image;
|
|
||||||
});
|
|
||||||
cache.set(url, pending);
|
|
||||||
return pending;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resourceUrlsForCanvas(canvasState: CanvasState) {
|
function resourceUrlsForCanvas(canvasState: CanvasState) {
|
||||||
const imageReferences = new Map<string, string>();
|
const imageReferences = new Map<string, string>();
|
||||||
for (const element of canvasState.elements) {
|
for (const element of canvasState.elements) {
|
||||||
@@ -277,19 +252,11 @@ function resourceUrlsForCanvas(canvasState: CanvasState) {
|
|||||||
return imageReferences;
|
return imageReferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sceneResourceKey(canvasState: CanvasState, projectId: string) {
|
async function loadSceneResources(canvasState: CanvasState, projectId: string) {
|
||||||
const background = canvasState.background.asset_id
|
const background = canvasState.background.asset_id
|
||||||
? `/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`
|
? loadCanvasImage(`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`)
|
||||||
: null;
|
|
||||||
const resources = [...resourceUrlsForCanvas(canvasState)].toSorted(([left], [right]) => left.localeCompare(right));
|
|
||||||
return JSON.stringify({ background, projectId, resources });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSceneResources(canvasState: CanvasState, projectId: string, loadImage: CanvasImageLoader = loadCanvasImage): Promise<SceneResources> {
|
|
||||||
const background = canvasState.background.asset_id
|
|
||||||
? loadImage(`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`)
|
|
||||||
: Promise.resolve(undefined);
|
: Promise.resolve(undefined);
|
||||||
const resources = Promise.all([...resourceUrlsForCanvas(canvasState)].map(async ([assetId, url]) => [assetId, await loadImage(url)] as const));
|
const resources = Promise.all([...resourceUrlsForCanvas(canvasState)].map(async ([assetId, url]) => [assetId, await loadCanvasImage(url)] as const));
|
||||||
const [image, loaded] = await Promise.all([background, resources]);
|
const [image, loaded] = await Promise.all([background, resources]);
|
||||||
return {
|
return {
|
||||||
background: image,
|
background: image,
|
||||||
@@ -347,29 +314,16 @@ export function EditorStage(props: EditorStageProps) {
|
|||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const gestureRef = useRef<Gesture | undefined>(undefined);
|
const gestureRef = useRef<Gesture | undefined>(undefined);
|
||||||
const longPressRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
const longPressRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const imageLoaderRef = useRef<CanvasImageLoader | undefined>(undefined);
|
|
||||||
const [sceneResources, setSceneResources] = useState<{ key: string; resources: SceneResources }>();
|
|
||||||
const [marquee, setMarquee] = useState<CanvasRect>();
|
const [marquee, setMarquee] = useState<CanvasRect>();
|
||||||
const resourceKey = sceneResourceKey(props.canvasState, props.projectId);
|
|
||||||
|
|
||||||
if (!imageLoaderRef.current) imageLoaderRef.current = createCachedCanvasImageLoader();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
void loadSceneResources(props.canvasState, props.projectId, imageLoaderRef.current).then((resources) => {
|
|
||||||
if (active) setSceneResources({ key: resourceKey, resources });
|
|
||||||
});
|
|
||||||
return () => { active = false; };
|
|
||||||
}, [props.projectId, resourceKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return undefined;
|
if (!canvas) return undefined;
|
||||||
if (canvas.width !== props.canvasState.pixel_width) canvas.width = props.canvasState.pixel_width;
|
canvas.width = props.canvasState.pixel_width;
|
||||||
if (canvas.height !== props.canvasState.pixel_height) canvas.height = props.canvasState.pixel_height;
|
canvas.height = props.canvasState.pixel_height;
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext("2d");
|
||||||
if (!context) return undefined;
|
if (!context) return undefined;
|
||||||
if (!sceneResources || sceneResources.key !== resourceKey) return undefined;
|
|
||||||
const render = (image: HTMLImageElement | undefined, resourceImages: Readonly<Record<string, HTMLImageElement>>) => {
|
const render = (image: HTMLImageElement | undefined, resourceImages: Readonly<Record<string, HTMLImageElement>>) => {
|
||||||
renderEditorScene(context, props.canvasState, props.fontStatuses, image, resourceImages);
|
renderEditorScene(context, props.canvasState, props.fontStatuses, image, resourceImages);
|
||||||
context.lineWidth = 4;
|
context.lineWidth = 4;
|
||||||
@@ -391,22 +345,21 @@ export function EditorStage(props: EditorStageProps) {
|
|||||||
if (marquee) context.strokeRect(marquee.x * canvas.width, marquee.y * canvas.height, marquee.width * canvas.width, marquee.height * canvas.height);
|
if (marquee) context.strokeRect(marquee.x * canvas.width, marquee.y * canvas.height, marquee.width * canvas.width, marquee.height * canvas.height);
|
||||||
context.restore();
|
context.restore();
|
||||||
};
|
};
|
||||||
render(sceneResources.resources.background, sceneResources.resources.resourceImages);
|
void loadSceneResources(props.canvasState, props.projectId).then(({ background, resourceImages }) => {
|
||||||
return undefined;
|
if (!active) return;
|
||||||
}, [marquee, props.canvasState, props.fontStatuses, props.guides, props.selectedIds, resourceKey, sceneResources]);
|
render(background, resourceImages);
|
||||||
|
});
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [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); }, []);
|
||||||
|
|
||||||
function handlePointerDown(event: PointerEvent<HTMLCanvasElement>) {
|
function handlePointerDown(event: PointerEvent<HTMLCanvasElement>) {
|
||||||
if (event.button !== 0) return;
|
if (event.button !== 0) return;
|
||||||
const bounds = event.currentTarget.getBoundingClientRect();
|
const start = pointFromEvent(event);
|
||||||
const start = pointFromClient(event.clientX, event.clientY, bounds);
|
|
||||||
const append = event.shiftKey;
|
const append = event.shiftKey;
|
||||||
const hit = props.onSelect(start, append);
|
const hit = props.onSelect(start, append);
|
||||||
gestureRef.current = {
|
gestureRef.current = { append, hit, longPressOpened: false, pointerId: event.pointerId, start };
|
||||||
append, bounds, hit, longPressOpened: false, moved: false, pointerId: event.pointerId, start,
|
|
||||||
startClient: { x: event.clientX, y: event.clientY },
|
|
||||||
};
|
|
||||||
event.currentTarget.setPointerCapture(event.pointerId);
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
longPressRef.current = setTimeout(() => {
|
longPressRef.current = setTimeout(() => {
|
||||||
const gesture = gestureRef.current;
|
const gesture = gestureRef.current;
|
||||||
@@ -422,15 +375,9 @@ export function EditorStage(props: EditorStageProps) {
|
|||||||
props.onPointerMoved();
|
props.onPointerMoved();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const clientDistance = Math.hypot(event.clientX - gesture.startClient.x, event.clientY - gesture.startClient.y);
|
const point = pointFromEvent(event);
|
||||||
if (!gesture.moved) {
|
|
||||||
if (clientDistance < DRAG_THRESHOLD_PX) return;
|
|
||||||
gesture.moved = true;
|
|
||||||
gesture.longPressOpened = false;
|
|
||||||
props.onDragStart();
|
|
||||||
}
|
|
||||||
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
|
|
||||||
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
||||||
|
if (Math.abs(delta.x) + Math.abs(delta.y) < 0.003) return;
|
||||||
if (longPressRef.current) clearTimeout(longPressRef.current);
|
if (longPressRef.current) clearTimeout(longPressRef.current);
|
||||||
props.onPointerMoved();
|
props.onPointerMoved();
|
||||||
if (gesture.hit && !gesture.longPressOpened) props.onMovePreview(delta);
|
if (gesture.hit && !gesture.longPressOpened) props.onMovePreview(delta);
|
||||||
@@ -441,18 +388,11 @@ export function EditorStage(props: EditorStageProps) {
|
|||||||
const gesture = gestureRef.current;
|
const gesture = gestureRef.current;
|
||||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||||
if (longPressRef.current) clearTimeout(longPressRef.current);
|
if (longPressRef.current) clearTimeout(longPressRef.current);
|
||||||
const clientDistance = Math.hypot(event.clientX - gesture.startClient.x, event.clientY - gesture.startClient.y);
|
const point = pointFromEvent(event);
|
||||||
const moved = gesture.moved || clientDistance >= DRAG_THRESHOLD_PX;
|
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
||||||
if (moved && !gesture.moved && !gesture.longPressOpened) {
|
const moved = Math.abs(delta.x) + Math.abs(delta.y) >= 0.003;
|
||||||
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
|
|
||||||
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
|
||||||
if (gesture.hit) props.onMovePreview(delta);
|
|
||||||
}
|
|
||||||
if (gesture.hit && moved && !gesture.longPressOpened) props.onMoveCommit();
|
if (gesture.hit && moved && !gesture.longPressOpened) props.onMoveCommit();
|
||||||
else if (!gesture.hit && moved) {
|
else if (!gesture.hit && moved) props.onMarquee({ height: delta.y, width: delta.x, x: gesture.start.x, y: gesture.start.y }, gesture.append);
|
||||||
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
|
|
||||||
props.onMarquee({ height: point.y - gesture.start.y, width: point.x - gesture.start.x, x: gesture.start.x, y: gesture.start.y }, gesture.append);
|
|
||||||
}
|
|
||||||
setMarquee(undefined);
|
setMarquee(undefined);
|
||||||
gestureRef.current = undefined;
|
gestureRef.current = undefined;
|
||||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
|||||||
@@ -592,26 +592,15 @@
|
|||||||
height: 18px;
|
height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-placeholder,
|
.project-placeholder {
|
||||||
.project-preview {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
height: 154px;
|
height: 154px;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-bottom: 1px solid #a5a59f;
|
border-bottom: 1px solid #a5a59f;
|
||||||
background: #d8d8d3;
|
background: #d8d8d3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-placeholder {
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-preview img {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-placeholder span {
|
.project-placeholder span {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: end center;
|
place-items: end center;
|
||||||
@@ -946,9 +935,9 @@
|
|||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-current > .project-placeholder,
|
.project-current > .project-placeholder {
|
||||||
.project-current > .project-preview {
|
height: auto;
|
||||||
height: 480px;
|
min-height: 480px;
|
||||||
border: 1px solid #73736d;
|
border: 1px solid #73736d;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -956,10 +945,6 @@
|
|||||||
font-size: 80px;
|
font-size: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-current > .project-preview img {
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-actions {
|
.project-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -1028,8 +1013,7 @@
|
|||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-history li .project-placeholder,
|
.project-history li .project-placeholder {
|
||||||
.project-history li .project-preview {
|
|
||||||
height: 88px;
|
height: 88px;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
@@ -1455,9 +1439,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-current > .project-placeholder,
|
.project-current > .project-placeholder {
|
||||||
.project-current > .project-preview {
|
min-height: 360px;
|
||||||
height: 360px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.local-only-footer {
|
.local-only-footer {
|
||||||
|
|||||||
@@ -231,33 +231,6 @@ function ProjectPlaceholder({ ratio, status }: { ratio: Ratio; status: ProjectSt
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectPreview({ alt, imageId, loading = "lazy", projectId, ratio, status }: {
|
|
||||||
alt: string;
|
|
||||||
imageId: string | null;
|
|
||||||
loading?: "eager" | "lazy";
|
|
||||||
projectId: string;
|
|
||||||
ratio: Ratio;
|
|
||||||
status: ProjectStatus;
|
|
||||||
}) {
|
|
||||||
const [loadFailed, setLoadFailed] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => setLoadFailed(false), [imageId, projectId]);
|
|
||||||
|
|
||||||
if (!imageId || loadFailed) return <ProjectPlaceholder ratio={ratio} status={status} />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="project-preview" data-ratio={ratio} data-status={status}>
|
|
||||||
<img
|
|
||||||
alt={alt}
|
|
||||||
decoding="async"
|
|
||||||
loading={loading}
|
|
||||||
onError={() => setLoadFailed(true)}
|
|
||||||
src={`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(imageId)}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkspacePage() {
|
export function WorkspacePage() {
|
||||||
const promptId = useId();
|
const promptId = useId();
|
||||||
const [session, setSession] = useState<SessionPayload>();
|
const [session, setSession] = useState<SessionPayload>();
|
||||||
@@ -595,13 +568,7 @@ function ProjectCard({ activeLimitReached, busy, onPurge, onRestore, onSelect, o
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
<ProjectPreview
|
<ProjectPlaceholder ratio={project.ratio} status={project.status} />
|
||||||
alt={`${project.name}预览图`}
|
|
||||||
imageId={project.current_image_id}
|
|
||||||
projectId={project.project_id}
|
|
||||||
ratio={project.ratio}
|
|
||||||
status={project.status}
|
|
||||||
/>
|
|
||||||
<div className="project-card-body">
|
<div className="project-card-body">
|
||||||
<div><h3 title={project.name}>{project.name}</h3><span>{project.status === "failed_empty" ? "生成失败" : project.status === "trashed" ? "回收站" : "项目"}</span></div>
|
<div><h3 title={project.name}>{project.name}</h3><span>{project.status === "failed_empty" ? "生成失败" : project.status === "trashed" ? "回收站" : "项目"}</span></div>
|
||||||
<p>{project.successful_image_count} 张成功图 · {project.ratio}</p>
|
<p>{project.successful_image_count} 张成功图 · {project.ratio}</p>
|
||||||
@@ -986,14 +953,7 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
|
|||||||
<div className="project-detail-grid">
|
<div className="project-detail-grid">
|
||||||
<section className="project-current" aria-labelledby="current-image-title">
|
<section className="project-current" aria-labelledby="current-image-title">
|
||||||
<header><h2 id="current-image-title">当前底图</h2><span>{project.pixel_width ?? 1080} × {project.pixel_height ?? 1440}</span></header>
|
<header><h2 id="current-image-title">当前底图</h2><span>{project.pixel_width ?? 1080} × {project.pixel_height ?? 1440}</span></header>
|
||||||
<ProjectPreview
|
<ProjectPlaceholder ratio={project.ratio} status={project.status} />
|
||||||
alt={`${project.name}当前底图`}
|
|
||||||
imageId={project.current_image_id}
|
|
||||||
loading="eager"
|
|
||||||
projectId={project.project_id}
|
|
||||||
ratio={project.ratio}
|
|
||||||
status={project.status}
|
|
||||||
/>
|
|
||||||
<div className="project-actions">
|
<div className="project-actions">
|
||||||
<button disabled={conflicted || atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button">继续生成</button>
|
<button disabled={conflicted || atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button">继续生成</button>
|
||||||
{conflicted || !project.current_image_id ? <button disabled type="button">进入编辑器</button> : <a href={`/app/projects/${project.project_id}/editor`}>进入编辑器</a>}
|
{conflicted || !project.current_image_id ? <button disabled type="button">进入编辑器</button> : <a href={`/app/projects/${project.project_id}/editor`}>进入编辑器</a>}
|
||||||
@@ -1010,13 +970,7 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
|
|||||||
<ol>
|
<ol>
|
||||||
{project.images.toReversed().map((image, index) => (
|
{project.images.toReversed().map((image, index) => (
|
||||||
<li key={image.image_id} data-current={image.image_id === project.current_image_id}>
|
<li key={image.image_id} data-current={image.image_id === project.current_image_id}>
|
||||||
<ProjectPreview
|
<ProjectPlaceholder ratio={project.ratio} status="active" />
|
||||||
alt={`生成结果 ${project.images.length - index}`}
|
|
||||||
imageId={image.image_id}
|
|
||||||
projectId={project.project_id}
|
|
||||||
ratio={project.ratio}
|
|
||||||
status="active"
|
|
||||||
/>
|
|
||||||
<div><strong>生成结果 {project.images.length - index}</strong><time dateTime={image.created_at}>{formatUpdatedAt(image.created_at)}</time><a href={`/api/v1/private-assets/projects/${project.project_id}/images/${image.image_id}`}>下载原始图</a></div>
|
<div><strong>生成结果 {project.images.length - index}</strong><time dateTime={image.created_at}>{formatUpdatedAt(image.created_at)}</time><a href={`/api/v1/private-assets/projects/${project.project_id}/images/${image.image_id}`}>下载原始图</a></div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -107,5 +107,7 @@ if (!workerPort && process.argv.includes("--dada-ai-probe")) {
|
|||||||
} catch {
|
} catch {
|
||||||
storageStatus = "unavailable";
|
storageStatus = "unavailable";
|
||||||
control.reportStatus("storage_unavailable");
|
control.reportStatus("storage_unavailable");
|
||||||
|
clearInterval(keepAlive);
|
||||||
|
setTimeout(() => process.exit(1), 50);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ export const AdminDiagnosticsResponseSchema = Type.Object({
|
|||||||
browser_support: Type.Array(Type.Object({
|
browser_support: Type.Array(Type.Object({
|
||||||
brand: Type.Union([Type.Literal("Google Chrome"), Type.Literal("Microsoft Edge")]),
|
brand: Type.Union([Type.Literal("Google Chrome"), Type.Literal("Microsoft Edge")]),
|
||||||
major: Type.Integer({ minimum: 1 }),
|
major: Type.Integer({ minimum: 1 }),
|
||||||
}, { additionalProperties: false }), { maxItems: 2 }),
|
}, { additionalProperties: false }), { maxItems: 8 }),
|
||||||
worker_status: Type.Union([Type.Literal("ready"), Type.Literal("degraded"), Type.Literal("unavailable")]),
|
worker_status: Type.Union([Type.Literal("ready"), Type.Literal("degraded"), Type.Literal("unavailable")]),
|
||||||
}, { additionalProperties: false }),
|
}, { additionalProperties: false }),
|
||||||
}, { additionalProperties: false, $id: "AdminDiagnosticsResponse" });
|
}, { additionalProperties: false, $id: "AdminDiagnosticsResponse" });
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export const ErrorDetailsSchema = Type.Object(
|
|||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
),
|
),
|
||||||
{ maxItems: 2 },
|
{ maxItems: 8 },
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
capacity_status: Type.Optional(
|
capacity_status: Type.Optional(
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ export const DEFERRED_EXTERNAL_TASKS = Object.freeze(["TASK-WP7-03", "TASK-WP7-0
|
|||||||
export function buildFinalReleaseRecord({ appVersion, browsers, buildCommit, frozenFromCommit, recordedAt, windows }) {
|
export function buildFinalReleaseRecord({ appVersion, browsers, buildCommit, frozenFromCommit, recordedAt, windows }) {
|
||||||
const record = {
|
const record = {
|
||||||
appVersion,
|
appVersion,
|
||||||
browsers: browsers.map(({ brand, fullVersion }) => ({ brand, fullVersion })),
|
browsers: browsers.map(({ brand, fullVersion, supportedMajorVersions }) => ({
|
||||||
|
brand,
|
||||||
|
fullVersion,
|
||||||
|
...(supportedMajorVersions ? { supportedMajorVersions: [...supportedMajorVersions] } : {}),
|
||||||
|
})),
|
||||||
buildCommit: buildCommit.toLowerCase(),
|
buildCommit: buildCommit.toLowerCase(),
|
||||||
deferredExternalTasks: [...DEFERRED_EXTERNAL_TASKS],
|
deferredExternalTasks: [...DEFERRED_EXTERNAL_TASKS],
|
||||||
finalRelease: true,
|
finalRelease: true,
|
||||||
@@ -44,8 +48,25 @@ export function validateFinalReleaseRecord(record) {
|
|||||||
} else {
|
} else {
|
||||||
const brands = record.browsers.map(({ brand }) => brand).sort();
|
const brands = record.browsers.map(({ brand }) => brand).sort();
|
||||||
if (brands.join("|") !== "Google Chrome|Microsoft Edge") errors.push("browserBrands");
|
if (brands.join("|") !== "Google Chrome|Microsoft Edge") errors.push("browserBrands");
|
||||||
|
const supportedMajorCount = record.browsers.reduce(
|
||||||
|
(count, browser) => count + (Array.isArray(browser.supportedMajorVersions)
|
||||||
|
? browser.supportedMajorVersions.length
|
||||||
|
: 1),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if (supportedMajorCount > 8) errors.push("supportedMajorVersions.total");
|
||||||
for (const browser of record.browsers) {
|
for (const browser of record.browsers) {
|
||||||
if (!VERSION.test(browser.fullVersion ?? "")) errors.push(`${browser.brand}.fullVersion`);
|
if (!VERSION.test(browser.fullVersion ?? "")) errors.push(`${browser.brand}.fullVersion`);
|
||||||
|
if (browser.supportedMajorVersions !== undefined) {
|
||||||
|
const values = browser.supportedMajorVersions;
|
||||||
|
const baselineMajor = Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10);
|
||||||
|
if (!Array.isArray(values) || values.length === 0 || values.length > 8
|
||||||
|
|| values.some((value) => !Number.isSafeInteger(value) || value < 1)
|
||||||
|
|| new Set(values).size !== values.length
|
||||||
|
|| !values.includes(baselineMajor)) {
|
||||||
|
errors.push(`${browser.brand}.supportedMajorVersions`);
|
||||||
|
}
|
||||||
|
}
|
||||||
if ("path" in browser || "executablePath" in browser || "executableSha256" in browser) errors.push(`${browser.brand}.privateMetadata`);
|
if ("path" in browser || "executablePath" in browser || "executableSha256" in browser) errors.push(`${browser.brand}.privateMetadata`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ internal static class Program
|
|||||||
};
|
};
|
||||||
var state = await runtime.StartAsync();
|
var state = await runtime.StartAsync();
|
||||||
if (!form.IsDisposed) form.SetState(state);
|
if (!form.IsDisposed) form.SetState(state);
|
||||||
if (state == SupervisorState.Ready) SupervisorForm.OpenProductInSupportedBrowser();
|
if (state == SupervisorState.Ready && !SupervisorForm.OpenProductInSupportedBrowser())
|
||||||
|
{
|
||||||
|
form.SetBrowserLaunchFailure();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
form.Shown += async (_, _) => await StartRuntimeAsync();
|
form.Shown += async (_, _) => await StartRuntimeAsync();
|
||||||
form.RestartRequested += async () => await StartRuntimeAsync();
|
form.RestartRequested += async () => await StartRuntimeAsync();
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ internal sealed class SupervisorForm : Form
|
|||||||
Font = new Font("Segoe UI", 9F);
|
Font = new Font("Segoe UI", 9F);
|
||||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||||
MaximizeBox = false;
|
MaximizeBox = false;
|
||||||
|
ShowInTaskbar = true;
|
||||||
StartPosition = FormStartPosition.CenterScreen;
|
StartPosition = FormStartPosition.CenterScreen;
|
||||||
Text = "Dada";
|
Text = "Dada";
|
||||||
|
|
||||||
@@ -90,10 +91,6 @@ internal sealed class SupervisorForm : Form
|
|||||||
trayIcon.DoubleClick += (_, _) => RestoreWindow();
|
trayIcon.DoubleClick += (_, _) => RestoreWindow();
|
||||||
|
|
||||||
FormClosing += (_, _) => trayIcon.Visible = false;
|
FormClosing += (_, _) => trayIcon.Visible = false;
|
||||||
Resize += (_, _) =>
|
|
||||||
{
|
|
||||||
if (WindowState == FormWindowState.Minimized) Hide();
|
|
||||||
};
|
|
||||||
SetState(initialState);
|
SetState(initialState);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +136,14 @@ internal sealed class SupervisorForm : Form
|
|||||||
Activate();
|
Activate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal void SetBrowserLaunchFailure()
|
||||||
|
{
|
||||||
|
if (state == SupervisorState.Ready)
|
||||||
|
{
|
||||||
|
statusDetail.Text = "本机服务运行正常,但未能自动打开浏览器;请点击“打开 Dada”或选择浏览器。";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
if (disposing) trayIcon.Dispose();
|
if (disposing) trayIcon.Dispose();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
|
|
||||||
@@ -13,10 +14,20 @@ internal static class SupportedBrowserLauncher
|
|||||||
{
|
{
|
||||||
var executable = FindExecutable(executableName);
|
var executable = FindExecutable(executableName);
|
||||||
if (executable is null) return false;
|
if (executable is null) return false;
|
||||||
var startInfo = new ProcessStartInfo(executable) { UseShellExecute = false };
|
try
|
||||||
startInfo.ArgumentList.Add(uri.AbsoluteUri);
|
{
|
||||||
Process.Start(startInfo);
|
var startInfo = new ProcessStartInfo(executable) { UseShellExecute = false };
|
||||||
return true;
|
startInfo.ArgumentList.Add(uri.AbsoluteUri);
|
||||||
|
return Process.Start(startInfo) is not null;
|
||||||
|
}
|
||||||
|
catch (Win32Exception)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? FindExecutable(string executableName)
|
private static string? FindExecutable(string executableName)
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ const supportedEdge = browserSupportFixture({
|
|||||||
brand: "Microsoft Edge",
|
brand: "Microsoft Edge",
|
||||||
fullVersion: "150.0.4078.99",
|
fullVersion: "150.0.4078.99",
|
||||||
});
|
});
|
||||||
|
const supportedChrome150 = browserSupportFixture({
|
||||||
|
brand: "Google Chrome",
|
||||||
|
fullVersion: "150.0.7871.187",
|
||||||
|
});
|
||||||
|
const supportedChrome151 = browserSupportFixture({
|
||||||
|
brand: "Google Chrome",
|
||||||
|
fullVersion: "151.0.0.0",
|
||||||
|
});
|
||||||
const rejectedIdentityCases = [
|
const rejectedIdentityCases = [
|
||||||
{
|
{
|
||||||
expectedReason: "platform_unsupported",
|
expectedReason: "platform_unsupported",
|
||||||
@@ -145,6 +153,7 @@ describe("TDD-WP0-BRW-001 supported browser contract", () => {
|
|||||||
status: "supported",
|
status: "supported",
|
||||||
supported_browsers: [
|
supported_browsers: [
|
||||||
{ brand: "Google Chrome", major: 150 },
|
{ brand: "Google Chrome", major: 150 },
|
||||||
|
{ brand: "Google Chrome", major: 151 },
|
||||||
{ brand: "Microsoft Edge", major: 150 },
|
{ brand: "Microsoft Edge", major: 150 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -188,6 +197,26 @@ describe("TDD-WP0-BRW-001 supported browser contract", () => {
|
|||||||
expect(staleCookie.statusCode).toBe(426);
|
expect(staleCookie.statusCode).toBe(426);
|
||||||
await restarted.close();
|
await restarted.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ expectedMajor: 150, fixture: supportedChrome150 },
|
||||||
|
{ expectedMajor: 151, fixture: supportedChrome151 },
|
||||||
|
])("accepts explicitly declared Chrome $expectedMajor", async ({ expectedMajor, fixture }) => {
|
||||||
|
const app = await createApp({ browserSupportRelease: testBrowserSupportRelease } as never);
|
||||||
|
const checked = await app.inject({
|
||||||
|
headers: fixture.headers,
|
||||||
|
method: "POST",
|
||||||
|
payload: fixture.body,
|
||||||
|
url: "/api/v1/support/check",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(checked.statusCode).toBe(200);
|
||||||
|
expect(checked.json()).toMatchObject({
|
||||||
|
browser: { brand: "Google Chrome", major: expectedMajor },
|
||||||
|
status: "supported",
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("TDD-WP0-BRW-002 hard block", () => {
|
describe("TDD-WP0-BRW-002 hard block", () => {
|
||||||
@@ -208,6 +237,7 @@ describe("TDD-WP0-BRW-002 hard block", () => {
|
|||||||
reason: expectedReason,
|
reason: expectedReason,
|
||||||
supported_browsers: [
|
supported_browsers: [
|
||||||
{ brand: "Google Chrome", major: 150 },
|
{ brand: "Google Chrome", major: 150 },
|
||||||
|
{ brand: "Google Chrome", major: 151 },
|
||||||
{ brand: "Microsoft Edge", major: 150 },
|
{ brand: "Microsoft Edge", major: 150 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,10 +33,6 @@ function routeSession(page: Page) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function generatedImageSvg(label: string) {
|
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400"><rect width="300" height="400" fill="#d9f24f"/><text x="150" y="210" text-anchor="middle">${label}</text></svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function captureEvidence(page: Page, caseId: string, name: string) {
|
async function captureEvidence(page: Page, caseId: string, name: string) {
|
||||||
const root = process.env.DADA_EVIDENCE_DIR_PROJECTS;
|
const root = process.env.DADA_EVIDENCE_DIR_PROJECTS;
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
@@ -79,12 +75,6 @@ test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async
|
|||||||
],
|
],
|
||||||
}), contentType: "application/json", status: 200,
|
}), contentType: "application/json", status: 200,
|
||||||
}));
|
}));
|
||||||
await page.route(`**/api/v1/private-assets/projects/${successId}/images/*`, (route) => route.fulfill({
|
|
||||||
body: generatedImageSvg("城市工作室"),
|
|
||||||
contentType: "image/svg+xml",
|
|
||||||
headers: { "Content-Disposition": "attachment; filename=\"dada-original.png\"" },
|
|
||||||
status: 200,
|
|
||||||
}));
|
|
||||||
let batchPayload: unknown;
|
let batchPayload: unknown;
|
||||||
await page.route("**/api/v1/projects/failed-empty/trash", async (route) => {
|
await page.route("**/api/v1/projects/failed-empty/trash", async (route) => {
|
||||||
batchPayload = route.request().postDataJSON();
|
batchPayload = route.request().postDataJSON();
|
||||||
@@ -96,12 +86,6 @@ test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async
|
|||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "项目" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "项目" })).toBeVisible();
|
||||||
await expect(page.getByText("2 / 20 active")).toBeVisible();
|
await expect(page.getByText("2 / 20 active")).toBeVisible();
|
||||||
const projectPreview = page.getByRole("img", { name: "城市工作室预览图" });
|
|
||||||
await expect(projectPreview).toHaveAttribute(
|
|
||||||
"src",
|
|
||||||
`/api/v1/private-assets/projects/${successId}/images/00000000-0000-4000-8000-000000000213`,
|
|
||||||
);
|
|
||||||
await expect(projectPreview).toHaveCSS("object-fit", "cover");
|
|
||||||
await expect(page.getByLabel("选择失败草稿:失败草稿")).toBeVisible();
|
await expect(page.getByLabel("选择失败草稿:失败草稿")).toBeVisible();
|
||||||
await expect(page.getByLabel("选择失败草稿:城市工作室")).toHaveCount(0);
|
await expect(page.getByLabel("选择失败草稿:城市工作室")).toHaveCount(0);
|
||||||
await page.getByLabel("选择失败草稿:失败草稿").check();
|
await page.getByLabel("选择失败草稿:失败草稿").check();
|
||||||
@@ -115,10 +99,9 @@ test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async
|
|||||||
test("TDD-WP2-PROJ-001 keeps ratio fixed and blocks an eleventh image in project detail", async ({ page }) => {
|
test("TDD-WP2-PROJ-001 keeps ratio fixed and blocks an eleventh image in project detail", async ({ page }) => {
|
||||||
await routeSession(page);
|
await routeSession(page);
|
||||||
const projectId = "00000000-0000-4000-8000-000000000221";
|
const projectId = "00000000-0000-4000-8000-000000000221";
|
||||||
const currentImageId = "00000000-0000-4000-8000-000000000222";
|
|
||||||
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
created_at: "2026-07-28T08:00:00.000Z", current_image_id: currentImageId,
|
created_at: "2026-07-28T08:00:00.000Z", current_image_id: "00000000-0000-4000-8000-000000000222",
|
||||||
draft_prompt: "城市工作室", generations: [], images: Array.from({ length: 10 }, (_, index) => ({
|
draft_prompt: "城市工作室", generations: [], images: Array.from({ length: 10 }, (_, index) => ({
|
||||||
created_at: `2026-07-28T08:${String(index).padStart(2, "0")}:00.000Z`,
|
created_at: `2026-07-28T08:${String(index).padStart(2, "0")}:00.000Z`,
|
||||||
generation_id: `00000000-0000-4000-8000-${String(223 + index).padStart(12, "0")}`,
|
generation_id: `00000000-0000-4000-8000-${String(223 + index).padStart(12, "0")}`,
|
||||||
@@ -128,25 +111,11 @@ test("TDD-WP2-PROJ-001 keeps ratio fixed and blocks an eleventh image in project
|
|||||||
status: "active", successful_image_count: 10, updated_at: "2026-07-28T08:10:00.000Z",
|
status: "active", successful_image_count: 10, updated_at: "2026-07-28T08:10:00.000Z",
|
||||||
}), contentType: "application/json", status: 200,
|
}), contentType: "application/json", status: 200,
|
||||||
}));
|
}));
|
||||||
await page.route(`**/api/v1/private-assets/projects/${projectId}/images/*`, (route) => route.fulfill({
|
|
||||||
body: generatedImageSvg("生成结果"),
|
|
||||||
contentType: "image/svg+xml",
|
|
||||||
headers: { "Content-Disposition": "attachment; filename=\"dada-original.png\"" },
|
|
||||||
status: 200,
|
|
||||||
}));
|
|
||||||
await page.goto(`${webUrl}/app/projects/${projectId}`);
|
await page.goto(`${webUrl}/app/projects/${projectId}`);
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "城市工作室" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "城市工作室" })).toBeVisible();
|
||||||
await expect(page.getByText("固定比例 3:4")).toBeVisible();
|
await expect(page.getByText("固定比例 3:4")).toBeVisible();
|
||||||
await expect(page.getByText("10 / 10 张成功图")).toBeVisible();
|
await expect(page.getByText("10 / 10 张成功图")).toBeVisible();
|
||||||
const currentImage = page.getByRole("img", { name: "城市工作室当前底图" });
|
|
||||||
await expect(currentImage).toHaveAttribute(
|
|
||||||
"src",
|
|
||||||
`/api/v1/private-assets/projects/${projectId}/images/${currentImageId}`,
|
|
||||||
);
|
|
||||||
await expect(currentImage).toHaveAttribute("loading", "eager");
|
|
||||||
await expect(currentImage).toHaveCSS("object-fit", "contain");
|
|
||||||
await expect(page.getByRole("img", { name: "生成结果 10" })).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "继续生成" })).toBeDisabled();
|
await expect(page.getByRole("button", { name: "继续生成" })).toBeDisabled();
|
||||||
await expect(page.getByText("请先删除一张非当前底图的历史图")).toBeVisible();
|
await expect(page.getByText("请先删除一张非当前底图的历史图")).toBeVisible();
|
||||||
await expect(page.getByRole("radio")).toHaveCount(0);
|
await expect(page.getByRole("radio")).toHaveCount(0);
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { homedir } from "node:os";
|
|||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import type { CanvasState } from "@dada/shared-contracts";
|
import type { CanvasState } from "@dada/shared-contracts";
|
||||||
|
|
||||||
import { P0A_TEXT_TEMPLATES, createTextTemplateElement } from "../../apps/web/src/text-assets.js";
|
|
||||||
|
|
||||||
let vite: ViteDevServer;
|
let vite: ViteDevServer;
|
||||||
let webUrl: string;
|
let webUrl: string;
|
||||||
|
|
||||||
@@ -198,89 +196,3 @@ test("TDD-WP4-STK-001 transforms, cycles, selects and reopens ordinary stickers"
|
|||||||
writeEvidence("TDD-WP4-STK-001-transform-sticker", "pixel-diff.json", { canvas_and_saved_state_match: true, export_source_canvas_state_stable: true });
|
writeEvidence("TDD-WP4-STK-001-transform-sticker", "pixel-diff.json", { canvas_and_saved_state_match: true, export_source_canvas_state_stable: true });
|
||||||
if (process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS, "TDD-WP4-STK-001-transform-sticker", "transformed-sticker.png") });
|
if (process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS, "TDD-WP4-STK-001-transform-sticker", "transformed-sticker.png") });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("POSTV1-08 keeps the canvas frame stable and previews drag before pointer release", async ({ page }) => {
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
const counters = { height: 0, width: 0 };
|
|
||||||
Object.defineProperty(window, "__dadaCanvasDimensionWrites", { value: counters });
|
|
||||||
for (const key of ["height", "width"] as const) {
|
|
||||||
const descriptor = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, key);
|
|
||||||
if (!descriptor?.get || !descriptor.set) throw new Error(`Canvas ${key} descriptor unavailable.`);
|
|
||||||
Object.defineProperty(HTMLCanvasElement.prototype, key, {
|
|
||||||
configurable: descriptor.configurable,
|
|
||||||
enumerable: descriptor.enumerable,
|
|
||||||
get: descriptor.get,
|
|
||||||
set(value: number) {
|
|
||||||
if (this.classList.contains("editor-canvas")) counters[key] += 1;
|
|
||||||
descriptor.set!.call(this, value);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const projectId = uuid(530);
|
|
||||||
const text = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, {
|
|
||||||
createdAt: "2026-08-03T08:00:00.000Z",
|
|
||||||
elementId: uuid(630),
|
|
||||||
}, 0, { position: { x: 0.5, y: 0.5 } });
|
|
||||||
const backend = { canvas: canvas([text]), saves: 0, version: 6 };
|
|
||||||
await routeEditor(page, projectId, backend);
|
|
||||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
|
||||||
const stage = page.getByLabel("编辑画布");
|
|
||||||
const bounds = await stage.boundingBox();
|
|
||||||
if (!bounds) throw new Error("Canvas bounds unavailable.");
|
|
||||||
const center = { x: bounds.x + bounds.width * 0.5, y: bounds.y + bounds.height * 0.5 };
|
|
||||||
await page.mouse.click(center.x, center.y);
|
|
||||||
await expect(page.getByLabel("文字内容")).toBeVisible();
|
|
||||||
await page.getByLabel("文字内容").fill("拖动中的文字");
|
|
||||||
await page.getByRole("spinbutton", { name: "有效字号", exact: true }).fill("64");
|
|
||||||
await page.getByLabel("文字填充色").fill("#FA5751");
|
|
||||||
await expect.poll(() => backend.saves, { timeout: 4_000 }).toBe(1);
|
|
||||||
expect(backend.canvas.elements[0]).toMatchObject({
|
|
||||||
content: "拖动中的文字",
|
|
||||||
scale: { x: 64 / 48, y: 64 / 48 },
|
|
||||||
style_parameters: { fill_color: "#FA5751" },
|
|
||||||
});
|
|
||||||
const savesBeforeDrag = backend.saves;
|
|
||||||
|
|
||||||
const before = await page.evaluate(() => {
|
|
||||||
return structuredClone((window as typeof window & { __dadaCanvasDimensionWrites: { height: number; width: number } }).__dadaCanvasDimensionWrites);
|
|
||||||
});
|
|
||||||
await page.mouse.move(center.x, center.y);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.waitForTimeout(650);
|
|
||||||
await expect(page.getByRole("menu")).toBeVisible();
|
|
||||||
await page.mouse.move(bounds.x + bounds.width * 0.68, center.y);
|
|
||||||
await expect(page.getByRole("menu")).toBeHidden();
|
|
||||||
await expect(page.getByLabel("文字内容")).toBeVisible();
|
|
||||||
|
|
||||||
const preview = 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;
|
|
||||||
let count = 0;
|
|
||||||
let totalX = 0;
|
|
||||||
for (let y = 0; y < canvas.height; y += 1) {
|
|
||||||
for (let x = 0; x < canvas.width; x += 1) {
|
|
||||||
const offset = (y * canvas.width + x) * 4;
|
|
||||||
if ((pixels[offset] ?? 255) < 20 && (pixels[offset + 1] ?? 0) >= 75 && (pixels[offset + 1] ?? 255) <= 120 && (pixels[offset + 2] ?? 0) >= 180) {
|
|
||||||
count += 1;
|
|
||||||
totalX += x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { blue_pixel_count: count, blue_x: count > 0 ? totalX / count / canvas.width : 0 };
|
|
||||||
});
|
|
||||||
const during = await page.evaluate(() => {
|
|
||||||
return structuredClone((window as typeof window & { __dadaCanvasDimensionWrites: { height: number; width: number } }).__dadaCanvasDimensionWrites);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(during).toEqual(before);
|
|
||||||
expect(preview.blue_pixel_count).toBeGreaterThan(100);
|
|
||||||
expect(preview.blue_x).toBeGreaterThan(0.60);
|
|
||||||
await page.mouse.up();
|
|
||||||
await expect.poll(() => backend.saves, { timeout: 4_000 }).toBeGreaterThan(savesBeforeDrag);
|
|
||||||
expect(backend.canvas.elements[0]?.position.x).toBeCloseTo(0.68, 2);
|
|
||||||
expect(backend.canvas.elements[0]?.content).toBe("拖动中的文字");
|
|
||||||
await expect(page.getByLabel("文字内容")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -212,81 +212,3 @@ test("TDD-WP4-TXT-002 waits for the archived font and commits exact style ranges
|
|||||||
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "pixel-diff.json", { background_alpha_separate: true, clipped_visible_text: false, effective_font_size: 96 });
|
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") });
|
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") });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("POSTV1-07 keeps the canvas anchored when the text template panel opens", async ({ page }) => {
|
|
||||||
const projectId = uuid(760);
|
|
||||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 5 };
|
|
||||||
await routeEditor(page, projectId, backend);
|
|
||||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
|
||||||
|
|
||||||
const stage = page.getByLabel("编辑画布");
|
|
||||||
const before = await stage.boundingBox();
|
|
||||||
if (!before) throw new Error("Canvas geometry is unavailable before opening text templates.");
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
|
||||||
await expect(page.locator(".editor-template-grid button")).toHaveCount(32);
|
|
||||||
const after = await stage.boundingBox();
|
|
||||||
if (!after) throw new Error("Canvas geometry is unavailable after opening text templates.");
|
|
||||||
const assetsPanelScroll = await page.getByLabel("素材与底图来源").evaluate((panel) => ({
|
|
||||||
clientHeight: panel.clientHeight,
|
|
||||||
overflowY: getComputedStyle(panel).overflowY,
|
|
||||||
scrollHeight: panel.scrollHeight,
|
|
||||||
}));
|
|
||||||
|
|
||||||
expect(Math.abs(after.x - before.x)).toBeLessThanOrEqual(1);
|
|
||||||
expect(Math.abs(after.y - before.y)).toBeLessThanOrEqual(1);
|
|
||||||
expect(Math.abs(after.width - before.width)).toBeLessThanOrEqual(1);
|
|
||||||
expect(Math.abs(after.height - before.height)).toBeLessThanOrEqual(1);
|
|
||||||
expect(assetsPanelScroll.overflowY).toBe("auto");
|
|
||||||
expect(assetsPanelScroll.scrollHeight).toBeGreaterThan(assetsPanelScroll.clientHeight);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("POSTV1-07 keeps text selection stable and dismisses move feedback", async ({ page }) => {
|
|
||||||
const projectId = uuid(770);
|
|
||||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 6 };
|
|
||||||
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: /H003 生活分享家/ }).click();
|
|
||||||
await expect.poll(() => backend.canvas.elements.length).toBe(1);
|
|
||||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
const stage = page.getByLabel("编辑画布");
|
|
||||||
const bounds = await stage.boundingBox();
|
|
||||||
const element = backend.canvas.elements[0];
|
|
||||||
if (!bounds || !element) throw new Error("Text selection geometry is unavailable.");
|
|
||||||
const positionBefore = structuredClone(element.position);
|
|
||||||
const savesBefore = backend.saves;
|
|
||||||
const clientX = bounds.x + bounds.width * element.position.x;
|
|
||||||
const clientY = bounds.y + bounds.height * element.position.y;
|
|
||||||
|
|
||||||
await page.mouse.move(clientX, clientY);
|
|
||||||
await page.mouse.down();
|
|
||||||
await expect(page.getByLabel("字体覆盖")).toBeVisible();
|
|
||||||
const selectedBounds = await stage.boundingBox();
|
|
||||||
const inspectorScroll = await page.getByLabel("对象参数").evaluate((panel) => ({
|
|
||||||
clientHeight: panel.clientHeight,
|
|
||||||
overflowY: getComputedStyle(panel).overflowY,
|
|
||||||
scrollHeight: panel.scrollHeight,
|
|
||||||
}));
|
|
||||||
if (!selectedBounds) throw new Error("Canvas geometry is unavailable after selecting text.");
|
|
||||||
expect(Math.abs(selectedBounds.y - bounds.y)).toBeLessThanOrEqual(1);
|
|
||||||
expect(inspectorScroll.overflowY).toBe("auto");
|
|
||||||
expect(inspectorScroll.scrollHeight).toBeGreaterThan(inspectorScroll.clientHeight);
|
|
||||||
await page.mouse.move(clientX + 1, clientY);
|
|
||||||
await page.mouse.up();
|
|
||||||
|
|
||||||
await expect(page.getByLabel("字体覆盖")).toBeVisible();
|
|
||||||
await page.waitForTimeout(800);
|
|
||||||
expect(backend.canvas.elements[0]?.position).toEqual(positionBefore);
|
|
||||||
expect(backend.saves).toBe(savesBefore);
|
|
||||||
|
|
||||||
await page.mouse.move(clientX, clientY);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(clientX + 12, clientY);
|
|
||||||
await page.mouse.up();
|
|
||||||
await expect(page.getByText("对象位置已提交", { exact: true })).toBeVisible();
|
|
||||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(savesBefore);
|
|
||||||
await expect(page.getByText("对象位置已提交", { exact: true })).toBeHidden({ timeout: 5_000 });
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ async function routeEditor(page: Page, projectId: string, backend: Backend, opti
|
|||||||
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => route.fulfill({ body: readFileSync(windowsFont), contentType: "font/ttf" }));
|
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => route.fulfill({ body: readFileSync(windowsFont), contentType: "font/ttf" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
test("TDD-WP4-EXP-001 cancel keeps automatically saved text outside the export", async ({ page }) => {
|
test("TDD-WP4-EXP-001 cancel keeps a pending text edit outside history and export", async ({ page }) => {
|
||||||
const projectId = "00000000-0000-4000-8000-000000000920";
|
const projectId = "00000000-0000-4000-8000-000000000920";
|
||||||
const assetId = "00000000-0000-4000-8000-000000000921";
|
const assetId = "00000000-0000-4000-8000-000000000921";
|
||||||
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 2 };
|
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 2 };
|
||||||
@@ -87,29 +87,27 @@ test("TDD-WP4-EXP-001 cancel keeps automatically saved text outside the export",
|
|||||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||||
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
||||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
|
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
|
||||||
await page.getByLabel("文字内容").fill("自动保存的导出文字");
|
await page.getByLabel("文字内容").fill("尚未提交的导出文字");
|
||||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(2);
|
|
||||||
const downloads: string[] = [];
|
const downloads: string[] = [];
|
||||||
page.on("download", (download) => downloads.push(download.suggestedFilename()));
|
page.on("download", (download) => downloads.push(download.suggestedFilename()));
|
||||||
await page.getByRole("button", { name: "导出", exact: true }).click();
|
await page.getByRole("button", { name: "导出", exact: true }).click();
|
||||||
const dialog = page.getByRole("dialog", { name: "导出成品" });
|
const dialog = page.getByRole("dialog", { name: "导出成品" });
|
||||||
await expect(dialog).not.toContainText("导出前需要提交当前修改");
|
await expect(dialog).toContainText("导出前需要提交当前修改");
|
||||||
await expect(dialog.getByRole("checkbox", { name: "将应用当前修改并导出" })).toHaveCount(0);
|
await expect(dialog.getByText("将应用当前修改并导出", { exact: true })).toBeVisible();
|
||||||
await dialog.getByRole("button", { name: "取消" }).click();
|
await dialog.getByRole("button", { name: "取消" }).click();
|
||||||
await expect(dialog).toHaveCount(0);
|
await expect(dialog).toHaveCount(0);
|
||||||
await expect(page.getByLabel("文字内容")).toHaveValue("自动保存的导出文字");
|
await expect(page.getByLabel("文字内容")).toHaveValue("尚未提交的导出文字");
|
||||||
expect(backend.saves).toBe(2);
|
expect(backend.saves).toBe(1);
|
||||||
expect(backend.canvas.elements[0]?.content).toBe("自动保存的导出文字");
|
|
||||||
expect(backend.latestBodies).toHaveLength(0);
|
expect(backend.latestBodies).toHaveLength(0);
|
||||||
expect(downloads).toHaveLength(0);
|
expect(downloads).toHaveLength(0);
|
||||||
const beforeUndo = { download_count: 0, latest_count: 0, save_count_after_cancel: backend.saves, state_version: backend.version };
|
const beforeUndo = { download_count: 0, latest_count: 0, save_count_after_cancel: backend.saves, state_version: backend.version };
|
||||||
await page.getByRole("button", { name: "撤销" }).click();
|
await page.getByRole("button", { name: "撤销" }).click();
|
||||||
await expect(page.getByLabel("文字内容")).toHaveValue("春日计划");
|
await expect(page.getByLabel("文字内容")).toHaveCount(0);
|
||||||
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "network-timeline.json", { ...beforeUndo, compose_calls: 0, export_save_calls: 0 });
|
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "network-timeline.json", { ...beforeUndo, compose_calls: 0, export_save_calls: 0 });
|
||||||
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "db-diff.json", { committed_text_after_cancel: "自动保存的导出文字", first_undo_restored_initial_text: true, latest_exports_changed: false });
|
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "db-diff.json", { committed_text_after_cancel: "春日计划", first_undo_removed_initial_element: true, latest_exports_changed: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("TDD-WP4-EXP-001 exports automatically saved text and saves the same bytes", async ({ page }) => {
|
test("TDD-WP4-EXP-001 confirm commits once, downloads, and saves the same bytes", async ({ page }) => {
|
||||||
const projectId = "00000000-0000-4000-8000-000000000930";
|
const projectId = "00000000-0000-4000-8000-000000000930";
|
||||||
const assetId = "00000000-0000-4000-8000-000000000931";
|
const assetId = "00000000-0000-4000-8000-000000000931";
|
||||||
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 4 };
|
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 4 };
|
||||||
@@ -119,7 +117,6 @@ test("TDD-WP4-EXP-001 exports automatically saved text and saves the same bytes"
|
|||||||
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
||||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
|
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
|
||||||
await page.getByLabel("文字内容").fill("确认后进入导出");
|
await page.getByLabel("文字内容").fill("确认后进入导出");
|
||||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(2);
|
|
||||||
await page.getByRole("button", { name: "导出", exact: true }).click();
|
await page.getByRole("button", { name: "导出", exact: true }).click();
|
||||||
const dialog = page.getByRole("dialog", { name: "导出成品" });
|
const dialog = page.getByRole("dialog", { name: "导出成品" });
|
||||||
if (process.env.DADA_EVIDENCE_DIR_EXPORT) {
|
if (process.env.DADA_EVIDENCE_DIR_EXPORT) {
|
||||||
@@ -127,14 +124,14 @@ test("TDD-WP4-EXP-001 exports automatically saved text and saves the same bytes"
|
|||||||
mkdirSync(dirname(screenshot), { recursive: true });
|
mkdirSync(dirname(screenshot), { recursive: true });
|
||||||
await page.screenshot({ fullPage: true, path: screenshot });
|
await page.screenshot({ fullPage: true, path: screenshot });
|
||||||
}
|
}
|
||||||
await expect(dialog.getByRole("checkbox", { name: "将应用当前修改并导出" })).toHaveCount(0);
|
await dialog.getByRole("checkbox", { name: "将应用当前修改并导出" }).check();
|
||||||
const downloadPromise = page.waitForEvent("download");
|
const downloadPromise = page.waitForEvent("download");
|
||||||
await dialog.getByRole("button", { name: "导出并下载" }).click();
|
await dialog.getByRole("button", { name: "导出并下载" }).click();
|
||||||
const download = await downloadPromise;
|
const download = await downloadPromise;
|
||||||
const downloadPath = await download.path();
|
const downloadPath = await download.path();
|
||||||
if (!downloadPath) throw new Error("Browser download did not expose a local path.");
|
if (!downloadPath) throw new Error("Browser download did not expose a local path.");
|
||||||
await expect(dialog.getByRole("status")).toHaveText("已下载并保存为最新成品");
|
await expect(dialog.getByRole("status")).toHaveText("已下载并保存为最新成品");
|
||||||
expect(backend.saves).toBe(2);
|
await expect.poll(() => backend.saves).toBe(2);
|
||||||
expect(backend.canvas.elements[0]?.content).toBe("确认后进入导出");
|
expect(backend.canvas.elements[0]?.content).toBe("确认后进入导出");
|
||||||
expect(backend.latestBodies).toHaveLength(1);
|
expect(backend.latestBodies).toHaveLength(1);
|
||||||
const downloaded = readFileSync(downloadPath);
|
const downloaded = readFileSync(downloadPath);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const testBrowserSupportRelease = {
|
export const testBrowserSupportRelease = {
|
||||||
appVersion: "1.2.3-test",
|
appVersion: "1.2.3-test",
|
||||||
browsers: [
|
browsers: [
|
||||||
{ brand: "Google Chrome", fullVersion: "150.0.7339.1" },
|
{ brand: "Google Chrome", fullVersion: "150.0.7339.1", supportedMajorVersions: [150, 151] },
|
||||||
{ brand: "Microsoft Edge", fullVersion: "150.0.4078.99" },
|
{ brand: "Microsoft Edge", fullVersion: "150.0.4078.99" },
|
||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ function release() {
|
|||||||
return buildFinalReleaseRecord({
|
return buildFinalReleaseRecord({
|
||||||
appVersion: "0.0.0",
|
appVersion: "0.0.0",
|
||||||
browsers: [
|
browsers: [
|
||||||
{ brand: "Google Chrome", fullVersion: "150.0.7871.187" },
|
{ brand: "Google Chrome", fullVersion: "150.0.7871.187", supportedMajorVersions: [150, 151] },
|
||||||
{ brand: "Microsoft Edge", fullVersion: "151.0.4129.59" },
|
{ brand: "Microsoft Edge", fullVersion: "151.0.4129.59" },
|
||||||
],
|
],
|
||||||
buildCommit: "a".repeat(40),
|
buildCommit: "a".repeat(40),
|
||||||
@@ -26,6 +26,32 @@ test("TDD-WP7-REL-001 creates a browser-gate compatible first-version record", (
|
|||||||
assert.equal(record.fixedPort, 43121);
|
assert.equal(record.fixedPort, 43121);
|
||||||
assert.deepEqual(record.deferredExternalTasks, ["TASK-WP7-03", "TASK-WP7-04"]);
|
assert.deepEqual(record.deferredExternalTasks, ["TASK-WP7-03", "TASK-WP7-04"]);
|
||||||
assert.deepEqual(record.browsers.map(({ brand }) => brand).sort(), ["Google Chrome", "Microsoft Edge"]);
|
assert.deepEqual(record.browsers.map(({ brand }) => brand).sort(), ["Google Chrome", "Microsoft Edge"]);
|
||||||
|
assert.deepEqual(record.browsers[0].supportedMajorVersions, [150, 151]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("TDD-WP7-REL-001 rejects unsafe or duplicate browser major lists", () => {
|
||||||
|
assert.throws(() => buildFinalReleaseRecord({
|
||||||
|
appVersion: "0.0.0",
|
||||||
|
browsers: [
|
||||||
|
{ brand: "Google Chrome", fullVersion: "150.0.7871.187", supportedMajorVersions: [150, 150] },
|
||||||
|
{ brand: "Microsoft Edge", fullVersion: "151.0.4129.59" },
|
||||||
|
],
|
||||||
|
buildCommit: "a".repeat(40),
|
||||||
|
frozenFromCommit: "b".repeat(40),
|
||||||
|
recordedAt: "2026-08-04T06:00:00.000Z",
|
||||||
|
windows: { arch: "x64", build: "26200.8875", displayVersion: "25H2" },
|
||||||
|
}), /Google Chrome\.supportedMajorVersions/);
|
||||||
|
assert.throws(() => buildFinalReleaseRecord({
|
||||||
|
appVersion: "0.0.0",
|
||||||
|
browsers: [
|
||||||
|
{ brand: "Google Chrome", fullVersion: "150.0.7871.187", supportedMajorVersions: [150, 151, 152, 153, 154, 155, 156, 157] },
|
||||||
|
{ brand: "Microsoft Edge", fullVersion: "151.0.4129.59" },
|
||||||
|
],
|
||||||
|
buildCommit: "a".repeat(40),
|
||||||
|
frozenFromCommit: "b".repeat(40),
|
||||||
|
recordedAt: "2026-08-04T06:00:00.000Z",
|
||||||
|
windows: { arch: "x64", build: "26200.8875", displayVersion: "25H2" },
|
||||||
|
}), /supportedMajorVersions\.total/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("TDD-WP7-SEC-001 rejects credential shapes and absolute user paths", () => {
|
test("TDD-WP7-SEC-001 rejects credential shapes and absolute user paths", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user