diff --git a/qml/EditorTools.qml b/qml/EditorTools.qml index aa0872c..e618175 100644 --- a/qml/EditorTools.qml +++ b/qml/EditorTools.qml @@ -65,7 +65,9 @@ Item { Item { id: caretAnchor; width: 8; height: 18; x: 32; y: 18 } LiquidActionButton { id: handle - visible: !!root.state.block && root.state.block.y >= 0 && root.state.block.y < root.height && root.editable + // Only a fresh paragraph's first editing session suppresses its handle. + visible: !!root.state.blockHandleVisible + && !!root.state.block && root.state.block.y >= 0 && root.state.block.y < root.height && root.editable x: Math.max(12, root.state.block ? root.state.block.x - width - 8 : 12); y: Math.max(0, root.state.block ? root.state.block.y : 0) implicitWidth: 26; implicitHeight: 26; iconOnly: true; iconName: "more"; iconSize: 16 materialForm: true @@ -73,6 +75,7 @@ Item { onClicked: { root.block = root.state.block; root.spellSuggestions = []; blockMenu.openFromButton(handle) } MouseArea { anchors.fill: parent + cursorShape: pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor property real startY: 0 property int startPos: 0 onPressed: function(mouse) { startY = mapToItem(root, mouse.x, mouse.y).y; startPos = root.state.block.pos; root.dragging = false } diff --git a/qml/Main.qml b/qml/Main.qml index d4f9929..97d8f39 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -312,23 +312,18 @@ ApplicationWindow { Shortcut { sequence: "Escape"; enabled: organizer.dragging; onActivated: { organizePreview.Drag.cancel(); organizer.endDrag() } } component ToolbarAction: LiquidActionButton { property string help: "" - iconOnly: true; implicitWidth: 32; implicitHeight: 26 + iconOnly: true; toolbarAction: true materialForm: false; backdrop: readingBackdrop focusPolicy: Qt.TabFocus Accessible.name: help KosToolTip { target: parent; backdrop: readingBackdrop; text: parent.help } } component HeaderAction: ToolbarAction { - implicitWidth: tokens.headerButtonHeight - implicitHeight: tokens.headerButtonHeight - width: tokens.headerButtonHeight; height: tokens.headerButtonHeight - Layout.minimumHeight: tokens.headerButtonHeight - Layout.maximumHeight: tokens.headerButtonHeight - iconSize: tokens.headerIconSize + Layout.minimumHeight: implicitHeight + Layout.maximumHeight: implicitHeight } component ListNavigationActions: LiquidActionButtonGroup { id: listActions - buttonSize: tokens.headerButtonHeight materialForm: false; backdrop: readingBackdrop icons: root.currentFolder ? ["back", "more"] : ["more"] labels: root.currentFolder @@ -426,7 +421,7 @@ ApplicationWindow { ToolbarAction { id: entryMenu anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter - implicitWidth: 26; implicitHeight: 26; iconName: "more" + iconName: "more" visible: entry.showMenuButton && (entryHover.hovered || entryButton.activeFocus || activeFocus) help: entry.folderPath.split("/").pop() + "的文件夹操作" enabled: !controller.busy @@ -459,11 +454,11 @@ ApplicationWindow { KosSidebarToggle { id: sidebarButton objectName: "sidebarToggleButton" - implicitWidth: tokens.headerButtonHeight; implicitHeight: tokens.headerButtonHeight + implicitWidth: LiquidStyle.toolbarButtonHeight; implicitHeight: LiquidStyle.toolbarButtonHeight scale: 1 // Keep the shared rounded sidebar glyph used by the other apps. - // Scale its original 18px width; do not substitute panelLeft. - contentItem.scale: tokens.headerIconSize / 18 + // Scale from the shared glyph size; do not substitute panelLeft. + contentItem.scale: LiquidStyle.toolbarIconSize / contentItem.size glyphColor: AppTheme.text; glyphPaneColor: glyphColor focusPolicy: Qt.TabFocus Accessible.name: "显示或隐藏导航 · Ctrl+Shift+B" @@ -488,7 +483,7 @@ ApplicationWindow { LiquidActionButton { visible: root.trashView ? recycleBin.selectionMode : organizer.selectionMode text: "取消"; Accessible.name: "取消选择" - implicitWidth: 52; implicitHeight: 26; materialForm: true + toolbarAction: true; materialForm: true onClicked: root.trashView ? recycleBin.clearSelection() : organizer.clearSelection() } ListNavigationActions {} @@ -499,8 +494,8 @@ ApplicationWindow { x: root.editorX < root.width ? Math.max(178, root.editorX + 12) : 178 anchors.verticalCenter: parent.verticalCenter readonly property real controlsLeft: windowControls.x + (windowControls.buttonLayout.length ? windowControls.buttonLayout[0].x : 0) - width: Math.max(0, controlsLeft - 10 - x); height: tokens.headerButtonHeight - readonly property bool wideSearch: root.width >= 1320 && width >= noteActions.implicitWidth + editorActions.implicitWidth + tokens.headerButtonHeight + 210 + width: Math.max(0, controlsLeft - 10 - x); height: LiquidStyle.toolbarButtonHeight + readonly property bool wideSearch: root.width >= 1320 && width >= noteActions.implicitWidth + editorActions.implicitWidth + LiquidStyle.toolbarButtonHeight + 210 readonly property bool searchShown: wideSearch || root.searchOpen || controller.query.length > 0 readonly property bool searching: !wideSearch && searchShown Row { @@ -514,7 +509,7 @@ ApplicationWindow { id: editorActionsViewport visible: !headerActions.searching readonly property real availableWidth: Math.max(0, globalActions.x - noteActions.width - 24) - width: Math.min(editorActions.implicitWidth, availableWidth); height: tokens.headerButtonHeight + width: Math.min(editorActions.implicitWidth, availableWidth); height: LiquidStyle.toolbarButtonHeight anchors.verticalCenter: parent.verticalCenter x: Math.max(noteActions.width + 12, Math.min((parent.width - width) / 2, globalActions.x - 12 - width)) contentWidth: editorActions.implicitWidth; contentHeight: height @@ -550,10 +545,10 @@ ApplicationWindow { LiquidActionButtonGroup { id: editorActions objectName: "bodyEditingButtonGroup" - height: tokens.headerButtonHeight + height: LiquidStyle.toolbarButtonHeight anchors.verticalCenter: parent.verticalCenter clip: false - buttonSize: tokens.headerButtonHeight; materialForm: false; backdrop: readingBackdrop + materialForm: false; backdrop: readingBackdrop icons: session.sourceMode ? ["undo", "forward", ""] : ["undo", "forward", "", "", "", "", "reminder", "grid", "more"] labels: session.sourceMode ? [controller.undoText, controller.redoText, "返回正文"] : [controller.undoText, controller.redoText, "段落样式", "粗体 · Ctrl+B", "斜体 · Ctrl+I", "删除线 · Ctrl+Shift+X", "列表与插入", "表格与行列操作", "更多编辑工具"] @@ -608,10 +603,10 @@ ApplicationWindow { anchors.fill: parent materialForm: false; liquidFinish: true; liquidIntensity: "light"; backdrop: readingBackdrop placeholderText: "搜索笔记"; text: controller.query - leftPadding: 30; rightPadding: headerActions.searching ? 36 : 10 + leftPadding: 30; rightPadding: headerActions.searching ? LiquidStyle.toolbarButtonHeight + 10 : 10 VectorIcon { x: 8; anchors.verticalCenter: parent.verticalCenter - name: "search"; size: tokens.headerIconSize; color: search.textColor + name: "search"; size: LiquidStyle.toolbarIconSize; color: search.textColor } onTextEdited: if (!inputMethodComposing) root.updateGlobalQuery(text) onInputMethodComposingChanged: if (!inputMethodComposing && text !== controller.query) root.updateGlobalQuery(text) diff --git a/qml/NotesTokens.qml b/qml/NotesTokens.qml index 1c2a7f7..527185d 100644 --- a/qml/NotesTokens.qml +++ b/qml/NotesTokens.qml @@ -12,9 +12,6 @@ QtObject { readonly property int sidebarWidth: 224 readonly property int listWidth: 285 readonly property int toolbarHeight: 47 - // nextkdeAppDemo LiquidActionButton: toolbar height 26, standard icon 16. - readonly property int headerButtonHeight: 26 - readonly property int headerIconSize: 16 readonly property int navigationSize: 13 readonly property int navigationHeight: 28 readonly property int noteRowHeight: 68 diff --git a/tests/BlockHandleRegression.mjs b/tests/BlockHandleRegression.mjs new file mode 100644 index 0000000..d44ad3c --- /dev/null +++ b/tests/BlockHandleRegression.mjs @@ -0,0 +1,85 @@ +// VM-only: node tests/BlockHandleRegression.mjs after installing web/editor dependencies. +import assert from 'node:assert/strict'; +import {Schema} from '../web/editor/node_modules/@milkdown/prose/lib/model.js'; +import {EditorState,TextSelection} from '../web/editor/node_modules/@milkdown/prose/lib/state.js'; +import {makeTools} from '../web/editor/tools.js'; + +// Geometry is irrelevant to this interaction-state regression. +globalThis.Element=class {getBoundingClientRect(){return {left:48,top:20,height:26,bottom:46};}}; +globalThis.ResizeObserver=class {observe(){} disconnect(){}}; +const schema=new Schema({nodes:{doc:{content:'paragraph+'},paragraph:{content:'text*'},text:{}}}); +let focused=true,hitPos=1; +const tools=makeTools({canEdit:()=>true,busy:()=>false,bridge:()=>({ui(){}}),epoch:()=>1,report(){}}); +const paragraph=text=>schema.node('paragraph',null,text?schema.text(text):null); +const view={ + state:EditorState.create({schema,doc:schema.node('doc',null,[paragraph('第一段'),paragraph('第二段')]),plugins:[tools.plugin]}), + dom:new Element(),nodeDOM:()=>new Element(),posAtCoords:()=>({pos:hitPos}),hasFocus:()=>focused, + dispatch(tr){this.state=this.state.apply(tr);},focus(){focused=true;}, +}; +const lifecycle=tools.plugin.spec.view(view); +const events=tools.plugin.props.handleDOMEvents; +const visible=()=>{tools.uiNow();return tools.state().blockHandleVisible;}; +const hover=pos=>{hitPos=pos;events.mousemove(view,{buttons:0,clientX:60,clientY:25});return visible();}; +try{ + assert.equal(hover(1),true,'existing content retains its handle while editing'); + assert.equal(hover(6),true,'show another paragraph while the editor retains focus'); + view.state=view.state.apply(view.state.tr.insertText('字')); + assert.equal(visible(),true,'typing in the first paragraph must not hide the hovered second paragraph'); + assert.equal(hover(1),true,'typing in existing content must not suppress its handle'); + assert.equal(hover(7),true,'hovering another paragraph restores its handle'); + view.state=view.state.apply(view.state.tr.setSelection(TextSelection.create(view.state.doc,7))); + assert.equal(visible(),true,'moving the caret into existing content keeps its handle'); + assert.equal(hover(1),true,'the previous paragraph is now available'); + assert.equal(hover(7),true); + focused=false;events.blur();assert.equal(visible(),true,'leaving the editor makes the current paragraph available'); + focused=true;events.focus();assert.equal(visible(),true,'returning focus to existing content retains its handle'); + const unchanged=JSON.stringify(view.state.doc.toJSON()); + const selectedBlocks=()=>tools.plugin.props.decorations(view.state).find().filter(d=>d.type.attrs?.class==='editor-selected-block'); + tools.run('selectBlock',{pos:0}); + assert.equal(tools.plugin.props.attributes().class,'editor-block-selection'); + assert.deepEqual(selectedBlocks().map(d=>[d.from,d.to]),[[0,view.state.doc.firstChild.nodeSize]],'single block highlight covers the node, not its text'); + tools.run('selectBlock',{pos:view.state.doc.firstChild.nodeSize,extend:true}); + assert.equal(selectedBlocks().length,2,'extended block selection highlights both complete blocks'); + assert.equal(JSON.stringify(view.state.doc.toJSON()),unchanged,'block styling must not change document content'); + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc,1,2))); + assert.equal(selectedBlocks().length,0,'ordinary text selection exits block styling'); + assert.equal(tools.plugin.props.attributes().class,undefined); + tools.reset();assert.equal(visible(),false,'a new document must not inherit a hovered handle'); + // Drag the final block upward; the old deletion boundary used to resolve to the last block. + view.state=EditorState.create({schema,doc:schema.node('doc',null,[paragraph('A'),paragraph('BB'),paragraph('CCC'),paragraph('DDDD')]),plugins:[tools.plugin]}); + focused=true; + const position=text=>{let found;view.state.doc.forEach((node,pos)=>{if(node.textContent===text)found=pos;});return found;}; + const expectHandle=text=>{tools.uiNow();assert.equal(tools.state().block.pos,position(text));assert.equal(tools.state().block.title,text);assert.equal(tools.state().blockHandleVisible,true,'moved content remains draggable with editor focus');}; + hover(position('DDDD')+1); + hitPos=position('BB')+1; + assert.equal(tools.run('moveBlock',{pos:position('DDDD'),y:20}),true); + expectHandle('DDDD'); + assert.deepEqual(Array.from({length:view.state.doc.childCount},(_,i)=>view.state.doc.child(i).textContent),['A','DDDD','BB','CCC']); + hitPos=position('CCC')+1; + assert.equal(tools.run('moveBlock',{pos:position('DDDD'),y:46}),true); + expectHandle('DDDD'); + assert.equal(tools.run('moveBlock',{pos:position('DDDD'),direction:'up'}),true);expectHandle('DDDD'); + assert.equal(tools.run('moveBlock',{pos:position('DDDD'),direction:'down'}),true);expectHandle('DDDD'); + // Moving a different block must preserve the actual hovered block too. + hover(position('BB')+1); + assert.equal(tools.run('moveBlock',{pos:position('DDDD'),direction:'up'}),true);expectHandle('BB'); + tools.reset(); + const freshDoc=schema.node('doc',null,[paragraph('Existing'),paragraph()]); + view.state=EditorState.create({schema,doc:freshDoc,selection:TextSelection.create(freshDoc,11),plugins:[tools.plugin]}); + assert.equal(hover(11),false,'new empty paragraph has no handle while starting to type'); + view.dispatch(view.state.tr.insertText('New')); + assert.equal(hover(11),false,'first typing session stays clear after text appears'); + assert.equal(hover(1),true,'other existing blocks remain available'); + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc,1))); + assert.equal(hover(11),true,'leaving the new paragraph completes its first editing session'); + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc,11))); + assert.equal(hover(11),true,'re-entering its existing text retains the handle'); + // Enter creates another empty paragraph; typing immediately must also stay clear. + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc,14))); + view.dispatch(view.state.tr.split(14)); + view.dispatch(view.state.tr.insertText('Immediate')); + assert.equal(hover(view.state.selection.from),false,'first input is tracked without waiting for a UI report'); + focused=false;events.blur();assert.equal(visible(),true,'leaving editing restores the new content handle'); + focused=true;events.focus();assert.equal(visible(),true,'existing content stays draggable after refocus'); + console.log('PASS: new-paragraph typing suppression; existing and moved blocks remain draggable; selection styling.'); +}finally{lifecycle.destroy();} diff --git a/third_party/nextkdeAppDemo b/third_party/nextkdeAppDemo index 92923aa..40c6007 160000 --- a/third_party/nextkdeAppDemo +++ b/third_party/nextkdeAppDemo @@ -1 +1 @@ -Subproject commit 92923aa43af5313915ddd55f580b81e94662473c +Subproject commit 40c6007a95db864683924056adbf0f6385dcb7e3 diff --git a/web/editor/dist/editor.js b/web/editor/dist/editor.js index cc38c0f..d52fe5f 100644 --- a/web/editor/dist/editor.js +++ b/web/editor/dist/editor.js @@ -88187,9 +88187,9 @@ ["toc", "\u63D2\u5165\u76EE\u5F55", "toc contents \u76EE\u5F55"] ]; function makeTools(env) { - let view2, hover = 0, collapsed = /* @__PURE__ */ new Set(), query = "", caseSensitive = false, searchIndex = -1, slashIndex = 0, lastSlash = "", uiTimer, lastUI = "", layoutToken = 0; + let view2, hover = 0, blockHandleVisible = false, collapsed = /* @__PURE__ */ new Set(), query = "", caseSensitive = false, searchIndex = -1, slashIndex = 0, lastSlash = "", uiTimer, lastUI = "", layoutToken = 0; let cachedDoc, cachedText = "", cachedStats = {}, cachedOutline = [], cachedMatchesDoc, cachedMatchesQuery, cachedMatchesCase, cachedMatches = []; - let suppressedSlash = null; + let suppressedSlash = null, blockSelection = false, newParagraphPos = null; let pending = 0, request = 0, clipboardBlock = null; const imports = /* @__PURE__ */ new Map(), bookmarks = /* @__PURE__ */ new Map(); const key4 = new PluginKey("nextnotesTools"); @@ -88212,6 +88212,16 @@ function targetBlock(args = {}) { return topBlock(Number.isInteger(args.pos) ? args.pos : hover); } + function trackNewParagraph(state, focused) { + const { $from } = state.selection; + if (!focused || !(state.selection instanceof TextSelection) || $from.parent.type.name !== "paragraph") { + newParagraphPos = null; + return; + } + const pos = $from.before(); + if (newParagraphPos !== pos) newParagraphPos = null; + if (!$from.parent.content.size) newParagraphPos = pos; + } function rootBlocks() { const out = []; view2.state.doc.forEach((node2, pos) => out.push({ node: node2, pos })); @@ -88329,11 +88339,14 @@ } const outline = cachedOutline.map((b) => ({ ...b, folded: collapsed.has(b.pos) })); const selected = view2.state.selection.empty ? "" : view2.state.doc.textBetween(view2.state.selection.from, view2.state.selection.to, "\n"); + trackNewParagraph(view2.state, view2.hasFocus()); + const editingBlock = newParagraphPos === null ? null : topBlock(newParagraphPos + 1); const state = { epoch: env.epoch(), outline, stats: { ...cachedStats, selected: Array.from(selected.replace(/\s/g, "")).length }, block: geometry(topBlock(hover)), + blockHandleVisible: blockHandleVisible && editable2() && editingBlock?.pos !== hover, slash: slash(), search: { total: matches2().length, current: searchIndex + 1 }, pending @@ -88359,6 +88372,13 @@ function schedule() { if (!uiTimer) uiTimer = setTimeout(ui, 35); } + function hideBlockHandle() { + if (blockHandleVisible) { + blockHandleVisible = false; + schedule(); + } + return false; + } function refresh() { if (view2 && !env.busy()) view2.dispatch(view2.state.tr.setMeta(key4, "refresh")); schedule(); @@ -88400,6 +88420,7 @@ let tr = view2.state.tr.delete(r2.from, r2.to); const at = tr.mapping.map(target, -1); tr = tr.insert(at, content4); + tr.setMeta("nextnotesMovedBlock", { from: r2.from, to: r2.to, at }); tr.setSelection(TextSelection.near(tr.doc.resolve(Math.min(tr.doc.content.size, at + 1)))); commit(tr.scrollIntoView()); return true; @@ -88549,7 +88570,7 @@ const b = targetBlock(args); if (!b) return false; const selection = args.extend ? TextSelection.between(view2.state.doc.resolve(Math.min(view2.state.selection.from, b.pos)), view2.state.doc.resolve(Math.max(view2.state.selection.to, b.pos + b.node.nodeSize))) : NodeSelection.create(view2.state.doc, b.pos); - view2.dispatch(view2.state.tr.setSelection(selection)); + view2.dispatch(view2.state.tr.setSelection(selection).setMeta(key4, "block-selection")); view2.focus(); return true; } @@ -88690,26 +88711,57 @@ await Promise.all(Array.from(view2.dom.querySelectorAll("img")).map((image2) => image2.decode?.().catch(() => { }))); const clone = view2.dom.cloneNode(true); - clone.classList.remove("ProseMirror-focused"); + clone.classList.remove("ProseMirror-focused", "editor-block-selection"); + for (const el of clone.querySelectorAll(".editor-selected-block,.ProseMirror-selectednode")) el.classList.remove("editor-selected-block", "ProseMirror-selectednode"); for (const el of clone.querySelectorAll("[contenteditable]")) el.removeAttribute("contenteditable"); for (const el of clone.querySelectorAll(".folded-content")) el.classList.remove("folded-content"); for (const el of clone.querySelectorAll(".editor-search-match")) el.classList.remove("editor-search-match"); + for (const el of clone.querySelectorAll(".editor-empty-line")) { + el.classList.remove("editor-empty-line"); + el.removeAttribute("data-placeholder"); + } for (const el of clone.querySelectorAll(".ProseMirror-gapcursor,.ProseMirror-separator,.ProseMirror-trailingBreak,.ProseMirror-widget,.column-resize-handle")) el.remove(); env.bridge().exportReady(token, env.epoch(), clone.outerHTML); return clone.outerHTML; } const plugin = new Plugin({ key: key4, - state: { init: () => DecorationSet.empty, apply(tr, old) { + state: { init: () => DecorationSet.empty, apply(tr, old, oldState, newState) { + trackNewParagraph(oldState, !!view2?.hasFocus()); + if (newParagraphPos !== null && tr.docChanged) { + const mapped = tr.mapping.mapResult(newParagraphPos); + newParagraphPos = mapped.deleted || tr.getMeta("nextnotesMovedBlock") ? null : mapped.pos; + } + trackNewParagraph(newState, !!view2?.hasFocus()); + if (tr.selectionSet || tr.docChanged) blockSelection = tr.getMeta(key4) === "block-selection"; if (tr.docChanged) { collapsed = new Set(Array.from(collapsed, (p) => tr.mapping.map(p))); - hover = tr.mapping.map(hover); + const moved = tr.getMeta("nextnotesMovedBlock"); + hover = moved && hover >= moved.from && hover < moved.to ? moved.at + hover - moved.from : tr.mapping.map(hover); for (const item of bookmarks.values()) item.bookmark = item.bookmark.map(tr.mapping); } return env.busy() ? old.map(tr.mapping, tr.doc) : tr.docChanged || tr.getMeta(key4) ? decorations2(tr.doc === view2?.state.doc ? view2.state : { doc: tr.doc }) : old; } }, props: { - decorations: (state) => key4.getState(state), + attributes: () => blockSelection ? { class: "editor-block-selection" } : {}, + decorations(state) { + const base2 = key4.getState(state), { $from, empty: empty3 } = state.selection; + if (blockSelection && !empty3) { + const items = []; + state.doc.nodesBetween(state.selection.from, state.selection.to, (node2, pos2) => { + items.push(Decoration.node(pos2, pos2 + node2.nodeSize, { class: "editor-selected-block" })); + return false; + }); + return base2.add(state.doc, items); + } + if (!editable2() || !empty3 || $from.parent.type.name !== "paragraph" || $from.parent.content.size) return base2; + const pos = $from.before(); + return base2.add(state.doc, [Decoration.node( + pos, + pos + $from.parent.nodeSize, + { class: "editor-empty-line", "data-placeholder": '\u8F93\u5165"/"\u8FDB\u5165\u9009\u62E9' } + )]); + }, handleKeyDown(v2, event) { if (env.busy()) return false; const state = slash(); @@ -88729,17 +88781,29 @@ if (event.altKey && event.shiftKey && ["ArrowUp", "ArrowDown"].includes(event.key)) return moveBlock({ pos: topBlock(v2.state.selection.from)?.pos, direction: event.key === "ArrowUp" ? "up" : "down", selection: true }); return false; }, - handleDOMEvents: { mousemove(v2, event) { - const hit = v2.posAtCoords({ left: event.clientX, top: event.clientY }); - if (hit) { - const b = topBlock(hit.pos); - if (b && b.pos !== hover) { + handleDOMEvents: { + focus() { + schedule(); + return false; + }, + blur() { + newParagraphPos = null; + schedule(); + return false; + }, + mousemove(v2, event) { + if (event.buttons || !editable2()) return hideBlockHandle(); + const hit = v2.posAtCoords({ left: event.clientX, top: event.clientY }); + const b = hit ? topBlock(hit.pos) : null; + if (!b || b.node.type.name === "raw_source") return hideBlockHandle(); + if (!blockHandleVisible || b.pos !== hover) { hover = b.pos; + blockHandleVisible = true; schedule(); } + return false; } - return false; - } } + } }, view(v2) { view2 = v2; @@ -88772,6 +88836,9 @@ uiTimer = null; collapsed.clear(); hover = 0; + blockHandleVisible = false; + blockSelection = false; + newParagraphPos = null; query = ""; lastUI = ""; searchIndex = -1; diff --git a/web/editor/editor.css b/web/editor/editor.css index f10027b..f794608 100644 --- a/web/editor/editor.css +++ b/web/editor/editor.css @@ -5,8 +5,13 @@ p { margin:0 0 .75em; }h1,h2,h3,h4,h5,h6 { font-weight:650;line-height:1.35;margin:1.2em 0 .5em; }h1 {font-size:2em;}h2{font-size:1.55em;}h3{font-size:1.25em;}h4,h5,h6{font-size:1.05em;} .ProseMirror>:first-child{margin-top:0;} blockquote { margin:1em 0;padding:0 1em;border-left:3px solid var(--border);color:var(--muted); }ul,ol {padding-left:1.7em;}li>p{margin:.15em 0;}li>ul,li>ol{margin:.2em 0;}li[data-checked]{list-style:none;position:relative;}li[data-checked]::before{content:'☐';position:absolute;left:-1.4em;cursor:pointer;color:var(--muted);}li[data-checked=true]::before{content:'☑';color:var(--accent);}li[data-checked=true]>p{color:var(--muted);} pre {padding:.8em 1em;border-radius:6px;background:color-mix(in srgb,var(--text) 6%,transparent);overflow-x:auto;white-space:pre;}code{font-family:monospace;font-size:.92em;} :not(pre)>code{padding:.12em .3em;border-radius:3px;background:color-mix(in srgb,var(--text) 7%,transparent);} -a{color:var(--accent);text-decoration:underline;}img{max-width:100%;height:auto;}hr{border:0;border-top:1px solid var(--border);margin:1.5em 0;}table{border-collapse:collapse;width:100%;table-layout:fixed;margin:1em 0;}td,th{border:1px solid var(--border);padding:.4em .65em;vertical-align:top;min-width:3em;position:relative;}th{font-weight:600;background:color-mix(in srgb,var(--text) 5%,transparent);}td>p,th>p{margin:0;}.selectedCell::after{position:absolute;inset:0;background:var(--selection);opacity:.45;content:'';pointer-events:none;}.column-resize-handle{position:absolute;right:-2px;top:0;bottom:-2px;width:4px;background:var(--accent);pointer-events:none;}.resize-cursor{cursor:col-resize;}::selection{background:var(--selection);color:var(--selectionText,white);}#error{padding:0 20px;color:var(--muted);}.ProseMirror-selectednode{outline:1px solid var(--border);}.ProseMirror-gapcursor{display:none;pointer-events:none;position:absolute;}.ProseMirror-gapcursor:after{content:'';display:block;position:absolute;top:-2px;width:20px;border-top:1px solid var(--text);animation:blink 1s steps(2,start) infinite;}.ProseMirror-focused .ProseMirror-gapcursor{display:block;}@keyframes blink{to{visibility:hidden;}} +a{color:var(--accent);text-decoration:underline;}img{max-width:100%;height:auto;}hr{border:0;border-top:1px solid var(--border);margin:1.5em 0;}table{border-collapse:collapse;width:100%;table-layout:fixed;margin:1em 0;}td,th{border:1px solid var(--border);padding:.4em .65em;vertical-align:top;min-width:3em;position:relative;}th{font-weight:600;background:color-mix(in srgb,var(--text) 5%,transparent);}td>p,th>p{margin:0;}.selectedCell::after{position:absolute;inset:0;background:var(--selection);opacity:.45;content:'';pointer-events:none;}.column-resize-handle{position:absolute;right:-2px;top:0;bottom:-2px;width:4px;background:var(--accent);pointer-events:none;}.resize-cursor{cursor:col-resize;}::selection{background:var(--selection);color:var(--selectionText,white);}#error{padding:0 20px;color:var(--muted);}.ProseMirror-selectednode{outline:none;}.ProseMirror-gapcursor{display:none;pointer-events:none;position:absolute;}.ProseMirror-gapcursor:after{content:'';display:block;position:absolute;top:-2px;width:20px;border-top:1px solid var(--text);animation:blink 1s steps(2,start) infinite;}.ProseMirror-focused .ProseMirror-gapcursor{display:block;}@keyframes blink{to{visibility:hidden;}} /* Editing aids never alter the Markdown document. */ +.ProseMirror .editor-selected-block,.ProseMirror .ProseMirror-selectednode{background-color:color-mix(in srgb,var(--text) 8%,transparent);border-radius:3px;outline:none;} +.ProseMirror.editor-block-selection::selection,.ProseMirror.editor-block-selection ::selection,.ProseMirror-hideselection::selection,.ProseMirror-hideselection ::selection{background:transparent;color:inherit;} +.ProseMirror-hideselection{caret-color:transparent;} +.editor-empty-line{position:relative;} +.ProseMirror-focused[contenteditable="true"] p.editor-empty-line::before{content:attr(data-placeholder);position:absolute;top:0;left:0;max-width:100%;overflow:hidden;white-space:nowrap;color:color-mix(in srgb,var(--muted) 65%,transparent);pointer-events:none;user-select:none;} .folded-content{display:none!important;}.folded-heading:after{content:' …';font-size:.7em;color:var(--muted);}.editor-search-match{background:color-mix(in srgb,#f7c948 55%,transparent);color:inherit;} .editor-code{margin:1em 0;}.editor-code pre{margin:0;}.editor-code[data-language]:before{content:attr(data-language);display:block;font:11px/1.7 var(--font);color:var(--muted);}.diagram-preview{padding:12px 0;overflow:auto;}.diagram-preview[hidden]{display:none;}.diagram-preview img{display:block;margin:auto;max-height:620px;} .token.comment,.token.prolog,.token.doctype,.token.cdata{color:#6c7986;}.token.punctuation{color:#637184;}.token.property,.token.tag,.token.boolean,.token.number,.token.constant,.token.symbol{color:#a33a57;}.token.selector,.token.attr-name,.token.string,.token.char,.token.builtin{color:#237a47;}.token.operator,.token.entity,.token.url{color:#946420;}.token.atrule,.token.attr-value,.token.keyword{color:#7051b8;}.token.function,.token.class-name{color:#246aa6;} @@ -15,6 +20,8 @@ a{color:var(--accent);text-decoration:underline;}img{max-width:100%;height:auto; [data-type="footnote_definition"],.footnote-definition{font-size:.9em;color:var(--muted);} @page{size:A4;margin:16mm 17mm 18mm;} @media print{ + .editor-selected-block,.ProseMirror-selectednode{background-color:transparent!important;} + .editor-empty-line::before{content:none!important;} :root{color-scheme:light;--background:white;--text:#111;--muted:#555;--border:#bbb;--accent:#165ea6;} p{orphans:3;widows:3;}html,body{background:white!important;color:#111!important;font-size:11pt!important;}#editor{max-width:none;padding:0;margin:0;}.ProseMirror{min-height:0!important;white-space:pre-wrap;} .folded-content{display:block!important;}.folded-heading:after{content:none;}.editor-search-match{background:none!important;}.ProseMirror-widget,.ProseMirror-gapcursor,.column-resize-handle{display:none!important;} diff --git a/web/editor/tools.js b/web/editor/tools.js index c06b71e..27e778a 100644 --- a/web/editor/tools.js +++ b/web/editor/tools.js @@ -12,9 +12,9 @@ const slashEntries=[ ['math','数学公式','math latex 数学 公式'],['footnote','脚注','footnote 脚注'],['attachment','图片或附件','image file 图片 附件'],['toc','插入目录','toc contents 目录'], ]; export function makeTools(env){ - let view,hover=0,collapsed=new Set(),query='',caseSensitive=false,searchIndex=-1,slashIndex=0,lastSlash='',uiTimer,lastUI='',layoutToken=0; + let view,hover=0,blockHandleVisible=false,collapsed=new Set(),query='',caseSensitive=false,searchIndex=-1,slashIndex=0,lastSlash='',uiTimer,lastUI='',layoutToken=0; let cachedDoc,cachedText='',cachedStats={},cachedOutline=[],cachedMatchesDoc,cachedMatchesQuery,cachedMatchesCase,cachedMatches=[]; - let suppressedSlash=null;let pending=0,request=0,clipboardBlock=null;const imports=new Map(),bookmarks=new Map(); + let suppressedSlash=null,blockSelection=false,newParagraphPos=null;let pending=0,request=0,clipboardBlock=null;const imports=new Map(),bookmarks=new Map(); const key=new PluginKey('nextnotesTools'); const editable=()=>env.canEdit()&&!env.busy(); const notify=text=>env.bridge()?.notice(text); @@ -25,6 +25,13 @@ export function makeTools(env){ const last=doc.lastChild;return last?{pos:doc.content.size-last.nodeSize,node:last}:null; } function targetBlock(args={}){return topBlock(Number.isInteger(args.pos)?args.pos:hover);} + function trackNewParagraph(state,focused){ + const {$from}=state.selection; + if(!focused||!(state.selection instanceof TextSelection)||$from.parent.type.name!=='paragraph'){newParagraphPos=null;return;} + const pos=$from.before(); + if(newParagraphPos!==pos)newParagraphPos=null; + if(!$from.parent.content.size)newParagraphPos=pos; + } function rootBlocks(){const out=[];view.state.doc.forEach((node,pos)=>out.push({node,pos}));return out;} function matches(doc=view.state.doc){ if(!query)return []; @@ -95,8 +102,12 @@ export function makeTools(env){ } const outline=cachedOutline.map(b=>({...b,folded:collapsed.has(b.pos)})); const selected=view.state.selection.empty?'':view.state.doc.textBetween(view.state.selection.from,view.state.selection.to,'\n'); + // A fresh paragraph stays unobstructed for its first editing session. + // Existing content remains draggable even while its text has focus. + trackNewParagraph(view.state,view.hasFocus()); + const editingBlock=newParagraphPos===null?null:topBlock(newParagraphPos+1); const state={epoch:env.epoch(),outline,stats:{...cachedStats,selected:Array.from(selected.replace(/\s/g,'')).length}, - block:geometry(topBlock(hover)),slash:slash(),search:{total:matches().length,current:searchIndex+1},pending}; + block:geometry(topBlock(hover)),blockHandleVisible:blockHandleVisible&&editable()&&editingBlock?.pos!==hover,slash:slash(),search:{total:matches().length,current:searchIndex+1},pending}; const block=topBlock(view.state.selection.from); if(block?.node.type.name==='code_block')state.code={pos:block.pos,source:block.node.textContent,language:block.node.attrs.language||''}; state.tableWidths=blocks.filter(b=>b.node.type.name==='table').map(b=>Array.from({length:b.node.firstChild.childCount},(_,i)=>b.node.firstChild.child(i).attrs.colwidth)); @@ -110,6 +121,7 @@ export function makeTools(env){ const encoded=JSON.stringify(state);if(encoded!==lastUI){lastUI=encoded;env.bridge().ui(encoded);} } function schedule(){if(!uiTimer)uiTimer=setTimeout(ui,35);} + function hideBlockHandle(){if(blockHandleVisible){blockHandleVisible=false;schedule();}return false;} function refresh(){if(view&&!env.busy())view.dispatch(view.state.tr.setMeta(key,'refresh'));schedule();} function boundary(tr){return closeHistory(tr);} function commit(tr){view.dispatch(boundary(tr));view.dispatch(closeHistory(view.state.tr));env.report();schedule();} @@ -131,7 +143,9 @@ export function makeTools(env){ } if(target===undefined||target>=r.from&&target<=r.to)return false; const content=view.state.doc.slice(r.from,r.to).content;let tr=view.state.tr.delete(r.from,r.to); - const at=tr.mapping.map(target,-1);tr=tr.insert(at,content);tr.setSelection(TextSelection.near(tr.doc.resolve(Math.min(tr.doc.content.size,at+1))));commit(tr.scrollIntoView());return true; + const at=tr.mapping.map(target,-1);tr=tr.insert(at,content); + tr.setMeta('nextnotesMovedBlock',{from:r.from,to:r.to,at}); + tr.setSelection(TextSelection.near(tr.doc.resolve(Math.min(tr.doc.content.size,at+1))));commit(tr.scrollIntoView());return true; } function navigateSearch(direction=1){ const all=matches();if(!all.length){searchIndex=-1;schedule();return false;} @@ -193,7 +207,7 @@ export function makeTools(env){ if(name==='selectBlock'){ const b=targetBlock(args);if(!b)return false; const selection=args.extend?TextSelection.between(view.state.doc.resolve(Math.min(view.state.selection.from,b.pos)),view.state.doc.resolve(Math.max(view.state.selection.to,b.pos+b.node.nodeSize))):NodeSelection.create(view.state.doc,b.pos); - view.dispatch(view.state.tr.setSelection(selection));view.focus();return true; + view.dispatch(view.state.tr.setSelection(selection).setMeta(key,'block-selection'));view.focus();return true; } if(name==='moveBlock')return moveBlock(args); if(name==='duplicateBlock'||name==='deleteBlock'||name==='copyBlock'){ @@ -262,20 +276,51 @@ export function makeTools(env){ // Wait for diagram rendering and decode images before PDF capture. await env.waitForPreview?.(); await Promise.all(Array.from(view.dom.querySelectorAll('img')).map(image=>image.decode?.().catch(()=>{}))); - const clone=view.dom.cloneNode(true);clone.classList.remove('ProseMirror-focused'); + const clone=view.dom.cloneNode(true);clone.classList.remove('ProseMirror-focused','editor-block-selection'); + for(const el of clone.querySelectorAll('.editor-selected-block,.ProseMirror-selectednode'))el.classList.remove('editor-selected-block','ProseMirror-selectednode'); for(const el of clone.querySelectorAll('[contenteditable]'))el.removeAttribute('contenteditable'); for(const el of clone.querySelectorAll('.folded-content'))el.classList.remove('folded-content'); for(const el of clone.querySelectorAll('.editor-search-match'))el.classList.remove('editor-search-match'); + for(const el of clone.querySelectorAll('.editor-empty-line')){el.classList.remove('editor-empty-line');el.removeAttribute('data-placeholder');} for(const el of clone.querySelectorAll('.ProseMirror-gapcursor,.ProseMirror-separator,.ProseMirror-trailingBreak,.ProseMirror-widget,.column-resize-handle'))el.remove(); env.bridge().exportReady(token,env.epoch(),clone.outerHTML); return clone.outerHTML; } const plugin=new Plugin({key, - state:{init:()=>DecorationSet.empty,apply(tr,old){ - if(tr.docChanged){collapsed=new Set(Array.from(collapsed,p=>tr.mapping.map(p)));hover=tr.mapping.map(hover);for(const item of bookmarks.values())item.bookmark=item.bookmark.map(tr.mapping);} + state:{init:()=>DecorationSet.empty,apply(tr,old,oldState,newState){ + // Capture an empty paragraph before its first keystroke, even between UI reports. + trackNewParagraph(oldState,!!view?.hasFocus()); + if(newParagraphPos!==null&&tr.docChanged){ + const mapped=tr.mapping.mapResult(newParagraphPos); + newParagraphPos=mapped.deleted||tr.getMeta('nextnotesMovedBlock')?null:mapped.pos; + } + trackNewParagraph(newState,!!view?.hasFocus()); + if(tr.selectionSet||tr.docChanged)blockSelection=tr.getMeta(key)==='block-selection'; + if(tr.docChanged){ + collapsed=new Set(Array.from(collapsed,p=>tr.mapping.map(p))); + // A move is delete + insert: ordinary mapping loses the moved block's identity. + const moved=tr.getMeta('nextnotesMovedBlock'); + hover=moved&&hover>=moved.from&&hoverkey.getState(state), + props:{attributes:()=>blockSelection?{class:'editor-block-selection'}:{}, + decorations(state){ + const base=key.getState(state),{$from,empty}=state.selection; + if(blockSelection&&!empty){ + const items=[]; + state.doc.nodesBetween(state.selection.from,state.selection.to,(node,pos)=>{ + items.push(Decoration.node(pos,pos+node.nodeSize,{class:'editor-selected-block'}));return false; + }); + return base.add(state.doc,items); + } + // Decorate only the current empty paragraph; no text or document mutation. + if(!editable()||!empty||$from.parent.type.name!=='paragraph'||$from.parent.content.size)return base; + const pos=$from.before(); + return base.add(state.doc,[Decoration.node(pos,pos+$from.parent.nodeSize, + {class:'editor-empty-line','data-placeholder':'输入"/"进入选择'})]); + }, handleKeyDown(v,event){ if(env.busy())return false;const state=slash(); if(state&&['ArrowDown','ArrowUp','Enter','Escape'].includes(event.key)){ @@ -286,12 +331,22 @@ export function makeTools(env){ if(event.altKey&&event.shiftKey&&['ArrowUp','ArrowDown'].includes(event.key))return moveBlock({pos:topBlock(v.state.selection.from)?.pos,direction:event.key==='ArrowUp'?'up':'down',selection:true}); return false; }, - handleDOMEvents:{mousemove(v,event){const hit=v.posAtCoords({left:event.clientX,top:event.clientY});if(hit){const b=topBlock(hit.pos);if(b&&b.pos!==hover){hover=b.pos;schedule();}}return false;}} + handleDOMEvents:{ + focus(){schedule();return false;},blur(){newParagraphPos=null;schedule();return false;}, + mousemove(v,event){ + if(event.buttons||!editable())return hideBlockHandle(); + const hit=v.posAtCoords({left:event.clientX,top:event.clientY}); + const b=hit?topBlock(hit.pos):null; + if(!b||b.node.type.name==='raw_source')return hideBlockHandle(); + if(!blockHandleVisible||b.pos!==hover){hover=b.pos;blockHandleVisible=true;schedule();} + return false; + } + } }, view(v){view=v;const observer=new ResizeObserver(schedule);observer.observe(v.dom);schedule();return {update(){env.refreshToc?.();schedule();},destroy(){observer.disconnect();clearTimeout(uiTimer);uiTimer=null;}};} }); return {plugin,run,refresh,state:()=>lastUI?JSON.parse(lastUI):{},ui:schedule,uiNow:ui,uploader,importResult,exportReady,hasPending:()=>pending>0, - reset(){clearTimeout(uiTimer);uiTimer=null;collapsed.clear();hover=0;query='';lastUI='';searchIndex=-1;lastSlash='';for(const item of imports.values())item.resolve({ok:false});imports.clear();bookmarks.clear();pending=0;}, + reset(){clearTimeout(uiTimer);uiTimer=null;collapsed.clear();hover=0;blockHandleVisible=false;blockSelection=false;newParagraphPos=null;query='';lastUI='';searchIndex=-1;lastSlash='';for(const item of imports.values())item.resolve({ok:false});imports.clear();bookmarks.clear();pending=0;}, selectedCode:()=>topBlock(view.state.selection.from), }; }