Magic Notes previously supported rich-text records without a paged drawing or PDF annotation workflow. Add the integrated PeopleLib Fabric and Quill editor with flowing text, pen/highlighter, object transforms, images, paper templates, PDF backgrounds with native text extraction, and raster PDF export. Saves are manual; undo/redo remains scoped to an annotation page or Quill mode. Analyze drafts, saved canvases and canvas-source todos with page images and text when the default model supports images, or an explicitly labelled text-only fallback. Keep successful saves when automatic analysis fails and prevent MCP plain-text replacement of canvas records. Move entry bodies and binary assets into local note files with schema 38 migration and verified readback. SQLite retains metadata, indexes, revisions, todos and comments. Backups must coordinate SQLite and the adjacent notes directory while writes are paused or the app is closed; no one-click backup action is included. Show a continuous record stream with independent collapsible record index and AI pane, real first-page thumbnails, and scroll-only record navigation. Open a blank text/canvas composer by default and reset it after creation. Protect unsaved drafts and establish normalized editor baselines after saving so clean records do not prompt again when leaving. Validation: latest recorded focused run passed 149 tests across five files; typecheck, lint and git diff --check passed. The prior user-reported full run passed 4,713 tests before later corrections and UI changes; it is not a full validation of this final snapshot. Tests were not rerun for this commit, as requested; independent systematic review follows the commit. Release note: Create paged notes with handwriting, flowing text and PDF annotations, browse records through thumbnails, and request AI comments with an explicit text-only fallback. Existing notes migrate to local file storage; backups now need both the database and notes directory.
257 lines
13 KiB
JavaScript
257 lines
13 KiB
JavaScript
// Run with: node tests/magic-canvas-wheel.electron.mjs
|
|
import assert from 'node:assert/strict';
|
|
import console from 'node:console';
|
|
import { spawn } from 'node:child_process';
|
|
import { copyFile, mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, resolve } from 'node:path';
|
|
import process from 'node:process';
|
|
import { clearTimeout, setTimeout } from 'node:timers';
|
|
import { fileURLToPath, URL } from 'node:url';
|
|
|
|
const root = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
|
|
if (!process.env.CANVAS_WHEEL_DRIVER) {
|
|
console.log('Starting canvas wheel Electron regression');
|
|
const { createServer } = await import('vite');
|
|
const electron = join(root, 'node_modules/electron/dist',
|
|
(await readFile(join(root, 'node_modules/electron/path.txt'), 'utf8')).trim());
|
|
const profile = await mkdtemp(join(process.env.CANVAS_WHEEL_TMP || tmpdir(), 'goodbuddy-canvas-wheel-'));
|
|
const server = await createServer({
|
|
configFile: false, root, cacheDir: join(profile, 'vite'),
|
|
server: { host: '127.0.0.1', port: 0, watch: null },
|
|
optimizeDeps: { noDiscovery: true, include: ['fabric', 'quill', 'jspdf'] },
|
|
plugins: [{
|
|
name: 'canvas-wheel-fixture',
|
|
configureServer(server) {
|
|
server.middlewares.use('/wheel.html', async (_request, response) => {
|
|
response.setHeader('Content-Type', 'text/html');
|
|
response.end(await server.transformIndexHtml('/wheel.html', `<!doctype html>
|
|
<html><head><meta charset="utf-8"><style>
|
|
/* Constrain only the wheel fixture to exercise both scroll axes. */
|
|
#host > .canvas-note-root { height: 500px; }
|
|
#host .canvas-note-viewport { flex: 1 1 0; }
|
|
</style></head><body>
|
|
<div id="outer" style="width:700px;height:650px;overflow:auto">
|
|
<div style="width:1600px;height:1800px">
|
|
<div id="host" class="magic-canvas-editor" style="width:540px;height:500px"></div>
|
|
</div>
|
|
</div>
|
|
<div id="layout" style="display:none;height:650px;overflow:auto"></div>
|
|
<script type="module">
|
|
import '/src/renderer/src/styles.css';
|
|
import '/node_modules/quill/dist/quill.snow.css';
|
|
import '/src/renderer/src/magic-canvas.css';
|
|
import { mountCanvasNote } from '/src/renderer/src/magic-canvas/canvas-note.mjs';
|
|
window.setupLayout = async (width, saved) => {
|
|
await window.editor?.destroy();
|
|
document.querySelector('#outer').style.display = 'none';
|
|
const layout = document.querySelector('#layout');
|
|
layout.style.display = 'block';
|
|
layout.style.width = width + 'px';
|
|
layout.innerHTML = saved
|
|
? '<div class="magic-note-entry-stream"><article class="magic-note-entry"><div class="magic-canvas-content"><button class="magic-canvas-open">Edit</button><div class="magic-canvas-editor"></div></div></article></div>'
|
|
: '<div class="magic-note-composer"><div class="magic-canvas-editor"></div></div>';
|
|
window.editor = mountCanvasNote(layout.querySelector('.magic-canvas-editor'), {
|
|
version: 2, pages: [
|
|
{ id: 'portrait', width: 794, height: 1123, objects: [] },
|
|
{ id: 'landscape', width: 1123, height: 794, objects: [] }
|
|
]
|
|
}, { disabled: saved });
|
|
await window.editor.flush();
|
|
window.viewport = layout.querySelector('.canvas-note-viewport');
|
|
window.geometry = () => {
|
|
const page = layout.querySelector('.canvas-note-page').getBoundingClientRect();
|
|
const view = viewport.getBoundingClientRect();
|
|
const root = layout.querySelector('.canvas-note-root').getBoundingClientRect();
|
|
return { pageHeight: page.height, pageBottom: page.bottom, viewBottom: view.bottom,
|
|
rootHeight: root.height, maxY: viewport.scrollHeight - viewport.clientHeight,
|
|
maxX: viewport.scrollWidth - viewport.clientWidth,
|
|
outerMaxX: layout.scrollWidth - layout.clientWidth,
|
|
outerMaxY: layout.scrollHeight - layout.clientHeight };
|
|
};
|
|
};
|
|
window.setup = async (mode, small = false) => {
|
|
await window.editor?.destroy();
|
|
document.querySelector('#outer').scrollTo(0, 0);
|
|
window.changes = 0;
|
|
window.editor = mountCanvasNote(document.querySelector('#host'), {
|
|
version: 2,
|
|
pages: [{ id: 'wheel-page', width: small ? 300 : 1200, height: small ? 200 : 1400, objects: [] }],
|
|
flow: { version: 1, ops: [{ insert: 'Wheel regression body text\\n' }] }
|
|
}, { disabled: mode === 'readonly', onChange: () => window.changes++ });
|
|
await window.editor.flush();
|
|
if (mode !== 'readonly') document.querySelector('[data-tool="' + (mode === 'flow' ? 'flow-text' : 'pen') + '"]').click();
|
|
window.viewport = document.querySelector('.canvas-note-viewport');
|
|
window.beforeContent = JSON.stringify(window.editor.content());
|
|
window.wheels = [];
|
|
viewport.addEventListener('wheel', e => window.wheels.push({
|
|
x: e.deltaX, y: e.deltaY, shift: e.shiftKey, ctrl: e.ctrlKey,
|
|
prevented: e.defaultPrevented, trusted: e.isTrusted, target: e.target.className
|
|
}));
|
|
viewport.scrollTo(0, 0);
|
|
};
|
|
window.snapshot = () => ({ x: viewport.scrollLeft, y: viewport.scrollTop,
|
|
maxX: viewport.scrollWidth - viewport.clientWidth, maxY: viewport.scrollHeight - viewport.clientHeight,
|
|
outerX: document.querySelector('#outer').scrollLeft, outerY: document.querySelector('#outer').scrollTop,
|
|
wheels: window.wheels });
|
|
</script></body></html>`));
|
|
});
|
|
}
|
|
}]
|
|
});
|
|
try {
|
|
await server.listen();
|
|
console.log('Fixture ready', server.resolvedUrls.local[0]);
|
|
const env = { ...process.env };
|
|
delete env.ELECTRON_RUN_AS_NODE;
|
|
env.CANVAS_WHEEL_DRIVER = import.meta.url;
|
|
env.CANVAS_WHEEL_PROFILE = profile;
|
|
const bootstrap = join(profile, 'canvas-wheel-electron.cjs');
|
|
await copyFile(join(root, 'tests/support/canvas-wheel-electron.cjs'), bootstrap);
|
|
const child = spawn(electron, [bootstrap, server.resolvedUrls.local[0] + 'wheel.html'], { env, stdio: 'inherit' });
|
|
const timeout = setTimeout(() => child.kill(), 90000);
|
|
process.exitCode = await new Promise((done, reject) => {
|
|
child.once('error', reject);
|
|
child.once('exit', (code) => done(code ?? 1));
|
|
});
|
|
clearTimeout(timeout);
|
|
console.log('Electron exit', process.exitCode);
|
|
} finally {
|
|
await server.close();
|
|
await rm(profile, { recursive: true, force: true });
|
|
}
|
|
} else {
|
|
const { app, BrowserWindow } = await import('electron');
|
|
await app.whenReady();
|
|
const win = new BrowserWindow({ width: 1000, height: 800, show: true,
|
|
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } });
|
|
const run = (code) => win.webContents.executeJavaScript(code);
|
|
win.webContents.on('console-message', (event) => {
|
|
if (event.level === 'error') console.error('Renderer:', event.message);
|
|
});
|
|
const failures = [];
|
|
let passed = 0;
|
|
async function check(name, action) {
|
|
try { await action(); passed++; console.log('PASS', name); }
|
|
catch (error) { failures.push(name); console.error('FAIL', name, error.message); }
|
|
}
|
|
async function wheel(deltaX, deltaY, modifiers = []) {
|
|
const point = await run(`(() => {
|
|
const r = viewport.getBoundingClientRect();
|
|
return { x: Math.round(r.x + 160), y: Math.round(r.y + 110) };
|
|
})()`);
|
|
win.webContents.sendInputEvent({ type: 'mouseMove', ...point });
|
|
win.webContents.sendInputEvent({ type: 'mouseWheel', ...point, deltaX: -deltaX, deltaY: -deltaY,
|
|
canScroll: true, modifiers });
|
|
await sleep(220);
|
|
return run('snapshot()');
|
|
}
|
|
try {
|
|
await win.loadURL(process.argv[2]);
|
|
for (let i = 0; i < 200 && !await run('typeof setup === "function"'); i++) await sleep(50);
|
|
for (const mode of ['drawing', 'flow', 'readonly']) {
|
|
await run(`setup('${mode}')`);
|
|
await check(`${mode}: native deltaX forward/backward`, async () => {
|
|
const right = await wheel(100, 0);
|
|
assert.ok(right.x > 0 && right.y === 0, JSON.stringify(right));
|
|
assert.ok(Math.abs(right.x - 100) < 2, 'Wheel must not scroll twice');
|
|
const left = await wheel(-60, 0);
|
|
assert.ok(left.x < right.x && left.y === 0, JSON.stringify(left));
|
|
assert.ok(left.wheels.every(e => e.trusted));
|
|
});
|
|
await run('viewport.scrollTo(0, 0)');
|
|
await sleep(100);
|
|
await check(`${mode}: Shift+deltaY horizontal only`, async () => {
|
|
const state = await wheel(0, 100, ['shift']);
|
|
assert.ok(state.x > 0 && state.y === 0, JSON.stringify(state));
|
|
});
|
|
await check(`${mode}: Shift with native deltaX retains its axis`, async () => {
|
|
const before = await run('snapshot()');
|
|
const state = await wheel(-50, 0, ['shift']);
|
|
assert.ok(state.x < before.x && state.y === 0, JSON.stringify(state));
|
|
});
|
|
await run('viewport.scrollTo(0, 0)');
|
|
await sleep(100);
|
|
await check(`${mode}: vertical and diagonal trackpad deltas`, async () => {
|
|
const vertical = await wheel(0, 80);
|
|
assert.ok(vertical.y > 0 && vertical.x === 0, JSON.stringify(vertical));
|
|
const diagonal = await wheel(70, 60);
|
|
assert.ok(diagonal.x > 0 && diagonal.y > vertical.y, JSON.stringify(diagonal));
|
|
});
|
|
await check(`${mode}: Ctrl wheel is not consumed`, async () => {
|
|
const before = await run('snapshot()');
|
|
const state = await wheel(0, 60, ['control']);
|
|
assert.equal(state.wheels.at(-1).ctrl, true);
|
|
assert.equal(state.wheels.at(-1).prevented, false);
|
|
assert.equal(state.x, before.x);
|
|
assert.equal(state.y, before.y);
|
|
});
|
|
await check(`${mode}: right edge chains to outer page`, async () => {
|
|
await run('viewport.scrollLeft = viewport.scrollWidth');
|
|
await sleep(100);
|
|
const state = await wheel(100, 0);
|
|
assert.ok(state.outerX > 0, JSON.stringify(state));
|
|
assert.equal(state.wheels.at(-1).prevented, false);
|
|
await run('document.querySelector("#outer").scrollTo(0, 0)');
|
|
await sleep(100);
|
|
});
|
|
await check(`${mode}: bottom edge chains to outer page`, async () => {
|
|
await run('viewport.scrollTop = viewport.scrollHeight');
|
|
await sleep(100);
|
|
const state = await wheel(0, 100);
|
|
assert.ok(state.outerY > 0, JSON.stringify(state));
|
|
});
|
|
await check(`${mode}: scrolling does not edit content`, async () => {
|
|
assert.equal(await run('JSON.stringify(editor.content()) === beforeContent && changes === 0'), true);
|
|
});
|
|
await run(`setup('${mode}', true)`);
|
|
await check(`${mode}: no horizontal range leaves horizontal page scrolling available`, async () => {
|
|
const state = await wheel(100, 0);
|
|
assert.equal(state.maxX, 0);
|
|
assert.ok(state.outerX > 0, JSON.stringify(state));
|
|
assert.equal(state.wheels.at(-1).prevented, false);
|
|
await run('document.querySelector("#outer").scrollTo(0, 0)');
|
|
await sleep(100);
|
|
});
|
|
await check(`${mode}: no scroll range leaves page scrolling available`, async () => {
|
|
const state = await wheel(0, 100);
|
|
assert.equal(state.maxY, 0);
|
|
assert.ok(state.outerY > 0, JSON.stringify(state));
|
|
assert.equal(state.wheels.at(-1).prevented, false);
|
|
});
|
|
}
|
|
for (const saved of [false, true]) {
|
|
for (const width of [900, 360]) {
|
|
await check(`${saved ? 'saved' : 'editor'}: full current page at ${width}px`, async () => {
|
|
await run(`setupLayout(${width}, ${saved})`);
|
|
const portrait = await run('geometry()');
|
|
assert.ok(Math.abs(portrait.pageHeight - 1123) < 1);
|
|
assert.equal(portrait.maxY, 0, JSON.stringify(portrait));
|
|
assert.equal(portrait.outerMaxX, 0, JSON.stringify(portrait));
|
|
assert.ok(portrait.outerMaxY > 0 && portrait.rootHeight > 1123);
|
|
assert.ok(portrait.pageBottom <= portrait.viewBottom);
|
|
if (width === 360) assert.ok(portrait.maxX > 0);
|
|
await wheel(0, 100);
|
|
assert.ok(await run('document.querySelector("#layout").scrollTop > 0'));
|
|
await run('document.querySelector("#layout").scrollTop = 0; document.querySelector("#layout [title=下一页]").click(); editor.flush()');
|
|
const landscape = await run('geometry()');
|
|
assert.ok(Math.abs(landscape.pageHeight - 794) < 1);
|
|
assert.equal(landscape.maxY, 0, JSON.stringify(landscape));
|
|
assert.equal(landscape.outerMaxX, 0, JSON.stringify(landscape));
|
|
assert.ok(landscape.rootHeight < portrait.rootHeight);
|
|
assert.equal(await run('document.querySelectorAll("#layout .canvas-note-page").length'), 1);
|
|
});
|
|
}
|
|
}
|
|
console.log(JSON.stringify({ electron: process.versions.electron, passed, failures }));
|
|
win.destroy();
|
|
app.exit(failures.length ? 1 : 0);
|
|
} catch (error) {
|
|
console.error(error);
|
|
win.destroy();
|
|
app.exit(1);
|
|
}
|
|
}
|