From 743d21b985d716af6b1133f9f34d6a5a9a6c5d8c Mon Sep 17 00:00:00 2001 From: liubw Date: Tue, 10 Nov 2026 23:21:53 +0800 Subject: [PATCH 1/2] feat(icons): use the shared icon font for note icons Replace FolderIcons.js raster lookups with the shared icon font, refresh editor tools and packaging assets, bump nextkdeAppDemo. --- CMakeLists.txt | 3 +- docs/notes-interaction-model.md | 6 +- docs/product-interaction-review.md | 2 + packaging/org.nextks.notes.svg | 25 +- qml/EditorTools.qml | 4 +- qml/FolderIcons.js | 38 +-- qml/Main.qml | 357 +++++++++++++++++++---------- qml/NoteNameEditor.qml | 83 +++++++ qml/NotesOrganizer.qml | 127 ++++++---- qml/NotesTokens.qml | 1 + src/NotesController.cpp | 15 +- src/NotesController.h | 3 +- src/main.cpp | 2 + src/storage/ManagedStore.cpp | 33 +-- tests/NotesControllerTest.cpp | 19 ++ tests/NotesRepositoryTest.cpp | 57 +++++ tests/chrome/tst_selection.qml | 126 +++++++++- third_party/nextkdeAppDemo | 2 +- 18 files changed, 656 insertions(+), 247 deletions(-) create mode 100644 qml/NoteNameEditor.qml 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/packaging/org.nextks.notes.svg b/packaging/org.nextks.notes.svg index 61499f9..c96c584 100644 --- a/packaging/org.nextks.notes.svg +++ b/packaging/org.nextks.notes.svg @@ -1,7 +1,24 @@ - - - - + + + + + + + + + + + + + + + + + + + + + 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..32c7582 160000 --- a/third_party/nextkdeAppDemo +++ b/third_party/nextkdeAppDemo @@ -1 +1 @@ -Subproject commit 62950fd1737c2e344deba95217490cb47c35945e +Subproject commit 32c75825044d314ce9590f883ef895f3e7436b99 From dfb4d2879c1f48dace7d4a4f21c411918cb37afd Mon Sep 17 00:00:00 2001 From: liubw Date: Wed, 11 Nov 2026 10:52:23 +0800 Subject: [PATCH 2/2] Update shared UI for complete font icon coverage --- third_party/nextkdeAppDemo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/third_party/nextkdeAppDemo b/third_party/nextkdeAppDemo index 32c7582..92923aa 160000 --- a/third_party/nextkdeAppDemo +++ b/third_party/nextkdeAppDemo @@ -1 +1 @@ -Subproject commit 32c75825044d314ce9590f883ef895f3e7436b99 +Subproject commit 92923aa43af5313915ddd55f580b81e94662473c