diff --git a/CMakeLists.txt b/CMakeLists.txt index 2fe16f8..93d5852 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.21) file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/VERSION" NEXTNOTES_VERSION LIMIT_COUNT 1) project(NextNotes VERSION ${NEXTNOTES_VERSION} LANGUAGES CXX) -set(NEXTNOTES_RELEASE_DATE "2026-11-09" CACHE STRING "AppStream release date") +set(NEXTNOTES_RELEASE_DATE "2026-11-12" CACHE STRING "AppStream release date") set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt6 6.8 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2 QuickDialogs2 Svg WebEngineCore WebEngineQuick WebChannel) @@ -21,7 +21,7 @@ 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/NoteNameEditor.qml qml/NotesOrganizer.qml qml/SourceEditor.qml qml/EditorTools.qml qml/CommandRegistry.qml qml/NotesTokens.qml qml/NoteReadingView.qml) + QML_FILES qml/FolderBrowser.qml qml/NotesBrowser.qml 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/*) @@ -33,7 +33,7 @@ if(NEXTNOTES_BUILD_TESTS) enable_testing() qt_add_executable(notes-chrome-test tests/chrome/main.cpp) qt_add_qml_module(notes-chrome-test URI NextNotes.ChromeTests VERSION 1.0 RESOURCE_PREFIX /qt/qml - QML_FILES qml/NotesWindowControls.qml qml/NotesOrganizer.qml qml/FolderIcons.js qml/NotesTokens.qml + QML_FILES qml/NotesWindowControls.qml qml/NotesOrganizer.qml qml/FolderIcons.js qml/NotesTokens.qml qml/FolderBrowser.qml qml/NotesBrowser.qml qml/NotesSettings.qml qml/NotesPopover.qml qml/NotesContentPopover.qml qml/PopupPlacement.js) qt_add_resources(notes-chrome-test test-window-controls PREFIX /nextnotes/platform BASE qml FILES qml/NativeWindowControls.qml) target_compile_definitions(notes-chrome-test PRIVATE QUICK_TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/tests/chrome") diff --git a/README.md b/README.md index de00785..a439740 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,11 @@ - GFM 表格:输入完整表头与分隔行后按 Enter、粘贴 Markdown 表格,或输入 `|3x3| ` 建立三列三行表格。顶部“表格与行列操作”提供增删行列、列对齐、删表和退出;表格内 Tab / Shift+Tab 导航,Ctrl+Enter 退出。 - Markdown 及富文本粘贴、统一撤销/重做;格式按钮和快捷键调用同一编辑内核命令。 +笔记浏览栏上方“更多”菜单提供列表/画廊切换,各文件夹分别记住视图;子文件夹同步显示为列表或卡片。画廊显示紧凑的标题、摘要和日期卡片,沿用排序、多选、右键及拖放归类。设置入口位于左侧栏底部。设置按通用、笔记管理、数据分组,具体入口见[界面交互说明](docs/notes-interaction-model.md)。 + 本轮块拖动、上下文菜单、完整斜杠菜单与紧凑查找栏的调整、验证范围和效果图见[编辑器交互复查](docs/editor-interaction-review.md)。 -正文工具已补齐:`/` 双语过滤与键盘选择、块拖动/移动/复制/多选、大纲与标题折叠、`[toc]` 实时目录、查找替换与字数统计、图片/附件导入与可恢复清理、图片显示宽度、代码语言/高亮/复制、离线 Mermaid、KaTeX 行内/块公式、脚注、英文拼写检查,以及独立 HTML / PDF 导出与 PDF 打印预览。工具入口为格式栏“正文编辑与导出”;Ctrl+H 打开替换。公式可通过工具菜单或 `/` 插入,双击公式编辑。图片/表格宽度保存在应用偏好中,不扩展 Markdown 语法。折叠仅影响编辑视图,导出会展开全文。附件清理先等待正文保存,再检查磁盘引用,移入专用回收区,可恢复。 +正文工具已补齐:`/` 双语过滤与键盘选择、块拖动/移动/复制/多选、大纲与标题折叠、`[toc]` 实时目录、查找替换与字数统计、图片/附件导入与可恢复清理、图片显示宽度、代码语言/高亮/复制、离线 Mermaid、KaTeX 行内/块公式、脚注、英文拼写检查,以及独立 HTML / PDF 导出与 PDF 打印预览。常用格式操作直接位于顶部按钮组,“列表”“表格”“插入”各自独立;唯一“更多”提供文档操作、源码、大纲及图片/代码/附件管理;Ctrl+H 打开替换。公式可通过工具菜单或 `/` 插入,双击公式编辑。图片/表格宽度保存在应用偏好中,不扩展 Markdown 语法。折叠仅影响编辑视图,导出会展开全文。附件清理先等待正文保存,再检查磁盘引用,移入专用回收区,可恢复。 `web/editor/editor.js` 负责上游插件配置与命令适配;`qml/SourceEditor.qml` 通过 WebChannel 传递主题、焦点与工作区位置;`src/EditorSessionWeb.cpp` 缓存已提交 Markdown,并按文档代次和递增序号拒绝过期消息。现有 `EditorSession::execute/link/undo/redo` 和 controller 存储接口保留。切换笔记、切库和关闭前先向编辑区取得最新已提交状态;自动保存回包不重建编辑器或撤销栈。 diff --git a/VERSION b/VERSION index 17e51c3..d917d3e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.1 +0.1.2 diff --git a/docs/notes-interaction-model.md b/docs/notes-interaction-model.md index e3d3fa7..480330f 100644 --- a/docs/notes-interaction-model.md +++ b/docs/notes-interaction-model.md @@ -4,12 +4,12 @@ ## 日常界面 -- 顶部左侧放新建和当前笔记操作,中间用一个公共按钮组统一承载撤销/重做、段落样式、粗体/斜体/删除线、插入、表格和更多编辑工具,右侧放搜索和设置;正文不再单独占用一行格式工具栏。编辑按钮组优先居中,空间不足时避开左右操作并支持横向滚动,键盘聚焦时跟随显示;格式状态并入底部状态栏。 +- 顶部左侧放新建,中间公共按钮组提供配套的撤销/重做、段落样式、粗体/斜体/删除线、行内代码,以及独立的引用、代码块、链接和任务清单。列表菜单只放列表与缩进;插入菜单放图片/附件、公式、脚注、目录和分隔线;表格保留全部行列操作。唯一正文“更多”位于按钮组末尾,按钮组支持横向滚动和键盘焦点显露;右侧搜索默认收起为图标按钮,点击或按搜索快捷键后展开输入框;宽窗口不自动展开。顶部液态按钮统一使用中档强度,按钮组竖线高 12px、在主题边框原有透明度上再乘 0.65,正文区域最小宽 720px;缩小窗口时正文不再继续变窄:先收起左侧导航,再收起笔记列表,最后只剩正文单栏,窗口最小宽度即正文最小宽度。侧栏隐藏时(列表+正文或仅正文)不再显示“笔记”标题和“全部/收藏/最近删除”分类按钮,顶部只保留侧栏按钮;列表+正文时点击该按钮展开侧栏并由列表让出位置,再点一次返回列表。拖动浏览栏分隔条同样不会把正文压到该宽度以下,设置放在左侧栏底部,使用普通导航按钮。 - 侧栏分为“视图”和“文件夹”:视图包含全部笔记、收藏、最近删除及已保存的搜索筛选;文件夹包含“随手记”、初始化时添加的“默认文件夹”和用户创建的顶层文件夹;子文件夹放在对应笔记列表上方,通过返回上一级逐层导航。侧栏底部不显示新建文件夹,暂不增加“新建视图”入口;保存搜索仍从设置进入。 - “随手记”和“默认文件夹”都是仅初始化一次的普通文件夹,支持重命名、移动、删除和恢复,之后不自动补回。升级时将原收件箱的正常笔记一次性归入“随手记”,保留笔记 ID 并调整路径链接;有待恢复草稿或无法调整的链接时,整批保留原位并提示。在具体文件夹中新建归入当前文件夹;新建或导入未指定文件夹时显示为“未分类”,可在“全部笔记”中找到,不会隐式重建“随手记”。 - 正文左右各保留 48px 留白,笔记名称与正文左对齐;块操作按钮放在左侧留白内,与正文相隔 8px,并至少离开编辑区边界 12px。 - 正文上方提供独立的“笔记名称”输入框,按 Enter 或“保存名称”提交;新建笔记的名称默认为“未命名笔记”,正文为空。名称保存在 Markdown 元数据的 `title` 字段中,修改名称不改写正文标题,也不将名称列入正文大纲。正文标题仍通过格式工具或 Markdown 设置。 -- 笔记菜单包含置顶、收藏、移动、修改笔记名称、笔记信息、分享与导出、创建副本和最近删除。分享与导出以二级菜单提供当前笔记的 PDF、Markdown、HTML 和打印。自动保存不再占用普通笔记菜单的首位。 +- 唯一“更多”合并当前笔记和编辑工具:改名、信息、分享与导出、源码/正文切换、大纲、图片属性、代码语言与复制、附件管理。置顶、收藏、移动、副本和删除保留在列表/画廊右键菜单。分享与导出仍提供 PDF、Markdown、HTML 和打印;外部文件额外显示保存与导入。 - 列表右键菜单沿用邮件列表的定位:在鼠标点击位置弹出无箭头 LiquidPopover,只在接近窗口边缘时收进可见区域。笔记、子文件夹及最近删除列表使用同一规则;键盘打开笔记行菜单时以当前行中点作为位置,工具栏按钮仍就近弹出。 - 全部菜单与浮动表单采用统一触发规则:右键无箭头,按钮下方居中并指向按钮,后续菜单继承最初入口;空间不足时向上调整。完整范围和检查边界见 [菜单与浮动表单](popover-interactions.md)。 - 标签、关联和历史是当前笔记的信息,保留在“笔记信息”;模板管理从这套标签页中移出。 @@ -17,17 +17,21 @@ - 文件夹标题显示分类名称,不将完整分类路径当作页面标题。选择移动/导入目标时仍保留层级以区分同名分类。 - 空笔记空间提供次级“导入已有笔记”引导;普通使用时导入从设置进入。启动文案不再叫“打开我的笔记”。 +## 列表与画廊 + +浏览栏上方的“更多”菜单提供“列表视图”和“画廊视图”,显示当前选项。全部笔记和各个文件夹分别记住偏好;文件夹中的子文件夹同步以列表或卡片呈现,保持键盘导航、多选、右键与拖放。列表保留时间分组;画廊为紧凑文字卡片,显示标题、最多两行摘要、一行日期及置顶/收藏状态。卡片高度 104px,行列间距 12px,默认浏览栏宽 448px,至少保持两列。两种视图共用笔记模型、排序、多选、右键和拖放归类,切换保留游标与选择。画廊宽度独立保存,回收站仍用列表。方向键移动游标,画廊上下键跨行,Enter 打开。 + ## 设置 -设置位于顶部,快捷键 Ctrl+,;采用公共 LiquidDialog、LiquidSegmentedControl、SettingsGroup、SettingsItem 和既有控件,无独立自绘弹窗背景。 +设置位于左侧栏底部,使用普通导航按钮,快捷键 Ctrl+,;采用公共 LiquidDialog、LiquidSegmentedControl、SettingsGroup、SettingsItem 和既有控件,无独立自绘弹窗背景。 | 分区 | 内容 | | --- | --- | | 通用 | 阅读主题、正文字号、减少透明/动画、自动保存说明、快捷键 | -| 数据 | 笔记导入、整夹收集、导入记录、旧笔记迁移;全部笔记导出;完整备份恢复 | -| 高级 | 模板、视图、批量标签重命名、恢复草稿;临时编辑外部 Markdown、当前笔记源码 | +| 笔记管理 | 模板、保存搜索为智能视图、批量标签重命名、恢复草稿 | +| 数据 | 笔记导入、整夹收集、导入记录、旧笔记迁移;全部笔记导出;完整备份恢复;临时编辑外部 Markdown | -选择一项管理操作后先退出设置,再打开相应表单;不在设置上层连续堆叠操作窗口。设置正文可滚动,底部完成按钮固定,键盘聚焦到下方控件时跟随滚动。 +三个页签保持相同高度,按窗口可用空间缩小。选择管理操作后等待设置关闭动画完成,再打开相应表单;不在设置上层连续堆叠操作窗口。设置正文可滚动,底部完成按钮固定,键盘聚焦到下方控件时跟随滚动。 保留 Ctrl+O 的外部 Markdown 快捷操作;新增 Ctrl+Shift+I 导入。外部文件不加入聚合,只在明确导入后纳管;其身份和保存动作仅在实际打开外部文件时出现。 diff --git a/qml/EditorTools.qml b/qml/EditorTools.qml index cf1aba4..263f8d9 100644 --- a/qml/EditorTools.qml +++ b/qml/EditorTools.qml @@ -13,7 +13,7 @@ Item { readonly property real outlineWidth: outlineVisible && width > 600 ? 200 : 0 readonly property var state: source.editorState readonly property bool editable: !source.readOnly && !session.composing - readonly property bool popupActive: slashMenu.visible || blockMenu.visible || toolsMenu.visible || mathPanel.visible || codePanel.visible || imagePanel.visible || helpPanel.visible || attachmentsPanel.visible || outlinePanel.visible + readonly property bool popupActive: slashMenu.visible || blockMenu.visible || mathPanel.visible || codePanel.visible || imagePanel.visible || attachmentsPanel.visible || outlinePanel.visible property var block: ({}) property var spellSuggestions: [] property int documentEpoch: -1 @@ -32,7 +32,7 @@ Item { if (restore) source.editor.forceActiveFocus() } function previewDrag() { - if (dragInside) post("previewBlockMove", {pos: pressedBlock.pos, y: dragY}) + if (dragInside) post("previewBlockMove", {pos: pressedBlock.pos, x: dragX, y: dragY, offsetY: handlePointer.startY - pressedBlock.y}) else post("cancelBlockMove") } function closeSlash() { slashMenu.suppressOnClose = false; slashMenu.close() } @@ -42,8 +42,16 @@ Item { else if (originMenu) outlinePanel.openFromMenu(originMenu) else outlinePanel.openFromButton(anchor) } - function showShortcuts(anchor) { helpPanel.openFromButton(anchor) } - function openMenu(anchor) { toolsMenu.openFromButton(anchor) } + function openTool(name, originMenu) { + if (name === "math") openMath(originMenu) + else if (name === "image") openImage(originMenu) + else if (name === "code") openCode(originMenu) + else if (name === "outline") toggleOutline(null, originMenu) + else if (name === "attachments") { + controller.inspectAttachments() + attachmentsPanel.openFromMenu(originMenu) + } + } function openContext(x, y, suggestions) { pendingContext = {x: x, y: y, suggestions: suggestions || [], request: ++contextSequence} post("contextBlock", {x: x, y: y, request: contextSequence}) @@ -68,7 +76,7 @@ Item { function uiChanged() { if (state.epoch !== undefined && documentEpoch !== state.epoch) { documentEpoch = state.epoch - closeSlash(); blockMenu.close(); toolsMenu.close(); mathPanel.close(); codePanel.close(); imagePanel.close(); helpPanel.close(); attachmentsPanel.close(); outlinePanel.close() + closeSlash(); blockMenu.close(); mathPanel.close(); codePanel.close(); imagePanel.close(); attachmentsPanel.close(); outlinePanel.close() dragging = false; pressedBlock = null; pendingContext = null } if (pendingContext && state.context && state.context.request === pendingContext.request) { @@ -109,18 +117,23 @@ Item { anchors.fill: parent id: handlePointer cursorShape: pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor + hoverEnabled: true + preventStealing: true + property real startX: 0 property real startY: 0 property int startPos: 0 property bool gestureCanceled: false onPressed: function(mouse) { gestureCanceled = false root.pressedBlock = root.state.block - startY = mapToItem(root, mouse.x, mouse.y).y; startPos = root.pressedBlock.pos; root.dragging = false + const point = mapToItem(root, mouse.x, mouse.y) + startX = point.x; startY = point.y; startPos = root.pressedBlock.pos; root.dragging = false } onPositionChanged: function(mouse) { if (!pressed || gestureCanceled) return const point = mapToItem(root, mouse.x, mouse.y) - if (!root.dragging && Math.abs(point.y - startY) > 6) { + if (!root.dragging && Math.hypot(point.x - startX, point.y - startY) > 6) { + root.post("selectBlock", {pos: startPos, focus: false}) handle.forceActiveFocus(Qt.MouseFocusReason) root.dragging = true } @@ -142,7 +155,12 @@ Item { } onCanceled: root.cancelDrag(true) } - KosToolTip { target: handle; text: "拖动移动块,点击打开块操作" } + KosToolTip { + target: handle; text: "拖动移动块,点击打开块操作" + delay: 1400; showOnFocus: false + hovered: handlePointer.containsMouse + when: !handlePointer.pressed && !root.dragging && !root.popupActive + } } Timer { interval: 40; repeat: true @@ -245,17 +263,6 @@ Item { onObjectRemoved: function(index, object) { blockMenu.removeItem(object) } } } - NotesPopover { - id: toolsMenu; sourceItem: root.sceneItem - LiquidPopoverItem { text: "大纲 / 折叠"; onTriggered: root.toggleOutline(null, toolsMenu) } - LiquidPopoverItem { text: "插入自动目录"; enabled: root.editable; onTriggered: root.post("insertToc") } - LiquidPopoverItem { text: "插入图片或附件…"; enabled: root.editable && !root.controller.externalDocument; onTriggered: root.post("chooseAttachment") } - LiquidPopoverItem { text: "图片属性…"; enabled: !!root.state.image; onTriggered: root.openImage(toolsMenu) } - LiquidPopoverItem { text: "附件管理…"; enabled: !root.controller.externalDocument; onTriggered: { root.controller.inspectAttachments(); attachmentsPanel.openFromMenu(toolsMenu) } } - LiquidPopoverItem { text: "数学公式…"; enabled: root.editable; onTriggered: root.openMath(toolsMenu) } - LiquidPopoverItem { text: "插入脚注"; enabled: root.editable; onTriggered: root.post("footnote") } - LiquidPopoverItem { text: "代码语言与复制…"; enabled: !!root.state.code; onTriggered: root.openCode(toolsMenu) } - } NotesContentPopover { id: mathPanel; sourceItem: root.sceneItem; preferredWidth: 370 onOpened: formula.forceActiveFocus() @@ -326,14 +333,4 @@ Item { Label { Layout.fillWidth: true; text: "只回收未被笔记引用的附件,可在附件回收区恢复。"; wrapMode: Text.WordWrap; color: AppTheme.mutedText; font.pixelSize: 11 } } } - NotesContentPopover { - id: helpPanel; sourceItem: root.sceneItem; preferredWidth: 370 - contentItem: ColumnLayout { - Label { text: "正文快捷键"; font.bold: true; color: AppTheme.text } - Repeater { - model: ["/ 插入与转换内容块", "Ctrl+B / Ctrl+I 粗体 / 斜体", "Ctrl+Shift+X 删除线", "Ctrl+E 行内代码", "Ctrl+Z / Ctrl+Shift+Z 撤销 / 重做", "Ctrl+F / Ctrl+H 查找 / 替换", "Alt+Shift+↑ / ↓ 移动块", "Enter / Shift+Enter 分段 / 段内换行", "Tab / Shift+Tab 列表缩进、表格导航", "Ctrl+Enter 退出表格", "Ctrl+点击链接 打开链接", "大纲左右键 / 右键 折叠与展开"] - Label { required property string modelData; text: modelData; color: AppTheme.text; font.pixelSize: 12; topPadding: 2; bottomPadding: 2 } - } - } - } } diff --git a/qml/FolderBrowser.qml b/qml/FolderBrowser.qml new file mode 100644 index 0000000..954fe8d --- /dev/null +++ b/qml/FolderBrowser.qml @@ -0,0 +1,74 @@ +import QtQuick +import QtQuick.Controls + +GridView { + id: root + required property var organization + property string viewMode: "list" + readonly property bool gallery: viewMode === "gallery" + readonly property int columns: gallery ? Math.max(2, Math.floor(width / 196)) : 1 + property string cursorKey: "" + objectName: "childFolderBrowser" + cellWidth: width / columns + cellHeight: gallery ? 104 : 62 + clip: true + activeFocusOnTab: visible && count > 0 + keyNavigationEnabled: false + Accessible.name: gallery ? "子文件夹画廊" : "子文件夹列表" + + function keyAt(row) { return row >= 0 && row < count ? String(model[row]) : "" } + function focusCurrent() { + if (!count) return + if (currentIndex < 0) currentIndex = 0 + cursorKey = keyAt(currentIndex) + positionViewAtIndex(currentIndex, GridView.Contain) + if (currentItem) currentItem.forceActiveFocus(Qt.TabFocusReason) + } + function restoreCursor() { + let row = -1 + for (let i = 0; i < count; ++i) if (keyAt(i) === cursorKey) { row = i; break } + currentIndex = row >= 0 ? row : count ? 0 : -1 + cursorKey = keyAt(currentIndex) + if (activeFocus) focusCurrent() + } + function handleKey(event) { + if (event.modifiers === Qt.ControlModifier && event.key === Qt.Key_A) { + if (!organization.folderSelectionMode) organization.beginSelection("folders") + organization.selectAll(); event.accepted = true; return + } + if (event.modifiers === Qt.ControlModifier && event.key === Qt.Key_Space && currentIndex >= 0) { + if (!organization.folderSelectionMode) organization.beginSelection("folders") + organization.toggleFolder(keyAt(currentIndex)); event.accepted = true; return + } + if (event.key === Qt.Key_Escape && organization.selectionMode) { organization.clearSelection(); event.accepted = true; return } + if (currentItem && (event.key === Qt.Key_Menu || event.key === Qt.Key_F10 && event.modifiers === Qt.ShiftModifier)) { + currentItem.openContext(); event.accepted = true; return + } + if (event.modifiers !== Qt.NoModifier || !count) return + let row = currentIndex + if (event.key === Qt.Key_Down) row += columns + else if (event.key === Qt.Key_Up) row -= columns + else if (gallery && event.key === Qt.Key_Right) row += 1 + else if (gallery && event.key === Qt.Key_Left) row -= 1 + else if (event.key === Qt.Key_Home) row = 0 + else if (event.key === Qt.Key_End) row = count - 1 + else if ([Qt.Key_Return, Qt.Key_Enter, Qt.Key_Space].includes(event.key)) { + if (currentItem && !event.isAutoRepeat) currentItem.activate() + event.accepted = true; return + } else return + currentIndex = Math.max(0, Math.min(count - 1, row)) + focusCurrent(); event.accepted = true + } + onViewModeChanged: { + const hadFocus = activeFocus || !!currentItem && currentItem.activeFocus + Qt.callLater(function() { + forceLayout() + restoreCursor() + if (hadFocus) focusCurrent() + }) + } + onModelChanged: Qt.callLater(restoreCursor) + onActiveFocusChanged: if (activeFocus) Qt.callLater(function() { if (root.activeFocus) root.focusCurrent() }) + Keys.onPressed: event => handleKey(event) + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } +} diff --git a/qml/Main.qml b/qml/Main.qml index d6817ce..6e35e1c 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -11,7 +11,17 @@ ApplicationWindow { required property var controller width: controller.preference("width", tokens.windowWidth) height: controller.preference("height", tokens.windowHeight) - minimumWidth: 540; minimumHeight: 480 + readonly property int editorMinimumWidth: 720 + readonly property real browserMinimumWidth: 240 + minimumWidth: editorMinimumWidth + minimumHeight: 480 + // Wayland compositor scripts may bypass the advertised minimum; keep the + // editor no narrower than the full toolbar in every case. + onWidthChanged: { + if (width < editorMinimumWidth) width = editorMinimumWidth + if (width >= editorMinimumWidth + browserMinimumWidth + sidebarWidth + || width < editorMinimumWidth + browserMinimumWidth) listHidden = false + } visible: true title: qsTr("笔记") flags: Qt.Window | Qt.FramelessWindowHint @@ -23,15 +33,16 @@ ApplicationWindow { function editNote() { Qt.callLater(function() { source.editor.forceActiveFocus() }) } property bool sidebarRequested: controller.preference("sidebar", true) property bool listRequested: false + property bool listHidden: false + property bool compactSidebarRequested: false property bool trashView: false readonly property string listTitle: controller.query && !root.trashView ? "搜索结果" : root.trashView ? "最近删除" : controller.filter === "favorite" ? "收藏" : controller.filter === "inbox" ? "未分类" : controller.filter.startsWith("import:") ? "本次导入" : controller.filter.startsWith("folder:") ? controller.filter.slice(7).split("/").pop() : "全部笔记" function showTrash() { browseCategory(controller.filter); organizer.clearSelection(); trashView = true; controller.inspectRecovery() } property string activeLibrary: controller.library onActiveLibraryChanged: trashView = false - property bool compactSidebarRequested: false function toggleNavigation() { - if (width >= 1000) sidebarRequested = !sidebarRequested - else if (width >= 800) compactSidebarRequested = !compactSidebarRequested + if (width >= editorMinimumWidth + browserMinimumWidth + sidebarWidth) sidebarRequested = !sidebarRequested + else if (width >= editorMinimumWidth + sidebarWidth) compactSidebarRequested = !compactSidebarRequested else listRequested = !showList } readonly property var topLevelFolders: Array.from(controller.folders).filter(p => !p.includes("/")) @@ -43,7 +54,7 @@ ApplicationWindow { const parent = organizer.parentFolder(currentFolder) browseCategory(parent ? "folder:" + parent : "all") } - function revealList() { trashView = false; compactSidebarRequested = false; listRequested = true } + function revealList() { trashView = false; listRequested = true; listHidden = false; compactSidebarRequested = false } function noteLocation(path) { const parts = String(path || "").split("/"); parts.pop(); return parts.length ? parts.join(" › ") : "未分类" } // Presentation-only cleanup: the repository remains the source of Markdown. function summaryText(value) { @@ -60,11 +71,31 @@ ApplicationWindow { } property real sidebarWidth: controller.preference("sidebarWidth", tokens.sidebarWidth) property real listWidth: controller.preference("listWidth", tokens.listWidth) + property int viewPreferenceRevision: 0 + readonly property string viewPreferenceKey: "noteViewMode/" + encodeURIComponent(controller.library) + "/" + encodeURIComponent(currentFolder ? "folder:" + currentFolder : "all") + readonly property string noteViewMode: { + viewPreferenceRevision + return controller.preference(viewPreferenceKey, controller.preference("noteViewMode", "list")) === "gallery" ? "gallery" : "list" + } + function setNoteViewMode(mode) { + controller.setPreference(viewPreferenceKey, mode) + if (!currentFolder) controller.setPreference("noteViewMode", mode) + ++viewPreferenceRevision + } + property real galleryWidth: controller.preference("galleryWidth", 448) + readonly property bool gallery: noteViewMode === "gallery" && !trashView + readonly property real browserWidth: Math.min(root.gallery ? Math.max(416, root.galleryWidth) : root.listWidth, + Math.max(root.browserMinimumWidth, root.width - (root.showSidebar ? root.sidebarWidth : 0) - root.editorMinimumWidth)) readonly property bool hasLibrary: controller.library.length > 0 - 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 || paragraphMenu.visible || namePanel.visible || recoveryDialog.visible || discardDialog.visible || folderPanel.visible || notesSettings.visible || discardRecoveryDialog.visible + readonly property bool threeColumns: width >= editorMinimumWidth + browserMinimumWidth + sidebarWidth + readonly property bool sidebarAloneFits: width >= editorMinimumWidth + sidebarWidth + readonly property bool showSidebar: hasLibrary && sidebarRequested && (threeColumns || compactSidebarRequested && sidebarAloneFits) + readonly property bool showList: hasLibrary && !listHidden && !(showSidebar && !threeColumns) && ( + width >= editorMinimumWidth + browserMinimumWidth + || (width < 800 && listRequested) + || (trashView ? !recycleBin.currentEntry : !controller.path)) + readonly property real editorX: (showSidebar ? sidebarWidth : 0) + (showList ? (width < 800 ? width : browserWidth) : 0) + readonly property bool popupsOpen: (source.popupActive || knowledgePanel.popupActive || smartDialog.visible || missingLinkDialog.visible) || libraryActions.popupActive || organizer.popupActive || recycleBin.popupActive || actionMenu.visible || outputMenu.visible || insertMenu.visible || shortcutsDialog.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) @@ -73,7 +104,9 @@ ApplicationWindow { onSidebarRequestedChanged: controller.setPreference("sidebar", sidebarRequested) onSidebarWidthChanged: controller.setPreference("sidebarWidth", sidebarWidth) onListWidthChanged: controller.setPreference("listWidth", listWidth) + onGalleryWidthChanged: controller.setPreference("galleryWidth", galleryWidth) Binding { target: AppTheme; property: "compactDesign"; value: true } + Binding { target: LiquidStyle; property: "intensityLevel"; value: "medium" } Component.onCompleted: { AppTheme.reduceTransparency = controller.preference("reduceTransparency", false) AppTheme.reduceMotion = controller.preference("reduceMotion", false) @@ -133,7 +166,6 @@ 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 (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) @@ -171,7 +203,6 @@ ApplicationWindow { onModelChanged: Qt.callLater(restoreCursor) Keys.onPressed: function(event) { handleKey(event) } } - Connections { target: controller; function onNotesChanged() { Qt.callLater(noteList.restoreCursor) } } // Internal drag payloads never expose disk URLs or move source files. component OrganizePointer: MouseArea { id: pointer @@ -297,7 +328,7 @@ ApplicationWindow { Timer { interval: 30; repeat: true; running: organizer.dragging onTriggered: { - const view = [compactFolderList, sidebarFolderList, childFolderList, noteList].find(candidate => { + const view = [compactFolderList, sidebarFolderList, childFolderList, noteList.activeView].find(candidate => { if (!candidate.visible) return false const point = organizePreview.mapToItem(candidate, 12, 16) return point.x >= 0 && point.x <= candidate.width && point.y >= -16 && point.y <= candidate.height + 16 @@ -313,7 +344,7 @@ ApplicationWindow { component ToolbarAction: LiquidActionButton { property string help: "" iconOnly: true; toolbarAction: true - materialForm: false; backdrop: readingBackdrop + materialForm: false; backdrop: readingBackdrop; liquidIntensity: "medium" focusPolicy: Qt.TabFocus Accessible.name: help KosToolTip { target: parent; backdrop: readingBackdrop; text: parent.help } @@ -322,9 +353,25 @@ ApplicationWindow { Layout.minimumHeight: implicitHeight Layout.maximumHeight: implicitHeight } - component ListNavigationActions: LiquidActionButtonGroup { - id: listActions + component ToolbarActionGroup: LiquidActionButtonGroup { + id: toolbarGroup materialForm: false; backdrop: readingBackdrop + dividerColor: "transparent" + Repeater { + model: toolbarGroup.icons.length + Rectangle { + required property int index + x: index * toolbarGroup.buttonSize + y: (toolbarGroup.height - height) / 2 + width: 1; height: 12 + color: AppTheme.border + opacity: 0.65 + visible: index > 0 + } + } + } + component ListNavigationActions: ToolbarActionGroup { + id: listActions icons: root.currentFolder ? ["back", "more"] : ["more"] labels: root.currentFolder ? ["返回" + (organizer.parentFolder(root.currentFolder) ? organizer.parentFolder(root.currentFolder).split("/").pop() : "全部笔记"), "列表操作"] @@ -442,11 +489,12 @@ ApplicationWindow { onDoubleClicked: root.visibility === Window.Maximized ? root.showNormal() : root.showMaximized() } RowLayout { - x: 14; width: root.showSidebar ? root.sidebarWidth - 26 : 152 + x: 14; width: root.showSidebar ? root.sidebarWidth - 26 : LiquidStyle.toolbarButtonHeight anchors.verticalCenter: parent.verticalCenter; spacing: 8 Label { objectName: "notesTitle" - text: root.width < 800 && root.showList ? root.listTitle : root.title; color: AppTheme.text + visible: root.showSidebar + text: root.title; color: AppTheme.text font.pixelSize: 22; font.weight: Font.Bold Layout.fillWidth: true; Layout.preferredHeight: 28 verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight @@ -465,15 +513,15 @@ ApplicationWindow { background: LiquidActionButton { enabled: false; Accessible.ignored: true materialForm: false; backdrop: readingBackdrop - liquidIntensity: "strong" + liquidIntensity: "medium" } onClicked: root.toggleNavigation() } } RowLayout { visible: root.showList && root.width >= 800 - x: root.showSidebar ? root.sidebarWidth + 18 : 178 - width: root.showSidebar ? root.listWidth - 36 : Math.max(80, root.listWidth - 190) + x: root.showSidebar ? root.sidebarWidth + 18 : 54 + width: root.showSidebar ? root.browserWidth - 36 : Math.max(80, root.browserWidth - 66) anchors.verticalCenter: parent.verticalCenter Label { Layout.fillWidth: true; Layout.minimumWidth: 0; Layout.preferredWidth: 0 @@ -491,19 +539,17 @@ ApplicationWindow { Item { id: headerActions visible: root.hasLibrary - x: root.editorX < root.width ? Math.max(178, root.editorX + 12) : 178 + x: root.editorX < root.width ? Math.max(root.showSidebar ? 178 : 54, root.editorX + 12) : (root.showSidebar ? 178 : 54) anchors.verticalCenter: parent.verticalCenter readonly property real controlsLeft: windowControls.x + (windowControls.buttonLayout.length ? windowControls.buttonLayout[0].x : 0) width: Math.max(0, controlsLeft - 10 - x); height: LiquidStyle.toolbarButtonHeight - readonly property bool wideSearch: root.width >= 1320 && width >= noteActions.implicitWidth + editorActions.implicitWidth + LiquidStyle.toolbarButtonHeight + 210 - readonly property bool searchShown: wideSearch || root.searchOpen || controller.query.length > 0 - readonly property bool searching: !wideSearch && searchShown + readonly property bool searchShown: root.searchOpen || controller.query.length > 0 + readonly property bool searching: searchShown 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) } } Flickable { id: editorActionsViewport @@ -542,48 +588,64 @@ ApplicationWindow { editorActionsViewport.contentX = Math.max(0, Math.min(editorActionsViewport.contentWidth - editorActionsViewport.width, desired)) } } - LiquidActionButtonGroup { + ToolbarActionGroup { id: editorActions objectName: "bodyEditingButtonGroup" height: LiquidStyle.toolbarButtonHeight anchors.verticalCenter: parent.verticalCenter clip: false 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] + readonly property var actions: [ + {id: "undo", icon: "undo", label: controller.undoText, enabled: !root.popupsOpen && controller.canUndo}, + {id: "redo", icon: "redo", label: controller.redoText, enabled: !root.popupsOpen && controller.canRedo} + ].concat(session.sourceMode ? [ + {id: "sourceMode", icon: "", label: "返回正文", enabled: source.visible && !controller.busy && !session.composing} + ] : [ + {id: "paragraph", icon: "", label: "段落样式", enabled: root.canFormat}, + {id: "bold", icon: "bold", label: "粗体 · Ctrl+B", enabled: root.canFormat}, + {id: "italic", icon: "italic", label: "斜体 · Ctrl+I", enabled: root.canFormat}, + {id: "strike", icon: "strikethrough", label: "删除线 · Ctrl+Shift+X", enabled: root.canFormat}, + {id: "code", icon: "code", label: "行内代码 · Ctrl+E", enabled: root.canFormat}, + {id: "quote", icon: "text_quote", label: "引用", enabled: root.canFormat}, + {id: "codeblock", icon: "doc_plaintext", label: "代码块", enabled: root.canFormat}, + {id: "link", icon: "link", label: "添加链接", enabled: root.canFormat}, + {id: "task", icon: "checkmark_square", label: "任务清单", enabled: root.canFormat}, + {id: "list", icon: "list_bullet", label: "列表", enabled: root.canFormat}, + {id: "table", icon: "table", label: "表格与行列操作", enabled: root.canFormat}, + {id: "insert", icon: "plus", label: "插入", enabled: root.canFormat} + ]).concat([{id: "more", icon: "more", label: "更多", enabled: !!controller.path && !controller.busy}]) + icons: actions.map(action => action.icon) + labels: actions.map(action => action.label) + enabledActions: actions.map(action => action.enabled) + objectNames: actions.map(action => "editorAction_" + action.id) + function actionX(id) { return Math.max(0, actions.findIndex(action => action.id === id)) * buttonSize } 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) + const action = actions[index].id + if (action === "undo") controller.undo() + else if (action === "redo") controller.redo() + else if (action === "sourceMode") session.execute("sourceMode") + else if (action === "paragraph") paragraphMenu.openFromButton(paragraphButton) + else if (action === "list") formatMenu.openFromButton(listButton) + else if (action === "table") tableMenu.openFromButton(tableButton) + else if (action === "insert") insertMenu.openFromButton(insertButton) + else if (action === "link") linkPanel.openFromButton(linkButton) + else if (action === "more") actionMenu.openFromButton(moreButton) + else root.applyFormat(action) } - // 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 - } + Text { + x: editorActions.actionX(session.sourceMode ? "sourceMode" : "paragraph") + width: editorActions.buttonSize; height: editorActions.buttonSize + text: !session.sourceMode && session.headingLevel ? "H" + session.headingLevel : "正文" + horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter + color: editorActions.enabledActions[2] ? AppTheme.text : AppTheme.disabledText + font.pixelSize: 10; 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 } + Item { id: paragraphButton; x: editorActions.actionX("paragraph"); width: editorActions.buttonSize; height: width } + Item { id: listButton; x: editorActions.actionX("list"); width: editorActions.buttonSize; height: width } + Item { id: tableButton; x: editorActions.actionX("table"); width: editorActions.buttonSize; height: width } + Item { id: insertButton; x: editorActions.actionX("insert"); width: editorActions.buttonSize; height: width } + Item { id: linkButton; x: editorActions.actionX("link"); width: editorActions.buttonSize; height: width } + Item { id: moreButton; x: editorActions.actionX("more"); width: editorActions.buttonSize; height: width } } } RowLayout { @@ -591,7 +653,6 @@ ApplicationWindow { 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 @@ -610,7 +671,7 @@ ApplicationWindow { } 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() + onActiveFocusChanged: if (!activeFocus && text.length === 0 && root.searchOpen) root.closeGlobalSearch() Keys.onEscapePressed: function(event) { if (!inputMethodComposing) { root.closeGlobalSearch(); event.accepted = true } } Accessible.name: "搜索所有笔记" } @@ -736,20 +797,27 @@ ApplicationWindow { width: ListView.view.width; height: implicitHeight } } + NavigationRow { + objectName: "settingsButton" + Layout.fillWidth: true + iconName: "settings"; text: "设置" + Accessible.name: "设置 · Ctrl+," + onClicked: notesSettings.show(0) + } } } Rectangle { id: listColumn visible: root.showList x: root.showSidebar ? root.sidebarWidth : 0 - width: root.width < 800 ? root.width : root.listWidth; height: parent.height + width: root.width < 800 ? root.width : root.browserWidth; height: parent.height color: AppTheme.contentBackground Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: AppTheme.border; opacity: 0.5 } ColumnLayout { id: noteColumnLayout anchors.fill: parent; anchors.margins: 12; spacing: 12 RowLayout { - visible: !root.showSidebar; Layout.fillWidth: true + visible: !root.showSidebar && root.width < 800; Layout.fillWidth: true LiquidActionButton { text: "全部"; materialForm: true; onClicked: { root.browseCategory("all") } } LiquidActionButton { text: "收藏"; materialForm: true; onClicked: { root.browseCategory("favorite") } } LiquidActionButton { text: "最近删除"; materialForm: true; onClicked: root.showTrash() } @@ -806,12 +874,13 @@ ApplicationWindow { anchors.fill: parent; radius: 10 color: AppTheme.dark ? "#303032" : "#f7f7f8" } - KeyboardList { + FolderBrowser { id: childFolderList + organization: organizer + viewMode: root.noteViewMode visible: childFolderSection.visible && root.childFoldersExpanded anchors.fill: parent anchors.topMargin: 4; anchors.bottomMargin: 4 - clip: true; spacing: 0 model: root.childFolders Accessible.name: "子文件夹" delegate: ItemDelegate { @@ -824,8 +893,9 @@ ApplicationWindow { readonly property string summary: (details.createdUtc ? new Date(details.createdUtc).toLocaleDateString(Qt.locale(), "yyyy/M/d") + " 创建" : "创建日期未记录") + " · " + (details.noteCount || 0) + " 篇笔记" - width: childFolderList.width - height: contentItem.implicitHeight + topPadding + bottomPadding + readonly property bool card: childFolderList.gallery + width: childFolderList.cellWidth - (card ? 12 : 0) + height: childFolderList.cellHeight - (card ? 12 : 0) leftPadding: organizer.folderSelectionMode ? 36 : 12 rightPadding: 12; topPadding: 7; bottomPadding: 7 readonly property bool bulkSelected: organizer.selectedFolderPaths.includes(modelData) @@ -840,8 +910,9 @@ ApplicationWindow { background: Item { Rectangle { anchors.fill: parent; anchors.margins: 4 - radius: 7; border.width: 0 - color: childFolderRow.bulkSelected ? tokens.navigationSelected : childFolderRow.hovered ? AppTheme.controlBackground : "transparent" + radius: 7; border.width: childFolderRow.card && !childFolderRow.bulkSelected ? 1 : 0 + border.color: AppTheme.border + color: childFolderRow.bulkSelected ? tokens.navigationSelected : (childFolderRow.card || childFolderRow.hovered) ? AppTheme.controlBackground : "transparent" } } contentItem: Column { @@ -861,7 +932,7 @@ ApplicationWindow { elide: Text.ElideRight; font.pixelSize: 14; font.weight: Font.DemiBold; color: AppTheme.text } } - Label { width: parent.width; text: childFolderRow.summary; elide: Text.ElideRight; font.pixelSize: 12; color: AppTheme.mutedText } + Label { width: parent.width; text: childFolderRow.summary; elide: Text.ElideRight; wrapMode: childFolderRow.card ? Text.Wrap : Text.NoWrap; maximumLineCount: childFolderRow.card ? 2 : 1; font.pixelSize: 12; color: AppTheme.mutedText } } OrganizePointer { folderPath: childFolderRow.modelData; enabled: !controller.busy @@ -896,7 +967,6 @@ ApplicationWindow { y: Math.max(0, childFolderList.originY + childFolderList.contentHeight - (childFolderList.footerItem ? childFolderList.footerItem.height : 0) - childFolderList.contentY) width: childFolderList.width; height: Math.max(0, childFolderList.height - y) } - ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } } } } @@ -907,98 +977,112 @@ ApplicationWindow { text: "笔记 · " + noteList.count color: AppTheme.mutedText; font.pixelSize: 11; font.weight: Font.Medium } - KeyboardList { + Label { + visible: !root.trashView && !childFolderSection.visible + Layout.fillWidth: true + text: noteList.count + " 篇笔记"; color: AppTheme.mutedText; font.pixelSize: 11 + } + NotesBrowser { id: noteList visible: !root.trashView - Layout.fillWidth: true; Layout.fillHeight: true; clip: true; spacing: 4 - model: controller.noteModel - Accessible.name: "笔记" + Layout.fillWidth: true; Layout.fillHeight: true + controller: root.controller; organization: organizer + viewMode: root.gallery ? "gallery" : "list" + noteDelegate: noteRowDelegate - delegate: ItemDelegate { - id: noteRow - required property var modelData - required property int index - readonly property string rowStatus: [modelData.favorite ? "收藏" : "", modelData.id === controller.selectedId && modelData.path === controller.path ? controller.status : modelData.readOnlyReason ? "只读" : ""].filter(Boolean).join(" · ") - focusPolicy: Qt.StrongFocus - activeFocusOnTab: false - 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) - } - readonly property bool bulkSelected: organizer.selectedPaths.indexOf(modelData.path) >= 0 - readonly property string groupName: root.dateGroup(modelData) - readonly property bool startsGroup: !!groupName && (index === 0 || root.dateGroup(controller.notes[index - 1]) !== groupName) - // 32px heading plus a 4px gap before the note background. - readonly property int groupHeight: startsGroup ? 36 : 0 - readonly property int contentTopInset: startsGroup ? 8 : 12 - width: ListView.view.width; height: tokens.noteRowHeight + groupHeight - (12 - contentTopInset) - Label { - x: 6; y: 4; visible: noteRow.startsGroup; text: noteRow.groupName - font.pixelSize: 15; font.weight: Font.DemiBold; color: AppTheme.text - } - Rectangle { - x: 6; y: noteRow.groupHeight - 5; width: parent.width - 12; height: 1 - visible: noteRow.startsGroup - color: AppTheme.border; opacity: 0.6 - } - // Show selection controls only during an explicit multi-selection. - 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" - border.width: 0 - } - readonly property string thumbnail: "" - Image { - anchors.right: parent.right; anchors.rightMargin: 12 - y: noteRow.groupHeight + noteRow.contentTopInset - 2; width: 48; height: 48 - visible: !!noteRow.thumbnail; source: noteRow.thumbnail - fillMode: Image.PreserveAspectCrop; asynchronous: true; clip: true - sourceSize: Qt.size(96, 96) - } - rightPadding: thumbnail ? 70 : 12 - contentItem: Column { - spacing: 3 - Row { - width: parent.width; spacing: 6 - Label { width: parent.width - (favoriteMark.visible ? 20 : 0); text: (noteRow.modelData.pinned ? "置顶 · " : "") + noteRow.modelData.title; textFormat: Text.PlainText; elide: Text.ElideRight; font.pixelSize: 14; font.weight: Font.DemiBold; color: noteRow.highlighted ? "white" : AppTheme.text } - VectorIcon { id: favoriteMark; visible: !!noteRow.modelData.favorite; name: "heart"; size: 13; color: noteRow.highlighted ? "white" : AppTheme.mutedText } - } - Label { width: parent.width; text: new Date(noteRow.modelData.displayModifiedUtc).toLocaleDateString(Qt.locale(), "M/d") + " " + root.summaryText(noteRow.modelData.summary); textFormat: Text.PlainText; elide: Text.ElideRight; font.pixelSize: 12; color: noteRow.highlighted ? "white" : AppTheme.mutedText } - } - OrganizePointer { - notePath: noteRow.modelData.path; noteTitle: noteRow.modelData.title; enabled: !controller.busy - onClickedItem: function(button, modifiers, position) { - noteRow.forceActiveFocus(Qt.MouseFocusReason) - if (button === Qt.RightButton) organizer.openMenu(noteRow, noteRow.modelData.path, position) - else if (!organizer.click(noteRow.modelData, noteRow.index, modifiers)) noteRow.activate() - } - } - NotesSelectionBox { - id: noteSelectionBox - visible: organizer.noteSelectionMode - x: 6; y: noteRow.groupHeight + noteRow.contentTopInset; width: 24; height: 24 - padding: 4 - checked: noteRow.bulkSelected; text: "" - Accessible.name: "选择笔记 " + noteRow.modelData.title + "," + root.noteLocation(noteRow.modelData.path) - onClicked: organizer.toggle(noteRow.modelData.path) - } - onClicked: activate() - Accessible.role: Accessible.ListItem - Accessible.focusable: true - Accessible.selected: organizer.noteSelectionMode ? bulkSelected : highlighted - Accessible.onPressAction: activate() - Accessible.name: modelData.title + "," + root.noteLocation(modelData.path) + (rowStatus ? ", " + rowStatus : "") - } Label { x: 12; y: Math.max(0, (parent.height - implicitHeight) / 2); width: parent.width - 24; wrapMode: Text.Wrap; horizontalAlignment: Text.AlignHCenter; visible: noteList.count === 0; text: controller.query ? "没有找到相关笔记" : controller.filter === "favorite" ? "收藏的笔记会显示在这里" : controller.filter.startsWith("folder:") ? "这个文件夹还没有笔记" : "从第一篇笔记开始"; color: AppTheme.mutedText; font.pixelSize: 13 } } } } + Component { + id: noteRowDelegate + ItemDelegate { + id: noteRow + readonly property bool card: GridView.view !== null + required property var modelData + required property int index + readonly property string rowStatus: [modelData.favorite ? "收藏" : "", modelData.id === controller.selectedId && modelData.path === controller.path ? controller.status : modelData.readOnlyReason ? "只读" : ""].filter(Boolean).join(" · ") + focusPolicy: Qt.StrongFocus + activeFocusOnTab: false + Keys.onPressed: function(event) { noteList.handleKey(event) } + onActiveFocusChanged: if (activeFocus) { noteList.setCursor(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) + } + readonly property bool bulkSelected: organizer.selectedPaths.indexOf(modelData.path) >= 0 + readonly property string groupName: root.dateGroup(modelData) + readonly property bool startsGroup: !noteRow.card && !!groupName && (index === 0 || root.dateGroup(controller.notes[index - 1]) !== groupName) + // 32px heading plus a 4px gap before the note background. + readonly property int groupHeight: startsGroup ? 36 : 0 + readonly property int contentTopInset: startsGroup ? 8 : 12 + width: noteRow.card ? GridView.view.cellWidth - 12 : noteList.width + height: noteRow.card ? GridView.view.cellHeight - 12 : tokens.noteRowHeight + groupHeight - (12 - contentTopInset) + Label { + x: 6; y: 4; visible: noteRow.startsGroup; text: noteRow.groupName + font.pixelSize: 15; font.weight: Font.DemiBold; color: AppTheme.text + } + Rectangle { + x: 6; y: noteRow.groupHeight - 5; width: parent.width - 12; height: 1 + visible: noteRow.startsGroup + color: AppTheme.border; opacity: 0.6 + } + // Show selection controls only during an explicit multi-selection. + 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.card || noteRow.hovered) ? AppTheme.controlBackground : "transparent" + border.width: noteRow.card && !noteRow.highlighted ? 1 : 0 + border.color: AppTheme.border + } + readonly property string thumbnail: "" + Image { + anchors.right: parent.right; anchors.rightMargin: 12 + y: noteRow.groupHeight + noteRow.contentTopInset - 2; width: 48; height: 48 + visible: !!noteRow.thumbnail; source: noteRow.thumbnail + fillMode: Image.PreserveAspectCrop; asynchronous: true; clip: true + sourceSize: Qt.size(96, 96) + } + rightPadding: thumbnail ? 70 : 12 + contentItem: Column { + spacing: 3 + Row { + width: parent.width; spacing: 6 + Label { width: parent.width - (favoriteMark.visible ? 20 : 0); text: (noteRow.modelData.pinned ? "置顶 · " : "") + noteRow.modelData.title; textFormat: Text.PlainText; elide: Text.ElideRight; font.pixelSize: 14; font.weight: Font.DemiBold; color: noteRow.highlighted ? "white" : AppTheme.text } + VectorIcon { id: favoriteMark; visible: !!noteRow.modelData.favorite; name: "heart"; size: 13; color: noteRow.highlighted ? "white" : AppTheme.mutedText } + } + Label { width: parent.width; text: noteRow.card ? root.summaryText(noteRow.modelData.summary) : new Date(noteRow.modelData.displayModifiedUtc).toLocaleDateString(Qt.locale(), "M/d") + " " + root.summaryText(noteRow.modelData.summary); textFormat: Text.PlainText; elide: Text.ElideRight; wrapMode: noteRow.card ? Text.Wrap : Text.NoWrap; maximumLineCount: noteRow.card ? 2 : 1; font.pixelSize: 12; color: noteRow.highlighted ? "white" : AppTheme.mutedText } + Label { visible: noteRow.card; text: new Date(noteRow.modelData.displayModifiedUtc).toLocaleDateString(Qt.locale(), "yyyy/M/d"); font.pixelSize: 11; color: noteRow.highlighted ? "white" : AppTheme.mutedText } + } + OrganizePointer { + notePath: noteRow.modelData.path; noteTitle: noteRow.modelData.title; enabled: !controller.busy + onClickedItem: function(button, modifiers, position) { + noteRow.forceActiveFocus(Qt.MouseFocusReason) + if (button === Qt.RightButton) organizer.openMenu(noteRow, noteRow.modelData.path, position) + else if (!organizer.click(noteRow.modelData, noteRow.index, modifiers)) noteRow.activate() + } + } + NotesSelectionBox { + id: noteSelectionBox + visible: organizer.noteSelectionMode + x: 6; y: noteRow.groupHeight + noteRow.contentTopInset; width: 24; height: 24 + padding: 4 + checked: noteRow.bulkSelected; text: "" + Accessible.name: "选择笔记 " + noteRow.modelData.title + "," + root.noteLocation(noteRow.modelData.path) + onClicked: organizer.toggle(noteRow.modelData.path) + } + onClicked: activate() + Accessible.role: Accessible.ListItem + Accessible.focusable: true + Accessible.selected: organizer.noteSelectionMode ? bulkSelected : highlighted + Accessible.onPressAction: activate() + Accessible.name: modelData.title + "," + root.noteLocation(modelData.path) + (rowStatus ? ", " + rowStatus : "") + } + } + RecycleBin { id: recycleBin visible: root.trashView && root.hasLibrary @@ -1021,8 +1105,15 @@ ApplicationWindow { MouseArea { visible: root.showList && root.width >= 800; x: root.editorX - 3; width: 6; height: parent.height; cursorShape: Qt.SplitHCursor property real startWidth: 0; property real startX: 0 - onPressed: function(mouse) { startWidth = root.listWidth; startX = mapToItem(root.contentItem, mouse.x, mouse.y).x } - onPositionChanged: function(mouse) { if (pressed) root.listWidth = Math.max(240, Math.min(340, startWidth + mapToItem(root.contentItem, mouse.x, mouse.y).x - startX)) } + onPressed: function(mouse) { startWidth = root.gallery ? root.galleryWidth : root.listWidth; startX = mapToItem(root.contentItem, mouse.x, mouse.y).x } + onPositionChanged: function(mouse) { if (pressed) { + const next = startWidth + mapToItem(root.contentItem, mouse.x, mouse.y).x - startX + const maximum = Math.max(root.browserMinimumWidth, root.width - (root.showSidebar ? root.sidebarWidth : 0) - root.editorMinimumWidth) + if (root.gallery) { + const limit = Math.min(800, maximum) + root.galleryWidth = Math.max(Math.min(416, limit), Math.min(limit, next)) + } else root.listWidth = Math.max(240, Math.min(340, maximum, next)) + } } } Item { id: editorChrome @@ -1144,20 +1235,23 @@ ApplicationWindow { } } NotesPopover { - id: formatMenu; anchorItem: insertButton; sourceItem: readingScene + id: formatMenu; anchorItem: listButton; sourceItem: readingScene onClosed: Qt.callLater(root.restoreEditorFocus) LiquidPopoverItem { text: "项目列表"; onTriggered: root.applyFormat("bullet") } LiquidPopoverItem { text: "编号列表"; onTriggered: root.applyFormat("ordered") } LiquidPopoverItem { text: "任务清单"; onTriggered: root.applyFormat("task") } LiquidPopoverItem { text: "勾选 / 取消勾选"; onTriggered: root.applyFormat("check") } - LiquidPopoverItem { text: "行内代码"; onTriggered: root.applyFormat("code") } - LiquidPopoverItem { text: "插入表格"; onTriggered: root.applyFormat("table") } - LiquidPopoverItem { text: "引用"; onTriggered: root.applyFormat("quote") } - LiquidPopoverItem { text: "代码块"; onTriggered: root.applyFormat("codeblock") } - LiquidPopoverItem { text: "分隔线"; onTriggered: root.applyFormat("rule") } - LiquidPopoverItem { text: "增加列表缩进"; onTriggered: root.applyFormat("indent") } - LiquidPopoverItem { text: "减少列表缩进"; onTriggered: root.applyFormat("outdent") } - LiquidPopoverItem { text: "添加链接…"; onTriggered: linkPanel.openFromMenu(formatMenu) } + LiquidPopoverItem { text: "增加列表缩进"; enabled: root.canFormat; onTriggered: root.applyFormat("indent") } + LiquidPopoverItem { text: "减少列表缩进"; enabled: root.canFormat; onTriggered: root.applyFormat("outdent") } + } + NotesPopover { + id: insertMenu; anchorItem: insertButton; sourceItem: readingScene + onClosed: Qt.callLater(root.restoreEditorFocus) + LiquidPopoverItem { text: "图片或附件…"; enabled: root.canFormat && !controller.externalDocument; onTriggered: source.action("chooseAttachment") } + LiquidPopoverItem { text: "数学公式…"; enabled: root.canFormat; onTriggered: source.openTool("math", insertMenu) } + LiquidPopoverItem { text: "脚注"; enabled: root.canFormat; onTriggered: source.action("footnote") } + LiquidPopoverItem { text: "自动目录"; enabled: root.canFormat; onTriggered: source.action("insertToc") } + LiquidPopoverItem { text: "分隔线"; enabled: root.canFormat; onTriggered: root.applyFormat("rule") } } NotesPopover { id: tableMenu; anchorItem: tableButton; sourceItem: readingScene @@ -1200,14 +1294,15 @@ ApplicationWindow { onClosed: Qt.callLater(root.restoreEditorFocus) LiquidPopoverItem { text: "保存外部文件"; visible: controller.externalDocument; height: visible ? implicitHeight : 0; enabled: controller.dirty && !controller.busy; onTriggered: controller.save() } LiquidPopoverItem { text: "导入为笔记…"; visible: controller.externalDocument; height: visible ? implicitHeight : 0; enabled: !controller.busy; onTriggered: controller.importExternal() } - 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: 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]) } - LiquidPopoverItem { text: "删除笔记…"; enabled: !!controller.path && !controller.externalDocument && !controller.busy; onTriggered: organizer.deleteNotes([controller.path]) } + LiquidPopoverItem { text: session.sourceMode ? "返回正文" : "查看 Markdown 源码"; enabled: !!controller.path && !controller.busy && !session.composing && !session.webLoading; onTriggered: session.execute("sourceMode") } + MenuSeparator {} + LiquidPopoverItem { text: "大纲 / 折叠"; enabled: !session.sourceMode && source.webReady; onTriggered: source.openTool("outline", actionMenu) } + LiquidPopoverItem { text: "图片属性…"; enabled: !session.sourceMode && !!source.editorState.image; onTriggered: source.openTool("image", actionMenu) } + LiquidPopoverItem { text: "代码语言与复制…"; enabled: !session.sourceMode && !!source.editorState.code; onTriggered: source.openTool("code", actionMenu) } + LiquidPopoverItem { text: "附件管理…"; enabled: !session.sourceMode && !controller.externalDocument; onTriggered: source.openTool("attachments", actionMenu) } } NotesPopover { id: outputMenu; sourceItem: readingScene; anchorItem: moreButton @@ -1283,7 +1378,7 @@ ApplicationWindow { } } NotesSettings { - id: notesSettings; hostWindow: root; controller: root.controller; session: root.session; editorSource: source; sourceItem: readingScene + id: notesSettings; hostWindow: root; controller: root.controller; sourceItem: readingScene onActionRequested: function(action) { switch(action) { case "importFiles": libraryActions.importFiles(); break @@ -1299,7 +1394,38 @@ ApplicationWindow { case "recovery": root.showRecovery(); break case "external": libraryActions.openExternalFile(); break case "sourceMode": session.execute("sourceMode"); break - case "shortcuts": source.showShortcuts(moreButton); break + case "shortcuts": shortcutsDialog.open(); break + } + } + } + LiquidDialog { + id: shortcutsDialog + title: "键盘快捷键"; closeText: "完成"; standardButtons: Dialog.Close + sourceItem: readingScene; anchors.centerIn: parent + width: Math.min(620, root.width - 40); height: Math.min(580, root.height - 40) + contentItem: ScrollView { + id: shortcutScroll + clip: true; contentWidth: availableWidth + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ColumnLayout { + width: shortcutScroll.availableWidth; spacing: 18 + Repeater { + model: [ + {title: "应用与笔记", rows: ["Ctrl+N 新建笔记", "Ctrl+S 保存", "Ctrl+O 临时编辑外部 Markdown", "Ctrl+Shift+I 导入 Markdown", "Ctrl+, 设置", "Ctrl+Shift+F 搜索全部笔记", "Ctrl+Shift+B 显示或隐藏导航", "Alt+← / → 前一篇 / 后一篇笔记"]}, + {title: "列表与画廊", rows: ["方向键 移动游标,Enter / 空格打开", "画廊 ↑ / ↓ 移动一行;← / → 移动一项", "Home / End 第一项 / 最后一项", "Ctrl+A 选择当前结果(最多 200 篇)", "Ctrl+空格 / Ctrl+点击 切换选中", "Shift+方向键 / Shift+点击 连续选择", "菜单键 / Shift+F10 当前笔记右键菜单", "Esc 取消多选或拖动"]}, + {title: "正文编辑", rows: ["/ 插入与转换内容块", "Ctrl+B / Ctrl+I 粗体 / 斜体", "Ctrl+Shift+X 删除线", "Ctrl+E 行内代码", "Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y 撤销 / 重做", "Ctrl+F / Ctrl+H 查找 / 替换", "Alt+Shift+↑ / ↓ 移动块", "Enter / Shift+Enter 分段 / 段内换行", "Tab / Shift+Tab 列表缩进、表格导航", "Ctrl+Enter 退出表格", "Ctrl+点击链接 打开链接", "大纲左右键 / 右键 折叠与展开"]}, + {title: "Markdown 源码", rows: ["Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y 撤销 / 重做", "Tab / Shift+Tab 增加 / 减少缩进", "使用工具栏「正文」或更多菜单返回正文"]} + ] + ColumnLayout { + required property var modelData + Layout.fillWidth: true; spacing: 6 + Label { text: parent.modelData.title; font.weight: Font.DemiBold; color: AppTheme.text } + Repeater { + model: parent.modelData.rows + Label { required property string modelData; Layout.fillWidth: true; text: modelData; wrapMode: Text.Wrap; color: AppTheme.text; font.pixelSize: 12 } + } + } + } } } } @@ -1311,6 +1437,8 @@ ApplicationWindow { id: organizer; anchors.fill: parent controller: root.controller; sceneItem: readingScene selectableFolders: root.childFolders + viewMode: root.noteViewMode + onViewModeRequested: mode => root.setNoteViewMode(mode) onFolderSelectionModeChanged: if (folderSelectionMode) root.childFoldersExpanded = true onSmartFolderRequested: smartDialog.open() onExportRequested: function(paths, folder) { libraryActions.exportSelection(paths, folder) } diff --git a/qml/NoteNameEditor.qml b/qml/NoteNameEditor.qml index fec1e57..67efddd 100644 --- a/qml/NoteNameEditor.qml +++ b/qml/NoteNameEditor.qml @@ -12,6 +12,7 @@ Item { property string savedName: "" readonly property bool editing: nameInput.activeFocus readonly property bool modified: nameInput.text.trim() !== savedName + NotesTokens { id: tokens } implicitHeight: nameColumn.implicitHeight signal submitted() @@ -53,7 +54,7 @@ Item { 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 + selectionColor: tokens.primaryYellow; selectedTextColor: "#222222" background: null placeholderText: "未命名笔记" Accessible.name: "笔记名称" diff --git a/qml/NotesBrowser.qml b/qml/NotesBrowser.qml new file mode 100644 index 0000000..72b7080 --- /dev/null +++ b/qml/NotesBrowser.qml @@ -0,0 +1,118 @@ +import QtQuick +import QtQuick.Controls + +Item { + id: root + required property var controller + required property var organization + required property Component noteDelegate + property string viewMode: "list" + readonly property bool gallery: viewMode === "gallery" + readonly property var activeView: gallery ? grid : list + readonly property int count: controller.notes.length + readonly property int columns: Math.max(2, Math.floor(width / 196)) + property string cursorKey: "" + property string previousMode: "list" + property var positions: ({}) + + function keyAt(row) { + const note = controller.notes[row] + return note ? note.id + "\n" + note.path : "" + } + function indexOf(key) { + for (let i = 0; i < count; ++i) if (keyAt(i) === key) return i + return -1 + } + function setCursor(row) { + activeView.currentIndex = row + cursorKey = keyAt(row) + } + function focusCurrent() { + if (!count) return + if (activeView.currentIndex < 0) setCursor(0) + activeView.positionViewAtIndex(activeView.currentIndex, ListView.Contain) + if (activeView.currentItem) activeView.currentItem.forceActiveFocus(Qt.TabFocusReason) + } + function restoreCursor() { + const row = indexOf(cursorKey) + setCursor(row >= 0 ? row : count ? 0 : -1) + if (activeView.activeFocus) focusCurrent() + } + function positionViewAtBeginning() { + positions = ({}) + activeView.positionViewAtBeginning() + } + function switchView() { + const old = previousMode === "gallery" ? grid : list + const hadFocus = old.activeFocus || !!old.currentItem && old.currentItem.activeFocus + const first = old.indexAt(Math.max(1, old.width / 2), old.contentY + 10) + const saved = positions + saved[previousMode] = {key: keyAt(first >= 0 ? first : old.currentIndex), y: old.contentY} + positions = saved + previousMode = viewMode + Qt.callLater(function() { + restoreCursor() + const position = positions[viewMode] + const row = indexOf(position ? position.key : saved[old === grid ? "gallery" : "list"]?.key) + if (row >= 0) activeView.positionViewAtIndex(row, ListView.Beginning) + if (hadFocus) focusCurrent() + }) + } + function handleKey(event) { + const view = activeView, current = view.currentIndex + if (event.modifiers === Qt.ControlModifier && event.key === Qt.Key_A) { organization.selectAll(); event.accepted = true; return } + if (event.modifiers === Qt.ControlModifier && event.key === Qt.Key_Space && current >= 0) { organization.toggle(controller.notes[current].path); event.accepted = true; return } + if (event.key === Qt.Key_Escape && organization.selectionMode) { organization.clearSelection(); event.accepted = true; return } + if (view.currentItem && (event.key === Qt.Key_Menu || event.key === Qt.Key_F10 && event.modifiers === Qt.ShiftModifier)) { + organization.openMenu(view.currentItem, controller.notes[current].path) + event.accepted = true; return + } + if (event.modifiers & ~(Qt.ShiftModifier | Qt.ControlModifier)) return + let row = current + const step = gallery ? columns : 1 + if (event.key === Qt.Key_Down) row += step + else if (event.key === Qt.Key_Up) row -= step + else if (gallery && event.key === Qt.Key_Right) row += 1 + else if (gallery && event.key === Qt.Key_Left) row -= 1 + else if (event.key === Qt.Key_Home) row = 0 + else if (event.key === Qt.Key_End) row = count - 1 + else if (event.modifiers === Qt.NoModifier && [Qt.Key_Return, Qt.Key_Enter, Qt.Key_Space].indexOf(event.key) >= 0) { + if (view.currentItem && !event.isAutoRepeat) view.currentItem.activate() + event.accepted = true; return + } else return + if (!count) return + row = Math.max(0, Math.min(count - 1, row)) + if (event.modifiers & Qt.ShiftModifier) { + if (!organization.selectionAnchor && current >= 0) organization.selectionAnchor = controller.notes[current].path + organization.click(controller.notes[row], row, event.modifiers) + } + setCursor(row); focusCurrent(); event.accepted = true + } + onViewModeChanged: switchView() + Connections { target: root.controller; function onNotesChanged() { Qt.callLater(root.restoreCursor) } } + ListView { + id: list + objectName: "notesListView" + anchors.fill: parent; visible: !root.gallery; clip: true; spacing: 4 + model: root.controller.noteModel + delegate: root.noteDelegate + activeFocusOnTab: visible && count > 0; keyNavigationEnabled: false + Accessible.name: "笔记列表" + Keys.onPressed: event => root.handleKey(event) + onActiveFocusChanged: if (activeFocus) Qt.callLater(root.focusCurrent) + ScrollBar.vertical: ScrollBar {} + } + GridView { + id: grid + objectName: "notesGalleryView" + anchors.fill: parent; visible: root.gallery; clip: true + cellWidth: width / root.columns; cellHeight: 116 + model: root.controller.noteModel + delegate: root.noteDelegate + activeFocusOnTab: visible && count > 0; keyNavigationEnabled: false + Accessible.name: "笔记画廊" + Keys.onPressed: event => root.handleKey(event) + onActiveFocusChanged: if (activeFocus) Qt.callLater(root.focusCurrent) + ScrollBar.vertical: ScrollBar {} + } +} diff --git a/qml/NotesOrganizer.qml b/qml/NotesOrganizer.qml index 734f08c..dcb4c80 100644 --- a/qml/NotesOrganizer.qml +++ b/qml/NotesOrganizer.qml @@ -12,6 +12,8 @@ Item { readonly property var folderIconChoices: FolderIcons.choices signal exportRequested(var paths, string folder) signal smartFolderRequested() + property string viewMode: "list" + signal viewModeRequested(string mode) property var selectedPaths: [] property var selectedFolderPaths: [] property var selectableFolders: [] @@ -232,6 +234,19 @@ 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 { + objectName: "listViewMenuItem" + visible: !menu.rowMenu; height: visible ? implicitHeight : 0 + text: "列表视图"; checkable: true; checked: root.viewMode === "list" + onTriggered: root.viewModeRequested("list") + } + LiquidPopoverItem { + objectName: "galleryViewMenuItem" + visible: !menu.rowMenu; height: visible ? implicitHeight : 0 + text: "画廊视图"; checkable: true; checked: root.viewMode === "gallery" + onTriggered: root.viewModeRequested("gallery") + } + Rectangle { visible: !menu.rowMenu; height: visible ? 1 : 0; width: parent.width; color: AppTheme.border } LiquidPopoverItem { objectName: "selectNotesMenuItem" text: root.noteSelectionMode ? "取消选择笔记" : "选择笔记" diff --git a/qml/NotesSettings.qml b/qml/NotesSettings.qml index deed007..b8c78f0 100644 --- a/qml/NotesSettings.qml +++ b/qml/NotesSettings.qml @@ -8,18 +8,23 @@ LiquidDialog { id: root required property var hostWindow required property var controller - required property var session - required property var editorSource property int section: 0 property int bodySize: controller.preference("bodySize", 16) signal actionRequested(string action) title: "设置"; closeText: "完成"; standardButtons: Dialog.Close width: Math.min(620, parent.width - 40) - height: Math.min(section === 0 ? 520 : section === 2 ? 580 : 640, parent.height - 40) - readonly property bool managedNote: !!controller.path && !controller.externalDocument - readonly property bool canExport: !!controller.path && editorSource.webReady && !session.sourceMode && !session.composing && !session.webLoading && !controller.busy - function show(sectionIndex) { section = sectionIndex || 0; open() } - function request(action) { close(); Qt.callLater(function() { root.actionRequested(action) }) } + height: Math.min(580, parent.height - 40) + property string pendingAction: "" + function resetScroll() { settingsScroll.contentItem.contentY = 0 } + function show(sectionIndex) { section = sectionIndex || 0; pendingAction = ""; resetScroll(); open() } + function request(action) { pendingAction = action; close() } + onClosed: { + const action = pendingAction + pendingAction = "" + if (action) actionRequested(action) + } + onSectionChanged: resetScroll() + onHeightChanged: Qt.callLater(revealFocus) function revealFocus() { const item = hostWindow.activeFocusItem, flick = settingsScroll.contentItem if (!visible || !item || !flick || !flick.contentItem) return @@ -42,8 +47,8 @@ LiquidDialog { spacing: 18 LiquidSegmentedControl { Layout.fillWidth: true; implicitHeight: 30; materialForm: true - labels: ["通用", "数据", "高级"]; currentIndex: root.section - onSelectionRequested: function(index) { root.section = index; settingsScroll.contentItem.contentY = 0 } + labels: ["通用", "笔记管理", "数据"]; currentIndex: root.section + onSelectionRequested: function(index) { root.section = index } } ScrollView { id: settingsScroll; Layout.fillWidth: true; Layout.fillHeight: true @@ -70,7 +75,8 @@ LiquidDialog { Accessible.name: "正文字号"; materialForm: true appearanceMode: AppTheme.appearanceMode plainTrigger: true; indicatorStyle: "upDown"; indicatorBackground: true - popupMaterialForm: true; compactPopup: true; neutralOptionHighlight: true + popupMaterialForm: true; compactPopup: true; popupFontWeight: Font.Normal + popupBorderWidth: 0; popupShadowEnabled: true; neutralOptionHighlight: true model: Array.from({length: 13}, (_, i) => String(12 + i)) currentIndex: Math.max(0, Math.min(12, root.bodySize - 12)) onActivated: function(index) { root.bodySize = 12 + index; root.controller.setPreference("bodySize", root.bodySize) } @@ -91,33 +97,32 @@ LiquidDialog { ActionRow { title: "键盘快捷键"; actionText: "查看…"; onTriggered: root.request("shortcuts") } } SettingsGroup { - visible: root.section === 1; title: "导入笔记" + visible: root.section === 2; title: "导入笔记" ActionRow { title: "从 Markdown 导入"; description: "收集为独立笔记,原文件保持不变。"; actionText: "选择笔记…"; enabled: !root.controller.busy; onTriggered: root.request("importFiles") } ActionRow { title: "整个文件夹"; description: "可保留原有分类层级。"; actionText: "导入…"; enabled: !root.controller.busy; onTriggered: root.request("importFolder") } ActionRow { title: "导入记录"; actionText: "查看…"; enabled: !root.controller.busy; onTriggered: root.request("importHistory") } ActionRow { visible: !!root.controller.legacyLibrary; title: "之前的笔记"; actionText: "迁移…"; enabled: !root.controller.busy; onTriggered: root.request("importLegacy") } } SettingsGroup { - visible: root.section === 1; title: "导出" + visible: root.section === 2; title: "导出" ActionRow { title: "全部笔记"; description: "导出 Markdown 和关联附件。"; actionText: "导出…"; enabled: !root.controller.busy; onTriggered: root.request("exportAll") } } SettingsGroup { - visible: root.section === 1; title: "备份与恢复" + visible: root.section === 2; title: "备份与恢复" ActionRow { title: "完整备份"; description: "保留笔记、附件、历史和最近删除。"; actionText: "创建备份…"; enabled: !root.controller.busy; onTriggered: root.request("backup") } ActionRow { title: "恢复完整备份"; description: "替换当前空间前,自动保留一份恢复前副本。"; actionText: "选择备份…"; enabled: !root.controller.busy; onTriggered: root.request("restore") } } SettingsGroup { - visible: root.section === 2; title: "整理与记录" + visible: root.section === 1; title: "整理与记录" ActionRow { title: "笔记模板"; onTriggered: root.request("templates"); enabled: !root.controller.busy } - ActionRow { title: "视图"; description: "保存当前搜索和分类条件。"; actionText: "保存为视图…"; enabled: !root.controller.busy; onTriggered: root.request("smartCollection") } + ActionRow { title: "智能视图"; description: "保存当前搜索和分类条件,自动汇集符合条件的笔记。"; actionText: "保存当前搜索…"; enabled: !root.controller.busy; onTriggered: root.request("smartCollection") } ActionRow { title: "批量重命名标签"; enabled: !root.controller.busy; onTriggered: root.request("renameTags") } ActionRow { title: "恢复草稿"; description: "找回未完成保存的内容。"; actionText: "查看…"; enabled: !root.controller.busy; onTriggered: root.request("recovery") } } SettingsGroup { - visible: root.section === 2; title: "外部文件与源码" + visible: root.section === 2; title: "外部文件" ActionRow { title: "临时编辑 Markdown"; description: "直接编辑原文件,不加入我的笔记。"; actionText: "选择文件…"; enabled: !root.controller.busy; onTriggered: root.request("external") } - ActionRow { title: "当前笔记的 Markdown 源码"; actionText: root.session.sourceMode ? "返回正文" : "查看源码"; enabled: !!root.controller.path && !root.controller.busy && !root.session.composing && !root.session.webLoading; onTriggered: root.request("sourceMode") } } } } diff --git a/qml/SourceEditor.qml b/qml/SourceEditor.qml index fdc057d..2aca4bc 100644 --- a/qml/SourceEditor.qml +++ b/qml/SourceEditor.qml @@ -24,8 +24,7 @@ Item { property string notification: "" function notify(text) { notification = text; noticeTimer.restart() } function action(name, args) { if (webReady) bridge.actionRequested({epoch: session.webDocument().epoch, name: name, args: args || {}}) } - function showShortcuts(anchor) { tools.showShortcuts(anchor) } - function openTools(anchor) { if (!session.sourceMode) tools.openMenu(anchor) } + function openTool(name, originMenu) { if (!session.sourceMode) tools.openTool(name, originMenu) } function toggleOutline(anchor) { tools.toggleOutline(anchor) } function correctSpelling(word) { web.replaceMisspelledWord(word); editorFacade.forceActiveFocus() } function exportBaseName() { return String(root.controller.noteTitle || "笔记").replace(/[\\/:*?"<>|\u0000-\u001f]/g, "-").replace(/[. ]+$/g, "").slice(0,100) || "笔记" } @@ -46,10 +45,11 @@ Item { property bool restoring: false readonly property bool readOnly: controller.readOnly || session.protectedContent || session.webLoading || exporting function css(color) { return "rgba(" + Math.round(color.r * 255) + "," + Math.round(color.g * 255) + "," + Math.round(color.b * 255) + "," + color.a + ")" } + NotesTokens { id: tokens } readonly property var theme: ({ background: css(AppTheme.contentBackground), text: css(AppTheme.text), muted: css(AppTheme.mutedText), border: css(AppTheme.border), - accent: css(AppTheme.accent), selection: css(AppTheme.accent), selectionText: css(AppTheme.accentText), scheme: AppTheme.dark ? "dark" : "light", + accent: css(tokens.primaryYellow), selection: css(tokens.primaryYellow), selectionText: "#222222", scheme: AppTheme.dark ? "dark" : "light", size: bodySize + "px", width: readingWidth + "px", inset: inset + "px", font: "'Noto Sans CJK SC', sans-serif" }) property string lastOptions: "" diff --git a/tests/chrome/tst_browser.qml b/tests/chrome/tst_browser.qml new file mode 100644 index 0000000..e31019d --- /dev/null +++ b/tests/chrome/tst_browser.qml @@ -0,0 +1,91 @@ +import QtQuick +import QtQuick.Controls +import QtTest +import NextNotes.ChromeTests + +TestCase { + id: testCase + name: "NoteBrowserViews" + when: windowShown + visible: true + width: 900; height: 700 + QtObject { + id: controller + property var notes: Array.from({length: 12}, (_, i) => ({id: String(i), path: i + ".md"})) + readonly property var noteModel: notes + } + QtObject { + id: organization + property string selectionAnchor: "" + property var selectedPaths: [] + readonly property bool selectionMode: selectedPaths.length > 0 + function selectAll() { selectedPaths = controller.notes.map(note => note.path) } + function toggle(path) { selectedPaths = selectedPaths.includes(path) ? selectedPaths.filter(p => p !== path) : selectedPaths.concat([path]) } + function clearSelection() { selectedPaths = [] } + function click(note, index, modifiers) { + const start = controller.notes.findIndex(n => n.path === selectionAnchor) + selectedPaths = controller.notes.slice(Math.min(start, index), Math.max(start, index) + 1).map(n => n.path) + } + } + NotesBrowser { + id: browser + width: 720; height: 500 + controller: controller; organization: organization + noteDelegate: Component { + ItemDelegate { + required property var modelData + required property int index + width: browser.gallery ? browser.activeView.cellWidth - 12 : browser.width + height: browser.gallery ? 104 : 60 + function activate() {} + } + } + } + function press(key, modifiers) { + const event = {key: key, modifiers: modifiers || Qt.NoModifier, accepted: false, isAutoRepeat: false} + browser.handleKey(event) + verify(event.accepted) + } + function init() { + browser.viewMode = "list" + organization.selectionAnchor = "" + organization.selectedPaths = [] + wait(20) + browser.setCursor(0) + } + function test_gallery_moves_by_row_and_preserves_selection_on_switch() { + organization.selectedPaths = ["1.md", "2.md"] + organization.selectionAnchor = "1.md" + browser.setCursor(2) + browser.viewMode = "gallery" + tryCompare(browser.activeView, "currentIndex", 2) + compare(browser.columns, 3) + compare(organization.selectedPaths.join(), "1.md,2.md") + compare(organization.selectionAnchor, "1.md") + press(Qt.Key_Down) + compare(browser.activeView.currentIndex, 5) + press(Qt.Key_Left) + compare(browser.activeView.currentIndex, 4) + press(Qt.Key_Down, Qt.ShiftModifier) + compare(organization.selectedPaths.join(), "1.md,2.md,3.md,4.md,5.md,6.md,7.md") + browser.viewMode = "list" + tryCompare(browser.activeView, "currentIndex", 7) + compare(organization.selectionAnchor, "1.md") + } + function test_gallery_keeps_two_columns_at_minimum_width() { + browser.viewMode = "gallery" + browser.width = 320 + compare(browser.columns, 2) + browser.width = 720 + compare(browser.columns, 3) + } + function test_cursor_follows_identity_after_model_reorder() { + browser.setCursor(3) + const original = controller.notes.slice() + controller.notes = original.slice().reverse() + tryCompare(browser.activeView, "currentIndex", 8) + compare(browser.cursorKey, "3\n3.md") + controller.notes = original + tryCompare(browser.activeView, "currentIndex", 3) + } +} diff --git a/tests/chrome/tst_folders.qml b/tests/chrome/tst_folders.qml new file mode 100644 index 0000000..03b55c5 --- /dev/null +++ b/tests/chrome/tst_folders.qml @@ -0,0 +1,86 @@ +import QtQuick +import QtQuick.Controls +import QtTest +import NextNotes.ChromeTests + +TestCase { + id: testCase + name: "FolderBrowserViews" + when: windowShown + visible: true + width: 900; height: 700 + QtObject { + id: organization + property bool folderSelectionMode: false + readonly property bool selectionMode: folderSelectionMode + property var selectedFolderPaths: [] + function beginSelection(kind) { folderSelectionMode = kind === "folders" } + function selectAll() { selectedFolderPaths = browser.model.slice() } + function toggleFolder(path) { selectedFolderPaths = selectedFolderPaths.includes(path) ? selectedFolderPaths.filter(p => p !== path) : selectedFolderPaths.concat([path]) } + function clearSelection() { folderSelectionMode = false; selectedFolderPaths = [] } + } + FolderBrowser { + id: browser + width: 440; height: 400 + organization: organization + model: Array.from({length: 8}, (_, i) => "Parent/" + i) + delegate: ItemDelegate { + required property string modelData + required property int index + width: browser.cellWidth; height: browser.cellHeight + function activate() { testCase.activatedPath = modelData } + function openContext() { testCase.contextPath = modelData } + onActiveFocusChanged: if (activeFocus) { browser.currentIndex = index; browser.cursorKey = modelData } + } + } + property string activatedPath: "" + property string contextPath: "" + function press(key, modifiers) { + const event = {key: key, modifiers: modifiers || Qt.NoModifier, accepted: false, isAutoRepeat: false} + browser.handleKey(event) + verify(event.accepted) + } + function init() { + organization.clearSelection() + browser.viewMode = "list" + browser.currentIndex = 0 + browser.cursorKey = browser.keyAt(0) + activatedPath = ""; contextPath = "" + wait(20) + } + function test_switch_preserves_cursor_and_selection() { + browser.currentIndex = 2 + browser.cursorKey = browser.keyAt(2) + organization.beginSelection("folders") + organization.toggleFolder("Parent/1") + browser.viewMode = "gallery" + wait(20) + compare(browser.currentIndex, 2) + compare(browser.columns, 2) + press(Qt.Key_Down) + compare(browser.currentIndex, 4) + press(Qt.Key_Right) + compare(browser.currentIndex, 5) + compare(organization.selectedFolderPaths.join(), "Parent/1") + browser.viewMode = "list" + wait(20) + compare(browser.currentIndex, 5) + press(Qt.Key_Up) + compare(browser.currentIndex, 4) + press(Qt.Key_Return) + compare(activatedPath, "Parent/4") + press(Qt.Key_Menu) + compare(contextPath, "Parent/4") + } + function test_keyboard_selection_in_gallery() { + browser.viewMode = "gallery" + wait(20) + press(Qt.Key_Space, Qt.ControlModifier) + verify(organization.folderSelectionMode) + compare(organization.selectedFolderPaths.join(), "Parent/0") + press(Qt.Key_A, Qt.ControlModifier) + compare(organization.selectedFolderPaths.length, 8) + press(Qt.Key_Escape) + verify(!organization.selectionMode) + } +} diff --git a/tests/chrome/tst_selection.qml b/tests/chrome/tst_selection.qml index 11218a2..bf2b36a 100644 --- a/tests/chrome/tst_selection.qml +++ b/tests/chrome/tst_selection.qml @@ -45,6 +45,31 @@ TestCase { controller = createTemporaryObject(controllerComponent, testCase) organizer = createTemporaryObject(organizerComponent, testCase, {controller: controller}) } + function test_view_choices_belong_to_browser_menu() { + organizer.openMenu(menuButton, "") + const menu = findChild(organizer, "noteListMenu") + const listView = findChild(organizer, "listViewMenuItem") + const galleryView = findChild(organizer, "galleryViewMenuItem") + tryCompare(menu, "visible", true) + verify(listView.visible) + verify(galleryView.visible) + verify(listView.checked) + const spy = signalSpyComponent.createObject(testCase, {target: organizer, signalName: "viewModeRequested"}) + galleryView.triggered() + compare(spy.count, 1) + compare(spy.signalArguments[0][0], "gallery") + organizer.viewMode = "gallery" + verify(galleryView.checked) + verify(!listView.checked) + menu.close() + organizer.openMenu(row, "a.md", Qt.point(30, 12)) + tryCompare(menu, "visible", true) + verify(!galleryView.visible) + verify(!listView.visible) + menu.close() + spy.destroy() + } + Component { id: signalSpyComponent; SignalSpy {} } function test_toolbar_can_enter_note_selection_without_an_active_note() { controller.path = "outside-filter.md" organizer.openMenu(menuButton, "") diff --git a/tests/chrome/tst_settings.qml b/tests/chrome/tst_settings.qml new file mode 100644 index 0000000..97ffedd --- /dev/null +++ b/tests/chrome/tst_settings.qml @@ -0,0 +1,45 @@ +import QtQuick +import QtQuick.Controls +import QtTest +import NextNotes.ChromeTests + +TestCase { + id: testCase + name: "SettingsHandoff" + when: windowShown + visible: true + width: 800; height: 700 + Item { id: scene; anchors.fill: parent } + QtObject { + id: preferences + property bool busy: false + property string legacyLibrary: "" + function preference(key, fallback) { return fallback } + function setPreference(key, value) {} + } + NotesSettings { + id: settings + hostWindow: testCase.Window.window; controller: preferences; sourceItem: scene + } + SignalSpy { id: actions; target: settings; signalName: "actionRequested" } + function cleanup() { settings.close(); tryCompare(settings, "visible", false); actions.clear() } + function test_action_waits_until_dialog_is_closed() { + settings.show(0) + tryCompare(settings, "opened", true) + const initialHeight = settings.height + settings.section = 1 + compare(settings.height, initialHeight) + settings.section = 2 + compare(settings.height, initialHeight) + settings.request("templates") + tryCompare(actions, "count", 1) + verify(!settings.visible) + compare(actions.signalArguments[0][0], "templates") + compare(settings.pendingAction, "") + settings.show(0) + tryCompare(settings, "opened", true) + settings.close() + tryCompare(settings, "visible", false) + compare(actions.count, 1) + } +} diff --git a/web/editor/dist/editor.js b/web/editor/dist/editor.js index 7d98c0e..b933ae9 100644 --- a/web/editor/dist/editor.js +++ b/web/editor/dist/editor.js @@ -87946,7 +87946,7 @@ drawSelection(), EditorView2.editorAttributes.of({ class: "source-viewport" }), EditorView2.contentAttributes.of({ "aria-label": "Markdown \u6E90\u7801\u7F16\u8F91\u5668", spellcheck: "false" }), - EditorView2.theme({ "&": { font: "14px/1.65 monospace", backgroundColor: "transparent" }, ".cm-content": { padding: "12px", minHeight: "75vh" }, ".cm-line": { padding: "0" }, "&.cm-focused": { outline: "none" }, ".cm-scroller": { font: "inherit" }, ".cm-selectionBackground": { backgroundColor: "var(--selection) !important" } }), + EditorView2.theme({ "&": { font: "14px/1.65 monospace", backgroundColor: "transparent" }, ".cm-content": { padding: "12px", minHeight: "75vh" }, ".cm-line": { padding: "0" }, "&.cm-focused": { outline: "none" }, ".cm-scroller": { font: "inherit" }, ".cm-selectionBackground": { backgroundColor: "color-mix(in srgb,var(--selection) 40%,transparent) !important" } }), editable2.of([]), search3.of([]), keymap5.of([ @@ -88189,7 +88189,7 @@ function makeTools(env) { let view2, hover = 0, blockHandleVisible = false, collapsed = /* @__PURE__ */ new Set(), query = "", caseSensitive = false, searchIndex = -1, slashIndex = 0, lastSlash = "", uiTimer, lastUI = "", layoutToken = 0; let cachedDoc, cachedText = "", cachedStats = {}, cachedOutline = [], cachedMatchesDoc, cachedMatchesQuery, cachedMatchesCase, cachedMatches = []; - let suppressedSlash = null, blockSelection = false, newParagraphPos = null, dragPreview = null, contextRequest = null; + let suppressedSlash = null, blockSelection = false, newParagraphPos = null, dragPreview = null, dragGhost = null, dragSource = null, contextRequest = null; let pending = 0, request = 0, clipboardBlock = null; const imports = /* @__PURE__ */ new Map(), bookmarks = /* @__PURE__ */ new Map(); const key4 = new PluginKey("nextnotesTools"); @@ -88421,6 +88421,42 @@ } return { from: block.pos, to: block.pos + block.node.nodeSize }; } + function clearDragGhost() { + dragGhost?.remove(); + dragGhost = null; + dragSource?.classList.remove("editor-drag-source"); + dragSource = null; + } + function updateDragGhost(args) { + if (!Number.isFinite(args.x)) return; + const block = targetBlock(args), dom = block && view2.nodeDOM(block.pos); + if (!(dom instanceof Element)) return; + if (dragSource !== dom) { + clearDragGhost(); + const rect2 = dom.getBoundingClientRect(), clone = dom.cloneNode(true); + clone.classList.remove("editor-selected-block", "ProseMirror-selectednode", "editor-empty-line"); + for (const el of [clone, ...clone.querySelectorAll("*")]) { + el.removeAttribute("id"); + el.removeAttribute("contenteditable"); + el.removeAttribute("data-placeholder"); + el.classList.remove("editor-selected-block", "ProseMirror-selectednode"); + } + dragGhost = document.createElement("div"); + dragGhost.className = "editor-drag-ghost"; + dragGhost.setAttribute("aria-hidden", "true"); + dragGhost.inert = true; + dragGhost.style.width = rect2.width + "px"; + const content4 = document.createElement("div"); + content4.className = "editor-drag-content ProseMirror"; + content4.append(clone); + dragGhost.append(content4); + document.body.append(dragGhost); + dragSource = dom; + dragSource.classList.add("editor-drag-source"); + } + const rect = dom.getBoundingClientRect(), offset = Math.max(0, Math.min(args.offsetY || 0, rect.height)); + dragGhost.style.transform = `translate3d(${args.x + 18}px,${args.y - offset + 8}px,0)`; + } function dropTarget(args) { const r2 = range(args); if (!r2 || !Number.isFinite(args.y)) return null; @@ -88444,6 +88480,8 @@ target = after ? after.pos + after.node.nodeSize : void 0; } else if (typeof args.y === "number") target = dropTarget(args)?.pos; if (target === void 0 || target >= r2.from && target <= r2.to) return false; + clearDragGhost(); + dragPreview = null; const content4 = view2.state.doc.slice(r2.from, r2.to).content; let tr = view2.state.tr.delete(r2.from, r2.to); const at = tr.mapping.map(target, -1); @@ -88550,8 +88588,11 @@ return true; } if (name2 === "cancelBlockMove") { + const active3 = !!dragPreview; dragPreview = null; - schedule(); + clearDragGhost(); + if (active3) refresh(); + else schedule(); return true; } if (name2 === "search") { @@ -88605,17 +88646,20 @@ } if (!editable2()) return false; if (name2 === "previewBlockMove") { + const first = !dragPreview; dragPreview = args; - schedule(); + updateDragGhost(args); + if (first) refresh(); + else schedule(); return true; } if (name2 === "slashChoose") return applySlash(args.index); if (name2 === "selectBlock") { const b = targetBlock(args); if (!b) return false; - const selection = args.extend ? TextSelection.between(view2.state.doc.resolve(Math.min(view2.state.selection.from, b.pos)), view2.state.doc.resolve(Math.max(view2.state.selection.to, b.pos + b.node.nodeSize))) : NodeSelection.create(view2.state.doc, b.pos); + const selection = args.extend ? TextSelection.between(view2.state.doc.resolve(Math.min(view2.state.selection.from, b.pos)), view2.state.doc.resolve(Math.max(view2.state.selection.to, b.pos + b.node.nodeSize))) : args.focus === false && b.node.isTextblock ? TextSelection.create(view2.state.doc, b.pos + 1, b.pos + b.node.nodeSize - 1) : NodeSelection.create(view2.state.doc, b.pos); view2.dispatch(view2.state.tr.setSelection(selection).setMeta(key4, "block-selection")); - view2.focus(); + if (args.focus !== false) view2.focus(); return true; } if (name2 === "moveBlock") return moveBlock(args); @@ -88790,7 +88834,12 @@ props: { attributes: () => blockSelection ? { class: "editor-block-selection" } : {}, decorations(state) { - const base2 = key4.getState(state), { $from, empty: empty3 } = state.selection; + let base2 = key4.getState(state); + const { $from, empty: empty3 } = state.selection; + if (dragPreview) { + const b = targetBlock(dragPreview); + if (b) base2 = base2.add(state.doc, [Decoration.node(b.pos, b.pos + b.node.nodeSize, { class: "editor-drag-source" })]); + } if (blockSelection && !empty3) { const items = []; state.doc.nodesBetween(state.selection.from, state.selection.to, (node2, pos2) => { @@ -88855,9 +88904,11 @@ observer.observe(v2.dom); schedule(); return { update() { + if (dragSource?.isConnected) dragSource.classList.add("editor-drag-source"); env.refreshToc?.(); schedule(); }, destroy() { + clearDragGhost(); observer.disconnect(); clearTimeout(uiTimer); uiTimer = null; @@ -88876,6 +88927,7 @@ exportReady, hasPending: () => pending > 0, reset() { + clearDragGhost(); clearTimeout(uiTimer); uiTimer = null; collapsed.clear(); diff --git a/web/editor/editor.css b/web/editor/editor.css index 3fb1022..22ba04c 100644 --- a/web/editor/editor.css +++ b/web/editor/editor.css @@ -1,4 +1,4 @@ -:root { color-scheme:var(--scheme,light); --background:transparent; --text:#222; --muted:#777; --border:#ccc; --accent:#007aff; --selection:#cce2ff; --size:16px; --inset:24px; --width:740px; --font:sans-serif; } +:root { color-scheme:var(--scheme,light); --background:transparent; --text:#222; --muted:#777; --border:#ccc; --accent:#ffb921; --selection:#ffb921; --selectionText:#222222; --size:16px; --inset:24px; --width:740px; --font:sans-serif; } * { box-sizing:border-box; }html,body { margin:0;min-height:100%;background:var(--background);color:var(--text);font:var(--size)/1.65 var(--font); } #editor { max-width:calc(var(--width) + 2 * var(--inset));padding:18px var(--inset) 72px;margin:auto; } .ProseMirror { outline:none;min-height:calc(100vh - 90px);overflow-wrap:anywhere;white-space:pre-wrap; } @@ -13,11 +13,17 @@ pre {padding:.8em 1em;border-radius:6px;background:color-mix(in srgb,var(--text) a{color:var(--accent);text-decoration:underline;}img{max-width:100%;height:auto;}hr{border:0;border-top:1px solid var(--border);margin:1.5em 0;}table{border-collapse:collapse;width:100%;table-layout:fixed;margin:1em 0;}td,th{border:1px solid var(--border);padding:.4em .65em;vertical-align:top;min-width:3em;position:relative;}th{font-weight:600;background:color-mix(in srgb,var(--text) 5%,transparent);}td>p,th>p{margin:0;}.selectedCell::after{position:absolute;inset:0;background:var(--selection);opacity:.45;content:'';pointer-events:none;}.column-resize-handle{position:absolute;right:-2px;top:0;bottom:-2px;width:4px;background:var(--accent);pointer-events:none;}.resize-cursor{cursor:col-resize;}::selection{background:var(--selection);color:var(--selectionText,white);}#error{padding:0 20px;color:var(--muted);}.ProseMirror-selectednode{outline:none;}.ProseMirror-gapcursor{display:none;pointer-events:none;position:absolute;}.ProseMirror-gapcursor:after{content:'';display:block;position:absolute;top:-2px;width:20px;border-top:1px solid var(--text);animation:blink 1s steps(2,start) infinite;}.ProseMirror-focused .ProseMirror-gapcursor{display:block;}@keyframes blink{to{visibility:hidden;}} /* Editing aids never alter the Markdown document. */ .ProseMirror .editor-selected-block,.ProseMirror .ProseMirror-selectednode{background-color:color-mix(in srgb,var(--text) 8%,transparent);border-radius:3px;outline:none;} -.ProseMirror.editor-block-selection::selection,.ProseMirror.editor-block-selection ::selection,.ProseMirror-hideselection::selection,.ProseMirror-hideselection ::selection{background:transparent;color:inherit;} +:root[style*="--scheme: dark"] .ProseMirror .editor-selected-block,:root[style*="--scheme: dark"] .ProseMirror .ProseMirror-selectednode{background-color:color-mix(in srgb,var(--text) 22%,transparent);} +.ProseMirror.editor-block-selection::selection,.ProseMirror.editor-block-selection ::selection,.ProseMirror-hideselection::selection,.ProseMirror-hideselection ::selection{background:transparent;color:var(--text);} .ProseMirror-hideselection{caret-color:transparent;} +.ProseMirror .editor-drag-source{opacity:.35;} +.editor-drag-ghost{position:fixed;left:0;top:0;z-index:1000;max-width:calc(100vw - 32px);max-height:240px;overflow:hidden;padding:12px;border:1px solid var(--border);border-radius:8px;background:var(--background);box-shadow:0 8px 24px #0005;opacity:.85;pointer-events:none;user-select:none;will-change:transform;} +.editor-drag-content.ProseMirror{min-height:0;} +.editor-drag-content>:first-child{margin-top:0;} +.editor-drag-content>:last-child{margin-bottom:0;} .editor-empty-line{position:relative;} .ProseMirror-focused[contenteditable="true"] p.editor-empty-line::before{content:attr(data-placeholder);position:absolute;top:0;left:0;max-width:100%;overflow:hidden;white-space:nowrap;color:color-mix(in srgb,var(--muted) 65%,transparent);pointer-events:none;user-select:none;} -.folded-content{display:none!important;}.folded-heading:after{content:' …';font-size:.7em;color:var(--muted);}.editor-search-match{background:color-mix(in srgb,#f7c948 55%,transparent);color:inherit;} +.folded-content{display:none!important;}.folded-heading:after{content:' …';font-size:.7em;color:var(--muted);}.editor-search-match{background:color-mix(in srgb,var(--selection) 55%,transparent);color:inherit;} .editor-code{margin:1em 0;}.editor-code pre{margin:0;}.editor-code[data-language]:before{content:attr(data-language);display:block;font:11px/1.7 var(--font);color:var(--muted);}.diagram-preview{padding:12px 0;overflow:auto;}.diagram-preview[hidden]{display:none;}.diagram-preview img{display:block;margin:auto;max-height:620px;} .token.comment,.token.prolog,.token.doctype,.token.cdata{color:#6c7986;}.token.punctuation{color:#637184;}.token.property,.token.tag,.token.boolean,.token.number,.token.constant,.token.symbol{color:#a33a57;}.token.selector,.token.attr-name,.token.string,.token.char,.token.builtin{color:#237a47;}.token.operator,.token.entity,.token.url{color:#946420;}.token.atrule,.token.attr-value,.token.keyword{color:#7051b8;}.token.function,.token.class-name{color:#246aa6;} :root[style*="--scheme: dark"] .token.string{color:#9fc78b;}:root[style*="--scheme: dark"] .token.keyword{color:#c7a2e6;}:root[style*="--scheme: dark"] .token.number{color:#ef9ca8;} @@ -25,6 +31,7 @@ a{color:var(--accent);text-decoration:underline;}img{max-width:100%;height:auto; [data-type="footnote_definition"],.footnote-definition{font-size:.9em;color:var(--muted);} @page{size:A4;margin:16mm 17mm 18mm;} @media print{ + .editor-drag-ghost{display:none!important;}.ProseMirror .editor-drag-source{opacity:1;} .editor-selected-block,.ProseMirror-selectednode{background-color:transparent!important;} .editor-empty-line::before{content:none!important;} :root{color-scheme:light;--background:white;--text:#111;--muted:#555;--border:#bbb;--accent:#165ea6;} @@ -39,4 +46,4 @@ a{color:var(--accent);text-decoration:underline;}img{max-width:100%;height:auto; .wiki-completion { position: fixed; z-index: 100; max-height: 230px; width: 350px; overflow: auto; background: var(--background); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 4px; } .wiki-completion button { display: block; width: 100%; background: transparent; color: inherit; border: 0; text-align: left; padding: 8px; } -.wiki-completion button[aria-selected="true"] { background: color-mix(in srgb,var(--text) 12%,transparent); } +.wiki-completion button[aria-selected="true"] { background: var(--selection); color: var(--selectionText); } diff --git a/web/editor/source-view.js b/web/editor/source-view.js index 69c4006..bf8d6e5 100644 --- a/web/editor/source-view.js +++ b/web/editor/source-view.js @@ -19,7 +19,7 @@ export function sourceNodeView(env){return (node,outer,getPos,_decorations,inner EditorView.lineWrapping,drawSelection(), EditorView.editorAttributes.of({class:'source-viewport'}), EditorView.contentAttributes.of({'aria-label':'Markdown 源码编辑器',spellcheck:'false'}), - EditorView.theme({'&':{font:'14px/1.65 monospace',backgroundColor:'transparent'},'.cm-content':{padding:'12px',minHeight:'75vh'},'.cm-line':{padding:'0'},'&.cm-focused':{outline:'none'},'.cm-scroller':{font:'inherit'},'.cm-selectionBackground':{backgroundColor:'var(--selection) !important'}}), + EditorView.theme({'&':{font:'14px/1.65 monospace',backgroundColor:'transparent'},'.cm-content':{padding:'12px',minHeight:'75vh'},'.cm-line':{padding:'0'},'&.cm-focused':{outline:'none'},'.cm-scroller':{font:'inherit'},'.cm-selectionBackground':{backgroundColor:'color-mix(in srgb,var(--selection) 40%,transparent) !important'}}), editable.of([]),search.of([]), keymap.of([ {key:'Mod-z',run:()=>env.command('undo')}, diff --git a/web/editor/tools.js b/web/editor/tools.js index 8add318..73181d3 100644 --- a/web/editor/tools.js +++ b/web/editor/tools.js @@ -14,7 +14,7 @@ const slashEntries=[ export function makeTools(env){ let view,hover=0,blockHandleVisible=false,collapsed=new Set(),query='',caseSensitive=false,searchIndex=-1,slashIndex=0,lastSlash='',uiTimer,lastUI='',layoutToken=0; let cachedDoc,cachedText='',cachedStats={},cachedOutline=[],cachedMatchesDoc,cachedMatchesQuery,cachedMatchesCase,cachedMatches=[]; - let suppressedSlash=null,blockSelection=false,newParagraphPos=null,dragPreview=null,contextRequest=null;let pending=0,request=0,clipboardBlock=null;const imports=new Map(),bookmarks=new Map(); + let suppressedSlash=null,blockSelection=false,newParagraphPos=null,dragPreview=null,dragGhost=null,dragSource=null,contextRequest=null;let pending=0,request=0,clipboardBlock=null;const imports=new Map(),bookmarks=new Map(); const key=new PluginKey('nextnotesTools'); const editable=()=>env.canEdit()&&!env.busy(); const notify=text=>env.bridge()?.notice(text); @@ -142,6 +142,32 @@ export function makeTools(env){ if(args.selection&&!view.state.selection.empty){const first=topBlock(view.state.selection.from),last=topBlock(Math.max(view.state.selection.from,view.state.selection.to-1));if(block.pos>=first.pos&&block.pos<=last.pos)return {from:first.pos,to:last.pos+last.node.nodeSize};} return {from:block.pos,to:block.pos+block.node.nodeSize}; } + function clearDragGhost(){ + dragGhost?.remove();dragGhost=null; + dragSource?.classList.remove('editor-drag-source');dragSource=null; + } + function updateDragGhost(args){ + // Legacy callers can preview a boundary without supplying a pointer position. + if(!Number.isFinite(args.x))return; + const block=targetBlock(args),dom=block&&view.nodeDOM(block.pos); + if(!(dom instanceof Element))return; + if(dragSource!==dom){ + clearDragGhost(); + const rect=dom.getBoundingClientRect(),clone=dom.cloneNode(true); + clone.classList.remove('editor-selected-block','ProseMirror-selectednode','editor-empty-line'); + for(const el of [clone,...clone.querySelectorAll('*')]){ + el.removeAttribute('id');el.removeAttribute('contenteditable');el.removeAttribute('data-placeholder'); + el.classList.remove('editor-selected-block','ProseMirror-selectednode'); + } + dragGhost=document.createElement('div');dragGhost.className='editor-drag-ghost'; + dragGhost.setAttribute('aria-hidden','true');dragGhost.inert=true; + dragGhost.style.width=rect.width+'px'; + const content=document.createElement('div');content.className='editor-drag-content ProseMirror';content.append(clone);dragGhost.append(content); + document.body.append(dragGhost);dragSource=dom;dragSource.classList.add('editor-drag-source'); + } + const rect=dom.getBoundingClientRect(),offset=Math.max(0,Math.min(args.offsetY||0,rect.height)); + dragGhost.style.transform=`translate3d(${args.x+18}px,${args.y-offset+8}px,0)`; + } // Preview and release share one calculation: the line marks a real block boundary. // Ignore folded nodes, and use the gap between visible blocks rather than pointer y. function dropTarget(args){ @@ -163,6 +189,7 @@ export function makeTools(env){ else if(args.direction==='down'){const after=blocks.find(b=>b.pos>=r.to);target=after?after.pos+after.node.nodeSize:undefined;} else if(typeof args.y==='number')target=dropTarget(args)?.pos; if(target===undefined||target>=r.from&&target<=r.to)return false; + clearDragGhost();dragPreview=null; const content=view.state.doc.slice(r.from,r.to).content;let tr=view.state.tr.delete(r.from,r.to); const at=tr.mapping.map(target,-1);tr=tr.insert(at,content); tr.setMeta('nextnotesMovedBlock',{from:r.from,to:r.to,at}); @@ -214,7 +241,7 @@ export function makeTools(env){ const hit=view.posAtCoords({left:args.x,top:args.y}); contextRequest={request:args.request,pos:hit?.pos??view.state.selection.from};ui();return true; } - if(name==='cancelBlockMove'){dragPreview=null;schedule();return true;} + if(name==='cancelBlockMove'){const active=!!dragPreview;dragPreview=null;clearDragGhost();if(active)refresh();else schedule();return true;} if(name==='search'){query=args.query||'';caseSensitive=!!args.caseSensitive;searchIndex=-1;refresh();return true;} if(name==='findNext')return navigateSearch(args.direction||1); if(name==='dragScroll'){window.scrollBy(0,args.delta||0);schedule();return true;} @@ -229,12 +256,12 @@ export function makeTools(env){ let tr=view.state.tr;for(const m of [...chosen].reverse())tr=tr.insertText(args.replacement||'',m.from,m.to);commit(tr);notify(`已替换 ${chosen.length} 处`);return true; } if(!editable())return false; - if(name==='previewBlockMove'){dragPreview=args;schedule();return true;} + if(name==='previewBlockMove'){const first=!dragPreview;dragPreview=args;updateDragGhost(args);if(first)refresh();else schedule();return true;} if(name==='slashChoose')return applySlash(args.index); if(name==='selectBlock'){ const b=targetBlock(args);if(!b)return false; - const selection=args.extend?TextSelection.between(view.state.doc.resolve(Math.min(view.state.selection.from,b.pos)),view.state.doc.resolve(Math.max(view.state.selection.to,b.pos+b.node.nodeSize))):NodeSelection.create(view.state.doc,b.pos); - view.dispatch(view.state.tr.setSelection(selection).setMeta(key,'block-selection'));view.focus();return true; + const selection=args.extend?TextSelection.between(view.state.doc.resolve(Math.min(view.state.selection.from,b.pos)),view.state.doc.resolve(Math.max(view.state.selection.to,b.pos+b.node.nodeSize))):args.focus===false&&b.node.isTextblock?TextSelection.create(view.state.doc,b.pos+1,b.pos+b.node.nodeSize-1):NodeSelection.create(view.state.doc,b.pos); + view.dispatch(view.state.tr.setSelection(selection).setMeta(key,'block-selection'));if(args.focus!==false)view.focus();return true; } if(name==='moveBlock')return moveBlock(args); if(name==='duplicateBlock'||name==='deleteBlock'||name==='copyBlock'){ @@ -335,7 +362,8 @@ export function makeTools(env){ }}, props:{attributes:()=>blockSelection?{class:'editor-block-selection'}:{}, decorations(state){ - const base=key.getState(state),{$from,empty}=state.selection; + let base=key.getState(state);const {$from,empty}=state.selection; + if(dragPreview){const b=targetBlock(dragPreview);if(b)base=base.add(state.doc,[Decoration.node(b.pos,b.pos+b.node.nodeSize,{class:'editor-drag-source'})]);} if(blockSelection&&!empty){ const items=[]; state.doc.nodesBetween(state.selection.from,state.selection.to,(node,pos)=>{ @@ -371,10 +399,10 @@ export function makeTools(env){ } } }, - view(v){view=v;const observer=new ResizeObserver(schedule);observer.observe(v.dom);schedule();return {update(){env.refreshToc?.();schedule();},destroy(){observer.disconnect();clearTimeout(uiTimer);uiTimer=null;}};} + view(v){view=v;const observer=new ResizeObserver(schedule);observer.observe(v.dom);schedule();return {update(){if(dragSource?.isConnected)dragSource.classList.add('editor-drag-source');env.refreshToc?.();schedule();},destroy(){clearDragGhost();observer.disconnect();clearTimeout(uiTimer);uiTimer=null;}};} }); return {plugin,run,refresh,state:()=>lastUI?JSON.parse(lastUI):{},ui:schedule,uiNow:ui,uploader,importResult,exportReady,hasPending:()=>pending>0, - reset(){clearTimeout(uiTimer);uiTimer=null;collapsed.clear();hover=0;blockHandleVisible=false;blockSelection=false;newParagraphPos=null;dragPreview=null;contextRequest=null;suppressedSlash=null;slashIndex=0;query='';lastUI='';searchIndex=-1;lastSlash='';for(const item of imports.values())item.resolve({ok:false});imports.clear();bookmarks.clear();pending=0;}, + reset(){clearDragGhost();clearTimeout(uiTimer);uiTimer=null;collapsed.clear();hover=0;blockHandleVisible=false;blockSelection=false;newParagraphPos=null;dragPreview=null;contextRequest=null;suppressedSlash=null;slashIndex=0;query='';lastUI='';searchIndex=-1;lastSlash='';for(const item of imports.values())item.resolve({ok:false});imports.clear();bookmarks.clear();pending=0;}, selectedCode:()=>topBlock(view.state.selection.from), }; }