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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
- GFM 表格:输入完整表头与分隔行后按 Enter、粘贴 Markdown 表格,或输入 `|3x3| ` 建立三列三行表格。顶部“表格与行列操作”提供增删行列、列对齐、删表和退出;表格内 Tab / Shift+Tab 导航,Ctrl+Enter 退出。
- Markdown 及富文本粘贴、统一撤销/重做;格式按钮和快捷键调用同一编辑内核命令。

本轮块拖动、上下文菜单、完整斜杠菜单与紧凑查找栏的调整、验证范围和效果图见[编辑器交互复查](docs/editor-interaction-review.md)。

正文工具已补齐:`/` 双语过滤与键盘选择、块拖动/移动/复制/多选、大纲与标题折叠、`[toc]` 实时目录、查找替换与字数统计、图片/附件导入与可恢复清理、图片显示宽度、代码语言/高亮/复制、离线 Mermaid、KaTeX 行内/块公式、脚注、英文拼写检查,以及独立 HTML / PDF 导出与 PDF 打印预览。工具入口为格式栏“正文编辑与导出”;Ctrl+H 打开替换。公式可通过工具菜单或 `/` 插入,双击公式编辑。图片/表格宽度保存在应用偏好中,不扩展 Markdown 语法。折叠仅影响编辑视图,导出会展开全文。附件清理先等待正文保存,再检查磁盘引用,移入专用回收区,可恢复。

`web/editor/editor.js` 负责上游插件配置与命令适配;`qml/SourceEditor.qml` 通过 WebChannel 传递主题、焦点与工作区位置;`src/EditorSessionWeb.cpp` 缓存已提交 Markdown,并按文档代次和递增序号拒绝过期消息。现有 `EditorSession::execute/link/undo/redo` 和 controller 存储接口保留。切换笔记、切库和关闭前先向编辑区取得最新已提交状态;自动保存回包不重建编辑器或撤销栈。
Expand Down
57 changes: 57 additions & 0 deletions docs/editor-interaction-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# 编辑器交互调整(2026-11-11)

目标是让正文保持安静,辅助操作在需要时可发现,并让操作反馈与实际结果一致。

## 本次调整

- 块拖动的插入线标记实际块边界,预览与松开使用同一个落点计算;折叠内容不成为不可见落点。靠近编辑区边缘停住鼠标时持续滚动,Escape、越界松开和失去编辑条件会取消拖动。
- 右键菜单按本次点击的位置确定目标,不沿用上次悬停的块。不能上移、下移、转换或粘贴时,对应操作禁用;代码、公式和图片操作按目标内容显示。
- `/` 菜单保留紧凑高度并提供完整 16 项插入入口;支持方向键访问后面的项目。取消只影响当前输入位置和查询;无匹配时保留普通编辑按键。列表内的 `/` 作为正文输入,退出列表后恢复插入菜单。
- 正文查找默认只显示一行,需要时展开替换。导航和关闭采用紧凑图标,Escape 关闭查找;重新打开或切换笔记后恢复已有查询。
- 任务框使用固定尺寸的边框与独立勾选标记,避免字体字符导致勾选前后尺寸、位置变化。

## 效果图

以下均为 VM 中实际应用窗口的截图,笔记内容为合成数据。

![正文与紧凑工具栏](../screenshots/editor-interaction-2026-11-11/body.png)

正文查找默认一行;替换入口在需要时展开。输入 `01` 并点击下一个匹配后,计数为 `1/1`,正文选中对应文字。

![单行正文查找与匹配导航](../screenshots/editor-interaction-2026-11-11/find-query.png)

展开替换后增加一行,正文区域随之下移;空查询时替换按钮禁用。

![按需展开替换](../screenshots/editor-interaction-2026-11-11/replace-native.png)

输入 `01` 后,正文中的单处匹配高亮,匹配导航与替换动作启用。

![正文查询与匹配高亮](../screenshots/editor-interaction-2026-11-11/replace-query.png)

块菜单中,首块的“上移块”和尚无块剪贴板内容时的“粘贴块”禁用;普通段落不提供代码语言等专用操作。

![按当前块启用操作](../screenshots/editor-interaction-2026-11-11/block-menu.png)

复制后重开菜单,“粘贴块”随之启用。

![复制后的块菜单](../screenshots/editor-interaction-2026-11-11/copy-menu-after.png)

## 验证

构建、测试、原生交互和截图均限定在 `kos-vm-dev`;使用独立设置和可丢弃的合成笔记。

网页 bundle 已在 VM 重新构建并回传,SHA-256 为 `b76af6b3adb816ccd930cdeb78b50c2b0015cb6c09e7878d07d1410b2a0af3a1`。块手柄状态回归和六项真实编辑器集成检查已通过,覆盖右键目标、落点预览与撤销、折叠落点、16 项斜杠菜单键盘访问、取消状态与无结果查询。集成检查中的拖动通过工具动作验证;原始输出、失败记录及 VM 源码指纹见[验证记录](../output/editor-interaction-2026-11-11/README.md)。

原生操作已确认:第四段拖到第二段之前、再连续拖回末尾,移动后手柄仍跟随原块;在编辑区外松手不改变内容。长文底部保持鼠标不动时,滚动位置连续从 `176` 增至 `432`、`624`。Escape 独立取消检查通过,但连续移动后再次拖动的取消出现失败,因此不计为全场景通过;最终源码让拖动手柄取得键盘焦点并直接处理 Escape,保持取消后松手不提交的门控,此修复待运行复验。

原生查找已完成打开、输入 `01`、导航到 `1/1` 和展开/收起替换,截图均经查看。复制块后立即重开同一手柄菜单,“粘贴块”启用。

斜杠菜单的编辑器状态可以选到第 16 项 `toc`,但 VM 的原生弹层仍停留在顶部并高亮“正文”。最终源码改为在打开菜单、条目增删完成后同步最新选择,并将该行滚入视野;该修复已完成源码复核,尚未运行。下图记录的是修复前失败,不能作为末项可见的通过证据。

![修复前:编辑器索引与弹层不同步](../screenshots/editor-interaction-2026-11-11/slash-settled.png)

Advanced 原始回归因测试断言将整个文档的 `/\n` 与当前段落的 `/` 比较而失败;断言已修正。VM 临时副本增加 CDP 请求超时后顺序重跑,前九组通过,包括三种列表、任务框几何、块操作撤销、折叠/目录、查找替换、公式、脚注及 Mermaid。随后剪贴板图片导入等待超时,未执行后续表格宽度和最终元数据/schema 检查,因此完整套件未通过。原始失败、最终重跑输出及使用的临时驱动均保留。

最新 `Main.qml`、`SourceEditor.qml` 的跨文档查询恢复,以及 `EditorTools.qml` 的连续拖动 Escape 与斜杠菜单同步修复,均已完成源码复核。自动审批阻止向 VM 传入最新源码,因此这些修复没有运行验收。`source-hashes.json` 记录的是实际运行快照,不能当作最终提交的完整指纹。

PR 的三次 [Flatpak CI](https://github.com/NextKde/NkNotes/actions/runs/43035039939) 因 Ubuntu 软件源 DNS 解析失败,在依赖准备阶段满 60 分钟超时,未进入应用编译;日志和关联运行已保存在验证记录中。PR 暂不合入 `main`,待上述原生修复复验及最终源码构建完成。
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Manual VM-only integration test. This replaces the active disposable fixture.
import assert from 'node:assert/strict';
import fs from 'node:fs';
const endpoint=process.env.NEXTNOTES_CDP_URL||'http://127.0.0.1:9226';
const fixture=process.env.NEXTNOTES_TEST_FILE;
assert.ok(fixture,'Provide NEXTNOTES_TEST_FILE for the disposable note open in the VM.');
const target=(await (await fetch(endpoint+'/json/list')).json()).find(x=>x.url==='qrc:/editor/index.html');
const ws=new WebSocket(target.webSocketDebuggerUrl);await new Promise((resolve,reject)=>{ws.onopen=resolve;ws.onerror=reject;});
let sequence=0;const requests=new Map(),results=[];
ws.onmessage=event=>{const response=JSON.parse(event.data),request=requests.get(response.id);if(request){requests.delete(response.id);response.error?request.reject(response.error):request.resolve(response.result);}};
const call=(method,params={})=>new Promise((resolve,reject)=>{const id=++sequence;const timer=setTimeout(()=>{requests.delete(id);reject(Error('CDP timeout '+method+' '+JSON.stringify(params)))},8000);requests.set(id,{resolve:value=>{clearTimeout(timer);resolve(value)},reject:error=>{clearTimeout(timer);reject(error)}});ws.send(JSON.stringify({id,method,params}));});
const run=async expression=>{const response=await call('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(response.exceptionDetails)throw Error(JSON.stringify(response.exceptionDetails));return response.result.value;};
const wait=ms=>new Promise(resolve=>setTimeout(resolve,ms));
async function until(fn){const deadline=Date.now()+10000;while(Date.now()<deadline){if(await fn())return;await wait(50);}throw Error('Timed out waiting for editor state');}
const snapshot=()=>run('nextnotes.snapshot()');
const doc=async()=> (await snapshot()).doc;
const action=(name,args={})=>run(`nextnotes.tools(${JSON.stringify(name)},${JSON.stringify(args)})`);
const command=name=>run(`(()=>{nextnotes.command(${JSON.stringify(name)});return true})()`);
const key=async(key,code,vk,modifiers=0,text)=>{await call('Input.dispatchKeyEvent',{type:'keyDown',key,code,windowsVirtualKeyCode:vk,modifiers,text});await call('Input.dispatchKeyEvent',{type:'keyUp',key,code,windowsVirtualKeyCode:vk,modifiers});await wait(30);};
const text=value=>call('Input.insertText',{text:value});
await until(()=>run('!!window.nextnotes'));
const active=await snapshot();assert.equal(decodeURIComponent(new URL(active.baseUrl).pathname),fixture);assert.ok(fs.readFileSync(fixture,'utf8').startsWith('---\ncustom: untouched\n---\n'));
async function load(source){await run(`nextnotes.load({epoch:nextnotes.snapshot().epoch,baseUrl:nextnotes.snapshot().baseUrl,source:${JSON.stringify(source)}})`);await until(()=>run('nextnotes.view().editable'));await wait(100);}
const plain=()=>run(`nextnotes.view().state.doc.textBetween(0,nextnotes.view().state.doc.content.size,${JSON.stringify('\n')})`);
const position=(type,value)=>run(`(()=>{let result;nextnotes.view().state.doc.descendants((node,pos)=>{if(result===undefined&&node.type.name===${JSON.stringify(type)}&&node.textContent===${JSON.stringify(value)})result=pos;});return result;})()`);
const ok=name=>{results.push({name,passed:true});console.log(name);};
try{
await load('');await run('nextnotes.view().focus()');await text('/');await text('h2');await wait(120);
assert.ok((await run('nextnotes.toolsState().slash.items')).some(x=>x.command==='heading2'));
await key('Enter','Enter',13);assert.equal((await doc()).content[0].type,'heading');assert.equal((await doc()).content[0].attrs.level,2);
await command('undo');assert.equal(await plain(),'/h2');await command('redo');await text('Slash title');ok('slash_filter_keyboard_conversion_single_undo');
for(const kind of ['bullet','ordered','task']){
await load('');await run('nextnotes.view().focus()');await text('/');await wait(120);
const entries=await run('nextnotes.toolsState().slash.items');
await action('slashChoose',{index:entries.findIndex(entry=>entry.command===kind)});await wait(120);
assert.equal(await run('document.querySelectorAll("li .editor-empty-line").length'),0,'list items must not advertise slash insertion');
await text('/');await wait(120);
assert.equal(await run('nextnotes.toolsState().slash'),null,'slash inside '+kind+' is literal text');
assert.equal(await run('nextnotes.view().state.selection.$from.parent.textContent'),'/');
await key('Backspace','Backspace',8);await key('Enter','Enter',13);await text('/');await wait(120);
assert.ok(await run('nextnotes.toolsState().slash'),'slash insertion returns after leaving '+kind);
}
ok('slash_lists_literal_input_and_exit');
await load('- [ ] Task\n');await run('nextnotes.view().focus()');
const taskBox=()=>run(`(()=>{const box=getComputedStyle(document.querySelector('li[data-checked]'),'::before');return {width:box.width,height:box.height,left:box.left,top:box.top,border:box.borderWidth};})()`);
const uncheckedBox=await taskBox();assert.ok(parseFloat(uncheckedBox.width)>0);
await command('check');assert.equal(await run('document.querySelector("li[data-checked]").dataset.checked'),'true');
assert.deepEqual(await taskBox(),uncheckedBox,'checking a task must not resize or move its box');
await command('check');assert.equal(await run('document.querySelector("li[data-checked]").dataset.checked'),'false');
assert.deepEqual(await taskBox(),uncheckedBox);ok('task_checkbox_stable_geometry');
await load('A\n\nB\n\nC\n');await action('duplicateBlock',{pos:3});assert.equal(await plain(),'A\nB\nB\nC');await command('undo');assert.equal(await plain(),'A\nB\nC');
await action('moveBlock',{pos:3,direction:'up'});assert.equal(await plain(),'B\nA\nC');await command('undo');
await action('moveBlock',{pos:3,direction:'down'});assert.equal(await plain(),'A\nC\nB');await command('undo');
await action('selectBlock',{pos:0});await action('selectBlock',{pos:6,extend:true});await action('duplicateBlock',{pos:3,selection:true});assert.equal(await plain(),'A\nB\nC\nA\nB\nC');
await command('undo');await action('deleteBlock',{pos:3});assert.equal(await plain(),'A\nC');await command('undo');ok('block_duplicate_move_multiselect_delete_undo');
await load('# Main\n\nIntro\n\n## One\n\none\n\n## Two\n\ntwo\n');const one=await position('heading','One'),source=(await snapshot()).source;
await action('toggleFold',{pos:one});assert.ok(await run(`Array.from(document.querySelectorAll('.folded-content')).some(node=>getComputedStyle(node).display==='none')`));assert.equal((await snapshot()).source,source);
await action('jump',{pos:one});assert.equal(await run('document.querySelectorAll(".folded-content").length'),0);
await key('End','End',35,2);await key('Enter','Enter',13);await action('insertToc');await wait(100);assert.ok((await snapshot()).source.includes('[toc]'));assert.equal(await run('document.querySelectorAll(".editor-toc a").length'),3);
await run(`(()=>{const v=nextnotes.view();v.dispatch(v.state.tr.insertText('Changed ',1));})()`);await wait(100);assert.match(await run('document.querySelector(".editor-toc a").textContent'),/Changed Main/);const tocSource=(await snapshot()).source;await load(tocSource);assert.equal((await doc()).content.filter(n=>n.type==='toc').length,1);await load('\\[toc]\n');assert.equal((await doc()).content[0].type,'paragraph');ok('outline_folding_readonly_view_live_toc');
await load('Alpha **alpha** ALPHA\n\nalpha\n');await action('search',{query:'alpha',caseSensitive:false});await wait(100);assert.equal((await run('nextnotes.toolsState().search')).total,4);
await action('replaceAll',{replacement:'Beta'});assert.equal(await plain(),'Beta Beta Beta\nBeta');assert.ok(JSON.stringify(await doc()).includes('"strong"'));await command('undo');assert.equal(await plain(),'Alpha alpha ALPHA\nalpha');
await action('search',{query:'alpha',caseSensitive:true});await wait(80);assert.equal((await run('nextnotes.toolsState().search')).total,2);await action('findNext');await action('replace',{replacement:'one'});assert.match(await plain(),/one/);
await load('a.b a+b [x] İZ\n');await action('search',{query:'a.b'});await wait(80);assert.equal((await run('nextnotes.toolsState().search')).total,1);ok('literal_case_search_replace_all_preserves_marks_undo');
await load('$E=mc^2$\n\n$$\n\\frac{a}{b}\n$$\n');assert.equal((await doc()).content[0].content[0].type,'math_inline');assert.equal((await doc()).content[1].type,'math_block');assert.equal(await run('document.querySelectorAll(".katex").length'),2);
await action('setMath',{pos:await position('math_block','\\frac{a}{b}'),display:true,source:'\\sum_{i=1}^n i'});assert.match((await snapshot()).source,/\\sum/);await command('undo');assert.match((await snapshot()).source,/\\frac/);ok('math_parse_render_edit_undo');
await load('Note\n');await key('End','End',35,2);await action('footnote',{text:'Footnote text'});let content=await doc();assert.ok(JSON.stringify(content).includes('"footnote_reference"'));assert.ok(content.content.some(x=>x.type==='footnote_definition'));assert.match((await snapshot()).source,/Footnote text/);ok('footnote_insert_serialization');
await load('```python\ndef hello(name):\n return "Hello " + name\n```\n\n```mermaid\nflowchart LR\n A[Write] --> B[Save]\n```\n');assert.ok(await run('document.querySelectorAll(".token").length')>0);
await until(()=>run('!!document.querySelector(".diagram-preview img")'));assert.ok(await run('document.querySelector(".diagram-preview img").src.startsWith("data:image/svg+xml")'));
const exported=await run('nextnotes.exportBody()');assert.ok(exported.includes('data:image/svg+xml'));assert.ok((await run(`new DOMParser().parseFromString(${JSON.stringify(exported)},'text/html').body.textContent`)).includes('def hello'));ok('code_highlighting_and_offline_mermaid_export');
await load('Image: ');await key('End','End',35,2);
await run(`(async()=>{const canvas=document.createElement('canvas');canvas.width=240;canvas.height=80;const context=canvas.getContext('2d');context.fillStyle='#dfe9f4';context.fillRect(0,0,240,80);context.fillStyle='#234';context.font='22px sans-serif';context.fillText('Attachment',38,48);const blob=await new Promise(resolve=>canvas.toBlob(resolve));const transfer=new DataTransfer();transfer.items.add(new File([blob],'Clipboard.png',{type:'image/png'}));nextnotes.view().dom.dispatchEvent(new ClipboardEvent('paste',{clipboardData:transfer,bubbles:true,cancelable:true}));})()`);
await until(()=>run('!!document.querySelector("img[data-original-src]")&&!nextnotes.snapshot().composing'));
const image=(await doc()).content[0].content.find(n=>n.type==='image');assert.ok(image.attrs.src.startsWith('assets/'));
await until(()=>run('document.querySelector("img[data-original-src]").naturalWidth===240'));
await wait(1100);assert.ok(fs.readFileSync(fixture,'utf8').includes(image.attrs.src));ok('clipboard_image_import_file_storage_insert_and_autosave');
await load('| A | B |\n| --- | --- |\n| a | b |\n');const original=(await snapshot()).source;
await run(`(()=>{const v=nextnotes.view();const cell=v.state.doc.firstChild.firstChild.firstChild;v.dispatch(v.state.tr.setNodeMarkup(2,undefined,{...cell.attrs,colwidth:[200]}));})()`);
assert.equal((await snapshot()).source,original);await wait(150);assert.deepEqual((await run('nextnotes.toolsState().tableWidths'))[0][0],[200]);ok('table_column_width_view_only_no_markdown_rewrite');
await run('nextnotes.view().state.doc.check();true');
await wait(1100);assert.ok(fs.readFileSync(fixture,'utf8').startsWith('---\ncustom: untouched\n---\n'));ok('metadata_and_schema_integrity');
if(process.env.NEXTNOTES_TEST_REPORT)fs.writeFileSync(process.env.NEXTNOTES_TEST_REPORT,JSON.stringify({results,snapshot:await snapshot()},null,2));
}catch(error){console.error(error);if(process.env.NEXTNOTES_TEST_REPORT)fs.writeFileSync(process.env.NEXTNOTES_TEST_REPORT,JSON.stringify({results,error:error.stack,snapshot:await snapshot()},null,2));process.exitCode=1;}
ws.close();setTimeout(()=>process.exit(process.exitCode||0),200);
Loading
Loading