mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-08 20:26:08 -05:00
Persist planner
This commit is contained in:
@@ -52,6 +52,10 @@ import {
|
||||
subWeaponIds,
|
||||
weaponCategories,
|
||||
} from "~/modules/in-game-lists/weapon-ids";
|
||||
import {
|
||||
useSearchParam,
|
||||
useSearchParamsTyped,
|
||||
} from "~/modules/search-params/hooks";
|
||||
import { logger } from "~/utils/logger";
|
||||
import {
|
||||
mainWeaponImageUrl,
|
||||
@@ -64,6 +68,12 @@ import {
|
||||
} from "~/utils/urls";
|
||||
import { LinkButton, SendouButton } from "../../../components/elements/Button";
|
||||
import { Image } from "../../../components/Image";
|
||||
import {
|
||||
PLANNER_BACKGROUND_STYLES,
|
||||
PLANNER_PERSISTENCE_KEY,
|
||||
STAGE_WATER_LEVELS,
|
||||
} from "../plans-constants";
|
||||
import { plansSearchParams } from "../plans-search-params";
|
||||
import type { StageWaterLevel } from "../plans-types";
|
||||
import styles from "./Planner.module.css";
|
||||
|
||||
@@ -88,12 +98,21 @@ export default function Planner() {
|
||||
const isWide = i18n.language.startsWith("fr");
|
||||
|
||||
const [editor, setEditor] = React.useState<Editor | null>(null);
|
||||
const [imgOutlined, setImgOutlined] = React.useState(false);
|
||||
const [topCollapsed, setTopCollapsed] = React.useState(false);
|
||||
const [weaponsCollapsed, setWeaponsCollapsed] = React.useState(false);
|
||||
const [rangesVisible, setRangesVisible] = React.useState(false);
|
||||
const [backgroundStyle, setBackgroundStyle] = React.useState<"MINI" | "OVER">(
|
||||
"MINI",
|
||||
const [imgOutlined, setImgOutlined] = useSearchParam(
|
||||
plansSearchParams,
|
||||
"outlined",
|
||||
);
|
||||
const [topCollapsed, setTopCollapsed] = useSearchParam(
|
||||
plansSearchParams,
|
||||
"hideTop",
|
||||
);
|
||||
const [weaponsCollapsed, setWeaponsCollapsed] = useSearchParam(
|
||||
plansSearchParams,
|
||||
"hideWeapons",
|
||||
);
|
||||
const [rangesVisible, setRangesVisible] = useSearchParam(
|
||||
plansSearchParams,
|
||||
"ranges",
|
||||
);
|
||||
const rangeCleanupRef = React.useRef<(() => void) | null>(null);
|
||||
const [activeDragItem, setActiveDragItem] = React.useState<{
|
||||
@@ -111,6 +130,93 @@ export default function Planner() {
|
||||
}),
|
||||
);
|
||||
|
||||
const showRanges = React.useCallback((editorToUse: Editor) => {
|
||||
const gameUnitsToPx = GAME_UNITS_TO_PX[canvasBackgroundStyle(editorToUse)];
|
||||
removeRangeCircles(editorToUse);
|
||||
for (const shape of editorToUse.getCurrentPageShapes()) {
|
||||
createRangeCircleForShape(editorToUse, shape, gameUnitsToPx);
|
||||
}
|
||||
|
||||
const unsubCreate = editorToUse.sideEffects.registerAfterCreateHandler(
|
||||
"shape",
|
||||
(shape) => {
|
||||
if (shape.meta.isRangeCircle) return;
|
||||
createRangeCircleForShape(editorToUse, shape, gameUnitsToPx);
|
||||
},
|
||||
);
|
||||
|
||||
const unsubChange = editorToUse.sideEffects.registerAfterChangeHandler(
|
||||
"shape",
|
||||
(_prev, next) => {
|
||||
if (next.meta.isRangeCircle) return;
|
||||
|
||||
const rangeCircles = editorToUse
|
||||
.getCurrentPageShapes()
|
||||
.filter(
|
||||
(s) =>
|
||||
s.meta.isRangeCircle === true && s.meta.weaponShapeId === next.id,
|
||||
);
|
||||
if (rangeCircles.length === 0) return;
|
||||
|
||||
const centerX = next.x + (next.props as { w: number }).w / 2;
|
||||
const centerY = next.y + (next.props as { h: number }).h / 2;
|
||||
|
||||
for (const rangeCircle of rangeCircles) {
|
||||
const radiusPx = (rangeCircle.props as { w: number }).w / 2;
|
||||
editorToUse.updateShape({
|
||||
id: rangeCircle.id,
|
||||
type: rangeCircle.type,
|
||||
isLocked: false,
|
||||
});
|
||||
editorToUse.updateShape({
|
||||
id: rangeCircle.id,
|
||||
type: rangeCircle.type,
|
||||
x: centerX - radiusPx,
|
||||
y: centerY - radiusPx,
|
||||
isLocked: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const unsubDelete = editorToUse.sideEffects.registerAfterDeleteHandler(
|
||||
"shape",
|
||||
(shape) => {
|
||||
if (shape.meta.isRangeCircle) return;
|
||||
|
||||
const rangeCircles = editorToUse
|
||||
.getCurrentPageShapes()
|
||||
.filter(
|
||||
(s) =>
|
||||
s.meta.isRangeCircle === true &&
|
||||
s.meta.weaponShapeId === shape.id,
|
||||
);
|
||||
if (rangeCircles.length === 0) return;
|
||||
|
||||
for (const rangeCircle of rangeCircles) {
|
||||
editorToUse.updateShape({
|
||||
id: rangeCircle.id,
|
||||
type: rangeCircle.type,
|
||||
isLocked: false,
|
||||
});
|
||||
}
|
||||
editorToUse.deleteShapes(rangeCircles);
|
||||
},
|
||||
);
|
||||
|
||||
rangeCleanupRef.current = () => {
|
||||
unsubCreate();
|
||||
unsubChange();
|
||||
unsubDelete();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hideRanges = React.useCallback((editorToUse: Editor) => {
|
||||
rangeCleanupRef.current?.();
|
||||
rangeCleanupRef.current = null;
|
||||
removeRangeCircles(editorToUse);
|
||||
}, []);
|
||||
|
||||
const handleMount = React.useCallback(
|
||||
(mountedEditor: Editor) => {
|
||||
setEditor(mountedEditor);
|
||||
@@ -118,8 +224,20 @@ export default function Planner() {
|
||||
locale: ourLanguageToTldrawLanguage(i18n.language),
|
||||
colorScheme: htmlThemeClass === "dark" ? "dark" : "light",
|
||||
});
|
||||
|
||||
// a restored plan can hold range circles that no side effect handler is watching anymore
|
||||
mountedEditor.run(
|
||||
() => {
|
||||
if (rangesVisible) {
|
||||
showRanges(mountedEditor);
|
||||
} else {
|
||||
removeRangeCircles(mountedEditor);
|
||||
}
|
||||
},
|
||||
{ history: "ignore" },
|
||||
);
|
||||
},
|
||||
[i18n, htmlThemeClass],
|
||||
[i18n, htmlThemeClass, rangesVisible, showRanges],
|
||||
);
|
||||
|
||||
const handleAddImage = React.useCallback(
|
||||
@@ -128,12 +246,14 @@ export default function Planner() {
|
||||
size,
|
||||
isLocked,
|
||||
point,
|
||||
meta,
|
||||
cb,
|
||||
}: {
|
||||
src: string;
|
||||
size: number[];
|
||||
isLocked: boolean;
|
||||
point: number[];
|
||||
meta?: { backgroundStyle?: "MINI" | "OVER" };
|
||||
cb?: () => void;
|
||||
}) => {
|
||||
if (!editor) return;
|
||||
@@ -170,6 +290,7 @@ export default function Planner() {
|
||||
y: point[1],
|
||||
isLocked: isLocked,
|
||||
id: shapeId,
|
||||
meta: meta ?? {},
|
||||
props: {
|
||||
assetId: assetId,
|
||||
w: size[0],
|
||||
@@ -229,92 +350,11 @@ export default function Planner() {
|
||||
if (!editor) return;
|
||||
|
||||
if (rangesVisible) {
|
||||
rangeCleanupRef.current?.();
|
||||
rangeCleanupRef.current = null;
|
||||
removeRangeCircles(editor);
|
||||
setRangesVisible(false);
|
||||
hideRanges(editor);
|
||||
} else {
|
||||
const gameUnitsToPx = GAME_UNITS_TO_PX[backgroundStyle];
|
||||
removeRangeCircles(editor);
|
||||
for (const shape of editor.getCurrentPageShapes()) {
|
||||
createRangeCircleForShape(editor, shape, gameUnitsToPx);
|
||||
}
|
||||
|
||||
const unsubCreate = editor.sideEffects.registerAfterCreateHandler(
|
||||
"shape",
|
||||
(shape) => {
|
||||
if (shape.meta.isRangeCircle) return;
|
||||
createRangeCircleForShape(editor, shape, gameUnitsToPx);
|
||||
},
|
||||
);
|
||||
|
||||
const unsubChange = editor.sideEffects.registerAfterChangeHandler(
|
||||
"shape",
|
||||
(_prev, next) => {
|
||||
if (next.meta.isRangeCircle) return;
|
||||
|
||||
const rangeCircles = editor
|
||||
.getCurrentPageShapes()
|
||||
.filter(
|
||||
(s) =>
|
||||
s.meta.isRangeCircle === true &&
|
||||
s.meta.weaponShapeId === next.id,
|
||||
);
|
||||
if (rangeCircles.length === 0) return;
|
||||
|
||||
const centerX = next.x + (next.props as { w: number }).w / 2;
|
||||
const centerY = next.y + (next.props as { h: number }).h / 2;
|
||||
|
||||
for (const rangeCircle of rangeCircles) {
|
||||
const radiusPx = (rangeCircle.props as { w: number }).w / 2;
|
||||
editor.updateShape({
|
||||
id: rangeCircle.id,
|
||||
type: rangeCircle.type,
|
||||
isLocked: false,
|
||||
});
|
||||
editor.updateShape({
|
||||
id: rangeCircle.id,
|
||||
type: rangeCircle.type,
|
||||
x: centerX - radiusPx,
|
||||
y: centerY - radiusPx,
|
||||
isLocked: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const unsubDelete = editor.sideEffects.registerAfterDeleteHandler(
|
||||
"shape",
|
||||
(shape) => {
|
||||
if (shape.meta.isRangeCircle) return;
|
||||
|
||||
const rangeCircles = editor
|
||||
.getCurrentPageShapes()
|
||||
.filter(
|
||||
(s) =>
|
||||
s.meta.isRangeCircle === true &&
|
||||
s.meta.weaponShapeId === shape.id,
|
||||
);
|
||||
if (rangeCircles.length === 0) return;
|
||||
|
||||
for (const rangeCircle of rangeCircles) {
|
||||
editor.updateShape({
|
||||
id: rangeCircle.id,
|
||||
type: rangeCircle.type,
|
||||
isLocked: false,
|
||||
});
|
||||
}
|
||||
editor.deleteShapes(rangeCircles);
|
||||
},
|
||||
);
|
||||
|
||||
rangeCleanupRef.current = () => {
|
||||
unsubCreate();
|
||||
unsubChange();
|
||||
unsubDelete();
|
||||
};
|
||||
setRangesVisible(true);
|
||||
showRanges(editor);
|
||||
}
|
||||
setRangesVisible(!rangesVisible);
|
||||
};
|
||||
|
||||
const handleAddBackgroundImage = React.useCallback(
|
||||
@@ -328,6 +368,9 @@ export default function Planner() {
|
||||
|
||||
editor.mark("pre-background-change");
|
||||
|
||||
hideRanges(editor);
|
||||
setRangesVisible(false);
|
||||
|
||||
const shapes = editor.getCurrentPageShapes();
|
||||
// i dont think locked shapes can be deleted
|
||||
for (const value of shapes) {
|
||||
@@ -340,15 +383,12 @@ export default function Planner() {
|
||||
size: [BACKGROUND_WIDTH, BACKGROUND_HEIGHT],
|
||||
isLocked: true,
|
||||
point: [0, 0],
|
||||
meta: { backgroundStyle: urlArgs.style },
|
||||
});
|
||||
|
||||
editor.zoomToFit();
|
||||
rangeCleanupRef.current?.();
|
||||
rangeCleanupRef.current = null;
|
||||
setRangesVisible(false);
|
||||
setBackgroundStyle(urlArgs.style);
|
||||
},
|
||||
[editor, handleAddImage],
|
||||
[editor, handleAddImage, hideRanges, setRangesVisible],
|
||||
);
|
||||
|
||||
// removes all tldraw ui that isnt needed
|
||||
@@ -436,6 +476,7 @@ export default function Planner() {
|
||||
</div>
|
||||
<div style={{ position: "fixed", inset: 0 }}>
|
||||
<Tldraw
|
||||
persistenceKey={PLANNER_PERSISTENCE_KEY}
|
||||
onMount={handleMount}
|
||||
components={tldrawComponents}
|
||||
options={TLDRAW_OPTIONS}
|
||||
@@ -685,18 +726,16 @@ function StageBackgroundSelector({
|
||||
}) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["game-misc", "common"]);
|
||||
const [stageId, setStageId] = React.useState<StageId>(stageIds[0]);
|
||||
const [mode, setMode] = React.useState<ModeShort>("SZ");
|
||||
const [backgroundStyle, setBackgroundStyle] = React.useState<"MINI" | "OVER">(
|
||||
"MINI",
|
||||
);
|
||||
const [waterLevel, setWaterLevel] = React.useState<StageWaterLevel>("up");
|
||||
const [
|
||||
{ stage: stageId, mode, style: backgroundStyle, water: waterLevel },
|
||||
setParams,
|
||||
] = useSearchParamsTyped(plansSearchParams);
|
||||
|
||||
const handleStageIdChange = (stageId: StageId) => {
|
||||
setStageId(stageId);
|
||||
if (stageId !== stagesObj.MAHI_MAHI_RESORT) {
|
||||
setWaterLevel("up");
|
||||
}
|
||||
setParams({
|
||||
stage: stageId,
|
||||
water: stageId === stagesObj.MAHI_MAHI_RESORT ? waterLevel : "up",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -720,7 +759,7 @@ function StageBackgroundSelector({
|
||||
<select
|
||||
className="w-max"
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as ModeShort)}
|
||||
onChange={(e) => setParams({ mode: e.target.value as ModeShort })}
|
||||
>
|
||||
{modesShort.map((mode) => {
|
||||
return (
|
||||
@@ -733,9 +772,11 @@ function StageBackgroundSelector({
|
||||
<select
|
||||
className="w-max"
|
||||
value={backgroundStyle}
|
||||
onChange={(e) => setBackgroundStyle(e.target.value as "MINI" | "OVER")}
|
||||
onChange={(e) =>
|
||||
setParams({ style: e.target.value as "MINI" | "OVER" })
|
||||
}
|
||||
>
|
||||
{(["MINI", "OVER"] as const).map((style) => {
|
||||
{PLANNER_BACKGROUND_STYLES.map((style) => {
|
||||
return (
|
||||
<option key={style} value={style}>
|
||||
{t(`common:plans.bgStyle.${style}`)}
|
||||
@@ -747,9 +788,11 @@ function StageBackgroundSelector({
|
||||
<select
|
||||
className="w-max"
|
||||
value={waterLevel}
|
||||
onChange={(e) => setWaterLevel(e.target.value as StageWaterLevel)}
|
||||
onChange={(e) =>
|
||||
setParams({ water: e.target.value as StageWaterLevel })
|
||||
}
|
||||
>
|
||||
{(["up", "down"] as const).map((level) => {
|
||||
{STAGE_WATER_LEVELS.map((level) => {
|
||||
return (
|
||||
<option key={level} value={level}>
|
||||
{t(`common:plans.waterLevel.${level}`)}
|
||||
@@ -921,6 +964,15 @@ function createCircle(
|
||||
});
|
||||
}
|
||||
|
||||
function canvasBackgroundStyle(editor: Editor): "MINI" | "OVER" {
|
||||
for (const shape of editor.getCurrentPageShapes()) {
|
||||
const style = shape.meta.backgroundStyle;
|
||||
if (style === "MINI" || style === "OVER") return style;
|
||||
}
|
||||
|
||||
return "MINI";
|
||||
}
|
||||
|
||||
function removeRangeCircles(editor: Editor) {
|
||||
const shapes = editor.getCurrentPageShapes();
|
||||
const rangeShapes = shapes.filter(
|
||||
|
||||
15
app/features/map-planner/plans-constants.ts
Normal file
15
app/features/map-planner/plans-constants.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { StageBackgroundStyle, StageWaterLevel } from "./plans-types";
|
||||
|
||||
/** Keys the tldraw document persisted in IndexedDB, letting a plan survive leaving the page. */
|
||||
export const PLANNER_PERSISTENCE_KEY = "map-planner";
|
||||
|
||||
/** Backgrounds the planner offers, a subset of the styles the image url builder supports. */
|
||||
export const PLANNER_BACKGROUND_STYLES = [
|
||||
"MINI",
|
||||
"OVER",
|
||||
] as const satisfies readonly StageBackgroundStyle[];
|
||||
|
||||
export const STAGE_WATER_LEVELS = [
|
||||
"up",
|
||||
"down",
|
||||
] as const satisfies readonly StageWaterLevel[];
|
||||
29
app/features/map-planner/plans-search-params.test.ts
Normal file
29
app/features/map-planner/plans-search-params.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, test } from "vitest";
|
||||
import {
|
||||
assertDecodesToDefault,
|
||||
assertRoundTrips,
|
||||
} from "~/modules/search-params/search-params-test-utils";
|
||||
import { plansSearchParams } from "./plans-search-params";
|
||||
|
||||
describe("plansSearchParams", () => {
|
||||
test("round-trips", () => {
|
||||
assertRoundTrips(plansSearchParams, {
|
||||
stage: [0, 7, 24],
|
||||
mode: ["TW", "SZ", "CB"],
|
||||
style: ["MINI", "OVER"],
|
||||
water: ["up", "down"],
|
||||
outlined: [false, true],
|
||||
ranges: [false, true],
|
||||
hideTop: [false, true],
|
||||
hideWeapons: [false, true],
|
||||
});
|
||||
});
|
||||
|
||||
test("malformed values decode to defaults", () => {
|
||||
assertDecodesToDefault(plansSearchParams, "stage", [["-1"], ["25"], ["a"]]);
|
||||
assertDecodesToDefault(plansSearchParams, "mode", [["SR"]]);
|
||||
assertDecodesToDefault(plansSearchParams, "style", [["ITEMS"]]);
|
||||
assertDecodesToDefault(plansSearchParams, "water", [["DOWN"]]);
|
||||
assertDecodesToDefault(plansSearchParams, "outlined", [["1"], ["yes"]]);
|
||||
});
|
||||
});
|
||||
29
app/features/map-planner/plans-search-params.ts
Normal file
29
app/features/map-planner/plans-search-params.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as v from "valibot";
|
||||
import { stageIds } from "~/modules/in-game-lists/stage-ids";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
import { modeShort, numericEnum } from "~/utils/schema";
|
||||
import {
|
||||
PLANNER_BACKGROUND_STYLES,
|
||||
STAGE_WATER_LEVELS,
|
||||
} from "./plans-constants";
|
||||
|
||||
export const plansSearchParams = SearchParams.define({
|
||||
stage: SP.param(numericEnum(stageIds), {
|
||||
default: stageIds[0],
|
||||
loader: false,
|
||||
}),
|
||||
mode: SP.param(modeShort, { default: "SZ", loader: false }),
|
||||
style: SP.param(v.picklist(PLANNER_BACKGROUND_STYLES), {
|
||||
default: "MINI",
|
||||
loader: false,
|
||||
}),
|
||||
water: SP.param(v.picklist(STAGE_WATER_LEVELS), {
|
||||
default: "up",
|
||||
loader: false,
|
||||
}),
|
||||
outlined: SP.param(v.boolean(), { default: false, loader: false }),
|
||||
ranges: SP.param(v.boolean(), { default: false, loader: false }),
|
||||
hideTop: SP.param(v.boolean(), { default: false, loader: false }),
|
||||
hideWeapons: SP.param(v.boolean(), { default: false, loader: false }),
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { stageIds } from "~/modules/in-game-lists/stage-ids";
|
||||
import { stageIds, stagesObj } from "~/modules/in-game-lists/stage-ids";
|
||||
import { expect, expectNoErrorPage, test } from "./helpers/playwright";
|
||||
import { MapListGeneratorPage } from "./pages/maps/map-list-generator-page";
|
||||
import { MapPlannerPage } from "./pages/plans/map-planner-page";
|
||||
@@ -58,4 +58,25 @@ test.describe("Map Planner", () => {
|
||||
await planner.dragWeaponToCanvas("Splattershot");
|
||||
await expect(planner.locators.imageShapes).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("restores the plan and the selected stage after a reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const planner = new MapPlannerPage(page);
|
||||
await planner.goto();
|
||||
|
||||
await planner.setBackground("Museum d'Alfonsino");
|
||||
await expect(page).toHaveURL(/stage=/);
|
||||
|
||||
await planner.openWeaponCategory("Shooters");
|
||||
await planner.dragWeaponToCanvas("Splattershot");
|
||||
await expect(planner.locators.imageShapes).toHaveCount(2);
|
||||
|
||||
await planner.reloadWithPersistedPlan(2);
|
||||
|
||||
await expect(planner.locators.imageShapes).toHaveCount(2);
|
||||
await expect(planner.locators.stageSelect).toHaveValue(
|
||||
String(stagesObj.MUSEUM_D_ALFONSINO),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { PLANNER_PERSISTENCE_KEY } from "~/features/map-planner/plans-constants";
|
||||
import { PLANNER_URL } from "~/utils/urls";
|
||||
import { expect, navigate } from "../../helpers/playwright";
|
||||
import { expect, expectIsHydrated, navigate } from "../../helpers/playwright";
|
||||
|
||||
const TLDRAW_DB_NAME = `TLDRAW_DOCUMENT_v2${PLANNER_PERSISTENCE_KEY}`;
|
||||
const TLDRAW_RECORDS_STORE = "records";
|
||||
|
||||
export class MapPlannerPage {
|
||||
private readonly page: Page;
|
||||
@@ -28,6 +32,60 @@ export class MapPlannerPage {
|
||||
await this.locators.setBackgroundButton.click();
|
||||
}
|
||||
|
||||
/** Reloads once the plan reached IndexedDB, tldraw throttling its writes. */
|
||||
async reloadWithPersistedPlan(expectedImageShapeCount: number) {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
this.page.evaluate(
|
||||
({ dbName, storeName }) =>
|
||||
new Promise<number>((resolve) => {
|
||||
const openRequest = indexedDB.open(dbName);
|
||||
openRequest.onerror = () => resolve(0);
|
||||
openRequest.onsuccess = () => {
|
||||
const db = openRequest.result;
|
||||
if (!db.objectStoreNames.contains(storeName)) {
|
||||
db.close();
|
||||
resolve(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const getAllRequest = db
|
||||
.transaction(storeName)
|
||||
.objectStore(storeName)
|
||||
.getAll();
|
||||
getAllRequest.onerror = () => {
|
||||
db.close();
|
||||
resolve(0);
|
||||
};
|
||||
getAllRequest.onsuccess = () => {
|
||||
db.close();
|
||||
resolve(
|
||||
(
|
||||
getAllRequest.result as {
|
||||
typeName?: string;
|
||||
type?: string;
|
||||
}[]
|
||||
).filter(
|
||||
(record) =>
|
||||
record.typeName === "shape" &&
|
||||
record.type === "image",
|
||||
).length,
|
||||
);
|
||||
};
|
||||
};
|
||||
}),
|
||||
{ dbName: TLDRAW_DB_NAME, storeName: TLDRAW_RECORDS_STORE },
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(expectedImageShapeCount);
|
||||
|
||||
await this.page.reload();
|
||||
await expectIsHydrated(this.page);
|
||||
await expect(this.locators.canvas).toBeVisible();
|
||||
}
|
||||
|
||||
async openWeaponCategory(categoryName: string) {
|
||||
await this.page.getByText(categoryName, { exact: true }).click();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user