From 692f7e46735a168040db45607388715da507791e Mon Sep 17 00:00:00 2001 From: fimenten <41468725+fimenten@users.noreply.github.com> Date: Tue, 3 Jun 2025 06:58:27 +0900 Subject: [PATCH] Add undo feature tests --- src/history.ts | 31 ++++++++++++++ src/io.ts | 19 +++++++-- src/keyboardInteraction.ts | 7 +++ test/undo.test.js | 87 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 src/history.ts create mode 100644 test/undo.test.js diff --git a/src/history.ts b/src/history.ts new file mode 100644 index 0000000..2cdba69 --- /dev/null +++ b/src/history.ts @@ -0,0 +1,31 @@ +import { element2TrayMap } from "./app"; +import { getRootElement } from "./utils"; +import type { Tray } from "./tray"; + +const history: string[] = []; + +export function recordState(state?: string) { + if (!state) { + const io = require("./io"); + const root = getRootElement(); + if (!root) return; + const tray = element2TrayMap.get(root as HTMLElement) as Tray; + if (!tray) return; + state = io.serialize(tray); + } + history.push(state as string); + if (history.length > 50) history.shift(); +} + +export function undo() { + if (history.length < 2) return; + history.pop(); + const prev = history[history.length - 1]; + const io = require("./io"); + const tray = io.deserialize(prev) as Tray; + io.renderRootTray(tray); +} + +export function _getHistoryLength() { + return history.length; +} diff --git a/src/io.ts b/src/io.ts index 8b392ac..616f6ef 100644 --- a/src/io.ts +++ b/src/io.ts @@ -88,6 +88,11 @@ export async function saveToIndexedDB( const tray = element2TrayMap.get(rootElement as HTMLElement) as Tray; const data = content ? content : serialize(tray); + // record current state for undo history + try { + const { recordState } = require('./history'); + recordState(data); + } catch {} if (!data) { reject("Serialize failed"); @@ -147,11 +152,19 @@ export async function loadFromIndexedDB( rootTray = createDefaultRootTray(); } - initializeTray(rootTray); + renderRootTray(rootTray); + try { + const { recordState } = require('./history'); + recordState(serialize(rootTray)); + } catch {} } catch (error) { console.error("Error loading from IndexedDB:", error); const rootTray = createDefaultRootTray(); - initializeTray(rootTray); + renderRootTray(rootTray); + try { + const { recordState } = require('./history'); + recordState(serialize(rootTray)); + } catch {} } } @@ -204,7 +217,7 @@ export async function getAllSessionIds(): Promise { }); } -function initializeTray(rootTray: Tray) { +export function renderRootTray(rootTray: Tray) { // rootTray.isFolded = false; // Minimize DOM manipulation diff --git a/src/keyboardInteraction.ts b/src/keyboardInteraction.ts index 2c73149..69724c7 100644 --- a/src/keyboardInteraction.ts +++ b/src/keyboardInteraction.ts @@ -5,6 +5,7 @@ import { Tray } from "./tray"; import { getTrayFromId, toggleEditMode } from "./utils"; import { selected_trays, cutSelected, copySelected, deleteSelected } from "./hamburger"; import { openContextMenuKeyboard } from "./contextMenu"; +import { undo } from "./history"; export function handleKeyDown(tray: Tray, event: KeyboardEvent): void { @@ -93,6 +94,12 @@ export function handleKeyDown(tray: Tray, event: KeyboardEvent): void { pasteFromClipboardInto(tray); } break; + case "z": + if (event.ctrlKey) { + event.preventDefault(); + undo(); + } + break; case "m": if (event.ctrlKey){ event.preventDefault(); diff --git a/test/undo.test.js b/test/undo.test.js new file mode 100644 index 0000000..d737bc7 --- /dev/null +++ b/test/undo.test.js @@ -0,0 +1,87 @@ +const assert = require('assert'); +const { test } = require('node:test'); + +// minimal DOM stubs similar to contextMenu tests +const body = { + children: [], + appendChild(el){ this.children.push(el); el.parent = this; }, + removeChild(el){ this.children = this.children.filter(c=>c!==el); } +}; +function createElement(){ + return { + children: [], + style: {}, + dataset: {}, + classList:{ add(){}, remove(){} }, + appendChild(child){ child.parent=this; this.children.push(child); }, + querySelector(){ return null; }, + querySelectorAll(){ return []; }, + getBoundingClientRect(){ return { width:100, height:100 }; }, + addEventListener(){}, + removeEventListener(){}, + focus(){} + }; +} +global.document = { body, createElement, querySelector(){return null;}, addEventListener(){}, removeEventListener(){} }; +global.window = { addEventListener(){}, innerWidth:800, innerHeight:600 }; +global.location = { search: "" }; + +delete require.cache[require.resolve('../cjs/history.js')]; +const history = require('../cjs/history.js'); +const io = require('../cjs/io.js'); + +// stub io functions used by undo +io.renderRootTray = (tray) => { io._rendered = tray; }; +io.deserialize = (data) => JSON.parse(data); + +const state1 = JSON.stringify({ name: 'first' }); +const state2 = JSON.stringify({ name: 'second' }); + +history.recordState(state1); +history.recordState(state2); + +test('undo restores previous state', () => { + history.undo(); + assert.strictEqual(io._rendered.name, 'first'); +}); + +test('recordState captures root state automatically', () => { + // reset module state + delete require.cache[require.resolve('../cjs/history.js')]; + delete require.cache[require.resolve('../cjs/app.js')]; + delete require.cache[require.resolve('../cjs/utils.js')]; + delete require.cache[require.resolve('../cjs/io.js')]; + + const app = require('../cjs/app.js'); + const utils = require('../cjs/utils.js'); + const io = require('../cjs/io.js'); + const history = require('../cjs/history.js'); + + const rootEl = createElement(); + rootEl.className = 'tray'; + document.querySelector = () => rootEl; + utils.getRootElement = () => rootEl; + const tray = { name: 'root' }; + app.element2TrayMap.set(rootEl, tray); + io.serialize = () => 'root-serialized'; + + history.recordState(); + assert.strictEqual(history._getHistoryLength(), 1); +}); + + + +test('handleKeyDown ctrl+z calls undo', () => { + delete require.cache[require.resolve('../cjs/history.js')]; + delete require.cache[require.resolve('../cjs/keyboardInteraction.js')]; + + const history = require('../cjs/history.js'); + let called = false; + history.undo = () => { called = true; }; + + const ki = require('../cjs/keyboardInteraction.js'); + const tray = { isEditing: false, element: { focus(){} } }; + ki.handleKeyDown(tray, { key: 'z', ctrlKey: true, preventDefault(){}, stopPropagation(){} }); + assert.ok(called); +}); +