Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/cardtile/serve/edit2-assets.mjs

Large diffs are not rendered by default.

112 changes: 112 additions & 0 deletions packages/cardtile/w2/cancel-new.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// ✕ on a tile just added by 「+加一張牌」 and never saved discards it — the card goes back to
// exactly the markdown it had before the pick. Done keeps it, as today; an EXISTING tile's ✕ also
// keeps it, as today. See edit2.mjs's `S.newTile` / `newTileCancelTarget` / `closeModal`.
//
// Two layers: `newTileCancelTarget` is pure (no DOM, imported straight from source) — the actual
// decision of what a cancel restores. The rest is end-to-end against the REAL generated bundle
// (serve/edit2-assets.mjs), the same host-boot.test.mjs style, because the bug was only ever
// visible through addCell → openCell → closeModal wired together.
import assert from 'node:assert/strict';
import test from 'node:test';
import vm from 'node:vm';
import { newTileCancelTarget } from './edit2.mjs';
import { EDIT2_JS } from '../serve/edit2-assets.mjs';

// ── pure ─────────────────────────────────────────────────────────────────────────────────────────

test('newTileCancelTarget: no tracked tile → not a cancel-worthy close', () => {
assert.equal(newTileCancelTarget(null, 3), null);
});

test('newTileCancelTarget: tracked tile, but a DIFFERENT slot is closing (an existing tile) → null', () => {
assert.equal(newTileCancelTarget({ slot: 2, preMd: 'A', undoFloor: 0 }, 5), null);
});

test('newTileCancelTarget: tracked tile IS the one closing → the pre-add markdown', () => {
assert.equal(newTileCancelTarget({ slot: 2, preMd: 'A', undoFloor: 0 }, 2), 'A');
});

// ── end-to-end, against the real bundle ─────────────────────────────────────────────────────────

/** minimal DOM stub — a value on every field defaults to '', so an unrelated field a test never
* touches (composeCell reads every PARAM row unconditionally) never crashes on `undefined.trim()`. */
function stubEl(id) {
const props = { id, dataset: {}, style: {}, hidden: false, children: [], value: '' };
const fns = {
addEventListener() {}, removeEventListener() {}, setAttribute(k, v) { props[`@${k}`] = v; }, getAttribute(k) { return props[`@${k}`] ?? null; },
appendChild(c) { props.children.push(c); return c; }, append() {}, prepend() {}, remove() {}, replaceChildren() {}, focus() {}, blur() {},
querySelector() { return null; }, querySelectorAll() { return []; }, closest() { return null; }, contains() { return false; },
getBoundingClientRect() { return { top: 0, left: 0, width: 0, height: 0, right: 0, bottom: 0 }; }, scrollIntoView() {},
insertAdjacentHTML() {}, insertBefore(c) { return c; }, cloneNode() { return stubEl(id); }, click() {},
};
props.classList = { add() {}, remove() {}, toggle() {}, contains() { return false; } };
return new Proxy(props, {
get(t, k) { if (k in fns) return fns[k]; if (k in t) return t[k]; if (k === 'contentWindow' || k === 'contentDocument') return null; return undefined; },
set(t, k, v) { t[k] = v; return true; },
});
}

function bootSandbox() {
const els = new Map();
const get = (id) => { if (!els.has(id)) els.set(id, stubEl(id)); return els.get(id); };
const body = stubEl('body');
const document = {
documentElement: stubEl('html'), body, head: stubEl('head'),
getElementById: get, querySelector: () => null, querySelectorAll: () => [],
createElement: (t) => stubEl(t), createTextNode: (t) => ({ t }), createDocumentFragment: () => stubEl('frag'),
addEventListener() {}, removeEventListener() {},
};
const storage = () => { const m = new Map(); return { getItem: (k) => m.get(k) ?? null, setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k) }; };
const window = {
document, location: new URL('https://card.feelreef.com/try/edit'),
navigator: { language: 'en' }, localStorage: storage(), sessionStorage: storage(),
parent: { postMessage() {} },
addEventListener() {}, removeEventListener() {},
setTimeout: () => 0, clearTimeout() {},
requestAnimationFrame: () => 0, matchMedia: () => ({ matches: false, addEventListener() {}, addListener() {} }),
getComputedStyle: () => ({ getPropertyValue: () => '' }), ResizeObserver: class { observe() {} disconnect() {} },
MutationObserver: class { observe() {} disconnect() {} }, console, structuredClone, URL, URLSearchParams,
};
window.window = window; window.self = window; window.globalThis = window;
const ctx = vm.createContext({ ...window, window });
vm.runInContext(EDIT2_JS, ctx);
ctx.window.__cardtileW2Boot({ sandbox: true, locale: 'en', tableBase: '/try/edit/t/en/' });
return { get, api: ctx.window.__cardtileW2 };
}

test('add a tile → ✕ before Done → markdown identical to before the add', () => {
const { api } = bootSandbox();
const before = api.md;
const faceLane = api.lanes.find((l) => l.kind !== 'drawer').key;
api.addCell('video', faceLane);
assert.notEqual(api.md, before, 'the add really did change the card first');
assert.ok(api.newTile, 'the new tile is tracked while its sheet is open');
api.closeModal();
assert.equal(api.md, before, '✕ put the card back to the exact bytes it had before the pick');
assert.equal(api.newTile, null);
});

test('add a tile → Done → the tile is present, and a LATER ✕ (a re-open) no longer discards it', () => {
const { api } = bootSandbox();
const before = api.md;
const faceLane = api.lanes.find((l) => l.kind !== 'drawer').key;
api.addCell('text', faceLane);
assert.ok(api.newTile);
api.saveCell();
assert.notEqual(api.md, before, 'Done keeps it');
const savedMd = api.md;
assert.equal(api.newTile, null, 'no longer "just added, never saved" once Done fired');
// reopening the same tile and pressing ✕ now is an EXISTING tile's ✕ — close, keep
const slot = api.slots.length - 1;
api.openCell(slot);
api.closeModal();
assert.equal(api.md, savedMd, 'existing tile ✕ keeps today\'s behaviour: close, keep');
});

test('✕ on an EXISTING tile (never added this session) keeps it — unchanged from today', () => {
const { api } = bootSandbox();
const before = api.md;
api.openCell(0);
api.closeModal();
assert.equal(api.md, before);
});
48 changes: 46 additions & 2 deletions packages/cardtile/w2/edit2.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,22 @@ const S = {
addLane: null, // which lane 「+加一張牌」 was pressed in
openSlot: null, // the tile whose sheet is open
pendingAssets: {},
// the tile `addCell` just created, if its sheet is still the one open and Done has not fired yet.
// `{ slot, preMd, undoFloor }` — see `newTileCancelTarget` and `closeModal`. Cleared the moment the
// sheet closes any other way (Done, Delete) or a different tile's sheet opens.
newTile: null,
};

/**
* Pure: does closing the sheet on `slot` right now (✕/Escape/scrim, never Done) discard a
* never-saved tile? `track` is `S.newTile`. Returns the markdown to restore, or null when this is
* an ordinary close (an existing tile, or `track` stale/absent) that should just hide the sheet.
*/
export function newTileCancelTarget(track, slot) {
if (!track || track.slot !== slot) return null;
return track.preMd;
}

const SANDBOX_STORAGE_KEY = 'cardtile:try:draft:v1';
const readDraft = () => { try { return (typeof window !== 'undefined' && window.localStorage.getItem(SANDBOX_STORAGE_KEY)) || ''; } catch { return ''; } };
const writeDraft = (md) => { try { if (typeof window !== 'undefined') window.localStorage.setItem(SANDBOX_STORAGE_KEY, md); } catch { /* best-effort */ } };
Expand Down Expand Up @@ -844,6 +858,10 @@ function openPicker(laneKey) {
* unfinished instead, and the sheet opens on top of them.
*/
function addCell(type, laneKey) {
// snapshot BEFORE the add commits, so a cancel on the sheet that is about to open can put the
// card back exactly as it was — same markdown bytes, no orphaned undo entry left behind.
const preMd = S.md;
const undoFloor = S.undo.length;
const blank = normalizeCell({ ...blankCell(type, TX), params: {} });
edit((m) => {
if (laneKey.startsWith('drawer:')) {
Expand All @@ -862,7 +880,12 @@ function addCell(type, laneKey) {
// the sheet opens on top of the new tile: the LAST slot in the lane it was added to
let last = -1;
boardSlots(S.model, bridgeCtx()).forEach((s, i) => { if (s.laneKey === laneKey) last = i; });
if (last >= 0) openCell(last);
if (last >= 0) {
S.newTile = { slot: last, preMd, undoFloor };
openCell(last);
} else {
S.newTile = null;
}
}

function addDrawer() {
Expand Down Expand Up @@ -923,6 +946,10 @@ function openCell(slot) {
const slots = boardSlots(S.model, bridgeCtx());
const s = slots[slot];
if (!s) return;
// opening any tile other than the one `addCell` just tracked ends that tracking — this is an
// existing tile's sheet, or a second sheet on the same new tile reopened after Done, and ✕ on it
// is today's behaviour (close, keep).
if (S.newTile && S.newTile.slot !== slot) S.newTile = null;
S.openSlot = slot;
S.pendingAssets = {};
const cell = s.cell;
Expand Down Expand Up @@ -982,7 +1009,19 @@ function wireForm(def) {
}
}

const closeModal = () => { el('modal').hidden = true; S.openSlot = null; S.pendingAssets = {}; };
// ✕ / Escape / scrim — never Done, never Delete (those call closeModal too, but only after
// already committing or clearing `S.newTile` themselves, so `newTileCancelTarget` sees nothing).
const closeModal = () => {
const restoreMd = newTileCancelTarget(S.newTile, S.openSlot);
if (restoreMd != null) {
S.undo.length = S.newTile.undoFloor; // drop the add (and any reorder since) — no-op undo entry
commit(restoreMd, { undoable: false });
}
S.newTile = null;
el('modal').hidden = true;
S.openSlot = null;
S.pendingAssets = {};
};

/** where the open tile sits inside its own lane — the sheet's up/down buttons work within a lane */
function openPlace() {
Expand All @@ -1006,6 +1045,7 @@ function moveCell(delta) {
if (!p) return;
const to = p.index + delta;
if (to < 0 || to >= p.count) return;
const trackingThis = S.newTile && S.newTile.slot === S.openSlot;
edit((m) => {
if (p.laneKey.startsWith('drawer:')) {
const d = m.drawers.find((x) => `drawer:${x.id}` === p.laneKey);
Expand All @@ -1018,6 +1058,7 @@ function moveCell(delta) {
});
// the sheet stays open on the SAME tile, which has simply moved
S.openSlot = boardSlots(S.model, bridgeCtx()).findIndex((s) => s.laneKey === p.laneKey && s.cellIndex === to);
if (trackingThis && S.newTile) S.newTile = { ...S.newTile, slot: S.openSlot }; // the new tile followed
syncMoveButtons();
}

Expand Down Expand Up @@ -1064,6 +1105,7 @@ function saveCell() {
m.cells = m.cells.map((c, i) => (i === at ? next : c));
return m;
});
S.newTile = null; // Done keeps it — this tile is no longer "just added, never saved"
closeModal();
}

Expand All @@ -1083,6 +1125,7 @@ function deleteCell() {
m.blocks = m.blocks.map((b, i) => (i === bi ? { ...b, count: b.count - 1 } : (b.start > at ? { ...b, start: b.start - 1 } : b)));
return m;
});
S.newTile = null; // gone either way — nothing left to restore
closeModal();
}

Expand Down Expand Up @@ -1379,6 +1422,7 @@ export function boot(opts = {}) {
get slots() { return boardSlots(S.model, bridgeCtx()); },
get tugs() { return drawerLinks(S.model, bridgeCtx()); },
get ready() { return !!(tableWin() && tableWin().__ready); },
get newTile() { return S.newTile; },
boardMd: () => boardMd(S.model, bridgeCtx()),
load: (md) => commit(md, { undoable: false }),
openPicker, addCell, openCell, saveCell, closeModal, deleteCell, undo,
Expand Down
Loading