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
5 changes: 4 additions & 1 deletion qml/EditorTools.qml
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,17 @@ 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
Accessible.name: "块操作,可拖动块,或使用 Alt+Shift+方向键移动"
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 }
Expand Down
35 changes: 15 additions & 20 deletions qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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 {}
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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", "列表与插入", "表格与行列操作", "更多编辑工具"]
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 0 additions & 3 deletions qml/NotesTokens.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions tests/BlockHandleRegression.mjs
Original file line number Diff line number Diff line change
@@ -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();}
Loading
Loading