From bc843b0424e3533f8dd6c69d58d8aea2499bc3f2 Mon Sep 17 00:00:00 2001 From: yinyin333333 Date: Wed, 8 Jul 2026 10:04:38 +0900 Subject: [PATCH 1/3] fixaddfixaddfix --- src/app.js | 1 + src/ui/canvas-grid.js | 47 ++++- src/ui/controllers/app-event-controller.js | 24 ++- src/ui/controllers/document-controller.js | 6 + src/ui/controllers/shell-controller.js | 1 + src/ui/global-shortcut-policy.js | 12 ++ tests/app-controller.test.js | 212 ++++++++++++++++++++- tests/app-shell.test.js | 2 + tests/grid-policy.test.js | 51 +++++ 9 files changed, 351 insertions(+), 5 deletions(-) diff --git a/src/app.js b/src/app.js index d34fb28..e8ffb42 100644 --- a/src/app.js +++ b/src/app.js @@ -388,6 +388,7 @@ const eventController = createAppEventController({ grid, commands, documentController, + hasOpenDocument, searchController, syncDockLayout, wirePaneResizers, diff --git a/src/ui/canvas-grid.js b/src/ui/canvas-grid.js index 4bd63f9..92a2657 100644 --- a/src/ui/canvas-grid.js +++ b/src/ui/canvas-grid.js @@ -4,7 +4,7 @@ import { boundedTableExtent, classifyGridHit, classifyPanePoint, classifyResizeH import { GridMetrics } from "./grid-metrics.js"; import { cellBackground, cellTextColor, createGridRenderStats, initialColumnFitWidth, syncGridThemeFromStyle } from "./grid-render-policy.js"; import { applyColumnSelection, applyRowSelection, applySelectionForHit, hasFullColumnRange, hasFullRowRange, keyboardSelectionTarget } from "./grid-selection-policy.js"; -import { applyGridScrollBounds, applyResizeDragState, centeredCellScrollState, centeredScrollOffset as centeredScrollOffsetPolicy, edgeCellScrollState } from "./grid-viewport-policy.js"; +import { applyGridScrollBounds, applyResizeDragState, centeredCellScrollState, centeredScrollOffset as centeredScrollOffsetPolicy, clampedGridScrollOffsets, edgeCellScrollState } from "./grid-viewport-policy.js"; import { drawGrid, drawGridActiveRowHeaderChrome, drawGridCell, drawGridDiagnosticMarker, drawGridRowHeader, fillGridText } from "./grid/grid-renderer.js"; import { isGridHoverAllowed, @@ -812,6 +812,51 @@ export class CanvasGrid { if ("scrollTop" in scrollState) this.host.scrollTop = scrollState.scrollTop; } + scrollToTop() { + this.scrollToOffsets({ scrollTop: 0 }); + } + + scrollToBottom() { + this.scrollToOffsets({ scrollTop: Number.MAX_SAFE_INTEGER }); + } + + scrollToLeft() { + this.scrollToOffsets({ scrollLeft: 0 }); + } + + scrollToRight() { + this.scrollToOffsets({ scrollLeft: Number.MAX_SAFE_INTEGER }); + } + + scrollPageUp() { + this.scrollToOffsets({ scrollTop: this.host.scrollTop - this.gridPageHeight() }); + } + + scrollPageDown() { + this.scrollToOffsets({ scrollTop: this.host.scrollTop + this.gridPageHeight() }); + } + + gridPageHeight() { + return Math.max(1, this.host.clientHeight - this.headerHeight - this.frozenRowHeight()); + } + + scrollToOffsets({ scrollLeft = this.host.scrollLeft, scrollTop = this.host.scrollTop }) { + const next = clampedGridScrollOffsets({ + scrollLeft, + scrollTop, + rowHeaderWidth: this.rowHeaderWidth, + headerHeight: this.headerHeight, + frozenColumnWidth: this.frozenColumnWidth(), + frozenRowHeight: this.frozenRowHeight(), + scrollableColumnWidth: this.scrollableColumnWidth(), + scrollableRowsHeight: this.scrollableRowsHeight(), + viewportWidth: this.host.clientWidth, + viewportHeight: this.host.clientHeight + }); + this.host.scrollLeft = next.scrollLeft; + this.host.scrollTop = next.scrollTop; + } + statusText() { const r = this.selection.rect; return `${this.doc.name} | ${this.doc.rowCount.toLocaleString()} rows x ${this.doc.columnCount.toLocaleString()} columns | R${this.selection.focus.row + 1}:C${this.selection.focus.column + 1} | Selection ${r.bottom - r.top + 1}x${r.right - r.left + 1} | ${this.doc.dirty ? "Modified" : "Saved"} | ${this.doc.encoding} | ${Math.round(this.doc.zoom * 100)}%`; diff --git a/src/ui/controllers/app-event-controller.js b/src/ui/controllers/app-event-controller.js index 7a41699..24040e4 100644 --- a/src/ui/controllers/app-event-controller.js +++ b/src/ui/controllers/app-event-controller.js @@ -1,5 +1,5 @@ import { isTauriRuntime } from "../../core/io.js"; -import { globalShortcutAction } from "../global-shortcut-policy.js"; +import { globalShortcutAction, gridScrollShortcutAction } from "../global-shortcut-policy.js"; import { isTextInputTarget } from "../search-policy.js"; export function createAppEventController({ @@ -8,6 +8,7 @@ export function createAppEventController({ grid, commands, documentController, + hasOpenDocument, searchController, syncDockLayout, wirePaneResizers, @@ -97,12 +98,33 @@ export function createAppEventController({ els.host.focus(); return; } + if (!editingCell && !isGridScrollShortcutBlocked(event.target)) { + const scrollAction = gridScrollShortcutAction(event); + if (scrollAction && hasOpenDocument?.()) return runGridScrollShortcutAction(event, scrollAction); + } const shortcutAction = globalShortcutAction(event, { editingCell }); if (editingCell && !shortcutAction) return; if (!editingCell && isTextInputTarget(event.target)) return; if (shortcutAction) return runGlobalShortcutAction(event, shortcutAction); } + function isGridScrollShortcutBlocked(target) { + if (isTextInputTarget(target)) return true; + const ElementCtor = globalThis.Element; + if (!ElementCtor || !(target instanceof ElementCtor)) return false; + return Boolean(target.closest(".modal, .modal-backdrop, .palette")); + } + + function runGridScrollShortcutAction(event, action) { + if (action === "scroll-top") return prevent(event, () => grid.scrollToTop()); + if (action === "scroll-bottom") return prevent(event, () => grid.scrollToBottom()); + if (action === "scroll-left") return prevent(event, () => grid.scrollToLeft()); + if (action === "scroll-right") return prevent(event, () => grid.scrollToRight()); + if (action === "scroll-page-up") return prevent(event, () => grid.scrollPageUp()); + if (action === "scroll-page-down") return prevent(event, () => grid.scrollPageDown()); + return undefined; + } + function runGlobalShortcutAction(event, action) { if (action === "zoom-in") return prevent(event, () => runCommand("zoom-in")); if (action === "zoom-out") return prevent(event, () => runCommand("zoom-out")); diff --git a/src/ui/controllers/document-controller.js b/src/ui/controllers/document-controller.js index 3d9e5ac..8f11596 100644 --- a/src/ui/controllers/document-controller.js +++ b/src/ui/controllers/document-controller.js @@ -104,6 +104,7 @@ export function createDocumentController({ state.active = plan.activeIndex; grid.setDocument(activeDoc()); renderChrome(); + focusGrid(); return; } resetUndoManagerForDocument(doc); @@ -126,6 +127,7 @@ export function createDocumentController({ } renderChrome(); scrollProblemsToActiveFile(); + focusGrid(); if (doc.largeFileMode) return; if (documentOpenSyncRoute(state.lint.engine) === "vector-open") { lspOpenDoc(doc).catch((error) => reportLspOpenFailure(doc, error, "document-open")); @@ -336,6 +338,10 @@ export function createDocumentController({ grid.commitEdit?.(); } + function focusGrid() { + els.host?.focus?.(); + } + async function showOpeningFeedback(message) { state.lint.status = message; renderChrome(); diff --git a/src/ui/controllers/shell-controller.js b/src/ui/controllers/shell-controller.js index 5fb2094..c0aa71f 100644 --- a/src/ui/controllers/shell-controller.js +++ b/src/ui/controllers/shell-controller.js @@ -136,6 +136,7 @@ export function createShellController({ renderChrome(); scrollProblemsToActiveFile(); scheduleHoverPrewarm("tab-switch"); + els.host?.focus?.(); } function bindExplorerFilter() { diff --git a/src/ui/global-shortcut-policy.js b/src/ui/global-shortcut-policy.js index 2d7c457..72c1714 100644 --- a/src/ui/global-shortcut-policy.js +++ b/src/ui/global-shortcut-policy.js @@ -4,6 +4,18 @@ export function isEditorShortcutAllowed(key, ctrlKey) { return Boolean(ctrlKey && EDITOR_ALLOWED_CTRL_KEYS.has(String(key).toLowerCase())); } +export function gridScrollShortcutAction(event) { + const key = String(event.key ?? ""); + const shiftKey = Boolean(event.shiftKey); + if (event.ctrlKey || event.metaKey || event.altKey) return null; + if (key === "Home") return shiftKey ? "scroll-left" : "scroll-top"; + if (key === "End") return shiftKey ? "scroll-right" : "scroll-bottom"; + if (shiftKey) return null; + if (key === "PageUp") return "scroll-page-up"; + if (key === "PageDown") return "scroll-page-down"; + return null; +} + export function globalShortcutAction(event, { editingCell = false } = {}) { const key = String(event.key ?? "").toLowerCase(); const ctrlKey = Boolean(event.ctrlKey); diff --git a/tests/app-controller.test.js b/tests/app-controller.test.js index 13a1b8a..0c80017 100644 --- a/tests/app-controller.test.js +++ b/tests/app-controller.test.js @@ -12,6 +12,7 @@ import { import { LARGE_FILE_THRESHOLDS } from "../src/core/large-file-policy.js"; import { fillSelectedCellsCommand } from "../src/core/operations.js"; import { CanvasGrid } from "../src/ui/canvas-grid.js"; +import { createAppEventController } from "../src/ui/controllers/app-event-controller.js"; import { createDocumentController } from "../src/ui/controllers/document-controller.js"; import { createSearchController } from "../src/ui/controllers/search-controller.js"; import { @@ -38,6 +39,7 @@ import { import { syncDockChildren } from "../src/ui/dock-sync.js"; import { globalShortcutAction, + gridScrollShortcutAction, isEditorShortcutAllowed } from "../src/ui/global-shortcut-policy.js"; import { @@ -573,7 +575,7 @@ test("opening another document saves the outgoing selection and scroll state", a let stateRef; const saves = []; selection.set(1, 0); - const { controller, state } = testDocumentController(active, gridState, { + const { controller, state, document, host } = testDocumentController(active, gridState, { saveSelectionState: () => { const doc = stateRef.docs[stateRef.active]; saves.push(doc.name); @@ -591,6 +593,7 @@ test("opening another document saves the outgoing selection and scroll state", a assert.deepEqual(active.selectionState.focus, { row: 1, column: 0 }); assert.equal(active.scrollLeft, 12); assert.equal(active.scrollTop, 240); + assert.equal(document.activeElement, host); }); test("activating an already-open document saves the outgoing selection and scroll state", async () => { @@ -609,7 +612,7 @@ test("activating an already-open document saves the outgoing selection and scrol let stateRef; const saves = []; selection.set(1, 0); - const { controller, state } = testDocumentController([active, existing], gridState, { + const { controller, state, document, host } = testDocumentController([active, existing], gridState, { saveSelectionState: () => { const doc = stateRef.docs[stateRef.active]; saves.push(doc.name); @@ -628,6 +631,7 @@ test("activating an already-open document saves the outgoing selection and scrol assert.deepEqual(active.selectionState.focus, { row: 1, column: 0 }); assert.equal(active.scrollLeft, 7); assert.equal(active.scrollTop, 360); + assert.equal(document.activeElement, host); }); test("closing the active tab commits editor changes before checking dirty state", async () => { @@ -1123,7 +1127,208 @@ test("Ctrl+B, Ctrl+L, and Ctrl+H use the shared panel and row-height reset paths assert.match(readme, /`Ctrl\+H`: reset all row heights to default/); }); +test("grid scroll shortcut policy maps only unmodified data-grid scroll keys", () => { + assert.equal(gridScrollShortcutAction({ key: "Home" }), "scroll-top"); + assert.equal(gridScrollShortcutAction({ key: "End" }), "scroll-bottom"); + assert.equal(gridScrollShortcutAction({ key: "Home", shiftKey: true }), "scroll-left"); + assert.equal(gridScrollShortcutAction({ key: "End", shiftKey: true }), "scroll-right"); + assert.equal(gridScrollShortcutAction({ key: "PageUp" }), "scroll-page-up"); + assert.equal(gridScrollShortcutAction({ key: "PageDown" }), "scroll-page-down"); + assert.equal(gridScrollShortcutAction({ key: "PageUp", shiftKey: true }), null); + assert.equal(gridScrollShortcutAction({ key: "Home", ctrlKey: true }), null); + assert.equal(gridScrollShortcutAction({ key: "End", altKey: true }), null); +}); + +test("data-grid scroll shortcuts require an open document and non-text focus", () => { + const previousDocument = globalThis.document; + const previousWindow = globalThis.window; + const previousElement = globalThis.Element; + const listeners = new Map(); + globalThis.document = { + addEventListener: (type, listener) => listeners.set(type, listener) + }; + globalThis.window = { addEventListener: () => {} }; + class FakeElement { + constructor(matchSelector = "") { + this.matchSelector = matchSelector; + } + closest(selector) { + return this.matchSelector && selector.includes(this.matchSelector) ? this : null; + } + } + globalThis.Element = FakeElement; + + try { + const calls = []; + const classList = (active = false) => ({ + contains: (name) => active && name === "active", + add: () => {}, + remove: () => {} + }); + let editorActive = false; + const editorClassList = { + contains: (name) => editorActive && name === "active", + add: () => {}, + remove: () => {} + }; + const addEventListener = () => {}; + const controller = createAppEventController({ + state: { active: 0 }, + els: { + closeDialog: { addEventListener }, + tabs: { addEventListener }, + fileInput: { addEventListener }, + paletteInput: { addEventListener }, + paletteResults: { querySelector: () => null }, + palette: { classList: classList(false) }, + contextMenu: { classList: classList(false) }, + searchPanel: { classList: classList(false) }, + host: { focus: () => calls.push("focus") }, + editor: { classList: editorClassList } + }, + grid: { + layout: () => {}, + scrollToTop: () => calls.push("top"), + scrollToBottom: () => calls.push("bottom"), + scrollToLeft: () => calls.push("left"), + scrollToRight: () => calls.push("right"), + scrollPageUp: () => calls.push("page-up"), + scrollPageDown: () => calls.push("page-down") + }, + commands: {}, + documentController: { handleCloseDialogClick: () => {}, openBrowserFiles: async () => {} }, + hasOpenDocument: () => true, + searchController: { wireEvents: () => {}, closeSearch: () => {}, showSearch: () => {} }, + syncDockLayout: () => {}, + wirePaneResizers: () => {}, + positionContextMenu: () => {}, + updateOverviewRuler: () => {}, + renderPalette: () => {}, + runCommand: () => {}, + switchBottomTab: () => {}, + showError: (error) => { throw error; }, + hideContextMenu: () => {}, + closeTab: () => {}, + openFile: () => {}, + toggleSidebar: () => {}, + toggleProblemsPanel: () => {}, + resetRowHeights: () => {}, + saveAs: () => {}, + saveFile: () => {}, + redo: () => {}, + undo: () => {}, + showPalette: () => {}, + copySelection: () => {}, + cutSelection: () => {}, + pasteSelection: () => {} + }); + controller.wireEvents(); + + let prevented = 0; + listeners.get("keydown")({ + key: "Home", + target: {}, + preventDefault: () => { prevented += 1; } + }); + listeners.get("keydown")({ + key: "End", + shiftKey: true, + target: {}, + preventDefault: () => { prevented += 1; } + }); + listeners.get("keydown")({ + key: "PageDown", + target: {}, + preventDefault: () => { prevented += 1; } + }); + assert.deepEqual(calls, ["top", "right", "page-down"]); + assert.equal(prevented, 3); + + listeners.get("keydown")({ + key: "PageUp", + target: new FakeElement("input"), + preventDefault: () => { prevented += 1; } + }); + assert.deepEqual(calls, ["top", "right", "page-down"]); + + listeners.get("keydown")({ + key: "Home", + target: new FakeElement(".modal"), + preventDefault: () => { prevented += 1; } + }); + assert.deepEqual(calls, ["top", "right", "page-down"]); + + editorActive = true; + listeners.get("keydown")({ + key: "End", + target: {}, + preventDefault: () => { prevented += 1; } + }); + assert.deepEqual(calls, ["top", "right", "page-down"]); + editorActive = false; + + const closedController = createAppEventController({ + state: { active: -1 }, + els: { + closeDialog: { addEventListener }, + tabs: { addEventListener }, + fileInput: { addEventListener }, + paletteInput: { addEventListener }, + paletteResults: { querySelector: () => null }, + palette: { classList: classList(false) }, + contextMenu: { classList: classList(false) }, + searchPanel: { classList: classList(false) }, + host: { focus: () => {} }, + editor: { classList: classList(false) } + }, + grid: { + layout: () => {}, + scrollToTop: () => calls.push("closed-top") + }, + commands: {}, + documentController: { handleCloseDialogClick: () => {}, openBrowserFiles: async () => {} }, + hasOpenDocument: () => false, + searchController: { wireEvents: () => {}, closeSearch: () => {}, showSearch: () => {} }, + syncDockLayout: () => {}, + wirePaneResizers: () => {}, + positionContextMenu: () => {}, + updateOverviewRuler: () => {}, + renderPalette: () => {}, + runCommand: () => {}, + switchBottomTab: () => {}, + showError: (error) => { throw error; }, + hideContextMenu: () => {}, + closeTab: () => {}, + openFile: () => {}, + toggleSidebar: () => {}, + toggleProblemsPanel: () => {}, + resetRowHeights: () => {}, + saveAs: () => {}, + saveFile: () => {}, + redo: () => {}, + undo: () => {}, + showPalette: () => {}, + copySelection: () => {}, + cutSelection: () => {}, + pasteSelection: () => {} + }); + closedController.wireEvents(); + listeners.get("keydown")({ + key: "Home", + target: {}, + preventDefault: () => { prevented += 1; } + }); + assert.equal(calls.includes("closed-top"), false); + } finally { + globalThis.document = previousDocument; + globalThis.window = previousWindow; + globalThis.Element = previousElement; + } +}); + function testDocumentController(docOrDocs, gridOverrides = {}, options = {}) { + const hostDocument = { activeElement: null }; + const host = { focus: () => { hostDocument.activeElement = host; } }; const state = { docs: Array.isArray(docOrDocs) ? docOrDocs : [docOrDocs], active: 0, lint: { engine: options.lintEngine ?? "legacy" } }; const grid = { commitEdit: () => {}, @@ -1134,6 +1339,7 @@ function testDocumentController(docOrDocs, gridOverrides = {}, options = {}) { const controller = createDocumentController({ state, els: { + host, closeDialog: { classList: { add: () => {}, remove: () => {} } }, closeDialogText: { textContent: "" }, fileInput: { click: () => {} } @@ -1162,7 +1368,7 @@ function testDocumentController(docOrDocs, gridOverrides = {}, options = {}) { updateGridDiagnostics: () => {}, scrollProblemsToActiveFile: () => {} }); - return { controller, state }; + return { controller, state, document: hostDocument, host }; } function deferred() { diff --git a/tests/app-shell.test.js b/tests/app-shell.test.js index 106ebb6..306a1f9 100644 --- a/tests/app-shell.test.js +++ b/tests/app-shell.test.js @@ -443,6 +443,7 @@ test("Explorer search preserves open document tab indexes", () => { ]; const els = { shell: document.getElementById("app"), + host: document.getElementById("gridHost"), ...Object.fromEntries(ids.map((id) => [id, document.getElementById(id)])) }; const controller = createShellController({ @@ -480,6 +481,7 @@ test("Explorer search preserves open document tab indexes", () => { assert.equal(state.active, 1); assert.equal(els.explorerFilter.value, ""); + assert.equal(document.activeElement, els.host); } finally { if (originalDocument === undefined) delete globalThis.document; else globalThis.document = originalDocument; diff --git a/tests/grid-policy.test.js b/tests/grid-policy.test.js index cca3317..f0852f3 100644 --- a/tests/grid-policy.test.js +++ b/tests/grid-policy.test.js @@ -406,6 +406,57 @@ test("centered scroll offsets clamp for short and bounded content", () => { }), {}); }); +test("grid explicit scroll shortcuts clamp to table bounds", () => { + const grid = { + host: { + scrollLeft: 40, + scrollTop: 300, + clientWidth: 300, + clientHeight: 200 + }, + rowHeaderWidth: 40, + headerHeight: 24, + frozenColumnWidth: () => 10, + frozenRowHeight: () => 20, + scrollableColumnWidth: () => 900, + scrollableRowsHeight: () => 800, + scrollToOffsets: CanvasGrid.prototype.scrollToOffsets, + gridPageHeight: CanvasGrid.prototype.gridPageHeight, + scrollToTop: CanvasGrid.prototype.scrollToTop, + scrollToBottom: CanvasGrid.prototype.scrollToBottom, + scrollToLeft: CanvasGrid.prototype.scrollToLeft, + scrollToRight: CanvasGrid.prototype.scrollToRight, + scrollPageUp: CanvasGrid.prototype.scrollPageUp, + scrollPageDown: CanvasGrid.prototype.scrollPageDown + }; + + grid.scrollToTop(); + assert.equal(grid.host.scrollTop, 0); + assert.equal(grid.host.scrollLeft, 40); + + grid.scrollToBottom(); + assert.equal(grid.host.scrollTop, 644); + + grid.scrollToLeft(); + assert.equal(grid.host.scrollLeft, 0); + assert.equal(grid.host.scrollTop, 644); + + grid.scrollToRight(); + assert.equal(grid.host.scrollLeft, 650); + + grid.host.scrollTop = 300; + grid.scrollPageUp(); + assert.equal(grid.host.scrollTop, 144); + + grid.scrollPageDown(); + assert.equal(grid.host.scrollTop, 300); + + grid.scrollPageDown(); + grid.scrollPageDown(); + grid.scrollPageDown(); + assert.equal(grid.host.scrollTop, 644); +}); + test("frozen pane edge uses a subtle raised effect instead of hard divider strokes", () => { const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8"); assert.deepEqual(frozenVerticalEdgeRects(80, 240), [ From 021e633192fa29b9cfcbc505e6be5c0d55618a58 Mon Sep 17 00:00:00 2001 From: yinyin333333 Date: Fri, 10 Jul 2026 14:49:31 +0900 Subject: [PATCH 2/3] search fix --- index.html | 2 +- src/ui/canvas-grid.js | 11 +++-- src/ui/controllers/search-controller.js | 7 ++- src/ui/grid-viewport-policy.js | 18 ++++++++ tests/app-controller.test.js | 21 +++++++-- tests/grid-policy.test.js | 58 +++++++++++++++++++++++++ tests/platform-tauri-contract.test.js | 2 +- 7 files changed, 108 insertions(+), 11 deletions(-) diff --git a/index.html b/index.html index 062f541..ad6b910 100644 --- a/index.html +++ b/index.html @@ -79,7 +79,7 @@