Keep paragraph formatting and checklist todos when removing canvas pages, and preserve accepted undo/redo history after oversized input is rejected. Merge restored queued text into the latest conversation draft and retain image delivery when OCR returns image references and generated warnings only. Deliver session MCP servers to bridged remote Continue Execute while keeping Ask read-only and preserving local model-profile isolation. Add regression coverage and update feature documentation. Correct stale conversation-limit and forced-color assertions and the measured Electron layout test deadline. Validation: 4,826 tests passed, 67 skipped; full typecheck and lint passed. Current-source Linux x64 Execute/Ask/Execute MCP delivery passed, using 10 real text-provider requests and no image-provider requests. Image generation itself was not exercised. Staged whitespace checks passed before commit. Release note: 修复恢复队列时覆盖新输入、画布删页丢失清单格式及超限输入清空撤销历史的问题;图片 OCR 无正文时保留原图发送方式,远程 Continue Execute 正确保留会话 MCP 工具。
136 lines
6.9 KiB
TypeScript
136 lines
6.9 KiB
TypeScript
import Quill from 'quill'
|
|
import { mountFlowText } from '../../src/renderer/src/magic-canvas/canvas-flow.mjs'
|
|
import { createRoot } from 'react-dom/client'
|
|
import { mountCanvasNote } from '../../src/renderer/src/magic-canvas/canvas-note.mjs'
|
|
import { MagicCanvasThumbnail } from '../../src/renderer/src/MagicCanvasThumbnail'
|
|
import { fromCoreContent, toCoreContent } from '../../src/renderer/src/magic-canvas/model'
|
|
import '../../src/renderer/src/i18n'
|
|
import 'quill/dist/quill.snow.css'
|
|
import '../../src/renderer/src/styles.css'
|
|
import '../../src/renderer/src/magic-canvas.css'
|
|
|
|
async function canvasRegression() {
|
|
const errors: string[] = []
|
|
const host = document.createElement('div')
|
|
host.className = 'magic-canvas-editor'
|
|
document.body.append(host)
|
|
let editor = mountCanvasNote(host, { version: 2, pages: [{
|
|
id: 'first', width: 794, height: 1123,
|
|
background: { type: 'template', template: 'blank' },
|
|
objects: [{ type: 'Path', canvasKind: 'pen', path: [['M', 20, 20], ['L', 150, 80]],
|
|
stroke: '#ff0000', strokeWidth: 4, fill: null }]
|
|
}], flow: { version: 1, ops: [{ insert: 'Flow line\n'.repeat(50) }] } }, { onError: (error) => errors.push(error) })
|
|
const initial = await editor.flush()
|
|
const ink = async () => {
|
|
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
|
const canvas = host.querySelector<HTMLCanvasElement>('.lower-canvas')!
|
|
const pixels = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height).data
|
|
let count = 0
|
|
for (let i = 3; i < pixels.length; i += 4) if (pixels[i]) count++
|
|
return count
|
|
}
|
|
const visibleInkBefore = await ink()
|
|
host.querySelector<HTMLButtonElement>('[data-tool="flow-text"]')!.click()
|
|
host.querySelector<HTMLButtonElement>('.canvas-note-next-page')!.click()
|
|
await editor.flush()
|
|
const beforeShrink = host.querySelector('.canvas-note-page-counter')!.textContent
|
|
const quill = Quill.find(host.querySelector('.canvas-flow-quill')!) as Quill
|
|
quill.setText('Short flow\n', 'user')
|
|
const saved = JSON.parse(JSON.stringify(await editor.flush()))
|
|
const afterShrink = host.querySelector('.canvas-note-page-counter')!.textContent
|
|
const visibleInkAfter = await ink()
|
|
const flowTransform = host.querySelector<HTMLElement>('.canvas-flow-surface')!.style.transform
|
|
await editor.destroy()
|
|
editor = mountCanvasNote(host, saved, { onError: (error) => errors.push(error) })
|
|
const reopened = await editor.flush()
|
|
const reopenedInk = await ink()
|
|
const styles = (surface: Element) => {
|
|
const style = getComputedStyle(surface)
|
|
return { fontSize: style.fontSize, lineHeight: style.lineHeight, columnFill: style.columnFill, padding: style.padding }
|
|
}
|
|
const editorStyles = styles(host.querySelector('.canvas-flow-surface')!)
|
|
const [expectedThumbnail] = await editor.capturePages({ firstPageOnly: true, thumbnailWidth: 240 })
|
|
await editor.destroy()
|
|
const thumbnailHost = document.createElement('div')
|
|
document.body.append(thumbnailHost)
|
|
let thumbnailStyles: ReturnType<typeof styles> | undefined
|
|
const observer = new MutationObserver(() => {
|
|
const surface = document.querySelector('.magic-note-thumbnail-renderer .canvas-flow-surface')
|
|
if (surface) thumbnailStyles = styles(surface)
|
|
})
|
|
observer.observe(document.body, { childList: true, subtree: true })
|
|
const root = createRoot(thumbnailHost)
|
|
root.render(<MagicCanvasThumbnail content={fromCoreContent(saved, [])} />)
|
|
for (let i = 0; i < 400 && !thumbnailHost.querySelector('img'); i++) await new Promise(resolve => setTimeout(resolve, 25))
|
|
const thumbnailMatchesEditor = thumbnailHost.querySelector('img')?.src === expectedThumbnail.dataUrl
|
|
root.unmount()
|
|
observer.disconnect()
|
|
thumbnailHost.remove()
|
|
const imageImports = []
|
|
const pixel = document.createElement('canvas')
|
|
pixel.width = pixel.height = 1
|
|
const png = await new Promise<Blob>((resolve) => pixel.toBlob(blob => resolve(blob!), 'image/png'))
|
|
for (const size of [2 * 1024 * 1024 + 1, 20 * 1024 * 1024, 20 * 1024 * 1024 + 1]) {
|
|
const importErrors: string[] = []
|
|
editor = mountCanvasNote(host, undefined, { onError: error => importErrors.push(error) })
|
|
await editor.flush()
|
|
const input = host.querySelector<HTMLInputElement>('.canvas-note-image-input')!
|
|
const transfer = new DataTransfer()
|
|
transfer.items.add(new File([png, new Uint8Array(size - png.size)], 'boundary.png', { type: 'image/png' }))
|
|
input.files = transfer.files
|
|
input.dispatchEvent(new Event('change'))
|
|
const imported = await editor.flush()
|
|
const persisted = fromCoreContent(imported, [])
|
|
await editor.destroy()
|
|
editor = mountCanvasNote(host, toCoreContent(persisted), { onError: error => importErrors.push(error) })
|
|
const restored = await editor.flush()
|
|
imageImports.push({ size, imported: imported.pages[0].objects.length,
|
|
restored: restored.pages[0].objects.length, errors: importErrors })
|
|
await editor.destroy()
|
|
}
|
|
host.remove()
|
|
const layer = document.createElement('div')
|
|
const toolbar = document.createElement('div')
|
|
document.body.append(layer, toolbar)
|
|
let flow = mountFlowText(layer, toolbar, { version: 1, ops: [
|
|
{ insert: 'Task' }, { insert: '\n', attributes: { list: 'unchecked' } }, { insert: '\n' }
|
|
] })
|
|
let flowQuill = Quill.find(layer.firstElementChild!) as Quill
|
|
const checklistBefore = flow.content()
|
|
flowQuill.setSelection(5, 0, 'api')
|
|
flow.insertPageBreak('page-2')
|
|
flow.removePageBreak('page-2')
|
|
const checklistAfter = await flow.flush()
|
|
flow.destroy()
|
|
const overflowErrors: string[] = []
|
|
flow = mountFlowText(layer, toolbar, { version: 1, ops: [{ insert: 'x'.repeat(19995) + '\n' }] },
|
|
{ onError: (error: string) => overflowErrors.push(error) })
|
|
flowQuill = Quill.find(layer.firstElementChild!) as Quill
|
|
flowQuill.setSelection(19995, 0, 'api')
|
|
flowQuill.insertText(19995, 'HELLO', 'user')
|
|
flowQuill.setSelection(20000, 0, 'api')
|
|
flowQuill.history.cutoff()
|
|
flowQuill.insertText(20000, '!', 'user')
|
|
const selectionAfterRejection = flowQuill.getSelection()
|
|
const acceptedLength = flow.text().length
|
|
flow.undo()
|
|
const undoneLength = flow.text().length
|
|
flowQuill.setSelection(3, 4, 'api')
|
|
flowQuill.insertText(3, 'REJECTED', 'user')
|
|
const selectionWithRedo = flowQuill.getSelection()
|
|
const retainedRedo = flow.canRedo()
|
|
flow.redo()
|
|
const redoneLength = flow.text().length
|
|
flow.destroy()
|
|
layer.remove()
|
|
toolbar.remove()
|
|
return { errors, initialPages: initial.pages.length, initialObjects: initial.pages[0].objects,
|
|
beforeShrink, afterShrink, savedPages: saved.pages.length, savedObjects: saved.pages[0].objects,
|
|
reopenedObjects: reopened.pages[0].objects, visibleInkBefore, visibleInkAfter, reopenedInk, flowTransform,
|
|
editorStyles, thumbnailStyles, thumbnailMatchesEditor, imageImports,
|
|
checklistBefore, checklistAfter, overflowErrors, selectionAfterRejection, selectionWithRedo,
|
|
acceptedLength, undoneLength, retainedRedo, redoneLength }
|
|
}
|
|
|
|
Object.assign(window, { canvasRegression })
|