diff --git a/CMakeLists.txt b/CMakeLists.txt
index 91189b6..2fe16f8 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -21,8 +21,9 @@ target_link_libraries(nextnotes-storage PUBLIC Qt6::Core SQLite::SQLite3)
qt_add_executable(nextnotes src/main.cpp src/EditorSession.cpp src/EditorSessionWeb.cpp src/EditorSession.h src/NotesController.cpp src/NotesLibrary.cpp src/EditorExport.cpp src/PdfExport.cpp src/NotesOrganizer.cpp src/NotesKnowledge.cpp src/NotesController.h)
target_sources(nextnotes PRIVATE src/EditorRenderLifecycle.cpp src/EditorRenderLifecycle.h)
qt_add_qml_module(nextnotes URI NextNotes VERSION 1.0 RESOURCE_PREFIX /qt/qml
- QML_FILES qml/NotesSelectionBox.qml qml/FolderIcons.js qml/PopupPlacement.js qml/NotesPopover.qml qml/NotesContentPopover.qml qml/NotesSettings.qml qml/LibraryActions.qml qml/NotesWindowControls.qml qml/KnowledgePanel.qml qml/RecycleBin.qml qml/Main.qml qml/NotesOrganizer.qml qml/SourceEditor.qml qml/EditorTools.qml qml/CommandRegistry.qml qml/NotesTokens.qml qml/NoteReadingView.qml)
+ QML_FILES qml/NotesSelectionBox.qml qml/FolderIcons.js qml/PopupPlacement.js qml/NotesPopover.qml qml/NotesContentPopover.qml qml/NotesSettings.qml qml/LibraryActions.qml qml/NotesWindowControls.qml qml/KnowledgePanel.qml qml/RecycleBin.qml qml/Main.qml qml/NoteNameEditor.qml qml/NotesOrganizer.qml qml/SourceEditor.qml qml/EditorTools.qml qml/CommandRegistry.qml qml/NotesTokens.qml qml/NoteReadingView.qml)
qt_add_resources(nextnotes window-controls PREFIX /nextnotes/platform BASE qml FILES qml/NativeWindowControls.qml)
+qt_add_resources(nextnotes app-icon PREFIX /nextnotes/icons BASE packaging FILES packaging/org.nextks.notes.svg)
file(GLOB_RECURSE EDITOR_ASSETS CONFIGURE_DEPENDS web/editor/dist/* web/editor/vendor/*)
qt_add_resources(nextnotes editor-assets PREFIX /editor BASE web/editor FILES web/editor/index.html web/editor/editor.css ${EDITOR_ASSETS})
target_link_libraries(nextnotes PRIVATE nextnotes-storage Kos::Ui Kos::UiPlugin Qt6::Core Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2 Qt6::QuickDialogs2 Qt6::Svg Qt6::WebEngineCore Qt6::WebEngineQuick Qt6::WebChannel)
diff --git a/docs/notes-interaction-model.md b/docs/notes-interaction-model.md
index a33e39b..e3d3fa7 100644
--- a/docs/notes-interaction-model.md
+++ b/docs/notes-interaction-model.md
@@ -4,10 +4,12 @@
## 日常界面
-- 顶部保留新建、撤销/重做、当前笔记操作、搜索和设置。
+- 顶部左侧放新建和当前笔记操作,中间用一个公共按钮组统一承载撤销/重做、段落样式、粗体/斜体/删除线、插入、表格和更多编辑工具,右侧放搜索和设置;正文不再单独占用一行格式工具栏。编辑按钮组优先居中,空间不足时避开左右操作并支持横向滚动,键盘聚焦时跟随显示;格式状态并入底部状态栏。
- 侧栏分为“视图”和“文件夹”:视图包含全部笔记、收藏、最近删除及已保存的搜索筛选;文件夹包含“随手记”、初始化时添加的“默认文件夹”和用户创建的顶层文件夹;子文件夹放在对应笔记列表上方,通过返回上一级逐层导航。侧栏底部不显示新建文件夹,暂不增加“新建视图”入口;保存搜索仍从设置进入。
- “随手记”和“默认文件夹”都是仅初始化一次的普通文件夹,支持重命名、移动、删除和恢复,之后不自动补回。升级时将原收件箱的正常笔记一次性归入“随手记”,保留笔记 ID 并调整路径链接;有待恢复草稿或无法调整的链接时,整批保留原位并提示。在具体文件夹中新建归入当前文件夹;新建或导入未指定文件夹时显示为“未分类”,可在“全部笔记”中找到,不会隐式重建“随手记”。
-- 笔记菜单包含置顶、收藏、移动、修改标题、笔记信息、分享与导出、创建副本和最近删除。分享与导出以二级菜单提供当前笔记的 PDF、Markdown、HTML 和打印。自动保存不再占用普通笔记菜单的首位。
+- 正文左右各保留 48px 留白,笔记名称与正文左对齐;块操作按钮放在左侧留白内,与正文相隔 8px,并至少离开编辑区边界 12px。
+- 正文上方提供独立的“笔记名称”输入框,按 Enter 或“保存名称”提交;新建笔记的名称默认为“未命名笔记”,正文为空。名称保存在 Markdown 元数据的 `title` 字段中,修改名称不改写正文标题,也不将名称列入正文大纲。正文标题仍通过格式工具或 Markdown 设置。
+- 笔记菜单包含置顶、收藏、移动、修改笔记名称、笔记信息、分享与导出、创建副本和最近删除。分享与导出以二级菜单提供当前笔记的 PDF、Markdown、HTML 和打印。自动保存不再占用普通笔记菜单的首位。
- 列表右键菜单沿用邮件列表的定位:在鼠标点击位置弹出无箭头 LiquidPopover,只在接近窗口边缘时收进可见区域。笔记、子文件夹及最近删除列表使用同一规则;键盘打开笔记行菜单时以当前行中点作为位置,工具栏按钮仍就近弹出。
- 全部菜单与浮动表单采用统一触发规则:右键无箭头,按钮下方居中并指向按钮,后续菜单继承最初入口;空间不足时向上调整。完整范围和检查边界见 [菜单与浮动表单](popover-interactions.md)。
- 标签、关联和历史是当前笔记的信息,保留在“笔记信息”;模板管理从这套标签页中移出。
diff --git a/docs/product-interaction-review.md b/docs/product-interaction-review.md
index 15021ff..2f7576a 100644
--- a/docs/product-interaction-review.md
+++ b/docs/product-interaction-review.md
@@ -1,5 +1,7 @@
# 笔记产品视角复查
+2026-11-10 更新:笔记名称现使用正文上方的独立输入框,按 Enter 或“保存名称”提交;不再同步改写正文开头的一级标题。新建笔记使用 `title` 元数据和空正文。下文关于名称与一级标题同步的记录属于旧版行为。
+
2026-11-06。目标是让日常操作围绕记录、查找、归类、回看和输出内容;设置管理全局偏好与整个笔记空间。文件、原始路径和格式只在明确导入、导出、临时编辑外部文件时出现。
## 本轮发现与调整
diff --git a/qml/EditorTools.qml b/qml/EditorTools.qml
index 7d7099b..aa0872c 100644
--- a/qml/EditorTools.qml
+++ b/qml/EditorTools.qml
@@ -66,7 +66,7 @@ Item {
LiquidActionButton {
id: handle
visible: !!root.state.block && root.state.block.y >= 0 && root.state.block.y < root.height && root.editable
- x: Math.max(0, root.state.block ? root.state.block.x - 28 : 0); y: Math.max(0, root.state.block ? root.state.block.y : 0)
+ 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+方向键移动"
@@ -96,7 +96,7 @@ Item {
}
KosToolTip { target: handle; text: "拖动移动块,点击打开块操作" }
}
- Rectangle { visible: root.dragging; x: 28; y: root.dragY; width: Math.max(0, root.width - root.outlineWidth - 52); height: 2; color: AppTheme.border }
+ Rectangle { visible: root.dragging; x: root.source.inset; y: root.dragY; width: Math.max(0, root.width - root.outlineWidth - root.source.inset * 2); height: 2; color: AppTheme.border }
Component {
id: outlineView
ColumnLayout {
diff --git a/qml/FolderIcons.js b/qml/FolderIcons.js
index 0392d7f..28a5da6 100644
--- a/qml/FolderIcons.js
+++ b/qml/FolderIcons.js
@@ -1,33 +1,6 @@
.pragma library
-// App-specific subjects extend the shared 24px outline icon vocabulary.
-var paths = {
- work: "M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2 M5 7h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2 M3 12a20 20 0 0 0 18 0 M12 11v4",
- study: "M12 5v16 M12 5C9 3 5 3 2 4v15c4-1 7-1 10 2 3-3 6-3 10-2V4c-3-1-7-1-10 1",
- sport: "M15 2a2 2 0 1 0 0 4 2 2 0 1 0 0-4 M5 10l4-3 4 2 3 4h4 M13 9l-3 5 4 3v5 M10 14l-3 5H3",
- running: "M3 6h5l2 6 4 2 5 1a3 3 0 0 1 2 3v2H3z M3 17h18 M10 12l-2 2 M13 14l-2 2",
- cycling: "M6 13a4 4 0 1 0 0 8 4 4 0 1 0 0-8 M18 13a4 4 0 1 0 0 8 4 4 0 1 0 0-8 M6 17l4-8 4 8H6 M10 9h7 M15 5h3l-1 4 1 8 M8 6h4",
- swimming: "M18 7a2 2 0 1 0 0 4 2 2 0 1 0 0-4 M3 12l6-6 5 3-4 5 M3 16q2-2 4 0t4 0 4 0 4 0 M3 21q2-2 4 0t4 0 4 0 4 0",
- fitness: "M5 6h3v12H5z M16 6h3v12h-3z M8 12h8 M2 9v6 M22 9v6 M2 12h3 M19 12h3",
- basketball: "M12 3a9 9 0 1 0 0 18 9 9 0 1 0 0-18 M3 12h18 M12 3v18 M6 5c7 4 7 10 0 14 M18 5c-7 4-7 10 0 14",
- football: "M12 3a9 9 0 1 0 0 18 9 9 0 1 0 0-18 M12 8l5 3-2 5H9l-2-5z M12 3v5 M17 11l4-2 M15 16l3 3 M9 16l-3 3 M7 11 3 9",
- badminton: "M4 4l6 14 4-1L21 4z M9 4l3 13 M15 4l-2 13 M7 11h10 M10 18l1 3h3l1-4",
- tennis: "M14 3c-4 0-7 3-7 7s2 6 5 6 7-4 7-8-2-5-5-5z M9 15l-6 6 M10 5l6 8 M7 9l6 7 M9 12l8-7",
- shopping: "M5 7h14l2 14H3z M8 8V6a4 4 0 0 1 8 0v2",
- travel: "M22 2 9 15 M22 2l-5 20-5-10L2 7z",
- food: "M4 3v6a3 3 0 0 0 6 0V3 M7 3v18 M20 3c-4 3-5 7-5 10h5 M20 3v18",
- coffee: "M3 8h14v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4z M17 9h2a3 3 0 0 1 0 6h-2 M6 3v2 M10 2v3 M14 3v2",
- health: "M9 3h6v6h6v6h-6v6H9v-6H3V9h6z",
- finance: "M20 8V5H5a3 3 0 0 0 0 6h16v10H5a3 3 0 0 1-3-3V8 M21 13h-5v5h5 M17 15.5h.01",
- gift: "M3 8h18v5H3z M5 13v8h14v-8 M12 8v13 M12 8H7a3 3 0 1 1 3-3z M12 8h5a3 3 0 1 0-3-3z",
- pets: "M8 14c2-4 6-4 8 0l3 4c2 4-3 5-7 3-4 2-9 1-7-3z M7 5a2 3 0 1 0 4 0 2 3 0 1 0-4 0 M14 5a2 3 0 1 0 4 0 2 3 0 1 0-4 0 M2 10a2 2 0 1 0 4 0 2 2 0 1 0-4 0 M19 10a2 2 0 1 0 4 0 2 2 0 1 0-4 0",
- garden: "M20 3C6 1 1 8 6 16c8 5 15 0 14-13 M3 21 15 9",
- car: "M4 11l2-6h12l2 6 M3 11h18v8H3z M5 19v2 M19 19v2 M7 15h.01 M17 15h.01",
- code: "M8 6 2 12l6 6 M16 6l6 6-6 6 M14 3l-4 18",
- camera: "M8 6l2-3h4l2 3h4a2 2 0 0 1 2 2v11H2V8a2 2 0 0 1 2-2z M8 12a4 4 0 1 0 8 0 4 4 0 1 0-8 0",
- ideas: "M8 16c0-3-3-4-3-8a7 7 0 0 1 14 0c0 4-3 5-3 8z M8 19h8 M10 22h4"
-}
-
+// Persisted category identifiers. Rendering and aliases belong to Kos.Icons.
var choices = [
{ name: "folder", label: "文件夹" }, { name: "home", label: "家庭" },
{ name: "work", label: "工作" }, { name: "study", label: "学习" },
@@ -55,12 +28,3 @@ var choices = [
{ name: "sliders", label: "调节" }, { name: "circle", label: "圆环" },
{ name: "waves", label: "波浪" }, { name: "info", label: "资料" }
]
-
-function source(name, color) {
- if (!paths[name]) return ""
- var ink = "rgb(" + Math.round(color.r * 255) + "," + Math.round(color.g * 255) + "," + Math.round(color.b * 255) + ")"
- return "data:image/svg+xml;utf8," + encodeURIComponent(
- '')
-}
diff --git a/qml/Main.qml b/qml/Main.qml
index b2d08c6..d4f9929 100644
--- a/qml/Main.qml
+++ b/qml/Main.qml
@@ -64,7 +64,7 @@ ApplicationWindow {
readonly property bool showSidebar: hasLibrary && (width >= 1000 ? sidebarRequested : width >= 800 && compactSidebarRequested)
readonly property bool showList: hasLibrary && !(width < 1000 && showSidebar) && (width >= 800 || listRequested || (trashView ? !recycleBin.currentEntry : !controller.path))
readonly property real editorX: (showSidebar ? sidebarWidth : 0) + (showList ? (width < 800 ? width : listWidth) : 0)
- readonly property bool popupsOpen: (source.popupActive || knowledgePanel.popupActive || smartDialog.visible || missingLinkDialog.visible) || libraryActions.popupActive || organizer.popupActive || recycleBin.popupActive || actionMenu.visible || outputMenu.visible || formatMenu.visible || tableMenu.visible || linkPanel.visible || paragraphSelect.popupOpened || namePanel.visible || recoveryDialog.visible || discardDialog.visible || folderPanel.visible || notesSettings.visible || discardRecoveryDialog.visible
+ readonly property bool popupsOpen: (source.popupActive || knowledgePanel.popupActive || smartDialog.visible || missingLinkDialog.visible) || libraryActions.popupActive || organizer.popupActive || recycleBin.popupActive || actionMenu.visible || outputMenu.visible || formatMenu.visible || tableMenu.visible || linkPanel.visible || paragraphMenu.visible || namePanel.visible || recoveryDialog.visible || discardDialog.visible || folderPanel.visible || notesSettings.visible || discardRecoveryDialog.visible
onClosing: function(close) {
if (source.exporting) { close.accepted = false; source.notify("正在导出,请完成后再关闭窗口"); return }
controller.setPreference("width", width); controller.setPreference("height", height)
@@ -133,7 +133,8 @@ ApplicationWindow {
function handleKey(event) {
if (model === controller.noteModel && event.modifiers === Qt.ControlModifier && event.key === Qt.Key_A) { organizer.selectAll(); event.accepted = true; return }
if (model === controller.noteModel && event.modifiers === Qt.ControlModifier && event.key === Qt.Key_Space && currentIndex >= 0) { organizer.toggle(controller.notes[currentIndex].path); event.accepted = true; return }
- if (model === controller.noteModel && event.key === Qt.Key_Escape && organizer.selectionMode) { organizer.clearSelection(); event.accepted = true; return }
+ if (keyboardList === childFolderList && event.modifiers === Qt.ControlModifier && event.key === Qt.Key_A) { organizer.beginSelection("folders"); organizer.selectAll(); event.accepted = true; return }
+ if (event.key === Qt.Key_Escape && organizer.selectionMode) { organizer.clearSelection(); event.accepted = true; return }
if (model === controller.noteModel && currentItem && (event.key === Qt.Key_Menu || (event.key === Qt.Key_F10 && event.modifiers === Qt.ShiftModifier))) {
organizer.openMenu(currentItem, controller.notes[currentIndex].path)
event.accepted = true; return
@@ -181,7 +182,8 @@ ApplicationWindow {
signal clickedItem(int button, int modifiers, point position)
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
- drag.target: pressedButtons & Qt.LeftButton ? organizePreview : null
+ property bool dragEnabled: true
+ drag.target: dragEnabled && (pressedButtons & Qt.LeftButton) ? organizePreview : null
drag.axis: Drag.XAndYAxis
onPressed: function(mouse) {
didDrag = false
@@ -319,6 +321,9 @@ ApplicationWindow {
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
}
component ListNavigationActions: LiquidActionButtonGroup {
@@ -327,8 +332,8 @@ ApplicationWindow {
materialForm: false; backdrop: readingBackdrop
icons: root.currentFolder ? ["back", "more"] : ["more"]
labels: root.currentFolder
- ? ["返回" + (organizer.parentFolder(root.currentFolder) ? organizer.parentFolder(root.currentFolder).split("/").pop() : "全部笔记"), "笔记列表操作"]
- : ["笔记列表操作"]
+ ? ["返回" + (organizer.parentFolder(root.currentFolder) ? organizer.parentFolder(root.currentFolder).split("/").pop() : "全部笔记"), "列表操作"]
+ : ["列表操作"]
enabledActions: root.currentFolder ? [!controller.busy, true] : [true]
onTriggered: function(index) {
if (root.currentFolder && index === 0) root.browseParentFolder()
@@ -401,7 +406,7 @@ ApplicationWindow {
id: entryButton
width: parent.width - (entryMenu.visible ? 28 : 0)
iconName: controller.folderIcons[entry.folderPath] || "folder"; text: entry.folderPath.split("/").pop()
- iconSource: FolderIcons.source(iconName, tokens.primaryYellow)
+
checked: !root.trashView && (controller.filter === "folder:" + entry.folderPath || (entry.highlightBranch && controller.filter.startsWith("folder:" + entry.folderPath + "/")))
focusPolicy: Qt.StrongFocus
activeFocusOnTab: !entry.keyboardView
@@ -488,64 +493,146 @@ ApplicationWindow {
}
ListNavigationActions {}
}
- RowLayout {
+ Item {
id: headerActions
visible: root.hasLibrary
x: root.editorX < root.width ? Math.max(178, root.editorX + 12) : 178
anchors.verticalCenter: parent.verticalCenter
- // Position against the actual declared circle, including its click inset.
readonly property real controlsLeft: windowControls.x + (windowControls.buttonLayout.length ? windowControls.buttonLayout[0].x : 0)
- width: Math.max(0, controlsLeft - 10 - x); height: tokens.headerButtonHeight; spacing: 10
- readonly property bool wideSearch: root.width >= 1320 && width >= 330
+ 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
readonly property bool searchShown: wideSearch || root.searchOpen || controller.query.length > 0
readonly property bool searching: !wideSearch && searchShown
- HeaderAction { objectName: "newNoteButton"; visible: !headerActions.searching; iconName: "editRounded"; help: "新建笔记 · Ctrl+N"; enabled: !controller.busy; onClicked: root.newNote() }
- LiquidActionButtonGroup {
- id: historyButtons
- visible: parent.width > 245 && !headerActions.searching
- buttonSize: tokens.headerButtonHeight; materialForm: false; backdrop: readingBackdrop
- icons: ["undo", "forward"]; labels: [controller.undoText, controller.redoText]
- enabledActions: [!root.popupsOpen && controller.canUndo, !root.popupsOpen && controller.canRedo]
- onTriggered: function(index) { if (index === 0) controller.undo(); else controller.redo() }
+ Row {
+ id: noteActions
+ visible: !headerActions.searching
+ height: parent.height; spacing: 8
+ HeaderAction { objectName: "newNoteButton"; iconName: "editRounded"; help: "新建笔记 · Ctrl+N"; enabled: !controller.busy; onClicked: root.newNote() }
+ HeaderAction { id: moreButton; enabled: !!controller.path; iconName: "more"; help: "更多笔记操作"; onClicked: actionMenu.openFromButton(moreButton) }
}
- HeaderAction { id: moreButton; enabled: !!controller.path; visible: !headerActions.searching; iconName: "more"; help: "更多笔记操作"; onClicked: { actionMenu.openFromButton(moreButton) } }
- Item { visible: !headerActions.searching; Layout.fillWidth: true; Layout.minimumWidth: 0 }
- HeaderAction { iconName: "settings"; help: "设置 · Ctrl+,"; visible: !headerActions.searching; onClicked: notesSettings.show(0) }
- Item {
- visible: headerActions.searchShown
- Layout.preferredWidth: 160; Layout.fillWidth: headerActions.searching
- Layout.minimumWidth: 0; Layout.fillHeight: true
- clip: true
- LiquidTextField {
- id: search
- objectName: "searchField"
- anchors.fill: parent
- materialForm: false; liquidFinish: true; liquidIntensity: "light"; backdrop: readingBackdrop
- placeholderText: "搜索笔记"; text: controller.query
- leftPadding: 30; rightPadding: headerActions.searching ? 36 : 10
- VectorIcon {
- x: 8; anchors.verticalCenter: parent.verticalCenter
- name: "search"; size: tokens.headerIconSize; color: search.textColor
+ Flickable {
+ 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
+ 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
+ flickableDirection: Flickable.HorizontalFlick
+ boundsBehavior: Flickable.StopAtBounds
+ // Keep the shared surface edges intact when no scrolling is needed.
+ clip: contentWidth > width
+ onWidthChanged: contentX = Math.max(0, Math.min(contentX, contentWidth - width))
+ onContentWidthChanged: contentX = Math.max(0, Math.min(contentX, contentWidth - width))
+ ScrollBar.horizontal: ScrollBar { policy: ScrollBar.AsNeeded; height: 3 }
+ WheelHandler {
+ onWheel: function(event) {
+ if (editorActionsViewport.contentWidth <= editorActionsViewport.width) return
+ const delta = event.pixelDelta.x || event.pixelDelta.y || event.angleDelta.x / 2 || event.angleDelta.y / 2
+ editorActionsViewport.contentX = Math.max(0, Math.min(editorActionsViewport.contentWidth - editorActionsViewport.width, editorActionsViewport.contentX - delta))
+ event.accepted = true
}
- onTextEdited: if (!inputMethodComposing) root.updateGlobalQuery(text)
- onInputMethodComposingChanged: if (!inputMethodComposing && text !== controller.query) root.updateGlobalQuery(text)
- onActiveFocusChanged: if (!activeFocus && !headerActions.wideSearch && text.length === 0 && root.searchOpen) root.closeGlobalSearch()
- Keys.onEscapePressed: function(event) { if (!inputMethodComposing) { root.closeGlobalSearch(); event.accepted = true } }
- Accessible.name: "搜索所有笔记"
}
- HeaderAction {
- anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter
- visible: headerActions.searching
- iconName: "plus"; contentItem.rotation: 45; help: "关闭搜索"
- enabled: !search.inputMethodComposing
- onClicked: root.closeGlobalSearch()
+ Connections {
+ target: root
+ function onActiveFocusItemChanged() {
+ const item = root.activeFocusItem
+ if (!item || !editorActionsViewport.visible) return
+ let ancestor = item
+ while (ancestor && ancestor !== editorActions) ancestor = ancestor.parent
+ if (!ancestor) return
+ const left = item.mapToItem(editorActions, 0, 0).x
+ const desired = left < editorActionsViewport.contentX ? left
+ : Math.max(editorActionsViewport.contentX, left + item.width - editorActionsViewport.width)
+ editorActionsViewport.contentX = Math.max(0, Math.min(editorActionsViewport.contentWidth - editorActionsViewport.width, desired))
+ }
+ }
+ LiquidActionButtonGroup {
+ id: editorActions
+ objectName: "bodyEditingButtonGroup"
+ height: tokens.headerButtonHeight
+ anchors.verticalCenter: parent.verticalCenter
+ clip: false
+ buttonSize: tokens.headerButtonHeight; 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", "列表与插入", "表格与行列操作", "更多编辑工具"]
+ enabledActions: session.sourceMode
+ ? [!root.popupsOpen && controller.canUndo, !root.popupsOpen && controller.canRedo, source.visible && !controller.busy && !session.composing]
+ : [!root.popupsOpen && controller.canUndo, !root.popupsOpen && controller.canRedo, root.canFormat, root.canFormat, root.canFormat, root.canFormat, root.canFormat, root.canFormat, source.visible && !source.exporting]
+ onTriggered: function(index) {
+ if (index === 0) controller.undo()
+ else if (index === 1) controller.redo()
+ else if (session.sourceMode) session.execute("sourceMode")
+ else if (index === 2) paragraphMenu.openFromButton(paragraphButton)
+ else if (index <= 5) root.applyFormat(["bold", "italic", "strike"][index - 3])
+ else if (index === 6) formatMenu.openFromButton(insertButton)
+ else if (index === 7) tableMenu.openFromButton(tableButton)
+ else source.openTools(editorToolsButton)
+ }
+ // Text marks fill the cells missing from the shared icon catalogue.
+ Repeater {
+ model: session.sourceMode ? ["正文"] : [session.headingLevel ? "H" + session.headingLevel : "正文", "B", "I", "S"]
+ Text {
+ required property string modelData
+ required property int index
+ x: (index + 2) * editorActions.buttonSize
+ width: editorActions.buttonSize; height: editorActions.buttonSize
+ text: modelData; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter
+ color: editorActions.enabledActions[index + 2] ? AppTheme.text : AppTheme.disabledText
+ font.pixelSize: index === 0 ? 10 : 14
+ font.bold: index === 1; font.italic: index === 2; font.strikeout: index === 3
+ Accessible.ignored: true
+ }
+ }
+ Item { id: paragraphButton; x: 2 * editorActions.buttonSize; width: editorActions.buttonSize; height: width }
+ Item { id: insertButton; x: 6 * editorActions.buttonSize; width: editorActions.buttonSize; height: width }
+ Item { id: tableButton; x: 7 * editorActions.buttonSize; width: editorActions.buttonSize; height: width }
+ Item { id: editorToolsButton; x: 8 * editorActions.buttonSize; width: editorActions.buttonSize; height: width }
}
}
- HeaderAction {
- objectName: "searchToggleButton"
- visible: !headerActions.searchShown
- iconName: "search"; help: "搜索所有笔记"; enabled: !controller.busy
- onClicked: root.openGlobalSearch()
+ RowLayout {
+ id: globalActions
+ anchors.right: parent.right
+ width: headerActions.searching ? parent.width : implicitWidth
+ height: parent.height; spacing: 10
+ HeaderAction { iconName: "settings"; help: "设置 · Ctrl+,"; visible: !headerActions.searching; onClicked: notesSettings.show(0) }
+ Item {
+ visible: headerActions.searchShown
+ Layout.preferredWidth: 160; Layout.fillWidth: headerActions.searching
+ Layout.minimumWidth: 0; Layout.fillHeight: true
+ clip: true
+ LiquidTextField {
+ id: search
+ objectName: "searchField"
+ anchors.fill: parent
+ materialForm: false; liquidFinish: true; liquidIntensity: "light"; backdrop: readingBackdrop
+ placeholderText: "搜索笔记"; text: controller.query
+ leftPadding: 30; rightPadding: headerActions.searching ? 36 : 10
+ VectorIcon {
+ x: 8; anchors.verticalCenter: parent.verticalCenter
+ name: "search"; size: tokens.headerIconSize; color: search.textColor
+ }
+ onTextEdited: if (!inputMethodComposing) root.updateGlobalQuery(text)
+ onInputMethodComposingChanged: if (!inputMethodComposing && text !== controller.query) root.updateGlobalQuery(text)
+ onActiveFocusChanged: if (!activeFocus && !headerActions.wideSearch && text.length === 0 && root.searchOpen) root.closeGlobalSearch()
+ Keys.onEscapePressed: function(event) { if (!inputMethodComposing) { root.closeGlobalSearch(); event.accepted = true } }
+ Accessible.name: "搜索所有笔记"
+ }
+ HeaderAction {
+ anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter
+ visible: headerActions.searching
+ iconName: "plus"; contentItem.rotation: 45; help: "关闭搜索"
+ enabled: !search.inputMethodComposing
+ onClicked: root.closeGlobalSearch()
+ }
+ }
+ HeaderAction {
+ objectName: "searchToggleButton"
+ visible: !headerActions.searchShown
+ iconName: "search"; help: "搜索所有笔记"; enabled: !controller.busy
+ onClicked: root.openGlobalSearch()
+ }
}
}
NotesWindowControls {
@@ -565,11 +652,11 @@ ApplicationWindow {
id: source
session: root.session; controller: root.controller
sceneItem: readingScene
- allowRestoreFocus: !root.popupsOpen
+ allowRestoreFocus: !root.popupsOpen && !noteNameEditor.editing
visible: root.hasLibrary && !root.trashView && !!controller.path && root.editorX < root.width
- x: root.editorX; y: formatBar.y + formatBar.height + 8 + (controller.externalDocument ? 36 : 0) + (findBar.visible ? findBar.implicitHeight + 8 : 0)
+ x: root.editorX; y: 8 + (controller.externalDocument ? 36 : noteNameEditor.height + 8) + (findBar.visible ? findBar.implicitHeight + 8 : 0)
width: Math.max(0, parent.width - x); height: Math.max(0, parent.height - y - statusArea.height)
- bodySize: notesSettings.bodySize; readingWidth: tokens.readingWidth; inset: tokens.inset
+ bodySize: notesSettings.bodySize; readingWidth: tokens.readingWidth; inset: tokens.editorInset
}
@@ -595,43 +682,6 @@ ApplicationWindow {
Qt.callLater(deliverFormat)
}
Connections { target: session; function onStateChanged() { if (root.pendingFormat) Qt.callLater(root.deliverFormat) } }
- Row {
- id: formatBar
- visible: source.visible
- x: root.editorX + 16; y: 6; height: 26; spacing: 8
- LiquidActionButton { visible: session.sourceMode; text: "返回正文"; materialForm: true; implicitHeight: 26; enabled: !controller.busy && !session.composing; onClicked: session.execute("sourceMode") }
- LiquidSelect {
- visible: !session.sourceMode
- id: paragraphSelect
- implicitHeight: 26; materialForm: false; backdrop: readingBackdrop
- appearanceMode: AppTheme.appearanceMode
- model: ["正文", "标题 1", "标题 2", "标题 3", "标题 4", "标题 5", "标题 6"]
- currentIndex: session.headingLevel
- enabled: root.canFormat
- Accessible.name: "段落样式"
- onActivated: function(index) { root.applyFormat(index === 0 ? "paragraph" : "heading" + index) }
- }
- LiquidActionButtonGroup {
- visible: !session.sourceMode
- buttonSize: 26; materialForm: false; backdrop: readingBackdrop
- icons: ["", "", ""]; labels: ["粗体 · Ctrl+B", "斜体 · Ctrl+I", "删除线 · Ctrl+Shift+X"]
- enabledActions: [root.canFormat, root.canFormat, root.canFormat]
- onTriggered: function(index) { root.applyFormat(["bold", "italic", "strike"][index]) }
- // Shared group owns surfaces and interaction; the pinned icon catalogue
- // has no italic/strike glyphs, so supply typographic marks only.
- Row {
- anchors.fill: parent
- Repeater {
- model: ["B", "I", "S"]
- Text { required property string modelData; required property int index; width: 26; height: 26; text: modelData; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter; color: root.canFormat ? AppTheme.text : AppTheme.disabledText; font.pixelSize: 14; font.bold: index === 0; font.italic: index === 1; font.strikeout: index === 2; Accessible.ignored: true }
- }
- }
- }
- ToolbarAction { id: insertButton; visible: !session.sourceMode; iconName: "reminder"; help: "列表与插入"; enabled: root.canFormat; onClicked: formatMenu.openFromButton(insertButton) }
- ToolbarAction { id: tableButton; visible: !session.sourceMode; iconName: "grid"; help: "表格与行列操作"; enabled: root.canFormat; onClicked: tableMenu.openFromButton(tableButton) }
- ToolbarAction { id: editorToolsButton; visible: !session.sourceMode; iconName: "more"; help: "更多编辑工具"; enabled: !source.exporting; onClicked: source.openTools(editorToolsButton) }
- Label { visible: root.width - root.editorX > 640; anchors.verticalCenter: parent.verticalCenter; text: session.formatState; font.pixelSize: 10; color: AppTheme.mutedText }
- }
LiquidBackdrop { id: readingBackdrop; sourceItem: readingScene; registerAsDefault: false }
KosEmptyState {
anchors.centerIn: parent; width: Math.min(420, root.width - 64)
@@ -721,7 +771,7 @@ ApplicationWindow {
visible: !!root.currentFolder && (root.childFolders.length > 0 || organizer.dragKind === "folder")
Layout.fillWidth: true
Layout.preferredHeight: root.childFoldersExpanded
- ? Math.min(childFolderHeading.implicitHeight + 6 + childFolderList.contentHeight, Math.max(100, noteColumnLayout.height * 0.35))
+ ? Math.min(childFolderHeading.implicitHeight + 6 + childFolderList.contentHeight + 8, Math.max(100, noteColumnLayout.height * 0.35))
: childFolderHeading.implicitHeight
ColumnLayout {
anchors.fill: parent; spacing: 6
@@ -730,7 +780,7 @@ ApplicationWindow {
Layout.fillWidth: true; spacing: 4
Label {
Layout.fillWidth: true; leftPadding: 0
- text: "子文件夹 · " + root.childFolders.length
+ text: "子文件夹 · " + root.childFolders.length + (organizer.folderSelectionMode ? " · 已选 " + organizer.selectedFolderPaths.length : "")
color: AppTheme.mutedText; font.pixelSize: 11; font.weight: Font.Medium
DropArea {
id: collapsedFolderDrop
@@ -756,11 +806,16 @@ ApplicationWindow {
Item {
visible: root.childFoldersExpanded
Layout.fillWidth: true; Layout.fillHeight: true
+ Rectangle {
+ anchors.fill: parent; radius: 10
+ color: AppTheme.dark ? "#303032" : "#f7f7f8"
+ }
KeyboardList {
id: childFolderList
visible: childFolderSection.visible && root.childFoldersExpanded
anchors.fill: parent
- clip: true; spacing: 4
+ anchors.topMargin: 4; anchors.bottomMargin: 4
+ clip: true; spacing: 0
model: root.childFolders
Accessible.name: "子文件夹"
delegate: ItemDelegate {
@@ -769,32 +824,43 @@ ApplicationWindow {
required property int index
readonly property var details: controller.folderDetails[modelData] || ({})
readonly property string folderIcon: controller.folderIcons[modelData] || "folder"
- readonly property string iconUrl: FolderIcons.source(folderIcon, tokens.primaryYellow)
+
readonly property string summary: (details.createdUtc
? new Date(details.createdUtc).toLocaleDateString(Qt.locale(), "yyyy/M/d") + " 创建"
: "创建日期未记录") + " · " + (details.noteCount || 0) + " 篇笔记"
- width: childFolderList.width; height: tokens.noteRowHeight
- leftPadding: 12; rightPadding: 12; topPadding: 12; bottomPadding: 12
+ width: childFolderList.width
+ height: contentItem.implicitHeight + topPadding + bottomPadding
+ leftPadding: organizer.folderSelectionMode ? 36 : 12
+ rightPadding: 12; topPadding: 7; bottomPadding: 7
+ readonly property bool bulkSelected: organizer.selectedFolderPaths.includes(modelData)
focusPolicy: Qt.StrongFocus; activeFocusOnTab: false
- function activate() { root.browseCategory("folder:" + modelData) }
+ function activate() {
+ if (organizer.folderSelectionMode) organizer.toggleFolder(modelData)
+ else root.browseCategory("folder:" + modelData)
+ }
function openContext(position) { organizer.openFolderMenu(modelData, childFolderRow, position || Qt.point(width / 2, height / 2)) }
Keys.onPressed: function(event) { childFolderList.handleKey(event) }
onActiveFocusChanged: if (activeFocus) { childFolderList.currentIndex = index; childFolderList.cursorKey = modelData }
- background: Rectangle {
- radius: 10; border.width: 0
- color: childFolderRow.hovered ? AppTheme.controlBackground : "transparent"
+ background: Item {
+ Rectangle {
+ anchors.fill: parent; anchors.margins: 4
+ radius: 7; border.width: 0
+ color: childFolderRow.bulkSelected ? tokens.navigationSelected : childFolderRow.hovered ? AppTheme.controlBackground : "transparent"
+ }
}
contentItem: Column {
spacing: 3
- Row {
+ RowLayout {
width: parent.width; spacing: 6
Item {
- width: 16; height: 18
- VectorIcon { anchors.centerIn: parent; visible: !childFolderRow.iconUrl; name: childFolderRow.folderIcon; size: 16; color: tokens.primaryYellow }
- Image { anchors.centerIn: parent; visible: !!childFolderRow.iconUrl; source: childFolderRow.iconUrl; width: 16; height: 16; sourceSize: Qt.size(32, 32) }
+ Layout.preferredWidth: 16; Layout.preferredHeight: 16
+ Layout.alignment: Qt.AlignVCenter
+ VectorIcon { anchors.centerIn: parent; name: childFolderRow.folderIcon; size: 16; color: tokens.primaryYellow }
+
}
Label {
- width: parent.width - 22
+ Layout.fillWidth: true; Layout.minimumWidth: 0
+ Layout.alignment: Qt.AlignVCenter
text: childFolderRow.modelData.split("/").pop(); textFormat: Text.PlainText
elide: Text.ElideRight; font.pixelSize: 14; font.weight: Font.DemiBold; color: AppTheme.text
}
@@ -803,15 +869,24 @@ ApplicationWindow {
}
OrganizePointer {
folderPath: childFolderRow.modelData; enabled: !controller.busy
+ dragEnabled: !organizer.folderSelectionMode
onClickedItem: function(button, modifiers, position) {
childFolderRow.forceActiveFocus(Qt.MouseFocusReason)
if (button === Qt.RightButton) childFolderRow.openContext(position)
else childFolderRow.activate()
}
}
+ NotesSelectionBox {
+ visible: organizer.folderSelectionMode; enabled: !controller.busy
+ x: 6; y: 7; width: 24; height: 24; padding: 4
+ checked: childFolderRow.bulkSelected; text: ""
+ Accessible.name: "选择文件夹 " + childFolderRow.modelData.split("/").join(" › ")
+ onClicked: organizer.toggleFolder(childFolderRow.modelData)
+ }
FolderDropTarget { folderPath: childFolderRow.modelData }
onClicked: activate()
Accessible.role: Accessible.ListItem
+ Accessible.selected: bulkSelected
Accessible.name: "文件夹 " + modelData.split("/").pop() + "," + summary
Accessible.onPressAction: activate()
}
@@ -853,6 +928,7 @@ ApplicationWindow {
Keys.onPressed: function(event) { ListView.view.handleKey(event) }
onActiveFocusChanged: if (activeFocus) { ListView.view.currentIndex = index; ListView.view.cursorKey = ListView.view.keyAt(index) }
function activate() {
+ if (organizer.noteSelectionMode) { organizer.toggle(modelData.path); return }
organizer.clearSelection()
organizer.selectionAnchor = modelData.path
controller.openNote(modelData.path, modelData.match === undefined ? -1 : modelData.match)
@@ -874,8 +950,8 @@ ApplicationWindow {
color: AppTheme.border; opacity: 0.6
}
// Show selection controls only during an explicit multi-selection.
- leftPadding: organizer.selectionMode ? 36 : 12; topPadding: contentTopInset + groupHeight; bottomPadding: 12
- highlighted: organizer.selectionMode ? bulkSelected : organizer.contextPath ? modelData.path === organizer.contextPath : (modelData.id === controller.selectedId && modelData.path === controller.path)
+ leftPadding: organizer.noteSelectionMode ? 36 : 12; topPadding: contentTopInset + groupHeight; bottomPadding: 12
+ highlighted: organizer.noteSelectionMode ? bulkSelected : organizer.contextPath ? modelData.path === organizer.contextPath : (modelData.id === controller.selectedId && modelData.path === controller.path)
background: Rectangle {
y: noteRow.groupHeight; height: noteRow.height - y
radius: 10; color: noteRow.highlighted ? tokens.notesYellow : noteRow.hovered ? AppTheme.controlBackground : "transparent"
@@ -909,7 +985,7 @@ ApplicationWindow {
}
NotesSelectionBox {
id: noteSelectionBox
- visible: organizer.selectionMode
+ visible: organizer.noteSelectionMode
x: 6; y: noteRow.groupHeight + noteRow.contentTopInset; width: 24; height: 24
padding: 4
checked: noteRow.bulkSelected; text: ""
@@ -919,7 +995,7 @@ ApplicationWindow {
onClicked: activate()
Accessible.role: Accessible.ListItem
Accessible.focusable: true
- Accessible.selected: organizer.selectionMode ? bulkSelected : highlighted
+ Accessible.selected: organizer.noteSelectionMode ? bulkSelected : highlighted
Accessible.onPressAction: activate()
Accessible.name: modelData.title + "," + root.noteLocation(modelData.path) + (rowStatus ? ", " + rowStatus : "")
}
@@ -956,8 +1032,16 @@ ApplicationWindow {
id: editorChrome
visible: root.hasLibrary && !root.trashView && root.editorX < root.width
x: root.editorX; width: root.width - x; height: parent.height
+ NoteNameEditor {
+ id: noteNameEditor
+ visible: source.visible && !controller.externalDocument
+ x: tokens.editorInset; y: 8; width: Math.max(0, parent.width - tokens.editorInset * 2)
+ height: implicitHeight
+ controller: root.controller; canRename: root.canEdit
+ onSubmitted: root.editNote()
+ }
ColumnLayout {
- id: findBar; visible: false; x: tokens.inset; y: formatBar.y + formatBar.height + 8 + (controller.externalDocument ? 36 : 0); width: parent.width - tokens.inset * 2; spacing: 4
+ id: findBar; visible: false; x: tokens.editorInset; y: 8 + (controller.externalDocument ? 36 : noteNameEditor.height + 8); width: Math.max(0, parent.width - tokens.editorInset * 2); spacing: 4
RowLayout {
Layout.fillWidth: true; spacing: 4
LiquidTextField { id: localFind; Layout.fillWidth: true; implicitHeight: 26; materialForm: true; placeholderText: "查找当前正文"; Accessible.name: "正文查找"; onTextEdited: if (!inputMethodComposing) root.updateSearch(); onInputMethodComposingChanged: if (!inputMethodComposing) root.updateSearch(); onAccepted: root.findNext() }
@@ -976,7 +1060,7 @@ ApplicationWindow {
}
RowLayout {
visible: controller.externalDocument
- x: tokens.inset; y: formatBar.y + formatBar.height + 8; width: parent.width - tokens.inset * 2
+ x: tokens.editorInset; y: 8; width: Math.max(0, parent.width - tokens.editorInset * 2)
Label { Layout.fillWidth: true; text: "外部文件 · " + controller.path.slice(9); textFormat: Text.PlainText; elide: Text.ElideMiddle; color: AppTheme.mutedText; font.pixelSize: 12 }
LiquidActionButton { text: "导入为笔记…"; materialForm: true; enabled: !controller.busy; onClicked: controller.importExternal() }
LiquidActionButton { text: "结束临时编辑"; materialForm: true; enabled: !controller.busy; onClicked: controller.closeExternal() }
@@ -994,7 +1078,7 @@ ApplicationWindow {
x: root.hasLibrary && root.editorX < root.width ? root.editorX : 16; width: Math.max(0, root.width - x - 16)
anchors.bottom: parent.bottom; spacing: 6
visible: (!root.trashView && root.editorX < root.width) || !!controller.error
- Label { Layout.fillWidth: true; elide: Text.ElideRight; text: controller.status + (controller.path && source.editorState.stats && source.editorState.stats.characters !== undefined ? " · " + source.editorState.stats.characters + " 字 · " + (source.editorState.stats.approximate ? "约 " : "") + source.editorState.stats.words + " 词 · 约 " + source.editorState.stats.minutes + " 分钟" + (source.editorState.stats.selected ? " · 选中 " + source.editorState.stats.selected + " 字" : "") : ""); color: AppTheme.mutedText; font.pixelSize: 10; leftPadding: tokens.inset; bottomPadding: controller.error ? 0 : 12 }
+ Label { Layout.fillWidth: true; elide: Text.ElideRight; text: controller.status + (source.visible && session.formatState ? " · " + session.formatState : "") + (controller.path && source.editorState.stats && source.editorState.stats.characters !== undefined ? " · " + source.editorState.stats.characters + " 字 · " + (source.editorState.stats.approximate ? "约 " : "") + source.editorState.stats.words + " 词 · 约 " + source.editorState.stats.minutes + " 分钟" + (source.editorState.stats.selected ? " · 选中 " + source.editorState.stats.selected + " 字" : "") : ""); color: AppTheme.mutedText; font.pixelSize: 10; leftPadding: tokens.editorInset; bottomPadding: controller.error ? 0 : 12 }
Rectangle {
visible: !!controller.error
Layout.fillWidth: true; implicitHeight: errorColumn.implicitHeight + 24; radius: 8; color: AppTheme.controlBackground
@@ -1019,17 +1103,31 @@ ApplicationWindow {
}
function restoreEditorFocus() {
// A menu action may open another panel or the local search field.
- if (!root.popupsOpen && !localFind.activeFocus && source.visible)
+ if (!root.popupsOpen && !localFind.activeFocus && !noteNameEditor.editing && source.visible)
source.editor.forceActiveFocus(Qt.OtherFocusReason)
}
function updateSearch() { source.action("search", {query: localFind.text, caseSensitive: caseSensitive.checked}) }
function findNext() { if (localFind.text) { updateSearch(); source.action("findNext", {direction: 1}) } }
function showRecovery() { controller.inspectRecovery(); recoveryDialog.open() }
function askName(kind, anchor, originMenu) {
- namePanel.kind = kind
- nameField.text = kind === "copy" ? "笔记副本" : kind === "move" ? (root.currentNote.title || controller.path.split("/").pop().replace(/\.md$/i, "")) : ""
+ if (kind === "move") { Qt.callLater(noteNameEditor.edit); return }
+ nameField.text = "笔记副本"
if (originMenu) namePanel.openFromMenu(originMenu); else namePanel.openFromButton(anchor)
}
+ NotesPopover {
+ id: paragraphMenu; anchorItem: paragraphButton; sourceItem: readingScene
+ onClosed: Qt.callLater(root.restoreEditorFocus)
+ Repeater {
+ model: ["正文", "标题 1", "标题 2", "标题 3", "标题 4", "标题 5", "标题 6"]
+ LiquidPopoverItem {
+ required property string modelData
+ required property int index
+ text: modelData; font.bold: session.headingLevel === index
+ enabled: root.canFormat
+ onTriggered: root.applyFormat(index === 0 ? "paragraph" : "heading" + index)
+ }
+ }
+ }
NotesPopover {
id: formatMenu; anchorItem: insertButton; sourceItem: readingScene
onClosed: Qt.callLater(root.restoreEditorFocus)
@@ -1090,7 +1188,7 @@ ApplicationWindow {
LiquidPopoverItem { text: root.currentNote.pinned ? "取消置顶" : "置顶"; visible: !controller.externalDocument; height: visible ? implicitHeight : 0; enabled: !!controller.path && !controller.busy; onTriggered: controller.organize(root.currentNote.pinned ? "unpin" : "pin", [controller.path]) }
LiquidPopoverItem { text: root.currentNote.favorite ? "取消收藏" : "收藏"; visible: !controller.externalDocument; height: visible ? implicitHeight : 0; enabled: !!controller.path && !controller.busy; onTriggered: controller.favorite() }
LiquidPopoverItem { text: "移动到文件夹…"; visible: !controller.externalDocument; height: visible ? implicitHeight : 0; enabled: !!controller.path && !controller.busy; onTriggered: organizer.moveNotes([controller.path], null, actionMenu) }
- LiquidPopoverItem { text: "修改标题…"; enabled: !!controller.path && !controller.externalDocument && !controller.busy; onTriggered: root.askName("move", null, actionMenu) }
+ LiquidPopoverItem { text: "修改笔记名称…"; enabled: !!controller.path && !controller.externalDocument && !controller.busy; onTriggered: root.askName("move", null, actionMenu) }
LiquidPopoverItem { text: "笔记信息…"; enabled: !!controller.path && !controller.externalDocument && !controller.busy; onTriggered: knowledgePanel.open("properties") }
LiquidPopoverItem { text: "分享与导出…"; enabled: !!controller.path && !controller.busy; onTriggered: outputMenu.openFromMenu(actionMenu) }
LiquidPopoverItem { text: "创建副本"; enabled: !!controller.path && !controller.externalDocument && !controller.busy; onTriggered: controller.organize("duplicate", [controller.path]) }
@@ -1108,20 +1206,18 @@ ApplicationWindow {
NotesContentPopover {
id: namePanel; sourceItem: readingScene; preferredWidth: 330
onOpened: nameField.forceActiveFocus()
- property string kind: "move"
- readonly property bool validName: !!nameField.text.trim() && !/[\r\n]/.test(nameField.text) && (kind === "move" ? nameField.text.trim().length <= 300 : !/[\\/]/.test(nameField.text) && ![".", "..", ".git", ".nextnotes"].includes(nameField.text.trim()))
+ readonly property bool validName: !!nameField.text.trim() && !/[\r\n\\/]/.test(nameField.text) && ![".", "..", ".git", ".nextnotes"].includes(nameField.text.trim())
contentItem: ColumnLayout {
- Label { text: namePanel.kind === "move" ? "修改笔记标题" : "保存独立副本"; font.weight: Font.DemiBold; color: AppTheme.text }
- Label { Layout.fillWidth: true; text: namePanel.kind === "move" ? "修改列表中的标题;正文开头若有标题,也会一并更新。" : "创建一篇独立笔记。"; wrapMode: Text.Wrap; color: AppTheme.mutedText; font.pixelSize: 12 }
- LiquidTextField { id: nameField; Layout.fillWidth: true; materialForm: true; Accessible.name: namePanel.kind === "move" ? "笔记标题" : "副本名称"; placeholderText: Accessible.name; onAccepted: namePanel.submit() }
+ Label { text: "保存独立副本"; font.weight: Font.DemiBold; color: AppTheme.text }
+ Label { Layout.fillWidth: true; text: "创建一篇独立笔记。"; wrapMode: Text.Wrap; color: AppTheme.mutedText; font.pixelSize: 12 }
+ LiquidTextField { id: nameField; Layout.fillWidth: true; materialForm: true; Accessible.name: "副本名称"; placeholderText: Accessible.name; onAccepted: namePanel.submit() }
LiquidActionButton { text: "确认"; materialForm: true; enabled: namePanel.validName && !nameField.inputMethodComposing && !controller.busy; onClicked: namePanel.submit() }
}
function submit() {
if (!validName || nameField.inputMethodComposing) return
const value = nameField.text.trim()
const folder = controller.path.includes("/") ? controller.path.slice(0, controller.path.lastIndexOf("/") + 1) : ""
- if (kind === "move") controller.renameNote(value)
- else controller.saveCopy((controller.externalDocument ? "" : folder) + value.replace(/\.md$/i, "") + ".md")
+ controller.saveCopy((controller.externalDocument ? "" : folder) + value.replace(/\.md$/i, "") + ".md")
close()
}
}
@@ -1140,7 +1236,7 @@ ApplicationWindow {
}
delegate: NavigationRow {
iconName: controller.folderIcons[modelData] || "folder"
- iconSource: FolderIcons.source(iconName, tokens.primaryYellow)
+
required property var modelData
required property int index
width: ListView.view.width; text: modelData.split("/").join(" › "); specificationForm: true
@@ -1196,7 +1292,14 @@ ApplicationWindow {
Shortcut { sequence: "Ctrl+Shift+I"; enabled: !root.popupsOpen && !controller.busy && !session.composing; onActivated: libraryActions.importFiles() }
LibraryActions { id: libraryActions; anchors.fill: parent; controller: root.controller; sceneItem: readingScene; onBrowseRequested: root.revealList() }
- NotesOrganizer { id: organizer; anchors.fill: parent; controller: root.controller; sceneItem: readingScene; onSmartFolderRequested: smartDialog.open(); onExportRequested: function(paths, folder) { libraryActions.exportSelection(paths, folder) } }
+ NotesOrganizer {
+ id: organizer; anchors.fill: parent
+ controller: root.controller; sceneItem: readingScene
+ selectableFolders: root.childFolders
+ onFolderSelectionModeChanged: if (folderSelectionMode) root.childFoldersExpanded = true
+ onSmartFolderRequested: smartDialog.open()
+ onExportRequested: function(paths, folder) { libraryActions.exportSelection(paths, folder) }
+ }
LiquidDialog {
acceptText: "放弃修改"; rejectText: "取消"; closeText: "关闭"
id: discardDialog; title: "放弃当前本地输入?"; sourceItem: readingScene
diff --git a/qml/NoteNameEditor.qml b/qml/NoteNameEditor.qml
new file mode 100644
index 0000000..fec1e57
--- /dev/null
+++ b/qml/NoteNameEditor.qml
@@ -0,0 +1,83 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import Kos.Ui
+
+Item {
+ id: root
+ required property var controller
+ property bool canRename: false
+ readonly property string documentKey: controller.library + "\n" + controller.path
+ property var drafts: ({})
+ property string savedName: ""
+ readonly property bool editing: nameInput.activeFocus
+ readonly property bool modified: nameInput.text.trim() !== savedName
+ implicitHeight: nameColumn.implicitHeight
+ signal submitted()
+
+ function synchronize() {
+ if (!controller.path || controller.externalDocument) return
+ savedName = controller.noteTitle
+ let draft = drafts[documentKey]
+ if (draft !== undefined && draft.trim() === savedName && !nameInput.activeFocus) {
+ delete drafts[documentKey]
+ draft = undefined
+ }
+ const value = draft === undefined ? savedName : draft
+ if (nameInput.text !== value) nameInput.text = value
+ }
+ function edit() { nameInput.forceActiveFocus(Qt.OtherFocusReason); nameInput.selectAll() }
+ function submit() {
+ const value = nameInput.text.trim()
+ if (!canRename || controller.busy || nameInput.inputMethodComposing || !value) return
+ if (value !== savedName) controller.renameNote(value)
+ else { delete drafts[documentKey]; nameInput.text = savedName }
+ submitted()
+ }
+ onDocumentKeyChanged: Qt.callLater(synchronize)
+ Component.onCompleted: synchronize()
+ Connections { target: root.controller; function onChanged() { root.synchronize() } }
+
+ ColumnLayout {
+ id: nameColumn
+ anchors.left: parent.left; anchors.right: parent.right
+ spacing: 4
+ Label { text: "笔记名称"; color: AppTheme.mutedText; font.pixelSize: 11 }
+ RowLayout {
+ Layout.fillWidth: true; spacing: 8
+ TextField {
+ id: nameInput
+ objectName: "noteNameInput"
+ Layout.fillWidth: true; Layout.minimumWidth: 0
+ implicitHeight: 38
+ leftPadding: 0; rightPadding: 0; topPadding: 4; bottomPadding: 4
+ font.pixelSize: 24; font.weight: Font.DemiBold
+ color: AppTheme.text; placeholderTextColor: AppTheme.mutedText
+ selectionColor: AppTheme.accent; selectedTextColor: AppTheme.accentText
+ background: null
+ placeholderText: "未命名笔记"
+ Accessible.name: "笔记名称"
+ maximumLength: 300; selectByMouse: true
+ readOnly: !root.canRename || root.controller.busy
+ onTextEdited: root.drafts[root.documentKey] = text
+ onActiveFocusChanged: if (!activeFocus) root.synchronize()
+ onAccepted: root.submit()
+ Keys.onEscapePressed: function(event) {
+ if (inputMethodComposing) return
+ delete root.drafts[root.documentKey]
+ text = root.savedName
+ root.submitted()
+ event.accepted = true
+ }
+ }
+ LiquidActionButton {
+ objectName: "saveNoteNameButton"
+ visible: root.modified
+ implicitHeight: 26; materialForm: true
+ text: "保存名称"
+ enabled: root.canRename && !root.controller.busy && !!nameInput.text.trim() && !nameInput.inputMethodComposing
+ onClicked: root.submit()
+ }
+ }
+ }
+}
diff --git a/qml/NotesOrganizer.qml b/qml/NotesOrganizer.qml
index 6b015c8..734f08c 100644
--- a/qml/NotesOrganizer.qml
+++ b/qml/NotesOrganizer.qml
@@ -13,7 +13,13 @@ Item {
signal exportRequested(var paths, string folder)
signal smartFolderRequested()
property var selectedPaths: []
- readonly property bool selectionMode: selectedPaths.length > 0
+ property var selectedFolderPaths: []
+ property var selectableFolders: []
+ property string selectionKind: ""
+ readonly property bool noteSelectionMode: selectionKind === "notes" || selectedPaths.length > 0
+ readonly property bool folderSelectionMode: selectionKind === "folders"
+ readonly property bool selectionMode: noteSelectionMode || folderSelectionMode
+ onSelectableFoldersChanged: selectedFolderPaths = selectedFolderPaths.filter(path => selectableFolders.includes(path))
property string dragKind: ""
property var dragPaths: []
property string dragFolder: ""
@@ -64,12 +70,30 @@ Item {
property string folderPath: ""
property string notice: ""
readonly property string contextPath: menu.visible && targets.length === 1 ? targets[0] : ""
- readonly property bool popupActive: menu.visible || tagActions.visible || sortActions.visible || movePanel.visible || deletePanel.visible || resultPanel.visible || folderMenu.visible || createFolderPanel.visible || trashFolderDialog.visible || editFolderDialog.visible || tagsPanel.visible || tagRenamePanel.visible || tagRenameConfirm.visible
+ readonly property bool popupActive: menu.visible || tagActions.visible || sortActions.visible || movePanel.visible || deletePanel.visible || resultPanel.visible || folderMenu.visible || trashFolderDialog.visible || editFolderDialog.visible || tagsPanel.visible || tagRenamePanel.visible || tagRenameConfirm.visible
readonly property var targetNotes: controller.notes.filter(n => targets.indexOf(n.path) >= 0)
property string scope: controller.library + "\n" + controller.filter + "\n" + controller.query
onScopeChanged: clearSelection()
- function clearSelection() { selectedPaths = []; selectionAnchor = "" }
+ function clearSelection() { selectedPaths = []; selectedFolderPaths = []; selectionAnchor = ""; selectionKind = "" }
+ function beginSelection(kind, initialPaths) {
+ clearSelection()
+ selectionKind = kind
+ if (kind === "notes" && initialPaths) {
+ selectedPaths = initialPaths.slice()
+ selectionAnchor = selectedPaths[0] || ""
+ }
+ }
+ function toggleFolder(path) {
+ if (!folderSelectionMode || !selectableFolders.includes(path)) return
+ let paths = selectedFolderPaths.slice()
+ const at = paths.indexOf(path)
+ if (at >= 0) paths.splice(at, 1)
+ else if (paths.length < 200) paths.push(path)
+ else { notice = "一次最多选择 200 个文件夹"; return }
+ selectedFolderPaths = paths
+ }
function toggle(path) {
+ if (folderSelectionMode) clearSelection()
let paths = selectedPaths.slice()
const at = paths.indexOf(path)
if (at >= 0) paths.splice(at, 1)
@@ -78,6 +102,7 @@ Item {
selectedPaths = paths; selectionAnchor = paths.length ? path : ""
}
function click(note, index, modifiers) {
+ if (folderSelectionMode) clearSelection()
if (modifiers & Qt.ShiftModifier) {
let anchor = controller.notes.findIndex(n => n.path === (selectionAnchor || controller.path))
if (anchor < 0) anchor = index
@@ -90,11 +115,17 @@ Item {
return true
}
if (modifiers & (Qt.ControlModifier | Qt.MetaModifier)) { toggle(note.path); return true }
+ if (selectionKind === "notes") { toggle(note.path); return true }
clearSelection()
selectionAnchor = note.path
return false
}
function selectAll() {
+ if (folderSelectionMode) {
+ if (selectableFolders.length > 200) { notice = "一次最多选择 200 个文件夹"; return }
+ selectedFolderPaths = selectableFolders.slice()
+ return
+ }
if (controller.notes.length > 200) { notice = "结果超过 200 篇,请先缩小筛选范围"; return }
selectedPaths = controller.notes.map(n => n.path)
if (!selectionAnchor && selectedPaths.length) selectionAnchor = selectedPaths[0]
@@ -128,10 +159,18 @@ Item {
deletePanel.open()
}
function newFolder(parentPath, anchor, originMenu) {
- folderPath = parentPath || ""; createFolderName.text = ""
- if (originMenu) createFolderPanel.openFromMenu(originMenu); else createFolderPanel.openFromButton(anchor)
+ folderPath = parentPath || ""
+ editFolderDialog.creating = true
+ editFolderDialog.originalPath = ""
+ editFolderName.text = ""
+ editFolderDialog.selectedIcon = "folder"
+ editFolderDialog.selectedParent = folderPath
+ editFolderDialog.errorMessage = ""
+ if (originMenu) originMenu.close()
+ editFolderDialog.open()
}
function editFolder() {
+ editFolderDialog.creating = false
editFolderDialog.originalPath = folderPath
editFolderName.text = folderPath.split("/").pop()
editFolderDialog.selectedIcon = controller.folderIcons[folderPath] || "folder"
@@ -154,8 +193,14 @@ Item {
if (root.selectionAnchor && !visible.has(root.selectionAnchor)) root.selectionAnchor = ""
}
function onTagRenameReady() { tagRenamePanel.close(); tagRenameConfirm.open() }
+ function onFolderCreated(path) {
+ if (editFolderDialog.visible && editFolderDialog.creating) editFolderDialog.close()
+ }
+ function onFolderCreationFailed(message) {
+ if (editFolderDialog.visible && editFolderDialog.creating) editFolderDialog.errorMessage = message
+ }
function onFolderEdited(success, message) {
- if (!editFolderDialog.visible) return
+ if (!editFolderDialog.visible || editFolderDialog.creating) return
if (success) editFolderDialog.close()
else editFolderDialog.errorMessage = message
}
@@ -187,8 +232,25 @@ Item {
property bool rowMenu: false
readonly property bool allPinned: root.targetNotes.length > 0 && root.targetNotes.every(n => n.pinned)
readonly property bool allFavorite: root.targetNotes.length > 0 && root.targetNotes.every(n => n.favorite)
- LiquidPopoverItem { text: root.selectionMode ? "取消选择" : "选择笔记"; enabled: root.selectionMode || root.targets.length > 0; onTriggered: { if (root.selectionMode) root.clearSelection(); else { root.selectedPaths = root.targets.slice(); root.selectionAnchor = root.selectedPaths[0] || "" } } }
- LiquidPopoverItem { visible: !menu.rowMenu; height: visible ? implicitHeight : 0; text: "全选当前结果"; enabled: !!root.controller.notes.length; onTriggered: root.selectAll() }
+ LiquidPopoverItem {
+ objectName: "selectNotesMenuItem"
+ text: root.noteSelectionMode ? "取消选择笔记" : "选择笔记"
+ enabled: !root.controller.busy && (root.noteSelectionMode || root.controller.notes.length > 0)
+ onTriggered: { if (root.noteSelectionMode) root.clearSelection(); else root.beginSelection("notes", menu.rowMenu ? root.targets : []) }
+ }
+ LiquidPopoverItem {
+ objectName: "selectFoldersMenuItem"
+ visible: !menu.rowMenu; height: visible ? implicitHeight : 0
+ text: root.folderSelectionMode ? "取消选择文件夹" : "选择文件夹"
+ enabled: !root.controller.busy && (root.folderSelectionMode || root.selectableFolders.length > 0)
+ onTriggered: { if (root.folderSelectionMode) root.clearSelection(); else root.beginSelection("folders") }
+ }
+ LiquidPopoverItem {
+ visible: !menu.rowMenu; height: visible ? implicitHeight : 0
+ text: root.folderSelectionMode ? "全选子文件夹" : "全选当前结果"
+ enabled: root.folderSelectionMode ? root.selectableFolders.length > 0 : root.controller.notes.length > 0
+ onTriggered: root.selectAll()
+ }
LiquidPopoverItem { visible: menu.rowMenu; height: visible ? implicitHeight : 0; text: menu.allPinned ? "取消置顶" : "置顶"; enabled: root.targets.length > 0 && !root.controller.busy; onTriggered: root.perform(menu.allPinned ? "unpin" : "pin") }
LiquidPopoverItem { visible: menu.rowMenu; height: visible ? implicitHeight : 0; text: menu.allFavorite ? "取消收藏" : "收藏"; enabled: root.targets.length > 0 && !root.controller.busy; onTriggered: root.perform(menu.allFavorite ? "unfavorite" : "favorite") }
LiquidPopoverItem { visible: menu.rowMenu; height: visible ? implicitHeight : 0; text: "标签…"; enabled: root.targets.length > 0 && !root.controller.busy; onTriggered: tagActions.openFromMenu(menu) }
@@ -336,38 +398,10 @@ Item {
FolderMenuItem { text: "导出其中的笔记…"; enabled: !root.controller.busy; onTriggered: root.exportRequested([], root.folderPath) }
FolderMenuItem { text: "删除文件夹…"; enabled: !root.controller.busy; onTriggered: trashFolderDialog.open() }
}
- NotesContentPopover {
- id: createFolderPanel; sourceItem: root.sceneItem; preferredWidth: 350
- onOpened: { createFolderName.forceActiveFocus(); createFolderName.selectAll() }
- function submit() {
- if (!validName || createFolderName.inputMethodComposing || root.controller.busy) return
- root.controller.createFolder(proposedPath)
- close()
- }
- readonly property string enteredName: createFolderName.text.trim()
- readonly property string proposedPath: (root.folderPath ? root.folderPath + "/" : "") + enteredName
- readonly property bool nameAlreadyUsed: Array.from(root.controller.folders).includes(proposedPath)
- readonly property bool validName: !!enteredName && !nameAlreadyUsed && enteredName !== "." && enteredName !== ".." && !/[\\/\u0000]/.test(enteredName) && ![".git", ".nextnotes"].includes(enteredName)
- contentItem: ColumnLayout {
- Label { text: root.folderPath ? "新建子文件夹" : "新建文件夹"; font.bold: true; color: AppTheme.text }
- Label {
- Layout.fillWidth: true
- text: "位置:" + (root.folderPath || "我的笔记").split("/").join(" › ")
- wrapMode: Text.Wrap; color: AppTheme.mutedText; font.pixelSize: 12
- }
- LiquidTextField { id: createFolderName; Layout.fillWidth: true; materialForm: true; Accessible.name: "文件夹名称"; onAccepted: createFolderPanel.submit() }
- Label { visible: createFolderPanel.nameAlreadyUsed; Layout.fillWidth: true; text: "这里已有同名文件夹,请换个名称。"; wrapMode: Text.Wrap; color: AppTheme.mutedText; font.pixelSize: 12 }
- RowLayout {
- Layout.alignment: Qt.AlignRight
- LiquidActionButton { text: "取消"; materialForm: true; onClicked: createFolderPanel.close() }
- LiquidActionButton { text: "创建"; materialForm: true; enabled: createFolderPanel.validName && !createFolderName.inputMethodComposing && !root.controller.busy; onClicked: createFolderPanel.submit() }
- }
- }
- }
LiquidDialog {
id: editFolderDialog
objectName: "editFolderDialog"
- title: "编辑文件夹"; sourceItem: root.sceneItem
+ title: creating ? (selectedParent ? "新建子文件夹" : "新建文件夹") : "编辑文件夹"; sourceItem: root.sceneItem
width: Math.min(340, root.width - 32)
padding: 16; spacing: 0
topPadding: 4; bottomPadding: 4
@@ -379,6 +413,7 @@ Item {
}
standardButtons: Dialog.NoButton
closePolicy: root.controller.busy ? Popup.NoAutoClose : Popup.CloseOnEscape
+ property bool creating: false
property string originalPath: ""
property string selectedIcon: "folder"
property string hoveredIconLabel: ""
@@ -388,18 +423,19 @@ Item {
readonly property color formGroupColor: AppTheme.dark ? "#363638" : "#EDEDEF"
onSelectedIconChanged: errorMessage = ""
onSelectedParentChanged: errorMessage = ""
- readonly property var destinations: [""].concat(Array.from(root.controller.folders)).filter(p => p !== originalPath && !p.startsWith(originalPath + "/"))
+ readonly property var destinations: [""].concat(Array.from(root.controller.folders)).filter(p => creating || (p !== originalPath && !p.startsWith(originalPath + "/")))
readonly property string enteredName: editFolderName.text.trim()
readonly property string proposedPath: (selectedParent ? selectedParent + "/" : "") + enteredName
readonly property bool nameAlreadyUsed: proposedPath !== originalPath && Array.from(root.controller.folders).includes(proposedPath)
readonly property bool validName: !!enteredName && enteredName !== "." && enteredName !== ".." && !/[\\/\u0000]/.test(enteredName) && ![".git", ".nextnotes"].includes(enteredName)
- readonly property bool hasChanges: proposedPath !== originalPath || selectedIcon !== (root.controller.folderIcons[originalPath] || "folder")
- readonly property bool canSave: validName && !nameAlreadyUsed && hasChanges && destinations.includes(selectedParent) && Array.from(root.controller.folders).includes(originalPath) && !editFolderName.inputMethodComposing && !root.controller.busy
+ readonly property bool hasChanges: creating || proposedPath !== originalPath || selectedIcon !== (root.controller.folderIcons[originalPath] || "folder")
+ readonly property bool canSave: validName && !nameAlreadyUsed && hasChanges && destinations.includes(selectedParent) && (creating || Array.from(root.controller.folders).includes(originalPath)) && !editFolderName.inputMethodComposing && !root.controller.busy
onOpened: { hoveredIconLabel = ""; editFolderName.forceActiveFocus(); editFolderName.selectAll() }
function submit() {
if (!canSave) return
errorMessage = ""
- root.controller.editFolder(originalPath, proposedPath, selectedIcon)
+ if (creating) root.controller.createFolder(proposedPath, selectedIcon)
+ else root.controller.editFolder(originalPath, proposedPath, selectedIcon)
}
contentItem: ColumnLayout {
spacing: 6
@@ -456,16 +492,9 @@ Item {
}
contentItem: Item {
VectorIcon {
- visible: !FolderIcons.paths[iconChoice.modelData.name]
anchors.centerIn: parent; name: iconChoice.modelData.name; size: 16
color: iconChoice.checked ? "#242424" : AppTheme.text
}
- Image {
- anchors.centerIn: parent; width: 16; height: 16
- sourceSize: Qt.size(32, 32)
- source: FolderIcons.source(iconChoice.modelData.name, iconChoice.checked ? Qt.rgba(0.141, 0.141, 0.141, 1) : AppTheme.text)
- visible: source.toString().length > 0
- }
}
KosToolTip { target: iconChoice; text: iconChoice.modelData.label; backdrop: editFolderDialog.contentBackdrop }
}
@@ -512,7 +541,7 @@ Item {
Item { Layout.fillWidth: true }
LiquidDialogButton { text: "取消"; enabled: !root.controller.busy; onClicked: editFolderDialog.close() }
LiquidDialogButton {
- text: root.controller.busy ? "保存中…" : "保存"
+ text: editFolderDialog.creating ? (root.controller.busy ? "创建中…" : "创建") : (root.controller.busy ? "保存中…" : "保存")
primaryAction: true; enabled: editFolderDialog.canSave
surfaceColor: enabled ? tokens.primaryYellow : AppTheme.controlBackground
accentColor: tokens.primaryYellow
diff --git a/qml/NotesTokens.qml b/qml/NotesTokens.qml
index 10f9165..1c2a7f7 100644
--- a/qml/NotesTokens.qml
+++ b/qml/NotesTokens.qml
@@ -21,5 +21,6 @@ QtObject {
readonly property int bodySize: 16
readonly property int readingWidth: 1200
readonly property int inset: 26
+ readonly property int editorInset: 48
readonly property int documentTitleSize: 24
}
diff --git a/src/NotesController.cpp b/src/NotesController.cpp
index cc166da..ee7aa93 100644
--- a/src/NotesController.cpp
+++ b/src/NotesController.cpp
@@ -110,10 +110,15 @@ void NotesController::createNote() {
if (!m_filter.startsWith("folder:")) m_filter = "all";
++m_searchSerial;
const QString path = folder + "笔记-" + QUuid::createUuid().toString(QUuid::WithoutBraces).left(8) + ".md";
- m_loading = true; send("create", {{"path", path}, {"text", "# 未命名笔记\n\n"}});
+ m_loading = true; send("create", {{"path", path}, {"text", "---\ntitle: 未命名笔记\n---\n\n"}});
});
}
-void NotesController::createFolder(const QString &path) { if (!busy()) send("createFolder", {{"path", path}}); }
+void NotesController::createFolder(const QString &path, const QString &icon) {
+ if (busy()) return;
+ m_organizing = true;
+ send("createFolder", {{"path", path}, {"icon", icon}});
+ emit changed();
+}
void NotesController::save() {
if (!dirty() || m_saving || m_path.isEmpty() || m_session->composing() || m_loading) return;
if (m_conflict) { m_error = "文件已在外部改变。本地输入仍保留;请另存副本,或明确放弃本地输入后重新读取。"; emit changed(); return; }
@@ -303,12 +308,16 @@ void NotesController::receive(quint64 id, const QString &op, const QVariantMap &
if (documentOp && !p.args.value("recoveryCopy").toBool() && p.epoch != m_epoch) return;
if(r.contains("undoState"))m_undoState=r.value("undoState").toMap();
if(r.value("historyRecorded").toBool() || op=="undo" || op=="redo")m_organizationHistory=true;
- if(op=="undo" || op=="redo" || op=="editFolder" || op=="moveFolder" || op=="purgeFolder")m_organizing=false;
+ if(op=="undo" || op=="redo" || op=="editFolder" || op=="createFolder" || op=="moveFolder" || op=="purgeFolder")m_organizing=false;
if (op == "save") m_saving = 0;
if (op == "createWiki" || op == "tagBatch" || op == "fromTemplate" || op == "dailyNote" || op == "setProperty" || op == "historyVersion" || op == "trashBatch" || op == "batch" || op == "rename" || op == "renameFolder" || op == "removeFolder" || op == "trashFolder" || op == "restoreFolder" || op == "importPreview" || op == "importNotes" || op == "exportNotes" || op == "renameNote" || op == "backup" || op == "restoreBackup") m_organizing = false;
if(op == "importPreview" || op == "importNotes")m_operationProgress.clear();
if ((documentOp && !p.args.value("recoveryCopy").toBool()) || op == "selectLibrary") m_loading = false;
if (!r.value("ok").toBool()) {
+ if (op == "createFolder") {
+ m_status = r.value("error") == "alreadyExists" ? QStringLiteral("这个位置已有同名文件夹(可能在最近删除中),请修改名称或父级文件夹。") : organizerError(r.value("error").toString());
+ emit changed(); emit folderCreationFailed(m_status); return;
+ }
if (op == "trashFolder" || op == "purgeFolder") {
m_status = organizerError(r.value("error").toString());
emit changed(); emit folderDeleted(false, m_status); return;
diff --git a/src/NotesController.h b/src/NotesController.h
index d36b4d5..10c0606 100644
--- a/src/NotesController.h
+++ b/src/NotesController.h
@@ -94,7 +94,7 @@ class NotesController final : public QObject {
Q_INVOKABLE void openNote(const QString &path, int match = -1);
Q_INVOKABLE void createNote();
Q_INVOKABLE void renameNote(const QString &title);
- Q_INVOKABLE void createFolder(const QString &);
+ Q_INVOKABLE void createFolder(const QString &, const QString &icon = QStringLiteral("folder"));
Q_INVOKABLE void save();
Q_INVOKABLE void requestClose();
Q_INVOKABLE void refresh(bool indexed = false);
@@ -187,6 +187,7 @@ class NotesController final : public QObject {
void notesChanged();
void foldersChanged();
void folderCreated(const QString &path);
+ void folderCreationFailed(const QString &message);
void folderEdited(bool success, const QString &message);
void folderDeleted(bool success, const QString &message);
void batchFinished();
diff --git a/src/main.cpp b/src/main.cpp
index ae732ea..461a71a 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,4 +1,5 @@
#include
+#include
#include
#include
#include
@@ -68,6 +69,7 @@ int main(int argc, char **argv) {
app.setOrganizationName("NextKde");
app.setApplicationName("NextNotes");
app.setDesktopFileName("org.nextks.notes");
+ app.setWindowIcon(QIcon(QStringLiteral(":/nextnotes/icons/org.nextks.notes.svg")));
if (qEnvironmentVariableIsEmpty("QTWEBENGINE_DICTIONARIES_PATH")) {
const QString folder = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + "/dictionaries-v1";
QFile dictionary(":/editor/vendor/dictionaries/en-US.bdic");
diff --git a/src/storage/ManagedStore.cpp b/src/storage/ManagedStore.cpp
index e1afe36..17edbed 100644
--- a/src/storage/ManagedStore.cpp
+++ b/src/storage/ManagedStore.cpp
@@ -43,6 +43,12 @@ bool validPath(const QString &path, bool empty = false) {
for (const auto &part : path.split('/')) if (part.isEmpty() || part == "." || part == ".." || part == ".nextnotes" || part == ".git") return false;
return true;
}
+bool validFolderIcon(const QString &icon) {
+ return QStringList{"folder", "editRounded", "album", "home", "flag", "heart", "calendar", "music", "grid", "sparkle", "artist", "reminder",
+ "archive", "envelope", "wallpaper", "keyboard", "settings", "queueList", "radio", "theme", "sliders", "circle", "waves", "info",
+ "work", "study", "sport", "running", "cycling", "swimming", "fitness", "basketball", "football", "badminton", "tennis",
+ "shopping", "travel", "food", "coffee", "health", "finance", "gift", "pets", "garden", "car", "code", "camera", "ideas"}.contains(icon);
+}
QByteArray bytesAt(const QString &path, qint64 max = 64 * 1024 * 1024) {
QFile f(path); require(QFileInfo(path).isFile() && QFileInfo(path).size() <= max, "readFailed", path);
require(f.open(QIODevice::ReadOnly), "readFailed", path); auto bytes = f.read(max + 1); require(f.error() == QFileDevice::NoError && bytes.size() <= max && f.atEnd(), "readFailed", path); return bytes;
@@ -527,7 +533,15 @@ QVariantMap ManagedStore::Impl::run(const QString &op,const QVariantMap &a) {
}
if(op=="checkpoint") {query("INSERT INTO drafts(path,text,base_hash,time) VALUES(?,?,?,?) ON CONFLICT(path) DO UPDATE SET text=excluded.text,base_hash=excluded.base_hash,time=excluded.time",{path,a.value("text"),a.value("baseHash"),utc()});return {{"ok",true},{"recoveryDurable",true}};}
if(op=="discardDraft"){query("DELETE FROM drafts WHERE path=?",{path});return ok();}
- if(op=="createFolder") {require(validPath(path),"invalidName");require(one("SELECT id FROM folders WHERE path=?",{path}).isEmpty(),"alreadyExists");folder(path);return ok();}
+ if (op == "createFolder") {
+ const auto icon = a.value("icon", QStringLiteral("folder")).toString();
+ require(validPath(path), "invalidName");
+ require(validFolderIcon(icon), "invalidIcon");
+ require(one("SELECT id FROM folders WHERE path=?", {path}).isEmpty(), "alreadyExists");
+ folder(path);
+ query("UPDATE folders SET icon=? WHERE path=?", {icon, path});
+ return ok();
+ }
if(op=="renameFolder") {require(!one("SELECT id FROM folders WHERE path=? AND deleted=''",{path}).isEmpty(),"notFound");auto target=a.value("target").toString();relocate(path,target,true);return {{"ok",true},{"newPath",target}};}
if (op == "moveFolder") {
const auto current = one("SELECT id FROM folders WHERE path=? AND deleted=''", {path});
@@ -561,10 +575,7 @@ QVariantMap ManagedStore::Impl::run(const QString &op,const QVariantMap &a) {
require(!current.isEmpty(), "notFound");
const auto target = a.value("target").toString(), icon = a.value("icon").toString();
require(validPath(target), "invalidName");
- require(QStringList{"folder", "editRounded", "album", "home", "flag", "heart", "calendar", "music", "grid", "sparkle", "artist", "reminder",
- "archive", "envelope", "wallpaper", "keyboard", "settings", "queueList", "radio", "theme", "sliders", "circle", "waves", "info",
- "work", "study", "sport", "running", "cycling", "swimming", "fitness", "basketball", "football", "badminton", "tennis",
- "shopping", "travel", "food", "coffee", "health", "finance", "gift", "pets", "garden", "car", "code", "camera", "ideas"}.contains(icon), "invalidIcon");
+ require(validFolderIcon(icon), "invalidIcon");
const auto parent = parentFolder(target);
require(parent != path && !parent.startsWith(path + '/'), "invalidParent");
require(parent.isEmpty() || !one("SELECT id FROM folders WHERE path=? AND deleted=''", {parent}).isEmpty(), "missingFolder");
@@ -650,17 +661,7 @@ QVariantMap ManagedStore::Impl::run(const QString &op,const QVariantMap &a) {
auto n=note(path);require(n["hash"].toString()==a.value("baseHash").toString(),"conflict");const auto title=a.value("title").toString().trimmed();require(!title.isEmpty()&&title.size()<=300&&!title.contains('\n'),"invalidName");
refreshIndex();
for(const auto &v:query("SELECT * FROM notes WHERE deleted=''")) {auto linked=v.toMap();const auto source=linked["path"].toString();QString text=linked["text"].toString();LinkPaths::rewrite(text,[&](const QString &target,bool wiki){if(!wiki)return target;const auto choices=index.resolve(source,target,true);if(choices.size()!=1||choices[0]!=path)return target;return relativeManagedPath(source,path);});if(text!=linked["text"].toString())updateText(linked,text);}
- n=note(path);QString text=n["text"].toString();int start=0;
- if(text.startsWith("---\n")) {
- const auto end=QRegularExpression("(?m)^(?:---|\\.\\.\\.)[ \\t]*$").match(text,4);
- if(end.hasMatch())start=end.capturedEnd();
- }
- while(start~#").contains(c))escaped+='\\';escaped+=c;}
- text.replace(start,heading.capturedLength(),"# "+escaped);
- }
+ n=note(path);const QString text=n["text"].toString();
updateText(n,Knowledge::update(text,"title",title));return ok();
}
if(op=="setProperty") {auto n=note(path);require(n["hash"].toString()==a.value("baseHash").toString(),"conflict");const auto key=a.value("key").toString();require(QRegularExpression("^[A-Za-z_][A-Za-z0-9_-]*$").match(key).hasMatch()&&key!="nextnotes_id","invalidProperty");updateText(n,Knowledge::update(n["text"].toString(),key,a.value("value"),a.value("remove").toBool()));return ok();}
diff --git a/tests/NotesControllerTest.cpp b/tests/NotesControllerTest.cpp
index eb78410..8703e4d 100644
--- a/tests/NotesControllerTest.cpp
+++ b/tests/NotesControllerTest.cpp
@@ -39,6 +39,25 @@ class NotesControllerTest : public QObject {
int count(const QString &op) { int n = 0; for (const auto &r : requests) if (r[1] == op) ++n; return n; }
};
private slots:
+ void newNoteStartsWithAnIndependentNameAndEmptyBody() {
+ Fixture f;
+ f.controller.createNote();
+ const auto request = f.take("create");
+ QVERIFY(!request.isEmpty());
+ QCOMPARE(request[2].toMap().value("text").toString(), QString("---\ntitle: 未命名笔记\n---\n\n"));
+ }
+ void changingNoteNameWaitsForTheBodyToSave() {
+ Fixture f;
+ f.type("正文修改");
+ f.controller.renameNote("独立名称");
+ QCOMPARE(f.count("renameNote"), 0);
+ f.answer("save", {{"baseHash", "saved-body"}});
+ const auto request = f.take("renameNote");
+ QVERIFY(!request.isEmpty());
+ QCOMPARE(request[2].toMap().value("baseHash").toString(), QString("saved-body"));
+ QCOMPARE(request[2].toMap().value("title").toString(), QString("独立名称"));
+ QCOMPARE(f.document.toPlainText(), QString("原文正文修改"));
+ }
void directNoteDeletionSavesBeforeRemovingCurrentDocument() {
Fixture f;
f.type(" changed");
diff --git a/tests/NotesRepositoryTest.cpp b/tests/NotesRepositoryTest.cpp
index bb6c493..568d33d 100644
--- a/tests/NotesRepositoryTest.cpp
+++ b/tests/NotesRepositoryTest.cpp
@@ -36,6 +36,63 @@ class NotesRepositoryTest : public QObject {
return {{"ok", false}, {"error", "missingRequest"}};
}
private slots:
+ void managedNoteNameIsIndependentOfBodyHeadings() {
+ QTemporaryDir directory;
+ const QString body = "\n# 正文章节\n\n正文内容\n\n## 子章节\n";
+ const QString source = "---\ntitle: 旧名称\ncustom: 保留\n---\n" + body;
+ QString noteId;
+ {
+ ManagedStore store(directory.path());
+ const auto created = store.dispatch("create", {{"path", "note.md"}, {"text", source}});
+ QVERIFY(created.value("ok").toBool());
+ noteId = created.value("id").toString();
+ QVERIFY(store.dispatch("create", {{"path", "link.md"}, {"text", "[[旧名称]]\n"}}).value("ok").toBool());
+ const auto renamed = store.dispatch("renameNote", {{"path", "note.md"}, {"baseHash", created.value("baseHash")}, {"title", "新名称 / #1"}});
+ QVERIFY(renamed.value("ok").toBool());
+ const auto document = store.dispatch("read", {{"path", "note.md"}});
+ const auto text = document.value("text").toString();
+ QCOMPARE(document.value("id").toString(), noteId);
+ QCOMPARE(MarkdownFile::field(text, "title"), QString("新名称 / #1"));
+ QCOMPARE(MarkdownFile::field(text, "custom"), QString("保留"));
+ QVERIFY(text.endsWith(body));
+ QVERIFY(!text.contains("# 新名称"));
+ QVERIFY(store.dispatch("read", {{"path", "link.md"}}).value("text").toString().contains("note.md"));
+ const auto stale = store.dispatch("renameNote", {{"path", "note.md"}, {"baseHash", created.value("baseHash")}, {"title", "过期修改"}});
+ QCOMPARE(stale.value("error").toString(), QString("conflict"));
+ QVERIFY(store.dispatch("undo", {}).value("ok").toBool());
+ QCOMPARE(store.dispatch("read", {{"path", "note.md"}}).value("text").toString(), source);
+ QCOMPARE(store.dispatch("read", {{"path", "link.md"}}).value("text").toString(), QString("[[旧名称]]\n"));
+ QVERIFY(store.dispatch("redo", {}).value("ok").toBool());
+ }
+ ManagedStore reopened(directory.path());
+ const auto document = reopened.dispatch("read", {{"path", "note.md"}});
+ QCOMPARE(document.value("id").toString(), noteId);
+ QCOMPARE(MarkdownFile::title(document.value("text").toString(), "fallback"), QString("新名称 / #1"));
+ QVERIFY(document.value("text").toString().endsWith(body));
+ }
+ void managedFolderCreationKeepsChosenIconAcrossUndoAndReopen() {
+ QTemporaryDir directory;
+ {
+ ManagedStore store(directory.path());
+ QVERIFY(store.dispatch("createFolder", {{"path", "Projects"}}).value("ok").toBool());
+ QCOMPARE(store.dispatch("list", {}).value("folderIcons").toMap().value("Projects").toString(), QString("folder"));
+ QVERIFY(store.dispatch("createFolder", {{"path", "Projects/Training"}, {"icon", "cycling"}}).value("ok").toBool());
+ QCOMPARE(store.dispatch("list", {}).value("folderIcons").toMap().value("Projects/Training").toString(), QString("cycling"));
+ QVERIFY(store.dispatch("undo", {}).value("ok").toBool());
+ QVERIFY(!store.dispatch("list", {}).value("folderIcons").toMap().contains("Projects/Training"));
+ QVERIFY(store.dispatch("redo", {}).value("ok").toBool());
+ QCOMPARE(store.dispatch("list", {}).value("folderIcons").toMap().value("Projects/Training").toString(), QString("cycling"));
+ const auto invalid = store.dispatch("createFolder", {{"path", "Rejected/Child"}, {"icon", "unknown-icon"}});
+ QVERIFY(!invalid.value("ok").toBool());
+ QCOMPARE(invalid.value("error").toString(), QString("invalidIcon"));
+ QVERIFY(!store.dispatch("list", {}).value("folderIcons").toMap().contains("Rejected"));
+ const auto duplicate = store.dispatch("createFolder", {{"path", "Projects/Training"}, {"icon", "home"}});
+ QVERIFY(!duplicate.value("ok").toBool());
+ QCOMPARE(store.dispatch("list", {}).value("folderIcons").toMap().value("Projects/Training").toString(), QString("cycling"));
+ }
+ ManagedStore reopened(directory.path());
+ QCOMPARE(reopened.dispatch("list", {}).value("folderIcons").toMap().value("Projects/Training").toString(), QString("cycling"));
+ }
void managedSandboxLockCrashHelper() {
#ifdef Q_OS_LINUX
const auto path = qEnvironmentVariable("NKNOTES_LOCK_TEST_DIR");
diff --git a/tests/chrome/tst_selection.qml b/tests/chrome/tst_selection.qml
index 5d636f1..11218a2 100644
--- a/tests/chrome/tst_selection.qml
+++ b/tests/chrome/tst_selection.qml
@@ -20,6 +20,9 @@ TestCase {
property string path: "a.md"
property var notes: [{path: "a.md"}, {path: "b.md"}, {path: "c.md"}]
property var folders: []
+ property var folderIcons: ({})
+ property string error: ""
+ property var folderRequest: ({})
property bool busy: false
property string sortKey: "modified"
property bool sortAscending: false
@@ -27,6 +30,12 @@ TestCase {
property var tagPlan: ({count: 0})
signal tagRenameReady()
signal batchFinished()
+ signal folderCreated(string path)
+ signal folderCreationFailed(string message)
+ signal folderEdited(bool success, string message)
+ signal folderDeleted(bool success, string message)
+ function createFolder(path, icon) { folderRequest = {path: path, icon: icon}; busy = true }
+ function editFolder(path, target, icon) { folderRequest = {path: path, target: target, icon: icon}; busy = true }
}
}
Component { id: organizerComponent; NotesOrganizer { width: 1000; height: 900; sceneItem: scene } }
@@ -36,6 +45,115 @@ TestCase {
controller = createTemporaryObject(controllerComponent, testCase)
organizer = createTemporaryObject(organizerComponent, testCase, {controller: controller})
}
+ function test_toolbar_can_enter_note_selection_without_an_active_note() {
+ controller.path = "outside-filter.md"
+ organizer.openMenu(menuButton, "")
+ const menu = findChild(organizer, "noteListMenu")
+ tryCompare(menu, "visible", true)
+ compare(organizer.targets.length, 0)
+ const selectNotes = findChild(organizer, "selectNotesMenuItem")
+ verify(selectNotes.enabled)
+ selectNotes.triggered()
+ menu.close()
+ verify(organizer.noteSelectionMode)
+ compare(organizer.selectedPaths.length, 0)
+ verify(organizer.click(controller.notes[1], 1, Qt.NoModifier))
+ compare(organizer.selectedPaths.join(), "b.md")
+ organizer.toggle("b.md")
+ compare(organizer.selectedPaths.length, 0)
+ verify(organizer.noteSelectionMode)
+ organizer.clearSelection()
+ verify(!organizer.selectionMode)
+ }
+ function test_folder_selection_is_independent_and_prunes_removed_rows() {
+ organizer.selectableFolders = ["Parent/A", "Parent/B"]
+ organizer.openMenu(menuButton, "")
+ const menu = findChild(organizer, "noteListMenu")
+ tryCompare(menu, "visible", true)
+ const selectFolders = findChild(organizer, "selectFoldersMenuItem")
+ verify(selectFolders.visible)
+ verify(selectFolders.enabled)
+ selectFolders.triggered()
+ menu.close()
+ verify(organizer.folderSelectionMode)
+ verify(!organizer.noteSelectionMode)
+ compare(organizer.selectedFolderPaths.length, 0)
+ organizer.toggleFolder("Parent/A")
+ organizer.selectAll()
+ compare(organizer.selectedFolderPaths.join(), "Parent/A,Parent/B")
+ compare(organizer.selectedPaths.length, 0)
+ organizer.selectableFolders = ["Parent/B"]
+ compare(organizer.selectedFolderPaths.join(), "Parent/B")
+ organizer.toggleFolder("Parent/B")
+ verify(organizer.folderSelectionMode)
+ organizer.toggleFolder("Parent/B")
+ organizer.beginSelection("notes")
+ verify(!organizer.folderSelectionMode)
+ verify(organizer.noteSelectionMode)
+ compare(organizer.selectedFolderPaths.length, 0)
+ controller.filter = "favorite"
+ verify(!organizer.selectionMode)
+ }
+ function test_create_folder_uses_editor_and_waits_for_result() {
+ controller.folders = ["Projects"]
+ organizer.newFolder("", menuButton, null)
+ const dialog = findChild(organizer, "editFolderDialog")
+ const name = findChild(organizer, "editFolderName")
+ tryCompare(dialog, "visible", true)
+ verify(dialog.creating)
+ verify(dialog.destinations.includes(""))
+ verify(!dialog.canSave)
+ name.text = "Training"
+ dialog.selectedParent = "Projects"
+ dialog.selectedIcon = "cycling"
+ verify(dialog.canSave)
+ dialog.submit()
+ compare(controller.folderRequest.path, "Projects/Training")
+ compare(controller.folderRequest.icon, "cycling")
+ verify(dialog.visible)
+ verify(!dialog.canSave)
+ controller.busy = false
+ controller.folderCreationFailed("名称冲突")
+ compare(dialog.errorMessage, "名称冲突")
+ verify(dialog.visible)
+ compare(name.text, "Training")
+ compare(dialog.selectedIcon, "cycling")
+ dialog.submit()
+ controller.busy = false
+ controller.folderCreated("Projects/Training")
+ tryCompare(dialog, "visible", false)
+ organizer.newFolder("Projects", menuButton, null)
+ tryCompare(dialog, "visible", true)
+ compare(dialog.title, "新建子文件夹")
+ compare(dialog.selectedParent, "Projects")
+ compare(dialog.selectedIcon, "folder")
+ compare(name.text, "")
+ compare(dialog.errorMessage, "")
+ dialog.close()
+ }
+ function test_edit_folder_still_preserves_parent_restrictions() {
+ controller.folders = ["Projects", "Projects/Child", "Other"]
+ controller.folderIcons = {Projects: "work"}
+ organizer.folderPath = "Projects"
+ organizer.editFolder()
+ const dialog = findChild(organizer, "editFolderDialog")
+ tryCompare(dialog, "visible", true)
+ verify(!dialog.creating)
+ compare(dialog.selectedIcon, "work")
+ verify(dialog.destinations.includes(""))
+ verify(!dialog.destinations.includes("Projects"))
+ verify(!dialog.destinations.includes("Projects/Child"))
+ verify(!dialog.canSave)
+ dialog.selectedIcon = "home"
+ verify(dialog.canSave)
+ dialog.submit()
+ compare(controller.folderRequest.path, "Projects")
+ compare(controller.folderRequest.target, "Projects")
+ compare(controller.folderRequest.icon, "home")
+ controller.busy = false
+ controller.folderEdited(true, "")
+ tryCompare(dialog, "visible", false)
+ }
function test_plain_click_leaves_selection_and_last_uncheck_exits() {
verify(organizer.click(controller.notes[1], 1, Qt.ControlModifier))
verify(organizer.selectionMode)
@@ -87,14 +205,14 @@ TestCase {
compare(organizer.targets.join(), "b.md")
menu.close()
}
- function test_toolbar_menu_opens_to_lower_left_and_prunes_selection() {
+ function test_toolbar_menu_opens_below_trigger_and_prunes_selection() {
const menu = findChild(organizer, "noteListMenu")
organizer.openMenu(menuButton, "")
tryCompare(menu, "visible", true)
- const below = menuButton.mapToItem(menu.parent, menuButton.width, menuButton.height + 4)
- tryVerify(() => Math.abs(menu.x + menu.width - below.x) < 1)
+ const below = menuButton.mapToItem(menu.parent, menuButton.width / 2, menuButton.height + 4)
+ tryVerify(() => Math.abs(menu.x + menu.width / 2 - below.x) < 1)
compare(menu.y, below.y)
- verify(!menu.arrowEnabled)
+ verify(menu.arrowEnabled)
menu.close()
controller.path = "outside-filter.md"
organizer.openMenu(menuButton, "")
diff --git a/third_party/nextkdeAppDemo b/third_party/nextkdeAppDemo
index 62950fd..92923aa 160000
--- a/third_party/nextkdeAppDemo
+++ b/third_party/nextkdeAppDemo
@@ -1 +1 @@
-Subproject commit 62950fd1737c2e344deba95217490cb47c35945e
+Subproject commit 92923aa43af5313915ddd55f580b81e94662473c