diff --git a/apps/electron-demo/electron/main.ts b/apps/electron-demo/electron/main.ts index 22126b17..6bb6af81 100644 --- a/apps/electron-demo/electron/main.ts +++ b/apps/electron-demo/electron/main.ts @@ -68,6 +68,7 @@ const MAIN_PLUGIN_PERMISSION_POLICIES = new Map ? bootPlan.runtimePlugins.some((Plugin) => bundledReferencePlugins.some((entry) => entry.Plugin === Plugin && entry.manifest.id === "wordcount")) : bootPlan.legacyUi.wordCount, + blockDrag: bootPlan.mode === "runtime" + ? bootPlan.runtimePlugins.some((Plugin) => + bundledReferencePlugins.some((entry) => entry.Plugin === Plugin && entry.manifest.id === "block-drag")) + : bootPlan.editorContributions.some((plugin) => plugin.name === "plugin-block-drag"), }, editorContributions: bootPlan.mode === "runtime" ? bootPlan.editorContributions diff --git a/apps/electron-demo/src/renderer/editor-shell.ts b/apps/electron-demo/src/renderer/editor-shell.ts index f63ec212..2638e047 100644 --- a/apps/electron-demo/src/renderer/editor-shell.ts +++ b/apps/electron-demo/src/renderer/editor-shell.ts @@ -6,6 +6,7 @@ import { type NexusPlugin, } from "@floatboat/nexus-core"; import { createGfmPreset } from "@floatboat/nexus-preset-gfm"; +import { createBlockDragPlugin } from "@floatboat/nexus-plugin-block-drag"; import { createHistoryPlugin } from "@floatboat/nexus-plugin-history"; import { createToolbarPlugin, @@ -66,6 +67,7 @@ export interface EditorShellOptions { toolbar: boolean; slashMenu: boolean; wordCount: boolean; + blockDrag?: boolean; }>; /** Extra static contributions selected by the host boot plan. */ editorContributions?: readonly NexusPlugin[]; @@ -93,7 +95,7 @@ export function createEditorShell(options: EditorShellOptions): EditorShell { resolveWikilink, suggestWikilinks, contributionMode = "legacy", - contributionFeatures = { toolbar: true, slashMenu: true, wordCount: true }, + contributionFeatures = { toolbar: true, slashMenu: true, wordCount: true, blockDrag: true }, editorContributions = [], } = options; const runtimeManaged = contributionMode === "runtime"; @@ -138,6 +140,7 @@ export function createEditorShell(options: EditorShellOptions): EditorShell { plugins: [ createGfmPreset(), createHistoryPlugin(), + ...(!runtimeManaged && contributionFeatures.blockDrag ? [createBlockDragPlugin()] : []), ...(!runtimeManaged && contributionFeatures.toolbar ? [createToolbarPlugin()] : []), createSearchPlugin(), wikilinksPlugin, diff --git a/apps/electron-demo/test/app-runtime-integration.test.ts b/apps/electron-demo/test/app-runtime-integration.test.ts index 5ecc7672..d18207c2 100644 --- a/apps/electron-demo/test/app-runtime-integration.test.ts +++ b/apps/electron-demo/test/app-runtime-integration.test.ts @@ -151,6 +151,21 @@ beforeEach(() => { }); describe("Electron renderer plugin runtime integration", () => { + it.each(["legacy", "runtime"] as const)("can disable block reordering in %s mode", async (mode) => { + const { boot } = await import("../src/renderer/app"); + installBridgeHarness(createBridgeHarness(), mode); + const app = await boot({ + root: document.getElementById("app")!, + featureFlags: { blockDrag: false }, + deferVaultRestore: false, + }); + + expect(document.querySelectorAll(".nexus-block-drag")).toHaveLength(0); + expect(app.runtime?.pluginManager.list().find((plugin) => + plugin.id === "block-drag" && plugin.state === "enabled")).toBeUndefined(); + await app.shutdown(); + }); + it("keeps the legacy path as the default and shuts it down once", async () => { const { boot } = await import("../src/renderer/app"); const harness = createBridgeHarness(); @@ -169,6 +184,7 @@ describe("Electron renderer plugin runtime integration", () => { expect(app.shell.slashMenu).not.toBeNull(); expect(app.shell.wordcount).not.toBeNull(); expect(document.querySelectorAll(".nexus-toolbar")).toHaveLength(1); + expect(document.querySelectorAll(".nexus-block-drag")).toHaveLength(1); app.state.activeFile = "/vault-note.md"; app.state.filePath = "/vault-note.md"; @@ -219,7 +235,9 @@ describe("Electron renderer plugin runtime integration", () => { "toolbar", "slash-menu", "wordcount", + "block-drag", ]); + expect(document.querySelectorAll(".nexus-block-drag")).toHaveLength(1); expect(app.slots["editor-toolbar"].querySelectorAll("[data-ui-action-id]")).toHaveLength(17); const slashCommandIds = app.shell.editor.getSlashCommands().map((command) => command.id); expect(slashCommandIds).toHaveLength(new Set(slashCommandIds).size); diff --git a/apps/electron-demo/test/editor-shell.test.ts b/apps/electron-demo/test/editor-shell.test.ts index bc762f32..9291939b 100644 --- a/apps/electron-demo/test/editor-shell.test.ts +++ b/apps/electron-demo/test/editor-shell.test.ts @@ -20,9 +20,34 @@ describe("createEditorShell", () => { }); expect(container.querySelector(".cm-editor")).not.toBeNull(); + expect(container.querySelectorAll(".nexus-block-drag")).toHaveLength(1); shell.destroy(); }); + it("moves blocks through the legacy keyboard binding and supports undo", () => { + const container = document.createElement("div"); + document.body.append(container); + const state = createState(); + state.content = "first\n\nsecond"; + const shell = createEditorShell({ + container, + state, + settings: defaultSettings(), + onStateChange: vi.fn(), + }); + + const event = new KeyboardEvent("keydown", { + key: "ArrowDown", altKey: true, shiftKey: true, bubbles: true, cancelable: true, + }); + container.querySelector(".cm-content")!.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + expect(shell.editor.getDocument()).toBe("second\n\nfirst"); + expect(shell.editor.undo()).toBe(true); + expect(shell.editor.getDocument()).toBe("first\n\nsecond"); + shell.destroy(); + container.remove(); + }); + it("marks state dirty when the editor content changes", async () => { const container = document.createElement("div"); const state = createState(); @@ -80,6 +105,7 @@ describe("createEditorShell", () => { expect(shell.slashMenu).toBeNull(); expect(shell.wordcount).toBeNull(); expect(container.querySelectorAll(".nexus-toolbar")).toHaveLength(0); + expect(container.querySelectorAll(".nexus-block-drag")).toHaveLength(0); expect(document.body.querySelectorAll(".nexus-slash-menu")).toHaveLength(0); expect(container.querySelectorAll("[data-testid='nexus-wordcount-bar']")).toHaveLength(0); diff --git a/apps/electron-demo/tsconfig.json b/apps/electron-demo/tsconfig.json index 54bab0a9..22a17df9 100644 --- a/apps/electron-demo/tsconfig.json +++ b/apps/electron-demo/tsconfig.json @@ -5,6 +5,7 @@ "paths": { "@floatboat/nexus-core": ["packages/core/src/index.ts"], "@floatboat/nexus-plugin-api": ["packages/plugin-api/src/index.ts"], + "@floatboat/nexus-plugin-block-drag": ["packages/plugin-block-drag/src/index.ts"], "@floatboat/nexus-plugin-runtime": ["packages/plugin-runtime/src/index.ts"], "@floatboat/nexus-preset-gfm": ["packages/preset-gfm/src/index.ts"], "@floatboat/nexus-plugin-history": ["packages/plugin-history/src/index.ts"], diff --git a/apps/electron-demo/vite.config.ts b/apps/electron-demo/vite.config.ts index ab18c4aa..41130aab 100644 --- a/apps/electron-demo/vite.config.ts +++ b/apps/electron-demo/vite.config.ts @@ -36,6 +36,10 @@ export default defineConfig({ __dirname, "../../packages/core/src/index.ts" ), + "@floatboat/nexus-plugin-block-drag": path.resolve( + __dirname, + "../../packages/plugin-block-drag/src/index.ts" + ), "@floatboat/nexus-plugin-api": path.resolve( __dirname, "../../packages/plugin-api/src/index.ts" diff --git a/docs/plugins/native-plugin-api.zh.md b/docs/plugins/native-plugin-api.zh.md index 601cdde4..22b2af4a 100644 --- a/docs/plugins/native-plugin-api.zh.md +++ b/docs/plugins/native-plugin-api.zh.md @@ -157,6 +157,13 @@ Nexus 不把活动编辑器建模成全局单例。一个插件实例可以服 需要特定窗口、Workspace 或 editor capability 时,传入对应的 `CapabilityRequestContext`。不要缓存“当前编辑器”或从全局 DOM 推断活动 leaf。Workspace 的 focused leaf、active view/file 和 recent editor 是相互独立且都可能为空的查询。 +原生插件如通过 editor extension 重排文档块,必须在表格失焦前调用 core 的 +`prepareBlockMove(view)`,并在返回 `true` 后重新解析块位置。该调用可能提交表格的 +本地输入并改变文档、选区;组合输入、未结束的表格手势、过期位置、宿主拒绝或改写 +提交时返回 `false`,插件应取消移动并保留输入。它是已有 `EditorView` 上的扩展 API, +不新增 capability,也不提供全局活动编辑器查询。详见 +[core 表格提交契约](../../packages/core/README.md#preparing-document-block-moves)。 + ## 5. Manifest 字段速查 | 字段 | 必填 | 约束 | diff --git a/openspec/changes/add-markdown-block-reordering/assets/after.png b/openspec/changes/add-markdown-block-reordering/assets/after.png new file mode 100644 index 00000000..57b9acef Binary files /dev/null and b/openspec/changes/add-markdown-block-reordering/assets/after.png differ diff --git a/openspec/changes/add-markdown-block-reordering/assets/dragging.png b/openspec/changes/add-markdown-block-reordering/assets/dragging.png new file mode 100644 index 00000000..735a105d Binary files /dev/null and b/openspec/changes/add-markdown-block-reordering/assets/dragging.png differ diff --git a/openspec/changes/add-markdown-block-reordering/design.md b/openspec/changes/add-markdown-block-reordering/design.md new file mode 100644 index 00000000..31f61d67 --- /dev/null +++ b/openspec/changes/add-markdown-block-reordering/design.md @@ -0,0 +1,61 @@ +## Context + +Nexus stores Markdown in CodeMirror and renders some structures, particularly tables, as interactive widgets. A widget may contain pending DOM input that the host has not accepted into the document. The native plugin platform also owns commands, hotkey preferences, editor attachment, and disposal. Block movement must preserve these boundaries. + +## Goals and Non-Goals + +Goals are lossless movement of supported outermost blocks, safe interaction with pending table edits, isolated undo history, consistent source/live-preview behavior, host-owned runtime shortcuts, and complete disposal of gestures and UI. + +Non-goals are touch gestures, grouped multi-block movement, cross-document moves, list-item sorting or nesting, and movement of a heading with its entire section. Selections are mapped through a single-block move; a selection spanning several blocks does not expand the movement scope. + +## Decisions + +### Keep block planning independent of the view + +`model.ts` reads the active Markdown language's Lezer tree. Each candidate covers the complete source lines of one outermost node, preserving indentation and trailing spaces. Unsupported root nodes remain in the block sequence so planning cannot silently drop them. + +Snapshots are cached by immutable `EditorState`. Reading requires a complete document parse, with a 25 ms `ensureSyntaxTree` budget. If the parse is unavailable, the model exposes no movable snapshot; partial viewport parses are never used to move a truncated container. Overlapping whole-line spans are also refused. + +### Preserve source and validate both changed boundaries + +A move uses one deletion and one insertion in a single `ChangeSet`. Existing separators travel with the source block; leading and trailing document whitespace remain intact. The planner first tries the unchanged separators. If necessary, it tries combinations of added blank lines at the affected removal and insertion boundaries, starting with the fewest changed boundaries. + +All candidates share a 50 ms parsing budget. Each candidate is parsed using the editor's active language parser. The result must contain exactly the original outermost blocks in the intended order, with identical raw block text and matching full nested node structure and relative positions. This validates the removal seam as well as both insertion seams. For example, removing the paragraph between independent lists is refused if the lists would merge. An unterminated code fence cannot be moved before text it would swallow. + +Parser timeouts and invalid candidates produce a safe no-op. The time budgets limit parser work; they are not a hard wall-clock guarantee for every allocation or tree walk. Validation runs when the target gap changes and again at release, rather than for every pointer coordinate. Large or unusually expensive documents may temporarily have no available move. + +### Prepare widget input before taking the movement snapshot + +`prepareBlockMove(view)` is a synchronous core API. It first checks table sessions for composition, active pointer interactions, and stale source positions. It then snapshots and flushes dirty cells through the normal host transaction pipeline. It returns `true` only when every relevant session has accepted its exact pending input; only then are editing locks released and widgets blurred. + +Rejection, replacement with different content, active IME input, or stale table offsets returns `false`. Pending input and its editing lock remain available instead of allowing a move of old source. An equivalent replacement transaction is accepted. A host replacement may already have changed the document, and accepted earlier table commits are not rolled back if a later session fails; `false` specifically prevents the subsequent block move. + +The pointer path prepares before capturing block positions and checks preparation again before committing. Any document change after gesture capture cancels that gesture. The keyboard path prepares before planning from the current document. Table commits and the later block move remain distinct edits in history. + +After preparation, the plugin verifies that block count, block-type order, and every non-table block's raw text still match the earlier snapshot. A multiline table edit can split the table into several parsed blocks even when its transaction was accepted. Such a structural change cancels the move while retaining the accepted input; the user can retry against the resulting document. This prevents a previously valid source ordinal from selecting another block. + +### Use one owner per interaction and per command + +`BlockDragController` owns a margin button, source highlight, insertion indicator, status announcement, pointer listeners, and an animation-frame loop for edge scrolling. Pointer movement must cross a small threshold before entering the drag state. The operation commits on release inside the editor. Escape, cancellation, lost capture, window blur, a changed document, read-only state, or disposal terminates the gesture. + +The controller uses CodeMirror geometry and scale factors for both source and live-preview layout. It does not use native HTML drag-and-drop or table grip events. Hidden blocks and folded headings are unavailable as movement sources. Focused grip buttons offer arrow-key movement and Escape to return focus to the editor. + +Legacy callers may opt into local `Alt+Shift+ArrowUp/ArrowDown` bindings. The lifecycle plugin installs the same extension with those bindings disabled and registers `block-drag:move-up` and `block-drag:move-down` through the command service. User overrides, cleared bindings, and conflicts therefore have one authority. Availability checks do not commit table edits or move content. The runtime resolves the editor by the execution context's editor ID and drops that mapping on detach. + +### Apply one isolated movement transaction + +The move carries `userEvent: "move.block"`, the `block-drag` transaction origin, and full history isolation. The transaction pipeline preserves the history annotations when it reconstructs transactions. Cursor and selection endpoints inside the moved source follow it; other positions map through the change set, including boundary cursors and multiple selections. Undoing one move restores the preceding document and selection without undoing earlier typing. + +### Keep the feature optional + +Core exports preparation but imports no block-drag package. The package exposes legacy and native plugin entry points. Reference host boot flags select one owner, and omission of the block-drag flag preserves existing host behavior. Disabling the lifecycle plugin quiesces gestures before removing extensions and commands; re-enabling uses the host's existing hotkey preferences. + +## Risks and Validation + +The highest-risk areas are source reinterpretation, rejected widget input, transaction metadata, and asynchronous pointer cleanup. Targeted tests cover those contracts, including host rejection and replacement, stale offsets, source/live-preview transactions, undo/redo, runtime hotkey overrides and conflicts, and disable/re-enable cleanup. + +DOM simulation cannot certify real pointer geometry, native IME event ordering, physical trackpads, zoom, or Electron behavior. Those acceptance checks remain explicit tasks. The table changes also require regression checks for click-to-edit, range selection, grip selection and sorting, deselection, and deletion. + +## Rollout and Rollback + +Ship only after maintainer review and the required acceptance checks. Hosts opt in through the plugin or reference feature flag. A host can disable the plugin to remove handles, movement bindings, and runtime commands without migrating any Markdown. Keep the additive preparation API and table-input correctness fixes subject to their own core regression review. diff --git a/openspec/changes/add-markdown-block-reordering/proposal.md b/openspec/changes/add-markdown-block-reordering/proposal.md new file mode 100644 index 00000000..925fcd01 --- /dev/null +++ b/openspec/changes/add-markdown-block-reordering/proposal.md @@ -0,0 +1,33 @@ +# Change: Add optional Markdown block reordering + +## Why + +Issue [#332](https://github.com/floatboatai/Nexus-Editor/issues/332) requests moving Markdown blocks directly in the editor. Today users must cut and paste source, including container boundaries and separators. The proposed plugin adds pointer and keyboard movement while preserving the existing Markdown document as the source of truth. + +## What Changes + +- Add the optional `@floatboat/nexus-plugin-block-drag` package with a framework-independent CodeMirror extension, a legacy `NexusPlugin` factory, and a native lifecycle plugin. +- Move one complete outermost paragraph, heading, list, blockquote, code block, table, or thematic break between top-level blocks in source and live-preview modes. +- Validate the complete Markdown tree before applying a move; retain the original source text and reject changes that merge or reinterpret blocks. +- Add `prepareBlockMove(view)` to core so a move cannot proceed with uncommitted or rejected table input. +- Revalidate block identity after preparation; an accepted edit that changes block structure remains saved, while the move is cancelled for a user retry. +- Register native move commands through the host command service so rebinding, clearing, conflict handling, active-editor routing, and unload follow the existing platform contract. +- Wire the optional feature into the reference host and Electron example without making core depend on the new plugin. + +## Scope + +The first version supports one outermost block at a time. Nested list items and quoted content move with their outer container. Images move with their containing paragraph. A heading moves as one heading block, not as a section containing later blocks. + +Touch dragging, moving several blocks as a group, cross-document movement, list-item nesting or internal sorting, and section movement are excluded. Custom or unsupported syntax nodes are retained in the document and cannot be selected as a movement source. + +## Impact + +- Added capability: `block-reordering`. +- Extended capability: `editor-core`, through an additive preparation API. +- Existing contracts reused: `plugin-editor-extensions`, `plugin-commands-events`, and `plugin-runtime`. +- Affected code: new plugin package, core table editing and transaction metadata, reference plugin boot wiring, and Electron demo integration. +- No new persistence format or workspace service is required. + +## Review Status + +Maintainer approval of this proposal is pending. The contributor explicitly authorized an independent prototype and preparation of a Draft PR for review; that authorization is not a claim of project approval. The implementation, tests, and documentation were primarily generated by AI, which must remain disclosed in the contribution. Real-browser and Electron acceptance work is tracked separately in `tasks.md`. diff --git a/openspec/changes/add-markdown-block-reordering/specs/block-reordering/spec.md b/openspec/changes/add-markdown-block-reordering/specs/block-reordering/spec.md new file mode 100644 index 00000000..60373a7e --- /dev/null +++ b/openspec/changes/add-markdown-block-reordering/specs/block-reordering/spec.md @@ -0,0 +1,95 @@ +## ADDED Requirements + +### Requirement: Optional outermost block movement + +The plugin SHALL move one complete supported outermost Markdown block between top-level block boundaries, in both source and live-preview modes. Nested content SHALL remain attached to its outer container. Unsupported nodes SHALL remain intact and SHALL NOT be offered as movement sources. + +#### Scenario: Move a list containing nested content +- **WHEN** a user moves an outermost list containing nested items and task markers +- **THEN** the entire list SHALL move as one block +- **AND** the original list text, indentation, markers, and nested structure SHALL be retained + +#### Scenario: Move a heading or image paragraph +- **WHEN** the source is a heading or a paragraph containing an image +- **THEN** only that complete outermost block SHALL move +- **AND** later section blocks SHALL NOT implicitly move with a heading + +#### Scenario: Unsupported or incomplete source +- **WHEN** the source is an unsupported node or a complete document parse is unavailable within the reading budget +- **THEN** the plugin SHALL NOT construct a move from that source +- **AND** the document SHALL remain unchanged + +### Requirement: Source-preserving validated transactions + +The planner SHALL retain original block text and existing separator whitespace, adding blank lines only when needed to preserve structure. Before dispatch it SHALL verify the complete reordered tree, including nested structure, at both removal and insertion boundaries. Invalid, stale, no-op, or over-budget plans SHALL produce no document change. + +#### Scenario: Removal would merge two lists +- **WHEN** removing a paragraph would join independent lists into one parsed list +- **THEN** the move SHALL be rejected if whitespace alone cannot preserve the original blocks +- **AND** no partial deletion or insertion SHALL be applied + +#### Scenario: Insertion would reinterpret a thematic break +- **WHEN** the original separators would make a moved thematic break become a setext heading +- **THEN** the planner SHALL add the required blank separator if the full tree can otherwise be preserved +- **AND** it SHALL retain all original block text + +#### Scenario: Snapshot becomes stale +- **WHEN** the document changes after pointer capture or supplied block ranges no longer describe the current document +- **THEN** the pending move SHALL be cancelled or rejected +- **AND** it SHALL NOT apply old offsets to the new document + +### Requirement: Pointer gesture lifecycle + +The plugin SHALL expose a focusable margin handle with source and valid-destination feedback. It SHALL start a drag only after the pointer crosses the activation threshold, scroll near the editor's vertical edges, and commit at most once on valid release. It SHALL cancel on Escape, pointer cancellation, lost capture, window blur, document changes, read-only transition, or plugin disposal. + +#### Scenario: Release at a valid destination +- **WHEN** a mouse drag crosses the threshold and ends at a valid gap inside the editor +- **THEN** the document SHALL remain unchanged until release +- **AND** one validated move SHALL be dispatched + +#### Scenario: Cancel or release outside +- **WHEN** the user cancels the gesture or releases outside the editor +- **THEN** no move SHALL be dispatched +- **AND** pointer capture, drag feedback, and auto-scroll work SHALL be released + +#### Scenario: Touch or active composition +- **WHEN** the initiating pointer is touch or an editor composition is active +- **THEN** the plugin SHALL NOT begin a block move + +### Requirement: Host-owned runtime commands + +The lifecycle plugin SHALL register movement commands with the host command service and SHALL NOT install duplicate global CodeMirror movement bindings. Availability checks SHALL be free of document changes. Command execution SHALL resolve the current editor context and honor user binding overrides, cleared bindings, conflicts, and resource ownership. + +#### Scenario: User clears or rebinds a shortcut +- **WHEN** the user clears or replaces a default movement binding +- **THEN** the removed default key SHALL NOT invoke a hidden local binding +- **AND** the command SHALL remain available through valid host command entry points + +#### Scenario: Conflicting commands +- **WHEN** another command has the same effective movement shortcut +- **THEN** the host's conflict decision SHALL remain authoritative +- **AND** the plugin SHALL NOT independently execute through a second keymap + +#### Scenario: Disable, re-enable, or detach +- **WHEN** the plugin is disabled or an editor detaches +- **THEN** its gestures, view mapping, handles, and applicable commands SHALL be released +- **AND** re-enabling SHALL NOT duplicate resources or discard persisted hotkey preferences + +### Requirement: Atomic movement history and selection + +A successful move SHALL be one isolated history transaction when history support is installed. The transaction SHALL carry movement origin metadata, map all selection ranges, and keep source and stationary boundary cursors attached to their content. + +#### Scenario: Undo after typing and movement +- **WHEN** the user types and then moves a block +- **THEN** one undo SHALL restore the document and selection immediately before the move +- **AND** earlier typing SHALL remain until a separate undo + +#### Scenario: Multiple cursor ranges +- **WHEN** one block moves while several selection ranges exist +- **THEN** ranges in the moved source SHALL follow that source +- **AND** other ranges SHALL map through the movement while retaining the main selection + +#### Scenario: Host rejects the move +- **WHEN** the transaction pipeline rejects the movement transaction +- **THEN** no move history entry SHALL be created +- **AND** the plugin SHALL report that the block could not be moved diff --git a/openspec/changes/add-markdown-block-reordering/specs/editor-core/spec.md b/openspec/changes/add-markdown-block-reordering/specs/editor-core/spec.md new file mode 100644 index 00000000..fede22a4 --- /dev/null +++ b/openspec/changes/add-markdown-block-reordering/specs/editor-core/spec.md @@ -0,0 +1,39 @@ +## ADDED Requirements + +### Requirement: Prepare pending widget input before block movement + +Core SHALL expose `prepareBlockMove(view): boolean` for synchronous preparation of pending table input. Callers SHALL invoke it before blurring widgets and SHALL read movement positions from the resulting current state. It SHALL return true only after relevant pending input has been accepted exactly and movement-blocking widget interactions have ended. + +#### Scenario: Pending table input is accepted +- **WHEN** a table cell contains pending text and the host accepts the exact update +- **THEN** preparation SHALL commit that text through the normal transaction pipeline +- **AND** release editing locks so a subsequent movement uses the committed table source + +#### Scenario: Host rejects or changes the pending input +- **WHEN** the host rejects a table update or replaces it with different content +- **THEN** preparation SHALL return false +- **AND** the unaccepted pending input SHALL remain available in its widget +- **AND** the caller SHALL NOT move old table source + +#### Scenario: Equivalent transaction replacement +- **WHEN** the host replaces the transaction while preserving the exact requested table content +- **THEN** preparation SHALL treat the input as accepted +- **AND** equivalent transaction wrapping SHALL NOT unnecessarily block movement + +#### Scenario: Composition, active gesture, or stale source +- **WHEN** a table session has active composition, an unfinished mouse interaction, or stale source positions +- **THEN** preparation SHALL return false before using those positions to commit input +- **AND** the pending input SHALL remain available for completion or resolution + +#### Scenario: Independent editor views +- **WHEN** one editor has a blocked table session and another editor has no blocking session +- **THEN** preparing the second editor SHALL NOT read or change the first editor's pending input + +### Requirement: Preserve history metadata through transaction processing + +Core SHALL retain history isolation and history-inclusion annotations when reconstructing an existing CodeMirror transaction through its transaction pipeline. + +#### Scenario: An isolated movement passes through host hooks +- **WHEN** the transaction pipeline reconstructs an isolated block movement +- **THEN** the resulting transaction SHALL retain its history boundary +- **AND** adjacent typing or movement SHALL NOT be merged into that undo event diff --git a/openspec/changes/add-markdown-block-reordering/tasks.md b/openspec/changes/add-markdown-block-reordering/tasks.md new file mode 100644 index 00000000..4068a858 --- /dev/null +++ b/openspec/changes/add-markdown-block-reordering/tasks.md @@ -0,0 +1,51 @@ +## 1. Proposal and Review + +- [x] 1.1 Define independent first-version scope, core preparation contract, and native command ownership. +- [x] 1.2 Record contributor authorization for a prototype and disclose that code was primarily generated by AI. +- [ ] 1.3 Obtain maintainer approval for the proposal and contribution approach. +- [x] 1.4 Run strict OpenSpec validation. + +## 2. Implementation + +- [x] 2.1 Add optional package entry points for legacy and native lifecycle use. +- [x] 2.2 Implement outermost block discovery, raw-source movement, structural validation, and selection mapping. +- [x] 2.3 Implement pointer threshold, visual feedback, edge scrolling, cancellation, read-only/composition checks, and disposal. +- [x] 2.4 Add host-owned native commands and configurable default hotkeys without duplicate runtime keymaps. +- [x] 2.5 Add fail-closed preparation for table edits, preserving rejected input and guarding stale positions. +- [x] 2.6 Preserve history metadata through core transaction reconstruction. +- [x] 2.7 Wire reference host boot flags and Electron example integration. +- [x] 2.8 Document package use, preparation semantics, parser budgets, exclusions, and validation limits. + +## 3. Automated Verification + +- [x] 3.1 Verify supported block kinds, original formatting, leading/trailing whitespace, CRLF input, invalid indexes, stale snapshots, and cursor mapping. +- [x] 3.2 Verify list merges, unterminated fences, code absorption, and necessary separator insertion. +- [x] 3.3 Verify pending table acceptance, rejection, different/equivalent replacement, stale offsets, composition, active gestures, and view isolation. +- [x] 3.4 Verify pointer commit/cancellation, source/live-preview transactions, isolated undo/redo, and extension removal in DOM simulation. +- [x] 3.5 Verify runtime rebinding, cleared shortcuts, conflicts, active-editor routing, disable/re-enable, and detach. +- [x] 3.6 Run full repository tests, type checks, API checks, and package/demo builds. +- [x] 3.7 Repeat required checks after the final code changes. + +Recorded results: 72 test files / 1,043 tests passed, with type checks, API checks, package build, demo build, and strict OpenSpec validation passing. The [validation record](validation.md) records the final rerun and separates automated checks from pending manual acceptance. + +## 4. Browser Acceptance + +- [x] 4.1 Drag nine block variants in source and live-preview modes; verify beginning/end destinations and undo/redo. +- [x] 4.2 Verify Escape cancellation and movement at CSS zoom factors 0.75 and 1.5. +- [x] 4.3 Verify handle keyboard movement, retained focus, and consecutive Up/Down actions. +- [x] 4.4 Verify read-only handle suppression and plugin disable during an active gesture. +- [x] 4.5 Verify edge auto-scroll with 100 paragraphs and that scrolling stops after cancellation. +- [x] 4.6 Reject a table cell save, attempt movement, accept a retry, and verify that undoing the move retains the accepted cell edit. +- [x] 4.7 Verify release outside the editor and loss of actual browser pointer capture. +- [x] 4.8 Verify resizing, horizontal overflow, and folded-heading geometry in a real browser, including folding the source during a gesture. +- [x] 4.9 Verify browser-level default/rebound/cleared/conflicting shortcuts, current-editor routing, late attachment, and disable/re-enable. +- [x] 4.10 Verify the table interaction matrix with automated browser pointer input: click-to-edit, range selection, grip-click column selection, row/column grip-drag reorder, outside deselection, delete-key removal, and editing after whole-table movement. +- [ ] 4.11 Verify screen-reader announcements and OS-triggered pointer cancellation. Synthetic pointer cancellation is covered by DOM tests. + +## 5. Electron and Manual Acceptance + +- [x] 5.1 Verify legacy-mode drag, undo, Escape cancellation, default shortcuts, and moving an edited table with separate undo in the actual Electron demo. +- [x] 5.2 Verify native-runtime drag, undo, Escape cancellation, default shortcuts, and moving an edited table with separate undo in the actual Electron demo. +- [ ] 5.3 Verify Electron table rejection/retry, shortcut configuration, and cleanup scenarios. +- [ ] 5.4 Run native Chinese/Japanese OS IME and physical mouse/trackpad checks, including composition near drag start. +- [x] 5.5 Record exact browser/Electron versions and operating systems in `validation.md`. diff --git a/openspec/changes/add-markdown-block-reordering/validation.md b/openspec/changes/add-markdown-block-reordering/validation.md new file mode 100644 index 00000000..744f62c1 --- /dev/null +++ b/openspec/changes/add-markdown-block-reordering/validation.md @@ -0,0 +1,87 @@ +# Validation record + +This is a Draft contribution. The implementation, tests, and documentation were primarily generated by AI. Maintainer approval and the unchecked acceptance items in [tasks.md](tasks.md) remain pending. + +## Automated checks + +| Check | Recorded result | +| --- | --- | +| Repository Vitest suite | 72 files, 1,043 tests passed | +| Repository type checks | Passed | +| Public API checks | Passed | +| Package build | Passed | +| Electron demo build | Passed | +| Existing Electron multi-window smoke | Passed | +| Strict OpenSpec validation | Passed | + +These checks passed on the final code changes on 2026-09-17. The existing Electron multi-window smoke also passed. Manual acceptance items remain separate below. + +## Browser smoke + +The Playwright smoke script completed **92 document/scroll assertions** with screenshots enabled (91 without screenshots) in source and live-preview modes, with additional focus, visibility, rejection, and browser-error assertions. The final script-cleanup run also passed with screenshots enabled: **92 document/scroll assertions**, including the extra screenshot-path release check. It exercised: + +- Beginning/end movement, undo, and redo for paragraphs, headings, quotes, nested lists, fenced code, indented code, thematic breaks, tables, and image paragraphs. +- Escape cancellation, CSS zoom factors 0.75 and 1.5, retained handle focus, and consecutive Up/Down keyboard movement. +- Read-only handle suppression, disabling the plugin during a gesture, auto-scroll through 100 paragraphs, and stopping scroll after cancellation. +- Rejected table input remaining available, successful retry, and undoing movement independently of the accepted cell edit. + +The script uses a real headless Chrome renderer with automated pointer and keyboard input. It does not establish physical input-device or native OS IME behavior. Setting `BLOCK_DRAG_SCREENSHOT` also saves the active drag and completed layout, with one additional document assertion after release. + +The additional `edge-smoke.mjs` completed **20 document assertions** plus geometry and lifecycle checks. It covers release outside the editor, loss of actual browser pointer capture, resizing, folded headings, folding during a gesture, horizontal overflow, and the existing table interaction matrix (editing, range/column selection and deletion, outside deselection, row/column reorder, and editing after whole-table movement). This acceptance found a horizontal-scrolling defect: long source lines scrolled the block handle out of view. The handle and indicators now use the visible content edge, with unit and real-browser regression coverage. + +The `runtime-smoke.mjs` completed **40 assertions** using actual browser keyboard events. It covers default, rebound, cleared, and conflicting hotkeys; routing to the focused editor; late editor attachment; disabling and re-enabling the plugin; and retention of hotkey preferences. Independent command cases select their starting block explicitly. This does not establish continuous cursor preservation when switching between editors; see the known limitation below. + +### Captured UI + +![Active source highlight and insertion position](assets/dragging.png) + +![Document after moving the first paragraph to the end](assets/after.png) + +## Electron smoke + +The actual Electron demo passed drag, undo, Escape cancellation, default movement shortcuts, moving a table with pending cell input, and undoing the table movement while retaining the accepted edit in both legacy and native-runtime modes. The smoke script waits for the active editor's ready handle after deferred vault restoration. It uses temporary user data and hides only its own test window with background throttling disabled: physical pointer events otherwise interfered with the automated gesture and correctly triggered lost-capture cancellation. These checks use Electron's renderer and automated input; physical input acceptance remains separate. Broader Electron table rejection/retry and shortcut configuration acceptance remain pending. + +## Environment + +| Component | Version | +| --- | --- | +| Operating system | macOS 26.6.2, arm64 | +| Chrome | 152.0.7977.84 | +| Electron | 35.7.5 | +| Playwright | 1.55.0 | +| Node.js | 23.7.0 | +| pnpm | 9.15.4 | + +## Reproduction + +From the repository root, start the browser fixture server: + +```sh +apps/electron-demo/node_modules/.bin/vite --config vitest.config.ts \ + --host 127.0.0.1 --port 5178 +``` + +In a second terminal: + +```sh +node packages/plugin-block-drag/test/browser/smoke.mjs +node packages/plugin-block-drag/test/browser/edge-smoke.mjs +node packages/plugin-block-drag/test/browser/runtime-smoke.mjs +``` + +Playwright needs an installed Chromium browser. Optionally set `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` to a local Chrome/Chromium executable. `BLOCK_DRAG_URL` overrides the fixture origin. Rebuild before running the independent Electron script: + +```sh +pnpm build:electron-demo +node packages/plugin-block-drag/test/browser/electron-smoke.mjs +``` + +## Remaining acceptance + +Native Chinese/Japanese OS IME, physical mouse/trackpad use, OS-triggered pointer cancellation, and screen-reader output have not been manually verified. The browser table checks insert Unicode text but do not exercise a native input-method session. DOM tests cover composition and pointer cancellation but do not replace physical acceptance. Broader Electron table rejection/retry and shortcut configuration remain pending. Exact pending items are listed separately in [tasks.md](tasks.md). + +## Known baseline limitation + +In the two-editor browser fixture, moving a block Down/Up in the first editor, switching focus to the second editor, and moving its first block Down can reset the second editor's selection to position zero on the following frame. A consecutive Up command then has no preceding block to move past. The document move itself remains correct. + +The selection reset was reproduced three times with the plugin enabled, three times with it disabled, and three times in a bare CodeMirror `EditorView` without Nexus or its runtime. With CodeMirror view 6.41.0 and the Chrome version above, its DOM observer applies a selection change after the document update. This PR does not change that baseline behavior. Runtime routing checks explicitly select each command's intended source; the single-editor consecutive handle-key checks remain separate. diff --git a/package.json b/package.json index 18beea66..a86143bf 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "0.0.14", "packageManager": "pnpm@9.15.4", "scripts": { - "build": "pnpm --filter @floatboat/nexus-core build && pnpm --filter @floatboat/nexus-plugin-api build && pnpm --filter @floatboat/nexus-plugin-runtime build && pnpm --filter @floatboat/nexus-react build && pnpm --filter @floatboat/nexus-vue build && pnpm --filter @floatboat/nexus-preset-gfm build && pnpm --filter @floatboat/nexus-plugin-slash build && pnpm --filter @floatboat/nexus-plugin-history build && pnpm --filter @floatboat/nexus-plugin-search build && pnpm --filter @floatboat/nexus-plugin-toolbar build && pnpm --filter @floatboat/nexus-plugin-math build && pnpm --filter @floatboat/nexus-plugin-vim build && pnpm --filter @floatboat/nexus-plugin-wordcount build && pnpm --filter @floatboat/nexus-reference-plugins build", + "build": "pnpm --filter @floatboat/nexus-core build && pnpm --filter @floatboat/nexus-plugin-api build && pnpm --filter @floatboat/nexus-plugin-runtime build && pnpm --filter @floatboat/nexus-react build && pnpm --filter @floatboat/nexus-vue build && pnpm --filter @floatboat/nexus-preset-gfm build && pnpm --filter @floatboat/nexus-plugin-slash build && pnpm --filter @floatboat/nexus-plugin-history build && pnpm --filter @floatboat/nexus-plugin-search build && pnpm --filter @floatboat/nexus-plugin-toolbar build && pnpm --filter @floatboat/nexus-plugin-math build && pnpm --filter @floatboat/nexus-plugin-vim build && pnpm --filter @floatboat/nexus-plugin-wordcount build && pnpm --filter @floatboat/nexus-plugin-block-drag build && pnpm --filter @floatboat/nexus-reference-plugins build", "check:api": "pnpm --filter @floatboat/nexus-plugin-api check:api && pnpm --filter @floatboat/nexus-plugin-runtime check:api && pnpm --filter @floatboat/nexus-reference-plugins check:api", "typecheck": "pnpm -r exec tsc --noEmit", "test": "vitest run", @@ -20,11 +20,12 @@ "@types/react-dom": "^19.2.2", "@vue/test-utils": "^2.4.6", "jsdom": "^25.0.1", + "playwright": "1.55.0", "react": "^19.2.0", "react-dom": "^19.2.0", "tsup": "^8.5.0", "typescript": "^5.9.3", - "vue": "^3.5.22", - "vitest": "^2.1.9" + "vitest": "^2.1.9", + "vue": "^3.5.22" } } diff --git a/packages/core/README.md b/packages/core/README.md index 267f274e..acbce7c6 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -71,6 +71,34 @@ editor.on("selectionChange", ({ anchor, head, ranges, mainIndex }) => { Multiple ranges in `setSelections` require `multiCursor: true` — without the flag CodeMirror collapses the selection to its main range. +## Preparing document block moves + +CodeMirror extensions that reorder document blocks must call +`prepareBlockMove(view: EditorView): boolean` before blurring a table widget. +Table cells keep input locally while editing, so the document may still contain +their previous value. + +```ts +import { prepareBlockMove } from "@floatboat/nexus-core"; + +if (!prepareBlockMove(view)) return false; +// Read the current document and resolve block positions after preparation. +``` + +Preparation synchronously submits pending cell input through the host transaction +pipeline and releases editing locks only when the resulting document contains the +exact submitted text. It returns `false` during composition or a table mouse +gesture, when table offsets are stale, or when the host rejects or changes the +submission. On failure, do not blur, reorder, or discard the pending widget input. +A rejected submission can be retried after the host accepts edits; a stale session +requires the host to reconcile the document and local input before moving. + +This function may change the document and selection. Recompute block positions +after a successful call, and guard again if the document changes before the move. +Successful preparation refreshes table widgets, so also reacquire any table DOM references. +It protects built-in table sessions; custom widgets manage their own commit +protocols. It is a CodeMirror extension API, not a native plugin capability. + ## Other config highlights See the `EditorConfig` type for the full surface: `livePreview`, `plugins`, `theme` / `setTheme`, `locale`, `readOnly`, `tabSize`, `direction`, `indentGuides`, `parseDelayMs`, `slashMenuLimit`, `onChange` / `onFocus` / `onBlur` / `onAssetUpload`. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 11b2a643..0122d383 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,5 @@ export { createEditor } from "./editor"; +export { prepareBlockMove } from "./live-preview-table"; export { DynamicEditorContributionSink, EDITOR_PLUGIN_PRIORITY_MAX, diff --git a/packages/core/src/live-preview-table.ts b/packages/core/src/live-preview-table.ts index 29d9f7c4..c58d984c 100644 --- a/packages/core/src/live-preview-table.ts +++ b/packages/core/src/live-preview-table.ts @@ -1,4 +1,5 @@ import { EditorView, WidgetType, runScopeHandlers } from "@codemirror/view"; +import { StateEffect } from "@codemirror/state"; import type { Table } from "mdast"; import type { LivePreviewLabels } from "./types"; @@ -6,6 +7,8 @@ import { setInputTargetProvider } from "./input-target-provider"; const tableEditingCounts = new WeakMap(); +export const tableEditingFinished = StateEffect.define(); + export function isTableEditing(view: EditorView | null | undefined): boolean { return !!view && (tableEditingCounts.get(view) ?? 0) > 0; } @@ -21,12 +24,22 @@ function decrementTableEditing(view: EditorView): void { } interface TableEditSession { + readonly from: number; flush(): boolean; + canPrepareMove(): boolean; + prepareMove(): boolean; + finishMove(): void; finish(): void; } const tableEditSessions = new WeakMap>(); +function orderedTableEditSessions(view: EditorView): TableEditSession[] { + // Changes below a table cannot invalidate its source offsets. This also + // permits retrying several pending sessions after a host rejected their edits. + return Array.from(tableEditSessions.get(view) ?? []).sort((a, b) => b.from - a.from); +} + function registerTableEditSession(view: EditorView, session: TableEditSession): void { let sessions = tableEditSessions.get(view); if (!sessions) { @@ -44,7 +57,7 @@ function unregisterTableEditSession(view: EditorView, session: TableEditSession) } export function flushPendingTableEdits(view: EditorView, finishEditing = false): boolean { - const sessions = Array.from(tableEditSessions.get(view) ?? []); + const sessions = orderedTableEditSessions(view); let changed = false; let firstError: unknown; let hasError = false; @@ -74,6 +87,28 @@ export function flushPendingTableEdits(view: EditorView, finishEditing = false): return changed; } +/** + * Commit pending table input before a document block is moved. Returns false + * while an interaction is active or the host did not accept the exact input. + * Call before blurring widgets, then read block positions from the new state. + */ +export function prepareBlockMove(view: EditorView): boolean { + if (view.composing || view.compositionStarted) return false; + const sessions = orderedTableEditSessions(view); + if (sessions.some((session) => !session.canPrepareMove())) return false; + for (const session of sessions) { + if (!session.prepareMove()) return false; + } + for (const session of sessions) session.finishMove(); + if (sessions.length > 0) { + // Refresh all rendered offsets after the commits. An effects-only update + // also works when the host rejects selection transactions, and a canceled + // move leaves fully functional widgets instead of closed edit sessions. + view.dispatch({ effects: tableEditingFinished.of(), filter: false }); + } + return true; +} + // 表格方向键导航的调试日志,仅在显式开启 floatboat:markdown-debug 标记后输出。 // 用于在真实环境里定位"光标没跳格 / 飞到第一行"等时序问题。 function tableNavDebug(message: string, details?: Record): void { @@ -677,6 +712,8 @@ export class EditableTableWidget extends WidgetType { const dataLineIndices: number[] = []; for (let i = 0; i < sourceLines.length; i++) if (!SEPARATOR_RE.test(sourceLines[i])) dataLineIndices.push(i); const dirtyRows = new Map(); + const composingCells = new Set(); + const snapshotCellSources = new Map void>(); // State let selectedCol = -1; @@ -699,6 +736,7 @@ export class EditableTableWidget extends WidgetType { range: false, drag: false, nativeSelection: false, + pending: false, }; const editingLockViews: Partial> = {}; let pendingNativeTextSelection: PendingNativeTextSelection | null = null; @@ -710,15 +748,50 @@ export class EditableTableWidget extends WidgetType { let removeInputTargetProvider: (() => void) | null = null; const tableEditSession: TableEditSession = { + get from() { return self.tableFrom; }, flush: () => syncDirtyRowsToDocument(), + canPrepareMove: () => { + const view = self.viewRef.current; + return !sessionClosed && wrapper.isConnected && !!view && + composingCells.size === 0 && !cellMouseDown && !isRangeSelecting && + !editingLocks.drag && !editingLocks.nativeSelection && + currentDocumentContainsOriginalTable(view); + }, + prepareMove: () => { + if (!tableEditSession.canPrepareMove()) return false; + for (const [cell, snapshot] of snapshotCellSources) { + if (cell.contentEditable === "true") snapshot(); + } + syncDirtyRowsToDocument(); + return dirtyRows.size === 0; + }, + finishMove: () => { + if (sessionClosed) return; + self.reusable = false; + clearRangeSelection(); + clearSelection(); + clearPendingNativeTextSelection(); + const active = wrapper.ownerDocument.activeElement; + if (active instanceof HTMLElement && wrapper.contains(active)) { + active.blur(); + active.contentEditable = "false"; + } + releaseEditingLock("focus"); + }, finish: () => { self.reusable = false; self.cleanupEditingLocks?.(); + const active = wrapper.ownerDocument.activeElement; + if (active instanceof HTMLElement && wrapper.contains(active)) { + active.blur(); + active.contentEditable = "false"; + } }, }; function hasEditingLocks(): boolean { - return editingLocks.focus || editingLocks.range || editingLocks.drag || editingLocks.nativeSelection; + return editingLocks.focus || editingLocks.range || editingLocks.drag || + editingLocks.nativeSelection || editingLocks.pending; } function updateSessionRegistration(): void { @@ -765,6 +838,9 @@ export class EditableTableWidget extends WidgetType { releaseEditingLock("focus"); releaseEditingLock("range"); releaseEditingLock("drag"); + releaseEditingLock("pending"); + composingCells.clear(); + snapshotCellSources.clear(); clearPendingNativeTextSelection(); if (registeredSessionView) { unregisterTableEditSession(registeredSessionView, tableEditSession); @@ -781,6 +857,9 @@ export class EditableTableWidget extends WidgetType { function rememberDirtyRow(lineIdx: number | undefined, row: HTMLElement): void { if (sessionClosed || lineIdx === undefined) return; dirtyRows.set(lineIdx, row); + // Keep rejected input alive even after the focused cell blurs. Without + // this lock a selection transaction can recreate the widget from old text. + acquireEditingLock("pending"); updateSessionRegistration(); } @@ -869,6 +948,7 @@ export class EditableTableWidget extends WidgetType { function clearDirtyRows(): void { dirtyRows.clear(); + releaseEditingLock("pending"); updateSessionRegistration(); } @@ -892,10 +972,7 @@ export class EditableTableWidget extends WidgetType { function syncDirtyRowsToDocument(): boolean { const v = self.viewRef.current; if (!v || dirtyRows.size === 0) return false; - if (!currentDocumentContainsOriginalTable(v)) { - clearDirtyRows(); - return false; - } + if (composingCells.size > 0 || !currentDocumentContainsOriginalTable(v)) return false; const snapshot = dirtySourceSnapshot(); if (!snapshot.changed) { @@ -906,7 +983,9 @@ export class EditableTableWidget extends WidgetType { const anchorLineIdx = snapshot.firstChangedLineIdx ?? 0; const anchor = lineStartOffset(sourceLines, anchorLineIdx, self.tableFrom); if (snapshot.firstChangedRow) restoreRowScrollPosition(anchorLineIdx, snapshot.firstChangedRow); - clearDirtyRows(); + const documentBefore = v.state.doc; + const expectedDocument = documentBefore.sliceString(0, self.tableFrom) + snapshot.source + + documentBefore.sliceString(self.tableFrom + self.source.length); v.dispatch({ changes: { from: self.tableFrom, @@ -915,7 +994,18 @@ export class EditableTableWidget extends WidgetType { }, selection: { anchor, head: anchor } }); + // Dispatch can be rejected or replaced by a host transaction filter. + // Only release the local input when the committed document matches it. + if (v.state.doc.toString() !== expectedDocument) return false; + self.source = snapshot.source; + sourceLines.splice(0, sourceLines.length, ...snapshot.source.split("\n")); + self.reusable = false; + clearDirtyRows(); v.requestMeasure(); + if (!self.editing) { + const selection = v.state.selection; + v.dispatch({ selection }); + } return true; } @@ -2094,6 +2184,7 @@ export class EditableTableWidget extends WidgetType { enterRawEditingMode(); }); td.addEventListener("blur", () => { + if (sessionClosed) return; // input/compositionend can be followed immediately by blur. Snapshot // before swapping raw DOM back to rich rendering so the final IME // candidate (or last ordinary keystroke) cannot be overwritten. @@ -2139,6 +2230,7 @@ export class EditableTableWidget extends WidgetType { // with the up-to-date AST. `queueMicrotask` lets the blur // settle before we re-enter CM6. queueMicrotask(() => { + if (sessionClosed) return; // 方向键在单元格间导航时,跳过这个 selection 派发:它会把焦点/光标抢回 // CM 文档选区(常是第 0 行),让光标"飞到第一行"而非落在目标单元格。 if (navigatingBetweenCells) { @@ -2162,6 +2254,7 @@ export class EditableTableWidget extends WidgetType { tableNavDebug("blur-dispatch:committed", { active: describeActiveCell() }); return; } + if (dirtyRows.size > 0 || sessionClosed) return; const v = self.viewRef.current; if (!v) return; const sel = v.state.selection.main; @@ -2175,10 +2268,13 @@ export class EditableTableWidget extends WidgetType { }); function rememberCellSourceEdit(): void { - if (sourceLineIdx === undefined) return; - td.dataset.source = td.textContent ?? ""; + if (sessionClosed || sourceLineIdx === undefined) return; + const source = td.textContent ?? ""; + if (source === td.dataset.source && !dirtyRows.has(sourceLineIdx)) return; + td.dataset.source = source; rememberDirtyRow(sourceLineIdx, tr); } + snapshotCellSources.set(td, rememberCellSourceEdit); const queueCompositionSourceSnapshot = (): void => { if (compositionSourceQueued) return; @@ -2198,6 +2294,7 @@ export class EditableTableWidget extends WidgetType { td.addEventListener("compositionstart", (event) => { event.stopPropagation(); cellComposing = true; + composingCells.add(td); acquireEditingLock("focus"); }, true); @@ -2208,6 +2305,7 @@ export class EditableTableWidget extends WidgetType { td.addEventListener("compositionend", (event) => { event.stopPropagation(); cellComposing = false; + composingCells.delete(td); rememberCellSourceEdit(); // 浏览器会在 compositionend 前后把候选词提交进 contentEditable。 // 延后一拍读取 TD,只更新待提交源码,避免输入阶段重绘长表格导致失焦。 diff --git a/packages/core/src/live-preview.ts b/packages/core/src/live-preview.ts index 24c55625..a19655d9 100644 --- a/packages/core/src/live-preview.ts +++ b/packages/core/src/live-preview.ts @@ -14,7 +14,7 @@ import { import { createLivePreviewDiagnostics } from "./live-preview-diag"; import { collectLivePreviewRanges, selectionIntersects, selectionOnSameLine } from "./live-preview-ranges"; import { renderLivePreviewNode } from "./live-preview-renderers"; -import { EditableTableWidget, isTableEditing } from "./live-preview-table"; +import { EditableTableWidget, isTableEditing, tableEditingFinished } from "./live-preview-table"; import type { LivePreviewConfig, LivePreviewLabels, @@ -1357,6 +1357,9 @@ export function createLivePreviewExtension( if (isTableEditing(viewRef.current)) { return tr.docChanged ? decos.map(tr.changes) : decos; } + if (tr.effects.some((effect) => effect.is(tableEditingFinished))) { + return build(tr.state, tr.state.selection.ranges, false); + } if (tr.effects.some((effect) => effect.is(rebuildForCompositionStart))) { compositionActive = true; return build(tr.state, tr.state.selection.ranges, true); diff --git a/packages/core/src/transaction-pipeline.ts b/packages/core/src/transaction-pipeline.ts index 7b4d089c..6813b2ce 100644 --- a/packages/core/src/transaction-pipeline.ts +++ b/packages/core/src/transaction-pipeline.ts @@ -6,6 +6,7 @@ import { type TransactionSpec, } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; +import { isolateHistory } from "@codemirror/commands"; import type { CoreEditorChange, @@ -114,10 +115,20 @@ function stableHookOrder(entries: Iterable>): HookEntry[] { .sort((a, b) => b.priority - a.priority || a.sequence - b.sequence); } -function toTransactionSpec(transaction: CoreEditorTransaction): TransactionSpec { - const annotations = transaction.origin.length > 0 - ? [editorTransactionOrigin.of(Object.freeze([...transaction.origin]))] - : undefined; +function historyAnnotations(transaction: Transaction): Annotation[] { + const annotations: Annotation[] = []; + const isolation = transaction.annotation(isolateHistory); + if (isolation !== undefined) annotations.push(isolateHistory.of(isolation)); + const addToHistory = transaction.annotation(Transaction.addToHistory); + if (addToHistory !== undefined) annotations.push(Transaction.addToHistory.of(addToHistory)); + return annotations; +} + +function toTransactionSpec(transaction: CoreEditorTransaction, previous?: Transaction): TransactionSpec { + const annotations = previous ? historyAnnotations(previous) : []; + if (transaction.origin.length > 0) { + annotations.push(editorTransactionOrigin.of(Object.freeze([...transaction.origin]))); + } return { changes: transaction.changes.map((change) => ({ from: change.from, @@ -253,13 +264,15 @@ export class CoreEditorTransactionPipeline implements EditorTransactionContribut for (const origin of transaction.annotation(editorTransactionOrigin) ?? []) { if (origins[origins.length - 1] !== origin) origins.push(origin); } + const annotations = historyAnnotations(transaction); + if (index === transactions.length - 1 && origins.length > 0) { + annotations.push(editorTransactionOrigin.of(Object.freeze(origins))); + } return { changes: transaction.changes, selection: transaction.selection, effects: transaction.effects, - annotations: index === transactions.length - 1 && origins.length > 0 - ? editorTransactionOrigin.of(Object.freeze(origins)) - : undefined, + annotations, scrollIntoView: transaction.scrollIntoView, userEvent: transaction.annotation(Transaction.userEvent), sequential: index > 0, @@ -307,7 +320,7 @@ export class CoreEditorTransactionPipeline implements EditorTransactionContribut } if (result.action === "replace") { transaction = transaction.startState.update({ - ...toTransactionSpec(inheritReplacement(context, result.transaction)), + ...toTransactionSpec(inheritReplacement(context, result.transaction), transaction), filter: false, }); context = makeContext(this.host.editor, transaction); diff --git a/packages/core/test/prepare-block-move.test.ts b/packages/core/test/prepare-block-move.test.ts new file mode 100644 index 00000000..cfecd0bc --- /dev/null +++ b/packages/core/test/prepare-block-move.test.ts @@ -0,0 +1,239 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { EditorView, ViewPlugin } from "@codemirror/view"; + +import { createGfmPreset } from "../../preset-gfm/src/index"; +import { createEditor, prepareBlockMove, type EditorAPI } from "../src/index"; +import { isTableEditing } from "../src/live-preview-table"; + +const tableSource = "| A | B |\n| --- | --- |\n| 1 | old |"; +const source = `${tableSource}\n\nAfter`; +const cleanups: Array<() => void> = []; + +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); +}); + +function createTableEditor(initialValue = source): { editor: EditorAPI; view: EditorView; cell: HTMLElement } { + const container = document.createElement("div"); + document.body.appendChild(container); + let capturedView: EditorView | undefined; + const editor = createEditor({ + container, + initialValue, + livePreview: true, + plugins: [createGfmPreset(), { + name: "capture-view", + cmExtensions: [ViewPlugin.fromClass(class { + constructor(view: EditorView) { capturedView = view; } + })], + }], + }); + cleanups.push(() => { editor.destroy(); container.remove(); }); + const cell = container.querySelectorAll(".nexus-cell")[3]; + if (!capturedView || !cell) throw new Error("Expected an editable table"); + return { editor, view: capturedView, cell }; +} + +function editCell(cell: HTMLElement, text: string): void { + document.getSelection()?.removeAllRanges(); + cell.dispatchEvent(new MouseEvent("mousedown", { + bubbles: true, cancelable: true, button: 0, clientX: 80, clientY: 40, + })); + document.dispatchEvent(new MouseEvent("mouseup", { + bubbles: true, button: 0, clientX: 80, clientY: 40, + })); + cell.textContent = text; + cell.dispatchEvent(new Event("input", { bubbles: true })); +} + +async function blurCell(cell: HTMLElement): Promise { + // jsdom does not focus contentEditable table cells, so HTMLElement.blur() + // alone does not emit the browser event that commits their local input. + cell.dispatchEvent(new Event("blur")); + await Promise.resolve(); +} + +describe("prepareBlockMove", () => { + it.each(["cancel", "reject-selection", "reject-move"])("keeps tables editable after preparation without a move (%s)", async (scenario) => { + const { editor, view, cell } = createTableEditor(); + editCell(cell, "saved"); + if (scenario !== "cancel") { + editor.getContributionSink().registerTransactionFilter("guard", (context) => { + // A widget refresh must not depend on an accepted selection transaction. + if (context.changes.length === 0 || context.userEvent === "move.block") { + return { action: "reject" }; + } + return { action: "accept" }; + }); + } + expect(prepareBlockMove(view)).toBe(true); + if (scenario === "reject-move") { + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: `After\n\n${tableSource.replace("old", "saved")}` }, + userEvent: "move.block", + }); + } + expect(editor.getDocument()).toBe(source.replace("old", "saved")); + const current = view.dom.querySelectorAll(".nexus-cell")[3]; + expect(current.isConnected).toBe(true); + editCell(current, "second edit"); + await blurCell(current); + expect(editor.getDocument()).toBe(source.replace("old", "second edit")); + expect(isTableEditing(view)).toBe(false); + }); + + it.each([false, true])("commits multiple pending tables without invalidating offsets (reverse focus: %s)", async (reverseFocus) => { + const initialValue = `${source}\n\n${tableSource}\n\nEnd`; + const { editor, view, cell } = createTableEditor(initialValue); + const second = view.dom.querySelectorAll(".nexus-cell")[7]; + let reject = true; + editor.getContributionSink().registerTransactionFilter("guard", (context) => + reject && context.changes.length > 0 ? { action: "reject" } : { action: "accept" } + ); + const pending: Array<[HTMLElement, string]> = [[cell, "first longer edit"], [second, "second edit"]]; + if (reverseFocus) pending.reverse(); + for (const [target, text] of pending) { + editCell(target, text); + await blurCell(target); + } + reject = false; + expect(prepareBlockMove(view)).toBe(true); + expect(editor.getDocument()).toBe( + `${source.replace("old", "first longer edit")}\n\n${tableSource.replace("old", "second edit")}\n\nEnd` + ); + expect(isTableEditing(view)).toBe(false); + + const currentSecond = view.dom.querySelectorAll(".nexus-cell")[7]; + editCell(currentSecond, "edited again after cancellation"); + await blurCell(currentSecond); + expect(editor.getDocument()).toContain("edited again after cancellation"); + expect(isTableEditing(view)).toBe(false); + }); + + it("preserves the source formatting of an unchanged focused table", () => { + const initialValue = "A | B\n--- | ---\n1 | old\n\nAfter"; + const { editor, view, cell } = createTableEditor(initialValue); + editCell(cell, "old"); + expect(prepareBlockMove(view)).toBe(true); + expect(editor.getDocument()).toBe(initialValue); + }); + + it("commits the focused cell before blur and releases the widget for a move", async () => { + const { editor, view, cell } = createTableEditor(); + editCell(cell, "saved"); + expect(editor.getDocument()).toBe(source); + expect(prepareBlockMove(view)).toBe(true); + expect(editor.getDocument()).toBe(source.replace("old", "saved")); + expect(isTableEditing(view)).toBe(false); + await Promise.resolve(); + + const moved = `After\n\n${tableSource.replace("old", "saved")}`; + view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: moved } }); + expect(editor.getDocument()).toBe(moved); + expect(view.dom.querySelectorAll(".nexus-table-wrapper")).toHaveLength(1); + expect(view.dom.textContent).toContain("saved"); + }); + + it("preserves rejected input across blur and selection changes and allows a retry", async () => { + const { editor, view, cell } = createTableEditor(); + let reject = true; + editor.getContributionSink().registerTransactionFilter("guard", (context) => + reject && context.changes.length > 0 ? { action: "reject" } : { action: "accept" } + ); + editCell(cell, "UNSAVED"); + expect(prepareBlockMove(view)).toBe(false); + await blurCell(cell); + editor.setSelection(source.length); + expect(editor.getDocument()).toBe(source); + expect(cell.isConnected).toBe(true); + expect(cell.textContent).toBe("UNSAVED"); + expect(isTableEditing(view)).toBe(true); + + reject = false; + expect(prepareBlockMove(view)).toBe(true); + expect(editor.getDocument()).toBe(source.replace("old", "UNSAVED")); + expect(isTableEditing(view)).toBe(false); + }); + + it("fails closed when a host replaces the pending input with different content", () => { + const { editor, view, cell } = createTableEditor(); + editor.getContributionSink().registerTransactionFilter("normalizer", (context) => { + if (context.changes.length === 0) return { action: "accept" }; + return { + action: "replace", + transaction: { + changes: context.changes.map((change) => ({ + ...change, insert: change.insert.replace("pending", "replacement"), + })), + origin: ["normalizer"], + }, + }; + }); + editCell(cell, "pending"); + expect(prepareBlockMove(view)).toBe(false); + expect(editor.getDocument()).toBe(source.replace("old", "replacement")); + expect(cell.isConnected).toBe(true); + expect(cell.textContent).toBe("pending"); + expect(isTableEditing(view)).toBe(true); + expect(prepareBlockMove(view)).toBe(false); + }); + + it("accepts equivalent replacement transactions", () => { + const { editor, view, cell } = createTableEditor(); + editor.getContributionSink().registerTransactionFilter("audit", (context) => ({ + action: "replace", + transaction: { changes: context.changes, selection: context.selectionAfter, origin: ["audit"] }, + })); + editCell(cell, "accepted"); + expect(prepareBlockMove(view)).toBe(true); + expect(editor.getDocument()).toBe(source.replace("old", "accepted")); + }); + + it("does not commit an IME candidate and accepts the completed text", () => { + const { editor, view, cell } = createTableEditor(); + editCell(cell, ""); + cell.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + cell.textContent = "zhong"; + expect(prepareBlockMove(view)).toBe(false); + expect(editor.getDocument()).toBe(source); + expect(cell.textContent).toBe("zhong"); + + cell.textContent = "中文"; + cell.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true, data: "中文" })); + expect(prepareBlockMove(view)).toBe(true); + expect(editor.getDocument()).toBe(source.replace("old", "中文")); + }); + + it("does not apply pending input at stale table offsets", () => { + const { editor, view, cell } = createTableEditor(); + editCell(cell, "pending"); + view.dispatch({ changes: { from: 0, insert: "New paragraph\n\n" } }); + const changed = editor.getDocument(); + expect(prepareBlockMove(view)).toBe(false); + expect(editor.getDocument()).toBe(changed); + expect(cell.textContent).toBe("pending"); + expect(isTableEditing(view)).toBe(true); + }); + + it("blocks movement until a table mouse gesture finishes", () => { + const { view, cell } = createTableEditor(); + cell.dispatchEvent(new MouseEvent("mousedown", { + bubbles: true, cancelable: true, button: 0, clientX: 80, clientY: 40, + })); + expect(prepareBlockMove(view)).toBe(false); + document.dispatchEvent(new MouseEvent("mouseup", { + bubbles: true, button: 0, clientX: 80, clientY: 40, + })); + expect(prepareBlockMove(view)).toBe(true); + }); + + it("keeps table sessions isolated between editor views", () => { + const first = createTableEditor(); + const second = createTableEditor(); + editCell(first.cell, "pending"); + first.cell.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + expect(prepareBlockMove(second.view)).toBe(true); + expect(first.editor.getDocument()).toBe(source); + expect(prepareBlockMove(first.view)).toBe(false); + }); +}); diff --git a/packages/core/test/transaction-pipeline.test.ts b/packages/core/test/transaction-pipeline.test.ts index 7c55e26d..e2916df7 100644 --- a/packages/core/test/transaction-pipeline.test.ts +++ b/packages/core/test/transaction-pipeline.test.ts @@ -1,6 +1,6 @@ import { EditorSelection, Transaction } from "@codemirror/state"; import { EditorView, ViewPlugin } from "@codemirror/view"; -import { history } from "@codemirror/commands"; +import { history, isolateHistory } from "@codemirror/commands"; import { describe, expect, it, vi } from "vitest"; import { @@ -40,6 +40,55 @@ function transaction( } describe("core editor transaction pipeline", () => { + it.each([false, true])("preserves undo boundaries through a host replacement (batch: %s)", async (batch) => { + const { editor, view } = createTestEditor("ab"); + await editor.getContributionSink().registerExtension("history", history()).ready; + editor.getContributionSink().registerTransactionFilter("audit", (context) => { + if (context.userEvent !== "input.type") return { action: "accept" }; + return { + action: "replace", + transaction: { + changes: context.changes, + selection: context.selectionAfter, + origin: ["audit"], + }, + }; + }); + view.dispatch({ changes: { from: 1, insert: "x" }, userEvent: "input.type" }); + const change = view.state.update({ + changes: { from: 2, insert: "y" }, + annotations: isolateHistory.of("full"), + userEvent: "input.type", + }); + if (batch) view.dispatch([change, change.state.update({ selection: { anchor: 3 } })]); + else view.dispatch(change); + view.dispatch({ changes: { from: 3, insert: "z" }, userEvent: "input.type" }); + expect(editor.getDocument()).toBe("axyzb"); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("axyb"); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("axb"); + expect(editor.redo()).toBe(true); + expect(editor.getDocument()).toBe("axyb"); + editor.destroy(); + }); + + it("preserves addToHistory through replacement filters", async () => { + const { editor, view } = createTestEditor("ab"); + await editor.getContributionSink().registerExtension("history", history()).ready; + editor.getContributionSink().registerTransactionFilter("audit", (context) => ({ + action: "replace", + transaction: { changes: context.changes, origin: ["audit"] }, + })); + view.dispatch({ + changes: { from: 1, insert: "untracked" }, + annotations: Transaction.addToHistory.of(false), + }); + expect(editor.getDocument()).toBe("auntrackedb"); + expect(editor.undo()).toBe(false); + editor.destroy(); + }); + it("filters before commit and only notifies listeners with the final update", () => { const { editor } = createTestEditor(); const sink = editor.getContributionSink(); diff --git a/packages/plugin-block-drag/README.md b/packages/plugin-block-drag/README.md new file mode 100644 index 00000000..ac07d838 --- /dev/null +++ b/packages/plugin-block-drag/README.md @@ -0,0 +1,97 @@ +# @floatboat/nexus-plugin-block-drag + +Optional Markdown block reordering for Nexus-Editor. A margin handle moves one complete outermost block in source or live-preview mode. The package has no workspace, vault, or Electron dependency, and core does not enable it automatically. + +This package is being proposed for issue [#332](https://github.com/floatboatai/Nexus-Editor/issues/332). The implementation, tests, and documentation were primarily generated by AI. The contribution remains a Draft pending maintainer review and the outstanding checks in the [validation record](../../openspec/changes/add-markdown-block-reordering/validation.md). + +## Editor integration + +```ts +import { createEditor } from "@floatboat/nexus-core"; +import { createBlockDragPlugin } from "@floatboat/nexus-plugin-block-drag"; +import { createHistoryPlugin } from "@floatboat/nexus-plugin-history"; + +const editor = createEditor({ + container: document.getElementById("editor")!, + initialValue: "# Notes\n\nFirst paragraph\n\nSecond paragraph", + plugins: [createHistoryPlugin(), createBlockDragPlugin()], +}); +``` + +Install history support when undo/redo is required. Each block movement is isolated from preceding typing and other moves. The plugin's built-in styles provide the handle and indicators; the host can override their classes and Nexus theme variables. + +`createBlockDragExtension(options?)` returns the underlying CodeMirror extension for hosts that manage extensions directly. `createBlockDragPlugin(options?)` wraps it as a legacy `NexusPlugin`. + +| Option | Default | Meaning | +| --- | --- | --- | +| `keyboard` | `true` | Install local `Alt+Shift+ArrowUp/ArrowDown` bindings. Set false when the host owns global movement commands. | +| `label` | `"Move block"` | Handle label and tooltip. | +| `movedMessage` | `"Block moved"` | Successful movement status text. | +| `blockedMessage` | `"Block could not be moved"` | Unavailable or rejected movement status text. | + +The focused handle also accepts Arrow Up/Down to move its block and Escape to return focus to the editor. Touch pointers are ignored. Dragging starts after a small movement threshold, and the document changes on release. Escape, pointer cancellation, lost capture, window blur, document changes, and disposal cancel a pending gesture. + +## Native plugin runtime + +The package exports `BlockDragLifecyclePlugin` and `blockDragLifecyclePluginManifest` for a host-controlled manifest/constructor registration. The lifecycle plugin requires the commands and editor-host capabilities. It uses public plugin APIs and installs its editor extension with local global hotkeys disabled. + +| Command ID | Default shortcut | +| --- | --- | +| `block-drag:move-up` | `Alt+Shift+ArrowUp` | +| `block-drag:move-down` | `Alt+Shift+ArrowDown` | + +The host command service owns rebinding, clearing, and conflict arbitration. Availability checks do not change document content or flush pending table input. Commands use the current editor context. Disabling the plugin stops gestures and removes its extensions and commands; re-enabling retains the host's saved hotkey preferences. + +Reference hosts can opt in with `blockDrag: true` in `createReferencePluginBootPlan`. Its `pluginPlatform` flag selects either the legacy factory or the lifecycle plugin; use one owner per editor. + +## Movement guarantees + +Supported sources are outermost paragraphs, headings, complete lists, blockquotes, fenced or indented code blocks, GFM tables, and thematic breaks. Images move with their paragraph. Nested content travels with its outermost container. Link definitions, HTML blocks, and custom unsupported nodes remain intact and are not offered as draggable sources. + +The planner retains the original block source, including markers, indentation, table syntax, and trailing spaces. It reuses existing separators and adds blank lines only where needed. It validates the complete result with the editor's active Markdown parser, including every block's nested structure. A move that would merge independent lists or make an unclosed fence swallow content is rejected. Selection ranges follow the corresponding source positions; they do not expand the operation into a multi-block move. + +Complete block discovery uses a 25 ms parser budget. Candidate verification shares a 50 ms parser budget and runs when a target gap changes and at release. Incomplete or over-budget parsing produces a safe no-op; large documents may temporarily have no available movement. These are parser budgets, not hard timing guarantees for the entire interaction. + +Read-only editors, active composition, hidden blocks, and folded headings cannot be movement sources. Expand a folded heading before moving it. Touch dragging, grouped multi-block movement, cross-document moves, list-item sorting/nesting, and whole-section movement are outside this version's scope. + +## Pending table input + +Core provides `prepareBlockMove(view): boolean` for callers that move source around widgets. The plugin calls it before taking movement positions and before pointer commit. + +Preparation snapshots pending table cell input before blur and sends it through the host transaction pipeline. It returns true only when the exact input was accepted and blocking interactions have finished. Rejected input, replacement with different content, active composition or mouse gestures, and stale table positions block movement. Unaccepted input remains in the widget for retry or resolution. An equivalent transaction replacement is accepted. + +Callers must stop when preparation returns false and must obtain positions from the current state after it returns true. The plugin also compares block count, block-type order, and the unchanged raw text of non-table blocks after preparation. If an accepted table edit changes that structure, it keeps the accepted input and cancels movement until the user retries, so an old block index cannot select another block. A failed preparation does not roll back changes already accepted by the host. An accepted cell edit and the subsequent block move have separate undo boundaries. + +## Validation + +```sh +pnpm vitest run packages/plugin-block-drag/test \ + packages/core/test/prepare-block-move.test.ts \ + packages/core/test/transaction-pipeline.test.ts +pnpm --filter @floatboat/nexus-plugin-block-drag exec tsc --noEmit +pnpm --filter @floatboat/nexus-plugin-block-drag build +``` + +Run the browser fixture server from the repository root: + +```sh +apps/electron-demo/node_modules/.bin/vite --config vitest.config.ts \ + --host 127.0.0.1 --port 5178 +``` + +In another terminal, run the browser smoke test. Playwright must have a Chromium installation; optionally set `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` to a local Chrome/Chromium executable. `BLOCK_DRAG_URL` overrides the default `http://127.0.0.1:5178` fixture origin. + +```sh +node packages/plugin-block-drag/test/browser/smoke.mjs +node packages/plugin-block-drag/test/browser/edge-smoke.mjs +node packages/plugin-block-drag/test/browser/runtime-smoke.mjs +``` + +Run the Electron smoke test against a fresh demo build: + +```sh +pnpm build:electron-demo +node packages/plugin-block-drag/test/browser/electron-smoke.mjs +``` + +Automated coverage includes source preservation, unsafe boundaries, selections, rejected table edits, history, and runtime command ownership. The browser scripts exercise real rendering, pointer and keyboard input, CSS zoom, scrolling, folding, table interactions, and runtime shortcut preferences. Native OS IME, physical input devices, and screen-reader output still require manual acceptance. See the [validation record](../../openspec/changes/add-markdown-block-reordering/validation.md) for exact evidence and known limitations, and the [checklist](../../openspec/changes/add-markdown-block-reordering/tasks.md) for remaining work. diff --git a/packages/plugin-block-drag/package.json b/packages/plugin-block-drag/package.json new file mode 100644 index 00000000..7f179cbf --- /dev/null +++ b/packages/plugin-block-drag/package.json @@ -0,0 +1,34 @@ +{ + "name": "@floatboat/nexus-plugin-block-drag", + "version": "0.0.14", + "description": "Optional Markdown block reordering for Nexus-Editor.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsup src/index.ts --format esm --dts --clean" + }, + "dependencies": { + "@codemirror/commands": "^6.10.3", + "@codemirror/language": "^6.12.3", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.41.0", + "@floatboat/nexus-core": "workspace:*", + "@floatboat/nexus-plugin-api": "workspace:*", + "@lezer/common": "^1.5.1" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/plugin-block-drag/src/extension.ts b/packages/plugin-block-drag/src/extension.ts new file mode 100644 index 00000000..fd42d02f --- /dev/null +++ b/packages/plugin-block-drag/src/extension.ts @@ -0,0 +1,637 @@ +import { isolateHistory } from "@codemirror/commands"; +import { foldedRanges, syntaxTree } from "@codemirror/language"; +import type { Extension, Text } from "@codemirror/state"; +import { + Direction, + EditorView, + ViewPlugin, + keymap, + type ViewUpdate, +} from "@codemirror/view"; +import { editorTransactionOrigin, prepareBlockMove } from "@floatboat/nexus-core"; + +import { planBlockMove, readBlocks, type BlockMove, type MarkdownBlock } from "./model"; + +export interface BlockDragOptions { + /** Disable for hosts whose command service owns keyboard bindings. */ + readonly keyboard?: boolean; + readonly label?: string; + readonly movedMessage?: string; + readonly blockedMessage?: string; +} + +interface Point { + readonly x: number; + readonly y: number; +} + +interface Gesture { + readonly pointerId: number; + readonly start: Point; + readonly document: Text; + readonly blocks: readonly MarkdownBlock[]; + readonly source: number; + active: boolean; + target: number; + plan: BlockMove | null; +} + +interface Layout { + readonly root: DOMRect; + readonly scroll: DOMRect; + readonly content: DOMRect; + readonly scrollLeft: number; + readonly source: { top: number; bottom: number } | null; + readonly block: MarkdownBlock | null; + readonly destination: number | null; +} + +const GUTTER_WIDTH = 28; +const HANDLE_WIDTH = 24; +const HANDLE_HEIGHT = 26; +const HANDLE_INSET = 1; +const DRAG_THRESHOLD = 5; +const AUTO_SCROLL_EDGE = 40; +const AUTO_SCROLL_MAX_STEP = 16; + +const controllers = new WeakMap(); + +class BlockDragController { + private readonly ownerDocument: Document; + private readonly ownerWindow: Window; + private readonly overlay: HTMLDivElement; + private readonly handle: HTMLButtonElement; + private readonly highlight: HTMLDivElement; + private readonly indicator: HTMLDivElement; + private readonly status: HTMLDivElement; + private readonly disposers: Array<() => void> = []; + private blocks: readonly MarkdownBlock[]; + private hovered: MarkdownBlock | null = null; + private point: Point | null = null; + private gesture: Gesture | null = null; + private frame: number | null = null; + private stopped = false; + private focusHandleAfterLayout = false; + + constructor( + private readonly view: EditorView, + private readonly options: BlockDragOptions, + ) { + this.ownerDocument = view.dom.ownerDocument; + this.ownerWindow = this.ownerDocument.defaultView!; + this.blocks = readBlocks(view.state); + this.overlay = this.createElement("nexus-block-drag"); + this.handle = this.ownerDocument.createElement("button"); + this.handle.type = "button"; + this.handle.className = "nexus-block-drag-handle"; + this.handle.textContent = "⠿"; + this.handle.title = options.label ?? "Move block"; + this.handle.setAttribute("aria-label", this.handle.title); + this.handle.hidden = true; + this.highlight = this.createElement("nexus-block-drag-highlight"); + this.indicator = this.createElement("nexus-block-drag-indicator"); + this.highlight.hidden = this.indicator.hidden = true; + this.status = this.createElement("nexus-block-drag-status"); + this.status.setAttribute("role", "status"); + this.overlay.append(this.highlight, this.indicator, this.handle, this.status); + view.dom.append(this.overlay); + this.listen(view.dom, "pointermove", this.hover); + this.listen(view.dom, "pointerleave", () => { + if (this.gesture || this.ownerDocument.activeElement === this.handle) return; + this.point = null; + this.hovered = null; + this.measure(); + }); + this.listen(this.handle, "pointerdown", this.begin); + this.listen(this.handle, "mousedown", (event) => event.preventDefault()); + this.listen(this.handle, "keydown", this.handleKey); + this.listen(this.handle, "focus", () => this.measure()); + this.listen(this.handle, "blur", () => this.measure()); + this.listen(this.ownerWindow, "pointermove", this.drag); + this.listen(this.ownerWindow, "pointerup", this.drop); + this.listen(this.ownerWindow, "pointercancel", this.cancelPointer); + this.listen(this.handle, "lostpointercapture", this.cancelPointer); + this.listen(this.ownerWindow, "blur", () => this.cancel()); + this.listen(this.ownerWindow, "resize", () => this.measure()); + this.listen(view.scrollDOM, "scroll", () => this.measure()); + this.listen( + this.ownerWindow, + "keydown", + (event) => { + if ((event as KeyboardEvent).key === "Escape" && this.gesture) { + event.preventDefault(); + event.stopPropagation(); + this.cancel(); + } + }, + true, + ); + controllers.set(view, this); + this.measure(); + } + + update(update: ViewUpdate): void { + if (this.stopped) return; + if (update.docChanged) { + this.cancel(); + this.hovered = null; + this.handle.hidden = true; + } + if (syntaxTree(update.startState) !== syntaxTree(update.state)) this.cancel(); + if ( + update.docChanged || + this.blocks.length === 0 || + syntaxTree(update.startState) !== syntaxTree(update.state) + ) { + this.blocks = readBlocks(update.state); + } + if ( + this.view.composing || + update.state.readOnly || + (this.gesture && !this.visibleBlock(this.gesture.blocks[this.gesture.source])) + ) { + this.cancel(); + } + if ( + update.docChanged || + update.selectionSet || + update.geometryChanged || + update.viewportChanged || + update.focusChanged || + update.startState.readOnly !== update.state.readOnly + ) { + this.measure(); + } + } + + canMove(direction: -1 | 1): boolean { + this.blocks = readBlocks(this.view.state); + if (!this.editable()) return false; + const index = this.currentIndex(); + return ( + index >= 0 && + this.visibleBlock(this.blocks[index]) && + this.plan(index, direction < 0 ? index - 1 : index + 2) !== null + ); + } + + move(direction: -1 | 1, fromHandle = false): boolean { + if (!this.editable()) return false; + this.blocks = readBlocks(this.view.state); + const index = fromHandle && this.hovered + ? this.blocks.findIndex((block) => block.from === this.hovered!.from) + : this.currentIndex(); + if (index < 0 || !this.visibleBlock(this.blocks[index])) return false; + this.cancel(); + if (!this.prepareBlocks()) { + this.announce(false); + return true; + } + const plan = this.plan(index, direction < 0 ? index - 1 : index + 2); + if (plan) { + this.commit(plan, fromHandle); + } else { + this.announce(false); + } + return true; + } + + stop(): void { + this.cancel(); + this.stopped = true; + this.focusHandleAfterLayout = false; + this.overlay.hidden = true; + } + + destroy(): void { + this.stop(); + this.disposers.forEach((dispose) => dispose()); + this.overlay.remove(); + if (controllers.get(this.view) === this) controllers.delete(this.view); + } + + private editable(): boolean { + if (this.stopped || this.view.state.readOnly || this.view.composing) return false; + const active = this.ownerDocument.activeElement; + return !active || active === this.view.contentDOM || !this.view.contentDOM.contains(active); + } + + private currentIndex(): number { + const position = this.view.state.selection.main.head; + return this.blocks.findIndex((block) => block.from <= position && position <= block.to); + } + + private prepareBlocks(): boolean { + const before = this.view.state.doc; + const previous = this.blocks; + if (previous.length === 0 || !prepareBlockMove(this.view)) return false; + this.blocks = readBlocks(this.view.state); + if (this.blocks.length !== previous.length) return false; + // A pending table edit may change offsets, but splitting or merging blocks + // would make the captured source index refer to different content. + return previous.every((block, index) => { + const current = this.blocks[index]; + return block.type === current.type && ( + block.type === "Table" || + before.sliceString(block.from, block.to) === + this.view.state.doc.sliceString(current.from, current.to) + ); + }); + } + + private visibleBlock(block: MarkdownBlock): boolean { + if (!block.movable || this.stopped || this.view.state.readOnly || this.view.composing) return false; + let hidden = false; + foldedRanges(this.view.state).between( + block.from, + Math.min(block.to + 1, this.view.state.doc.length), + (from) => { + if (from <= block.from || block.type.includes("Heading")) hidden = true; + }, + ); + return !hidden; + } + + private createElement(className: string): HTMLDivElement { + const element = this.ownerDocument.createElement("div"); + element.className = className; + return element; + } + + private listen( + target: EventTarget, + type: string, + listener: EventListener, + capture = false, + ): void { + target.addEventListener(type, listener, capture); + this.disposers.push(() => target.removeEventListener(type, listener, capture)); + } + + private readonly hover = (event: Event): void => { + const pointer = event as PointerEvent; + if (this.stopped || this.gesture || pointer.pointerType === "touch") return; + this.point = { x: pointer.clientX, y: pointer.clientY }; + if (event.target !== this.handle) this.measure(); + }; + + private readonly begin = (event: Event): void => { + const pointer = event as PointerEvent; + if ( + pointer.button !== 0 || + pointer.pointerType === "touch" || + !this.hovered || + !this.visibleBlock(this.hovered) + ) return; + event.preventDefault(); + const source = this.blocks.findIndex((block) => block.from === this.hovered!.from); + this.cancel(); + if (!this.prepareBlocks()) { + this.announce(false); + return; + } + if (source < 0 || source >= this.blocks.length || !this.visibleBlock(this.blocks[source])) return; + this.point = { x: pointer.clientX, y: pointer.clientY }; + this.gesture = { + pointerId: pointer.pointerId, + start: this.point, + document: this.view.state.doc, + blocks: this.blocks, + source, + active: false, + target: -1, + plan: null, + }; + try { + this.handle.setPointerCapture(pointer.pointerId); + } catch { + // Window listeners also cover hosts that cannot capture this pointer. + } + }; + + private readonly drag = (event: Event): void => { + const pointer = event as PointerEvent; + const gesture = this.gesture; + if (!gesture || pointer.pointerId !== gesture.pointerId) return; + if ((pointer.buttons & 1) === 0 || this.view.composing || this.view.state.readOnly) { + this.cancel(); + return; + } + event.preventDefault(); + this.point = { x: pointer.clientX, y: pointer.clientY }; + if ( + !gesture.active && + Math.hypot(this.point.x - gesture.start.x, this.point.y - gesture.start.y) >= DRAG_THRESHOLD + ) { + gesture.active = true; + this.overlay.classList.add("is-dragging"); + this.frame = this.ownerWindow.requestAnimationFrame(this.scroll); + } + this.measure(); + }; + + private readonly drop = (event: Event): void => { + const pointer = event as PointerEvent; + const gesture = this.gesture; + if (!gesture || pointer.pointerId !== gesture.pointerId) return; + let plan: BlockMove | null = null; + if ( + gesture.active && + gesture.document === this.view.state.doc && + this.visibleBlock(gesture.blocks[gesture.source]) + ) { + const target = this.gapAt({ x: pointer.clientX, y: pointer.clientY }); + plan = this.plan(gesture.source, target); + } + this.cancel(); + if (plan && prepareBlockMove(this.view) && gesture.document === this.view.state.doc) { + this.commit(plan); + } + }; + + private readonly cancelPointer = (event: Event): void => { + if ((event as PointerEvent).pointerId === this.gesture?.pointerId) this.cancel(); + }; + + private readonly handleKey = (event: Event): void => { + const key = (event as KeyboardEvent).key; + if (key === "ArrowUp" || key === "ArrowDown") { + event.preventDefault(); + event.stopPropagation(); + this.move(key === "ArrowUp" ? -1 : 1, true); + } else if (key === "Escape") { + event.preventDefault(); + this.cancel(); + this.view.focus(); + } + }; + + private commit(plan: BlockMove, fromHandle = false): void { + const before = this.view.state.doc; + this.view.dispatch({ + changes: plan.changes, + selection: plan.selection, + userEvent: "move.block", + annotations: [editorTransactionOrigin.of(["block-drag"]), isolateHistory.of("full")], + effects: EditorView.scrollIntoView(plan.movedFrom, { y: "nearest" }), + }); + this.point = null; + this.hovered = fromHandle + ? (this.view.state.doc === before + ? this.hovered + : this.blocks.find((block) => block.from === plan.movedFrom) ?? null) + : null; + this.focusHandleAfterLayout = fromHandle; + if (fromHandle && this.hovered) { + // Keep consecutive key events on this control while layout catches up. + this.handle.hidden = false; + this.handle.focus({ preventScroll: true }); + } else if (!fromHandle) { + this.view.focus(); + } + this.announce(this.view.state.doc !== before); + this.measure(); + } + + private announce(success: boolean): void { + this.status.textContent = success + ? this.options.movedMessage ?? "Block moved" + : this.options.blockedMessage ?? "Block could not be moved"; + } + + private cancel(): void { + const pointerId = this.gesture?.pointerId; + this.gesture = null; + if (this.frame !== null) this.ownerWindow.cancelAnimationFrame(this.frame); + this.frame = null; + if (pointerId !== undefined && this.handle.hasPointerCapture?.(pointerId)) { + this.handle.releasePointerCapture(pointerId); + } + this.overlay.classList.remove("is-dragging"); + this.highlight.hidden = this.indicator.hidden = true; + } + + private plan(source: number, gap: number): BlockMove | null { + if (gap < 0 || gap > this.blocks.length) return null; + const position = this.blocks[gap]?.from ?? this.view.state.doc.length; + let hidden = false; + foldedRanges(this.view.state).between(position, position, (from, to) => { + if (from < position && position <= to) hidden = true; + }); + return hidden ? null : planBlockMove(this.view.state, this.blocks, source, gap); + } + + private bounds(block: MarkdownBlock): { top: number; bottom: number } { + return { + top: this.view.documentTop + this.view.lineBlockAt(block.from).top * this.view.scaleY, + bottom: this.view.documentTop + this.view.lineBlockAt(block.to).bottom * this.view.scaleY, + }; + } + + private gapAt(point: Point): number { + const viewport = this.view.scrollDOM.getBoundingClientRect(); + if ( + point.x < viewport.left || point.x > viewport.right || + point.y < viewport.top || point.y > viewport.bottom + ) return -1; + const position = this.view.lineBlockAtHeight( + Math.max(0, (point.y - this.view.documentTop) / this.view.scaleY), + ).from; + const index = this.blocks.findIndex((block) => block.to >= position); + if (index < 0) return this.blocks.length; + const bounds = this.bounds(this.blocks[index]); + return point.y < (bounds.top + bounds.bottom) / 2 ? index : index + 1; + } + + private measure(): void { + if (this.stopped) return; + this.view.requestMeasure({ + key: this, + read: () => { + const root = this.view.dom.getBoundingClientRect(); + const scroll = this.view.scrollDOM.getBoundingClientRect(); + const content = this.view.contentDOM.getBoundingClientRect(); + let block: MarkdownBlock | null = null; + let destination: number | null = null; + if (this.gesture && this.point) { + block = this.gesture.blocks[this.gesture.source]; + if (this.gesture.active) { + const gap = this.gapAt(this.point); + if (gap !== this.gesture.target) { + this.gesture.target = gap; + this.gesture.plan = this.plan(this.gesture.source, gap); + } + if (this.gesture.plan) { + destination = gap === this.blocks.length + ? this.bounds(this.blocks[gap - 1]).bottom + : this.bounds(this.blocks[gap]).top; + } + } + } else if (this.hovered && (this.focusHandleAfterLayout || this.ownerDocument.activeElement === this.handle)) { + block = this.hovered; + } else if (this.point && this.point.y >= scroll.top && this.point.y <= scroll.bottom) { + const position = this.view.lineBlockAtHeight( + Math.max(0, (this.point.y - this.view.documentTop) / this.view.scaleY), + ).from; + block = this.blocks.find((candidate) => + candidate.from <= position && position <= candidate.to, + ) ?? null; + } else if (this.view.hasFocus) { + block = this.blocks[this.currentIndex()] ?? null; + } + if (block && !this.visibleBlock(block)) block = null; + return { + root, + scroll, + content, + scrollLeft: this.view.scrollDOM.scrollLeft, + block, + source: block ? this.bounds(block) : null, + destination, + }; + }, + write: (layout) => this.draw(layout), + }); + } + + private draw({ root, scroll, content, scrollLeft, source, block, destination }: Layout): void { + if (this.stopped) return; + this.hovered = block; + const visible = source !== null && source.bottom > scroll.top && source.top < scroll.bottom; + this.handle.hidden = !visible; + const rtl = this.view.textDirection === Direction.RTL; + // Keep the control beside the viewport when long source lines scroll. + const offset = scrollLeft * this.view.scaleX; + const contentLeft = Math.max(scroll.left, content.left + (rtl ? 0 : offset)); + const contentRight = Math.min(scroll.right, content.right + (rtl ? offset : 0)); + const x = rtl + ? contentRight - (HANDLE_WIDTH + HANDLE_INSET) * this.view.scaleX + : contentLeft + HANDLE_INSET * this.view.scaleX; + if (visible && source) { + this.handle.style.left = `${(x - root.left) / this.view.scaleX}px`; + this.handle.style.top = `${(Math.max(source.top, scroll.top) - root.top) / this.view.scaleY}px`; + } + if (visible && this.focusHandleAfterLayout) { + this.focusHandleAfterLayout = false; + this.handle.focus({ preventScroll: true }); + } + const left = (contentLeft - root.left) / this.view.scaleX + (rtl ? 0 : GUTTER_WIDTH); + const width = Math.max( + 0, + (contentRight - contentLeft) / this.view.scaleX - GUTTER_WIDTH, + ); + this.highlight.hidden = !this.gesture?.active || !visible; + if (source) { + Object.assign(this.highlight.style, { + left: `${left}px`, + width: `${width}px`, + top: `${(Math.max(source.top, scroll.top) - root.top) / this.view.scaleY}px`, + height: `${Math.max( + 0, + Math.min(source.bottom, scroll.bottom) - Math.max(source.top, scroll.top), + ) / this.view.scaleY}px`, + }); + } + this.indicator.hidden = destination === null || + destination < scroll.top || destination > scroll.bottom; + if (destination !== null) { + Object.assign(this.indicator.style, { + left: `${left}px`, + width: `${width}px`, + top: `${(destination - root.top) / this.view.scaleY}px`, + }); + } + } + + private readonly scroll = (): void => { + if (!this.gesture?.active || !this.point) return; + const rect = this.view.scrollDOM.getBoundingClientRect(); + if (this.point.x >= rect.left && this.point.x <= rect.right) { + const band = Math.min(AUTO_SCROLL_EDGE, rect.height / 4); + const delta = this.point.y < rect.top + band + ? this.point.y - rect.top - band + : this.point.y > rect.bottom - band + ? this.point.y - rect.bottom + band + : 0; + this.view.scrollDOM.scrollTop += Math.max( + -AUTO_SCROLL_MAX_STEP, + Math.min(AUTO_SCROLL_MAX_STEP, delta / 3), + ); + if (delta) this.measure(); + } + this.frame = this.ownerWindow.requestAnimationFrame(this.scroll); + }; +} + +const theme = EditorView.baseTheme({ + ".cm-content[data-block-reorder]": { paddingInlineStart: `${GUTTER_WIDTH}px` }, + ".nexus-block-drag": { + position: "absolute", + inset: "0", + overflow: "hidden", + pointerEvents: "none", + zIndex: "40", + }, + ".nexus-block-drag [hidden], .nexus-block-drag[hidden]": { display: "none" }, + ".nexus-block-drag-handle": { + position: "absolute", + width: `${HANDLE_WIDTH}px`, + height: `${HANDLE_HEIGHT}px`, + padding: "0", + border: "none", + borderRadius: "4px", + color: "var(--nexus-text-muted, #666)", + background: "transparent", + fontSize: "20px", + lineHeight: `${HANDLE_HEIGHT}px`, + cursor: "grab", + pointerEvents: "auto", + }, + ".nexus-block-drag-handle:hover": { background: "var(--nexus-bg-muted, #e8e8e8)" }, + ".nexus-block-drag-handle:focus-visible": { + outline: "2px solid var(--nexus-accent, #7054c5)", + outlineOffset: "-2px", + }, + ".is-dragging .nexus-block-drag-handle": { cursor: "grabbing" }, + ".nexus-block-drag-highlight": { + position: "absolute", + background: "color-mix(in srgb, var(--nexus-accent, #7054c5) 10%, transparent)", + }, + ".nexus-block-drag-indicator": { + position: "absolute", + height: "2px", + background: "var(--nexus-accent, #7054c5)", + }, + ".nexus-block-drag-status": { + position: "absolute", + width: "1px", + height: "1px", + overflow: "hidden", + clipPath: "inset(50%)", + }, +}); + +export function createBlockDragExtension(options: BlockDragOptions = {}): Extension { + const extension = ViewPlugin.define((view) => new BlockDragController(view, options)); + return [ + extension, + theme, + EditorView.contentAttributes.of({ "data-block-reorder": "" }), + options.keyboard === false ? [] : keymap.of([ + { key: "Alt-Shift-ArrowUp", run: (view) => moveBlock(view, -1) }, + { key: "Alt-Shift-ArrowDown", run: (view) => moveBlock(view, 1) }, + ]), + ]; +} + +export function canMoveBlock(view: EditorView, direction: -1 | 1): boolean { + return controllers.get(view)?.canMove(direction) ?? false; +} + +export function moveBlock(view: EditorView, direction: -1 | 1): boolean { + return controllers.get(view)?.move(direction) ?? false; +} + +export function stopBlockDrag(view: EditorView): void { + controllers.get(view)?.stop(); +} diff --git a/packages/plugin-block-drag/src/index.ts b/packages/plugin-block-drag/src/index.ts new file mode 100644 index 00000000..8a6ddf4f --- /dev/null +++ b/packages/plugin-block-drag/src/index.ts @@ -0,0 +1,9 @@ +import type { NexusPlugin } from "@floatboat/nexus-core"; +import { createBlockDragExtension, type BlockDragOptions } from "./extension"; + +export { createBlockDragExtension, type BlockDragOptions } from "./extension"; +export { BlockDragLifecyclePlugin, blockDragLifecyclePluginManifest } from "./runtime-plugin"; + +export function createBlockDragPlugin(options: BlockDragOptions = {}): NexusPlugin { + return { name: "plugin-block-drag", cmExtensions: [createBlockDragExtension(options)] }; +} diff --git a/packages/plugin-block-drag/src/model.ts b/packages/plugin-block-drag/src/model.ts new file mode 100644 index 00000000..d96582c3 --- /dev/null +++ b/packages/plugin-block-drag/src/model.ts @@ -0,0 +1,189 @@ +import { DocInput, ensureSyntaxTree, language } from "@codemirror/language"; +import { ChangeSet, EditorSelection, type EditorState, type Text } from "@codemirror/state"; +import { IterMode, type SyntaxNode, type Tree } from "@lezer/common"; + +export interface MarkdownBlock { + readonly from: number; + readonly to: number; + readonly type: string; + readonly movable: boolean; +} + +export interface BlockMove { + readonly changes: ChangeSet; + readonly selection: EditorSelection; + readonly movedFrom: number; +} + +interface BlockSnapshot { + blocks: readonly MarkdownBlock[]; + nodes: readonly SyntaxNode[]; + signatures?: readonly string[]; +} + +const snapshots = new WeakMap(); +const movableTypes = new Set([ + "Paragraph", "Blockquote", "BulletList", "OrderedList", "FencedCode", + "CodeBlock", "IndentedCode", "Table", "HorizontalRule", + "ATXHeading1", "ATXHeading2", "ATXHeading3", "ATXHeading4", "ATXHeading5", "ATXHeading6", + "SetextHeading1", "SetextHeading2" +]); + +function snapshotTree(tree: Tree, doc: Text): BlockSnapshot { + const blocks: MarkdownBlock[] = []; + const nodes: SyntaxNode[] = []; + for (let node = tree.topNode.firstChild; node; node = node.nextSibling) { + nodes.push(node); + blocks.push({ + from: doc.lineAt(node.from).from, + to: doc.lineAt(node.to).to, + type: node.name, + movable: movableTypes.has(node.name) + }); + } + return { blocks, nodes }; +} + +function readSnapshot(state: EditorState): BlockSnapshot | null { + const cached = snapshots.get(state); + if (cached) return cached; + // Never offer a partial block from the viewport's unfinished parse. + const tree = ensureSyntaxTree(state, state.doc.length, 25); + if (!tree || tree.topNode.name !== "Document") return null; + const snapshot = snapshotTree(tree, state.doc); + if (snapshot.blocks.some((block, index) => index > 0 && block.from < snapshot.blocks[index - 1].to)) { + return null; + } + snapshots.set(state, snapshot); + return snapshot; +} + +/** Outermost Markdown blocks, including immovable definitions and custom nodes. */ +export function readBlocks(state: EditorState): readonly MarkdownBlock[] { + return readSnapshot(state)?.blocks ?? []; +} + +function signature(node: SyntaxNode, from: number): string { + const cursor = node.cursor(IterMode.IgnoreMounts); + const parts: string[] = []; + cursor.iterate( + child => { parts.push(`(${child.name}:${child.from - from}:${child.to - from}`); }, + () => { parts.push(")"); } + ); + return parts.join(";"); +} + +function preservesBlocks( + state: EditorState, + snapshot: BlockSnapshot, + order: readonly number[], + changes: ChangeSet, + deadline: number +): boolean { + const parser = state.facet(language)?.parser; + if (!parser) return false; + const doc = changes.apply(state.doc); + const parse = parser.startParse(new DocInput(doc)); + let tree: Tree | null; + do { + tree = parse.advance(); + if (!tree && Date.now() >= deadline) return false; + } while (!tree); + const next = snapshotTree(tree, doc); + if (next.blocks.length !== order.length) return false; + snapshot.signatures ??= snapshot.nodes.map((node, index) => + signature(node, snapshot.blocks[index].from) + ); + return order.every((oldIndex, newIndex) => { + const before = snapshot.blocks[oldIndex]; + const after = next.blocks[newIndex]; + return state.doc.sliceString(before.from, before.to) === doc.sliceString(after.from, after.to) + && snapshot.signatures![oldIndex] === signature(next.nodes[newIndex], after.from); + }); +} + +function blankLinePadding(separator: string): string { + let newlines = 0; + for (const char of separator) { + if (char === "\n") newlines++; + } + return "\n".repeat(Math.max(0, 2 - newlines)); +} + +/** + * Plan one atomic move to a gap in the original block list (0 through length). + * Preserve source text verbatim; add blank lines only if the original separators + * would change Markdown structure. A move that still changes any subtree fails. + */ +export function planBlockMove( + state: EditorState, + blocks: readonly MarkdownBlock[], + sourceIndex: number, + targetGapIndex: number +): BlockMove | null { + if (!Number.isInteger(sourceIndex) || !Number.isInteger(targetGapIndex) + || sourceIndex < 0 || sourceIndex >= blocks.length + || targetGapIndex < 0 || targetGapIndex > blocks.length + || targetGapIndex === sourceIndex || targetGapIndex === sourceIndex + 1) return null; + const snapshot = readSnapshot(state); + if (!snapshot || snapshot.blocks.length !== blocks.length + || blocks.some((block, index) => { + const current = snapshot.blocks[index]; + return block.from !== current.from || block.to !== current.to + || block.type !== current.type || block.movable !== current.movable; + })) return null; + const source = blocks[sourceIndex]; + if (!source.movable) return null; + const beforeSource = blocks[sourceIndex - 1]; + const afterSource = blocks[sourceIndex + 1]; + const removeFrom = afterSource ? source.from : beforeSource.to; + const removeTo = afterSource ? afterSource.from : source.to; + const separator = afterSource + ? state.doc.sliceString(source.to, afterSource.from) + : state.doc.sliceString(beforeSource.to, source.from); + const right = blocks[targetGapIndex]; + const left = blocks[targetGapIndex - 1]; + const insertAt = right ? right.from : left.to; + const raw = state.doc.sliceString(source.from, source.to); + const order = blocks.map((_, index) => index).filter(index => index !== sourceIndex); + order.splice(targetGapIndex > sourceIndex ? targetGapIndex - 1 : targetGapIndex, 0, sourceIndex); + + const padding = [ + beforeSource && afterSource ? blankLinePadding(state.doc.sliceString(beforeSource.to, source.from)) : "", + left && right ? blankLinePadding(state.doc.sliceString(left.to, right.from)) : "", + blankLinePadding(separator) + ]; + const availablePadding = padding.reduce((mask, value, index) => value ? mask | (1 << index) : mask, 0); + const deadline = Date.now() + 50; + // Try the fewest boundary changes first, within one shared parse budget. + for (const mask of [0, 1, 2, 4, 3, 5, 6, 7]) { + if ((mask & availablePadding) !== mask) continue; + if (Date.now() >= deadline) return null; + const removalPadding = mask & 1 ? padding[0] : ""; + const leadingPadding = mask & 2 ? padding[1] : ""; + const separatorPadding = mask & 4 ? padding[2] : ""; + const prefix = right ? leadingPadding : separator + separatorPadding; + const insert = right ? prefix + raw + separator + separatorPadding : prefix + raw; + const changes = ChangeSet.of([ + { from: removeFrom, to: removeTo, insert: removalPadding }, + { from: insertAt, insert } + ], state.doc.length); + if (!preservesBlocks(state, snapshot, order, changes, deadline)) continue; + const movedFrom = changes.mapPos(insertAt, -1) + prefix.length; + const mapPosition = (position: number, assoc: number): number => + position >= source.from && position <= source.to + ? movedFrom + position - source.from + : changes.mapPos(position, position === insertAt ? (right ? 1 : -1) : assoc); + const selection = EditorSelection.create(state.selection.ranges.map(range => + EditorSelection.range( + mapPosition(range.anchor, range.assoc), + mapPosition(range.head, range.assoc), + range.goalColumn, + range.bidiLevel ?? undefined, + range.assoc + ) + ), state.selection.mainIndex); + return { changes, selection, movedFrom }; + } + return null; +} diff --git a/packages/plugin-block-drag/src/runtime-plugin.ts b/packages/plugin-block-drag/src/runtime-plugin.ts new file mode 100644 index 00000000..e872b025 --- /dev/null +++ b/packages/plugin-block-drag/src/runtime-plugin.ts @@ -0,0 +1,79 @@ +import { ViewPlugin, type EditorView } from "@codemirror/view"; +import { + COMMANDS_CAPABILITY, + EDITOR_HOST_CAPABILITY, + NexusPluginBase, + NexusPluginError, + type AuthorPluginManifest, + type EditorId, +} from "@floatboat/nexus-plugin-api"; + +import { + canMoveBlock, + createBlockDragExtension, + moveBlock, + stopBlockDrag, +} from "./extension"; + +export const blockDragLifecyclePluginManifest = Object.freeze({ + schemaVersion: 1, + id: "block-drag", + name: "Block Drag", + version: "1.0.0", + entrypoint: "block-drag.js", + apiVersion: "^1.0.0", + requiredCapabilities: [ + { id: COMMANDS_CAPABILITY.id, version: "^1.0.0", scope: "application" as const }, + { id: EDITOR_HOST_CAPABILITY.id, version: "^1.0.0", scope: "application" as const }, + ], +} satisfies AuthorPluginManifest); + +/** Runtime hotkeys belong to the command service, including user overrides. */ +export class BlockDragLifecyclePlugin extends NexusPluginBase { + private readonly views = new Map(); + + override onload(): void { + const commands = this.app.capabilities.require(COMMANDS_CAPABILITY, "^1.0.0"); + const editors = this.app.capabilities.require(EDITOR_HOST_CAPABILITY, "^1.0.0"); + + // Quiesce pointer sessions before owner disposal starts removing extensions. + this.register({ + quiesce: () => { + for (const view of this.views.values()) stopBlockDrag(view); + }, + dispose: () => this.views.clear(), + }); + + const views = this.views; + const extension = editors.registerEditorExtension((context) => [ + createBlockDragExtension({ keyboard: false }), + ViewPlugin.define((view) => { + views.set(context.editorId, view); + return { + destroy() { + if (views.get(context.editorId) === view) views.delete(context.editorId); + }, + }; + }), + ], { id: "block-drag" }); + if (!extension.ok) throw new NexusPluginError(extension.diagnostic); + + for (const direction of [-1, 1] as const) { + const up = direction === -1; + const result = commands.registerCommand({ + id: up ? "move-up" : "move-down", + name: up ? "Move block up" : "Move block down", + defaultHotkeys: [{ + key: up ? "ArrowUp" : "ArrowDown", + modifiers: ["Alt", "Shift"], + }], + editorCheckCallback: (checking, context) => { + const view = this.views.get(context.editorId); + if (!view) return false; + return checking ? canMoveBlock(view, direction) : moveBlock(view, direction); + }, + }); + if (!result.ok) throw new NexusPluginError(result.diagnostic); + } + } +} diff --git a/packages/plugin-block-drag/test/browser/edge-smoke.mjs b/packages/plugin-block-drag/test/browser/edge-smoke.mjs new file mode 100644 index 00000000..4ac8cb4e --- /dev/null +++ b/packages/plugin-block-drag/test/browser/edge-smoke.mjs @@ -0,0 +1,202 @@ +import assert from "node:assert/strict"; +import { chromium } from "playwright"; + +const origin = process.env.BLOCK_DRAG_URL ?? "http://127.0.0.1:5178"; +const browser = await chromium.launch({ + headless: true, + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, +}); +const page = await browser.newPage({ viewport: { width: 1100, height: 850 } }); +const errors = []; +page.on("pageerror", error => errors.push(error.message)); +const frames = () => page.evaluate(() => new Promise(resolve => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); +})); +let assertions = 0; +const documentText = () => page.evaluate(() => window.fixture.editor.getDocument()); +const expected = async value => { + assert.equal(await documentText(), value); + assertions++; +}; +const load = async source => { + await page.evaluate(source => window.fixture.load(source), source); + await frames(); +}; +const center = async locator => { + const box = await locator.boundingBox(); + assert.ok(box, "target is rendered"); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +}; +const hover = async position => { + const point = await page.evaluate(position => { + const view = window.fixture.view; + const rect = view.scrollDOM.getBoundingClientRect(); + return { + x: rect.left + 120, + y: view.documentTop + view.lineBlockAt(position).top * view.scaleY + 8, + }; + }, position); + await page.mouse.move(point.x, point.y); + await frames(); +}; +const begin = async (position = 0) => { + await hover(position); + const grip = await center(page.locator(".nexus-block-drag-handle")); + await page.mouse.move(grip.x, grip.y); + await page.mouse.down(); + await frames(); +}; +const endPoint = () => page.evaluate(() => { + const view = window.fixture.view; + const rect = view.scrollDOM.getBoundingClientRect(); + return { + x: rect.left + 160, + y: Math.min(rect.bottom - 5, view.documentTop + view.lineBlockAt(view.state.doc.length).bottom + 4), + }; +}); +const dragToEnd = async (position = 0) => { + await begin(position); + const end = await endPoint(); + await page.mouse.move(end.x, end.y, { steps: 12 }); + await page.mouse.up(); + await frames(); +}; +const pointerDrag = async (source, target) => { + const from = await center(source); + const to = await center(target); + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.mouse.move(to.x, to.y, { steps: 15 }); + await page.mouse.up(); + await frames(); +}; + +try { + for (const preview of [false, true]) { + await page.goto(`${origin}/packages/plugin-block-drag/test/browser/index.html?preview=${preview}`); + await page.waitForFunction(() => window.fixture); + const original = "First\n\nSecond\n\nThird"; + await load(original); + await begin(); + await page.mouse.move(900, 650, { steps: 15 }); + await page.mouse.up(); + await expected(original); + + await page.evaluate(() => { + window.capturedPointerId = null; + document.querySelector(".nexus-block-drag-handle").addEventListener( + "gotpointercapture", + event => { window.capturedPointerId = event.pointerId; }, + { once: true }, + ); + }); + await begin(); + const end = await endPoint(); + await page.mouse.move(end.x, end.y, { steps: 12 }); + const released = await page.evaluate(() => { + const handle = document.querySelector(".nexus-block-drag-handle"); + const pointerId = window.capturedPointerId; + if (pointerId === null || !handle.hasPointerCapture(pointerId)) return false; + handle.releasePointerCapture(pointerId); + return true; + }); + assert.equal(released, true, "release the browser's actual pointer capture"); + await frames(); + await page.mouse.up(); + await expected(original); + assert.equal(await page.locator(".is-dragging").count(), 0); + + await page.evaluate(() => { document.querySelector("#editor").style.width = "340px"; }); + await frames(); + await dragToEnd(); + await expected("Second\n\nThird\n\nFirst"); + await page.evaluate(() => { document.querySelector("#editor").style.width = "700px"; }); + + const folded = "# Heading\n\nHidden paragraph\n\n# End\n\nLast"; + await load(folded); + await page.evaluate(() => window.fixture.fold(9, 28)); + await hover(0); + assert.equal(await page.locator(".nexus-block-drag-handle").isVisible(), false); + await expected(folded); + await page.evaluate(() => window.fixture.fold(9, 28, false)); + await frames(); + await begin(); + await page.mouse.move(end.x, end.y, { steps: 10 }); + await page.evaluate(() => window.fixture.fold(9, 28)); + await page.mouse.up(); + await expected(folded); + assert.equal(await page.locator(".is-dragging").count(), 0); + + const wide = `\`\`\`\n${"long_code_".repeat(100)}\n\`\`\`\n\nTail`; + await load(wide); + await page.evaluate(() => { + window.fixture.view.contentDOM.style.whiteSpace = "pre"; + }); + await frames(); + const scrollable = await page.evaluate(() => { + const scroll = window.fixture.view.scrollDOM; + const wrapper = document.querySelector(".nexus-code-wrapper"); + return scroll.scrollWidth > scroll.clientWidth || + (wrapper && wrapper.scrollWidth > wrapper.clientWidth); + }); + assert.ok(scrollable, "wide content actually overflows"); + await page.evaluate(() => { + window.fixture.view.scrollDOM.scrollLeft = 180; + }); + await frames(); + await dragToEnd(); + await expected(`Tail\n\n${wide.slice(0, -6)}`); + } + + const table = "| A | B | C |\n| --- | --- | --- |\n| a1 | b1 | c1 |\n| a2 | b2 | c2 |\n\nTail"; + const cells = page.locator(".nexus-cell"); + await load(table); + assert.equal(await cells.nth(3).evaluate(cell => cell.isContentEditable), false); + await cells.nth(3).click(); + await cells.nth(3).fill("中文输入"); + await page.locator("h1").click(); + await frames(); + await expected(table.replace("a1", "中文输入")); + // This checks Unicode text handling, not a native OS input-method session. + + await load(table); + await pointerDrag(cells.nth(3), cells.nth(8)); + await page.keyboard.press("Delete"); + await frames(); + await expected("| A | B | C |\n| --- | --- | --- |\n| | | |\n| | | |\n\nTail"); + + await load(table); + await page.locator(".nexus-col-grip").nth(1).click(); + await page.keyboard.press("Delete"); + await frames(); + await expected("| A | C |\n| --- | --- |\n| a1 | c1 |\n| a2 | c2 |\n\nTail"); + + await load(table); + await page.locator(".nexus-col-grip").nth(1).click(); + await page.locator("h1").click(); + await page.locator(".nexus-table-wrapper").focus(); + await page.keyboard.press("Delete"); + await expected(table); + + await load(table); + await pointerDrag(page.locator(".nexus-col-grip").nth(0), page.locator(".nexus-col-grip").nth(2)); + await expected("| B | C | A |\n| --- | --- | --- |\n| b1 | c1 | a1 |\n| b2 | c2 | a2 |\n\nTail"); + + await load(table); + await pointerDrag(page.locator(".nexus-row-grip").nth(1), page.locator(".nexus-row-grip").nth(2)); + await expected("| A | B | C |\n| --- | --- | --- |\n| a2 | b2 | c2 |\n| a1 | b1 | c1 |\n\nTail"); + await dragToEnd(); + assert.ok((await documentText()).startsWith("Tail\n\n| A | B | C |")); + assertions++; + await cells.nth(3).click(); + await cells.nth(3).fill("after move"); + await page.locator("h1").click(); + await frames(); + assert.ok((await documentText()).includes("after move")); + assertions++; + + assert.deepEqual(errors, [], "no browser errors"); + console.log(`Browser edge/table acceptance passed: ${assertions} document assertions plus geometry and lifecycle checks.`); +} finally { + await browser.close(); +} diff --git a/packages/plugin-block-drag/test/browser/electron-smoke.mjs b/packages/plugin-block-drag/test/browser/electron-smoke.mjs new file mode 100644 index 00000000..f8f27cfb --- /dev/null +++ b/packages/plugin-block-drag/test/browser/electron-smoke.mjs @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { _electron as electron } from "playwright"; + +const root = fileURLToPath(new URL("../../../../", import.meta.url)); +const appDirectory = path.join(root, "apps/electron-demo"); +const require = createRequire(path.join(appDirectory, "package.json")); +const executablePath = require("electron"); +const frame = page => page.evaluate(() => new Promise(resolve => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); +})); +const original = ["First paragraph", "Second paragraph", "Third paragraph"]; +const modifier = process.platform === "darwin" ? "Meta" : "Control"; + +for (const mode of ["legacy", "runtime"]) { + const directory = await mkdtemp(path.join(tmpdir(), "nexus-block-electron-")); + const bootstrap = path.join(directory, "bootstrap.cjs"); + await writeFile(bootstrap, [ + "const { app } = require('electron');", + `app.setPath('userData', ${JSON.stringify(directory)});`, + `require(${JSON.stringify(path.join(appDirectory, "dist-electron/main.js"))});`, + "", + ].join("\n")); + const application = await electron.launch({ + executablePath, + args: [bootstrap], + env: { + ...process.env, + NEXUS_PLUGIN_PLATFORM: mode === "runtime" ? "1" : "0", + VITE_DEV_SERVER_URL: "", + }, + }); + try { + const page = await application.firstWindow(); + const errors = []; + page.on("pageerror", error => errors.push(error.message)); + const content = page.locator(".cm-content").first(); + await content.waitFor(); + await page.locator(".nexus-block-drag").waitFor({ state: "attached" }); + // A visible macOS window also receives the user's physical pointer events, + // which can release capture between Playwright's synthetic pointer events. + // Isolate this temporary test window while retaining renderer input and rAF. + await application.evaluate(({ BrowserWindow }) => { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.setBackgroundThrottling(false); + window.hide(); + } + }); + await content.click(); + await page.keyboard.press(`${modifier}+A`); + await page.keyboard.insertText(original.join("\n\n")); + await frame(page); + const lines = async () => { + const values = await page.locator(".cm-content .cm-line").allTextContents(); + return values.map(value => value.trim()).filter(Boolean); + }; + assert.deepEqual(await lines(), original); + const firstLine = page.locator(".cm-line").filter({ hasText: "First paragraph" }); + const handle = page.locator(".nexus-block-drag-handle"); + const hoverBlock = async (source = firstLine) => { + // Deferred vault restoration may reinstall runtime plugins after boot. + // Wait for the current handle to follow the intended source, not just exist. + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + const first = await source.boundingBox(); + assert.ok(first); + await page.mouse.move(first.x + 70, first.y + first.height / 2); + await page.mouse.move(first.x + 80, first.y + first.height / 2); + await frame(page); + const grip = await handle.boundingBox(); + const current = await source.boundingBox(); + if (grip && current && Math.abs(grip.y - current.y) < 2) return grip; + } + assert.fail(`${mode}: handle did not follow the intended source`); + }; + const grip = await hoverBlock(); + const lastLine = page.locator(".cm-line").filter({ hasText: "Third paragraph" }); + const last = await lastLine.boundingBox(); + assert.ok(last); + await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2); + await page.mouse.down(); + await page.mouse.move(last.x + 140, last.y + last.height - 2, { steps: 15 }); + await frame(page); + await page.mouse.up(); + await frame(page); + assert.deepEqual(await lines(), ["Second paragraph", "Third paragraph", "First paragraph"]); + await page.keyboard.press(`${modifier}+z`); + await frame(page); + assert.deepEqual(await lines(), original); + const again = await hoverBlock(); + await page.mouse.move(again.x + 12, again.y + 13); + await page.mouse.down(); + await page.mouse.move(last.x + 140, last.y + last.height - 2, { steps: 10 }); + await page.keyboard.press("Escape"); + await page.mouse.up(); + assert.deepEqual(await lines(), original); + if (process.env.BLOCK_DRAG_SCREENSHOT) { + await page.screenshot({ + path: process.env.BLOCK_DRAG_SCREENSHOT.replace(/\.png$/, `-${mode}.png`), + }); + } + await firstLine.click(); + await page.keyboard.press("Alt+Shift+ArrowDown"); + await frame(page); + assert.deepEqual(await lines(), ["Second paragraph", "First paragraph", "Third paragraph"]); + await firstLine.click(); + await page.keyboard.press("Alt+Shift+ArrowUp"); + await frame(page); + assert.deepEqual(await lines(), original); + + const tableSource = "| A | B |\n| --- | --- |\n| old | value |\n\nTail"; + await firstLine.click(); + await page.keyboard.press(`${modifier}+A`); + await page.keyboard.insertText(tableSource); + const table = page.locator(".nexus-table-wrapper"); + const cell = page.locator(".nexus-cell").nth(2); + await cell.click(); + await cell.fill("编辑已保存"); + const tableGrip = await hoverBlock(table); + await page.mouse.move(tableGrip.x + tableGrip.width / 2, tableGrip.y + tableGrip.height / 2); + await page.mouse.down(); + await frame(page); + const tail = page.locator(".cm-line").filter({ hasText: "Tail" }); + const tailBox = await tail.boundingBox(); + assert.ok(tailBox); + await page.mouse.move(tailBox.x + 100, tailBox.y + tailBox.height - 2, { steps: 15 }); + await page.mouse.up(); + await frame(page); + const tableFollowsTail = () => tail.evaluate(element => Boolean( + element.compareDocumentPosition(document.querySelector(".nexus-table-wrapper")) & + Node.DOCUMENT_POSITION_FOLLOWING, + )); + assert.equal(await tableFollowsTail(), true, "whole table moves after the paragraph"); + assert.equal(await cell.textContent(), "编辑已保存"); + await page.keyboard.press(`${modifier}+z`); + await frame(page); + assert.equal(await tableFollowsTail(), false, "one undo restores table position"); + assert.equal(await cell.textContent(), "编辑已保存", "undoing movement retains the cell edit"); + assert.deepEqual(errors, []); + console.log( + `Electron ${mode}: pointer drag, cancellation, default shortcuts, edited-table movement and isolated undo passed.`, + ); + } finally { + await application.evaluate(({ app }) => app.exit(0)).catch(() => {}); + await application.close().catch(() => {}); + await rm(directory, { recursive: true, force: true }); + } +} diff --git a/packages/plugin-block-drag/test/browser/index.html b/packages/plugin-block-drag/test/browser/index.html new file mode 100644 index 00000000..2e13ccc5 --- /dev/null +++ b/packages/plugin-block-drag/test/browser/index.html @@ -0,0 +1,23 @@ + + + + + Block reorder acceptance + + + +

Markdown block reordering

+

Drag the margin handle. Escape cancels; each move has its own undo step.

+
+

+  
+
+
diff --git a/packages/plugin-block-drag/test/browser/main.ts b/packages/plugin-block-drag/test/browser/main.ts
new file mode 100644
index 00000000..935460eb
--- /dev/null
+++ b/packages/plugin-block-drag/test/browser/main.ts
@@ -0,0 +1,70 @@
+import { history } from "@codemirror/commands";
+import { codeFolding, foldEffect, unfoldEffect } from "@codemirror/language";
+import { Compartment, EditorState } from "@codemirror/state";
+import { EditorView, ViewPlugin } from "@codemirror/view";
+import { createEditor } from "@floatboat/nexus-core";
+import { createGfmPreset } from "@floatboat/nexus-preset-gfm";
+import { createBlockDragExtension } from "../../src/extension";
+
+const drag = new Compartment();
+const readonly = new Compartment();
+let view!: EditorView;
+const documentElement = document.querySelector("#markdown")!;
+const editor = createEditor({
+  container: document.querySelector("#editor")!,
+  initialValue: "First paragraph\n\nSecond paragraph\n\nThird paragraph",
+  livePreview: new URLSearchParams(location.search).get("preview") !== "false",
+  plugins: [
+    createGfmPreset(),
+    {
+      name: "acceptance",
+      cmExtensions: [
+        history(),
+        codeFolding(),
+        drag.of(createBlockDragExtension()),
+        readonly.of([]),
+        ViewPlugin.define(instance => {
+          view = instance;
+          return {};
+        }),
+      ],
+    },
+  ],
+  onChange: document => {
+    documentElement.textContent = document;
+  },
+});
+documentElement.textContent = editor.getDocument();
+let rejected = 0;
+const fixture = {
+  editor,
+  view,
+  load(source: string): void {
+    editor.setDocument(source);
+    documentElement.textContent = editor.getDocument();
+  },
+  enable(enabled: boolean): void {
+    view.dispatch({ effects: drag.reconfigure(enabled ? createBlockDragExtension() : []) });
+  },
+  readOnly(enabled: boolean): void {
+    view.dispatch({ effects: readonly.reconfigure(EditorState.readOnly.of(enabled)) });
+  },
+  fold(from: number, to: number, folded = true): void {
+    view.dispatch({ effects: (folded ? foldEffect : unfoldEffect).of({ from, to }) });
+  },
+  rejectCellSave(): () => void {
+    const registration = editor.getContributionSink().registerTransactionFilter(
+      "acceptance-cell-guard",
+      context => {
+        if (context.changes.some(change => change.insert.includes("UNSAVED"))) {
+          rejected++;
+          return { action: "reject" };
+        }
+        return { action: "accept" };
+      },
+    );
+    return () => registration.dispose();
+  },
+  rejected: () => rejected,
+};
+Object.assign(window, { fixture });
diff --git a/packages/plugin-block-drag/test/browser/runtime-smoke.mjs b/packages/plugin-block-drag/test/browser/runtime-smoke.mjs
new file mode 100644
index 00000000..0e1e9803
--- /dev/null
+++ b/packages/plugin-block-drag/test/browser/runtime-smoke.mjs
@@ -0,0 +1,149 @@
+import assert from "node:assert/strict";
+import { chromium } from "playwright";
+
+const origin = process.env.BLOCK_DRAG_URL ?? "http://127.0.0.1:5178";
+const browser = await chromium.launch({
+  headless: true,
+  executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
+});
+const page = await browser.newPage({ viewport: { width: 1100, height: 700 } });
+const errors = [];
+page.on("pageerror", error => errors.push(error.message));
+let assertions = 0;
+const first = "First\n\nSecond\n\nThird";
+const second = "Alpha\n\nBeta\n\nGamma";
+const defaultDown = "Alt+Shift+ArrowDown";
+const defaultUp = "Alt+Shift+ArrowUp";
+const customDown = "Control+Shift+ArrowDown";
+const snapshot = () => page.evaluate(() => window.runtimeFixture.snapshot());
+const pauseFrames = () => page.evaluate(() => new Promise(resolve => {
+  requestAnimationFrame(() => requestAnimationFrame(resolve));
+}));
+const select = async (id, block = 0) => {
+  await page.locator(`#${id} .cm-line`).nth(block * 2).click({ position: { x: 60, y: 8 } });
+  await pauseFrames();
+};
+const preference = value => page.evaluate(value => window.runtimeFixture.preference(value), value);
+const press = async key => {
+  await page.keyboard.press(key);
+  await page.evaluate(() => window.runtimeFixture.settled());
+  await pauseFrames();
+};
+const expectDocuments = async (expectedFirst = first, expectedSecond = second) => {
+  assert.deepEqual((await snapshot()).documents, { first: expectedFirst, second: expectedSecond });
+  assertions++;
+};
+const expectDispatch = async (status, ok) => {
+  const actual = (await snapshot()).lastDispatch;
+  assert.equal(actual?.status, status);
+  if (ok !== undefined) assert.equal(actual.ok, ok);
+  assertions++;
+};
+
+try {
+  await page.goto(`${origin}/packages/plugin-block-drag/test/browser/runtime.html`);
+  await page.waitForFunction(() => window.runtimeFixture);
+  assert.equal((await snapshot()).overlays, 2);
+  assertions++;
+
+  await select("first");
+  await press(defaultDown);
+  await expectDocuments("Second\n\nFirst\n\nThird");
+  await expectDispatch("handled", true);
+  // Select the moved source so each routing check has its own starting selection.
+  await select("first", 1);
+  await press(defaultUp);
+  await expectDocuments();
+
+  await select("second");
+  await press(defaultDown);
+  await expectDocuments(first, "Beta\n\nAlpha\n\nGamma");
+  await select("second", 1);
+  await press(defaultUp);
+  await expectDocuments();
+  await page.locator("#outside").click();
+  await press(defaultDown);
+  await expectDocuments();
+  await expectDispatch("handled", false);
+
+  await preference({ mode: "custom", bindings: [{ key: "ArrowDown", modifiers: ["Ctrl", "Shift"] }] });
+  await select("first");
+  await press(defaultDown);
+  await expectDocuments();
+  await expectDispatch("pass");
+  await select("first");
+  await press(customDown);
+  await expectDocuments("Second\n\nFirst\n\nThird");
+  await expectDispatch("handled", true);
+  await select("first", 1);
+  await press(defaultUp);
+  await expectDocuments();
+
+  await preference({ mode: "cleared" });
+  for (const key of [defaultDown, customDown]) {
+    await select("first");
+    await press(key);
+    await expectDocuments();
+    await expectDispatch("pass");
+  }
+
+  await preference({ mode: "default" });
+  await page.evaluate(() => window.runtimeFixture.conflict(true));
+  await select("first");
+  await press(defaultDown);
+  await expectDocuments();
+  await expectDispatch("conflict");
+  assert.equal((await snapshot()).conflictingCalls, 0);
+  assertions++;
+  await page.evaluate(() => window.runtimeFixture.conflict(false));
+  await select("first");
+  await press(defaultDown);
+  await expectDocuments("Second\n\nFirst\n\nThird");
+  await select("first", 1);
+  await press(defaultUp);
+  await expectDocuments();
+
+  await preference({ mode: "custom", bindings: [{ key: "ArrowDown", modifiers: ["Ctrl", "Shift"] }] });
+  await page.evaluate(() => window.runtimeFixture.disable());
+  assert.equal((await snapshot()).overlays, 0);
+  assert.deepEqual((await snapshot()).commands, []);
+  assertions += 2;
+  for (const key of [defaultDown, customDown]) {
+    await select("first");
+    await press(key);
+    await expectDocuments();
+    await expectDispatch("pass");
+  }
+  await page.evaluate(() => window.runtimeFixture.enable());
+  assert.equal((await snapshot()).overlays, 2);
+  assert.equal((await snapshot()).commands.length, 2);
+  assertions += 2;
+  await select("first");
+  await press(defaultDown);
+  await expectDocuments();
+  await expectDispatch("pass");
+  await select("second");
+  await press(customDown);
+  await expectDocuments(first, "Beta\n\nAlpha\n\nGamma");
+  await expectDispatch("handled", true);
+  await select("second", 1);
+  await press(defaultUp);
+  await expectDocuments();
+
+  await preference({ mode: "cleared" });
+  await page.evaluate(async () => {
+    await window.runtimeFixture.disable();
+    await window.runtimeFixture.enable();
+  });
+  for (const key of [defaultDown, customDown]) {
+    await select("second");
+    await press(key);
+    await expectDocuments();
+    await expectDispatch("pass");
+  }
+  assert.deepEqual(errors, [], "no browser errors");
+  assertions++;
+  console.log(`Runtime browser shortcuts passed: ${assertions} assertions; defaults, rebinding, clearing, conflicts, focus routing, late attachment, disable/re-enable.`);
+} finally {
+  await browser.close();
+}
diff --git a/packages/plugin-block-drag/test/browser/runtime.html b/packages/plugin-block-drag/test/browser/runtime.html
new file mode 100644
index 00000000..e887fb6f
--- /dev/null
+++ b/packages/plugin-block-drag/test/browser/runtime.html
@@ -0,0 +1,28 @@
+
+
+
+  
+  Block reorder runtime acceptance
+  
+
+
+  

Runtime block reordering

+

Hotkeys follow the focused editor and the host's command preferences.

+
+

First editor

+

Second editor

+
+ + + + diff --git a/packages/plugin-block-drag/test/browser/runtime.ts b/packages/plugin-block-drag/test/browser/runtime.ts new file mode 100644 index 00000000..5fa74628 --- /dev/null +++ b/packages/plugin-block-drag/test/browser/runtime.ts @@ -0,0 +1,140 @@ +import { createEditor } from "@floatboat/nexus-core"; +import { createHistoryPlugin } from "@floatboat/nexus-plugin-history"; +import { + COMMANDS_CAPABILITY, + EDITOR_HOST_CAPABILITY, + type HotkeyPreference, + type ManagedResource, + type NexusApp, + type ResourceOwner, +} from "@floatboat/nexus-plugin-api"; +import { + CommandRegistry, + ComponentLifecycleRuntime, + DiagnosticBus, + EditorHostRegistry, + HotkeyRegistry, + RuntimeCapabilityRegistry, + normalizeAuthorManifest, +} from "@floatboat/nexus-plugin-runtime"; + +import { + BlockDragLifecyclePlugin, + blockDragLifecyclePluginManifest, +} from "../../src/runtime-plugin"; + +const capabilities = new RuntimeCapabilityRegistry(); +const commands = new CommandRegistry(); +const editors = new EditorHostRegistry(); +const hotkeys = new HotkeyRegistry(commands, { platform: "macos" }); +capabilities.registerOwnerBound(COMMANDS_CAPABILITY, ({ owner, registerResource }) => + commands.createService(owner, registerResource)); +capabilities.registerOwnerBound(EDITOR_HOST_CAPABILITY, ({ owner, registerResource }) => + editors.createService(owner, registerResource)); + +async function attach(id: string, source: string) { + const container = document.getElementById(id)!; + const editor = createEditor({ + container, + initialValue: source, + parseDelayMs: 0, + plugins: [createHistoryPlugin()], + }); + const attachment = editors.attach({ + editor, + surface: { kind: "document", root: container }, + }); + await attachment.ready; + return { editor, attachment, container }; +} + +let disable: (() => Promise) | null = null; +async function enable(): Promise { + if (disable) return; + const result = normalizeAuthorManifest(blockDragLifecyclePluginManifest, { + source: { kind: "development", locator: "fixture:block-drag-browser" }, + }); + if (!result.ok) throw new Error("Invalid block-drag manifest"); + const access = capabilities.createPluginAccess(result.manifest); + const app: NexusApp = { + apiVersion: "1.0.0", + host: { id: "block-drag-browser", name: "Browser acceptance", version: "1.0.0", platform: "web" }, + capabilities: access, + diagnostics: new DiagnosticBus(), + }; + const plugin = new BlockDragLifecyclePlugin(app, result.manifest); + const controller = new ComponentLifecycleRuntime().manage(plugin, result.manifest.identity); + access.bindOwner(plugin); + await controller.load(); + disable = async () => { + const result = await controller.unload(); + await access.dispose(); + if (!result.clean) throw new Error("Block-drag unload was not clean"); + disable = null; + }; +} + +const first = await attach("first", "First\n\nSecond\n\nThird"); +await enable(); +// A late attachment must receive the already active runtime extension. +const second = await attach("second", "Alpha\n\nBeta\n\nGamma"); +const fixtures = { first, second }; +let lastDispatch: { status: string; commandId?: string; ok?: boolean } | null = null; +let completion: Promise = Promise.resolve(); + +// Mirror the application's capture-phase command routing, deriving this fixture's +// active editor from the actual focused DOM surface rather than a supplied ID. +document.addEventListener("keydown", event => { + if (event.defaultPrevented || ["Alt", "Control", "Meta", "Shift"].includes(event.key)) return; + const active = Object.values(fixtures).find(fixture => + fixture.container.contains(document.activeElement)); + const result = hotkeys.dispatchKeyboardEvent(event, { + trigger: "hotkey", + editor: active?.attachment.context ?? null, + }); + lastDispatch = { status: result.status }; + if (result.status === "handled") { + const dispatch = lastDispatch; + dispatch.commandId = result.commandId; + completion = result.completion.then(result => { dispatch.ok = result.ok; }); + } else { + completion = Promise.resolve(); + } +}, true); + +const conflictResources: ManagedResource[] = []; +let conflictingCalls = 0; +async function conflict(enabled: boolean): Promise { + for (const resource of conflictResources.splice(0)) await resource.dispose(); + if (!enabled) return; + const owner = { pluginId: "acceptance-conflict", componentId: "acceptance-conflict/root" } as ResourceOwner; + const result = commands.createService(owner, resource => conflictResources.push(resource)).registerCommand({ + id: "move", + name: "Conflicting block movement", + callback: () => { conflictingCalls++; }, + defaultHotkeys: [{ key: "ArrowDown", modifiers: ["Alt", "Shift"] }], + }); + if (!result.ok) throw new Error("Could not register conflict fixture"); + for (const resource of conflictResources) await resource.activate?.(); +} + +Object.assign(window, { + runtimeFixture: { + async settled() { await completion; }, + snapshot() { + return { + documents: { first: first.editor.getDocument(), second: second.editor.getDocument() }, + overlays: document.querySelectorAll(".nexus-block-drag").length, + commands: commands.listCommands().map(command => command.id), + lastDispatch, + conflictingCalls, + }; + }, + preference(preference: HotkeyPreference) { + return hotkeys.setPreference("block-drag:move-down", preference); + }, + conflict, + enable, + async disable() { await disable?.(); }, + }, +}); diff --git a/packages/plugin-block-drag/test/browser/smoke.mjs b/packages/plugin-block-drag/test/browser/smoke.mjs new file mode 100644 index 00000000..d9978f2b --- /dev/null +++ b/packages/plugin-block-drag/test/browser/smoke.mjs @@ -0,0 +1,216 @@ +import assert from "node:assert/strict"; +import { dirname } from "node:path"; +import { mkdir } from "node:fs/promises"; +import { chromium } from "playwright"; + +const origin = process.env.BLOCK_DRAG_URL ?? "http://127.0.0.1:5178"; +const browser = await chromium.launch({ + headless: true, + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, +}); +const page = await browser.newPage({ viewport: { width: 1100, height: 850 } }); +const errors = []; +page.on("pageerror", error => errors.push(error.message)); +let assertions = 0; +const expected = async value => { + assert.equal(await page.evaluate(() => window.fixture.editor.getDocument()), value); + assertions++; +}; +const pauseFrames = () => page.evaluate(() => new Promise(resolve => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); +})); +const load = async source => { + await page.evaluate(value => window.fixture.load(value), source); + await page.evaluate(() => { window.fixture.view.scrollDOM.scrollTop = 0; }); + await pauseFrames(); +}; +const hoverBlock = async position => { + const point = await page.evaluate(position => { + const view = window.fixture.view; + const block = view.lineBlockAt(position); + const rect = view.contentDOM.getBoundingClientRect(); + return { + x: rect.left + 90, + y: view.documentTop + block.top * view.scaleY + 10, + }; + }, position); + await page.mouse.move(point.x, point.y); + await pauseFrames(); +}; +const handle = async position => { + await hoverBlock(position); + const box = await page.locator(".nexus-block-drag-handle").boundingBox(); + assert.ok(box, "hover exposes block handle"); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +}; +const dragToEnd = async (position = 0, cancel = false, beforeRelease) => { + const grip = await handle(position); + await page.mouse.move(grip.x, grip.y); + await page.mouse.down(); + await pauseFrames(); + const destination = await page.evaluate(() => { + const view = window.fixture.view; + const rect = view.scrollDOM.getBoundingClientRect(); + const last = view.lineBlockAt(view.state.doc.length); + return { + x: rect.left + 160, + y: Math.min(rect.bottom - 8, view.documentTop + last.bottom * view.scaleY + 5), + }; + }); + await page.mouse.move(destination.x, destination.y, { steps: 12 }); + await pauseFrames(); + if (beforeRelease) await beforeRelease(); + if (cancel) await page.keyboard.press("Escape"); + await page.mouse.up(); + await pauseFrames(); +}; + +const dragToStart = async position => { + const grip = await handle(position); + await page.mouse.move(grip.x, grip.y); + await page.mouse.down(); + await pauseFrames(); + const destination = await page.evaluate(() => { + const view = window.fixture.view; + return { + x: view.scrollDOM.getBoundingClientRect().left + 150, + y: view.documentTop + 2, + }; + }); + await page.mouse.move(destination.x, destination.y, { steps: 12 }); + await page.mouse.up(); + await pauseFrames(); +}; + +const pixel = "data:image/png;base64," + + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jPZkAAAAASUVORK5CYII="; +const blocks = [ + "First paragraph", + "# Heading", + "> Quote\n> second line", + "- First\n- Second\n - Nested", + "```js\nconst n = 1;\n```", + " code", + "---", + "| A | B |\n| --- | --- |\n| one | two |", + `Image ![pixel](${pixel})`, +]; + +try { + for (const preview of [false, true]) { + await page.goto(`${origin}/packages/plugin-block-drag/test/browser/index.html?preview=${preview}`); + await page.waitForFunction(() => window.fixture); + for (const block of blocks) { + const source = `${block}\n\nTail`; + await load(source); + await dragToEnd(); + await expected(`Tail\n\n${block}`); + await page.evaluate(() => window.fixture.editor.undo()); + await expected(source); + await page.evaluate(() => window.fixture.editor.redo()); + await expected(`Tail\n\n${block}`); + await dragToStart(6); + await expected(source); + } + await load("First\n\nSecond\n\nThird"); + await dragToEnd(0, true); + await expected("First\n\nSecond\n\nThird"); + for (const zoom of [0.75, 1.5]) { + await page.evaluate(zoom => { + document.querySelector("#editor").style.zoom = String(zoom); + }, zoom); + await load("First\n\nSecond"); + await dragToEnd(); + await expected("Second\n\nFirst"); + } + await page.evaluate(() => { document.querySelector("#editor").style.zoom = "1"; }); + await load("First\n\nSecond\n\nThird"); + await handle(0); + await page.locator(".nexus-block-drag-handle").focus(); + await page.keyboard.press("ArrowDown"); + await pauseFrames(); + assert.equal( + await page.locator(".nexus-block-drag-handle").evaluate(element => + element === document.activeElement + ), + true, + "keyboard movement retains handle focus" + ); + await page.keyboard.press("ArrowDown"); + await expected("Second\n\nThird\n\nFirst"); + await page.keyboard.press("ArrowUp"); + await page.keyboard.press("ArrowUp"); + await expected("First\n\nSecond\n\nThird"); + + await page.evaluate(() => window.fixture.readOnly(true)); + await hoverBlock(0); + assert.equal( + await page.locator(".nexus-block-drag-handle").isVisible(), + false, + "read-only handle must be hidden" + ); + await expected("First\n\nSecond\n\nThird"); + await page.evaluate(() => window.fixture.readOnly(false)); + const grip = await handle(0); + await page.mouse.move(grip.x, grip.y); + await page.mouse.down(); + await page.mouse.move(grip.x + 100, grip.y + 100, { steps: 5 }); + await page.evaluate(() => window.fixture.enable(false)); + await page.mouse.up(); + assert.equal(await page.locator(".nexus-block-drag").count(), 0); + await expected("First\n\nSecond\n\nThird"); + await page.evaluate(() => window.fixture.enable(true)); + + await load(Array.from({ length: 100 }, (_, i) => `Paragraph ${i}`).join("\n\n")); + const longGrip = await handle(0); + await page.mouse.move(longGrip.x, longGrip.y); + await page.mouse.down(); + const rect = await page.locator(".cm-scroller").boundingBox(); + await page.mouse.move(rect.x + 150, rect.y + rect.height - 3, { steps: 12 }); + await page.waitForFunction(() => window.fixture.view.scrollDOM.scrollTop > 100); + await page.keyboard.press("Escape"); + await page.mouse.up(); + const stopped = await page.evaluate(() => window.fixture.view.scrollDOM.scrollTop); + await pauseFrames(); + assert.equal(await page.evaluate(() => window.fixture.view.scrollDOM.scrollTop), stopped); + assertions++; + } + + const table = "| A | B |\n| --- | --- |\n| old | value |\n\nTail"; + await load(table); + await page.evaluate(() => { window.allowCellSave = window.fixture.rejectCellSave(); }); + const cell = page.locator(".nexus-cell").nth(2); + await cell.click(); + await cell.fill("UNSAVED"); + await dragToEnd(); + await expected(table); + assert.ok(await page.evaluate(() => window.fixture.rejected()) > 0); + assert.equal(await cell.textContent(), "UNSAVED"); + await page.evaluate(() => window.allowCellSave()); + await dragToEnd(); + await expected("Tail\n\n| A | B |\n| --- | --- |\n| UNSAVED | value |"); + await page.evaluate(() => window.fixture.editor.undo()); + await expected(table.replace("old", "UNSAVED")); + + await load("First paragraph\n\nSecond paragraph\n\nThird paragraph"); + if (process.env.BLOCK_DRAG_SCREENSHOT) { + const path = process.env.BLOCK_DRAG_SCREENSHOT; + const draggingPath = path.endsWith(".png") + ? `${path.slice(0, -4)}-dragging.png` + : `${path}-dragging.png`; + await mkdir(dirname(path), { recursive: true }); + await dragToEnd(0, false, () => page.screenshot({ path: draggingPath })); + await expected("Second paragraph\n\nThird paragraph\n\nFirst paragraph"); + await page.screenshot({ path }); + } else { + await dragToEnd(); + } + assert.deepEqual(errors, [], "no browser errors"); + console.log([ + `Browser block reordering passed: ${assertions} document/scroll assertions;`, + "source + live preview, all block types, undo/redo, cancellation, zoom,", + "keyboard focus, read-only, unload, autoscroll, rejected table save and retry.", + ].join(" ")); +} finally { + await browser.close(); +} diff --git a/packages/plugin-block-drag/test/extension.test.ts b/packages/plugin-block-drag/test/extension.test.ts new file mode 100644 index 00000000..de5f96b4 --- /dev/null +++ b/packages/plugin-block-drag/test/extension.test.ts @@ -0,0 +1,351 @@ +import { history } from "@codemirror/commands"; +import { codeFolding, foldEffect } from "@codemirror/language"; +import { Compartment, EditorState } from "@codemirror/state"; +import { EditorView, ViewPlugin } from "@codemirror/view"; +import { createEditor, type EditorConfig } from "@floatboat/nexus-core"; +import { createGfmPreset } from "@floatboat/nexus-preset-gfm"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createBlockDragExtension, createBlockDragPlugin } from "../src"; +import { canMoveBlock, moveBlock, stopBlockDrag } from "../src/extension"; + +const cleanups: Array<() => void> = []; + +afterEach(() => { + cleanups.splice(0).forEach((cleanup) => cleanup()); + vi.restoreAllMocks(); + document.body.replaceChildren(); +}); + +function editorFor( + source = "First\n\nSecond\n\nThird", + config: Partial = {}, +) { + const container = document.createElement("div"); + document.body.append(container); + let view!: EditorView; + const readonly = new Compartment(); + const editor = createEditor({ + container, + initialValue: source, + ...config, + plugins: [ + createGfmPreset(), + createBlockDragPlugin(), + { + name: "test-host", + cmExtensions: [ + history(), + codeFolding(), + readonly.of([]), + ViewPlugin.define((value) => { + view = value; + return {}; + }), + ], + }, + ], + }); + cleanups.push(() => editor.destroy()); + return { editor, view, container, readonly }; +} + +function pointer( + target: EventTarget, + type: string, + y: number, + options: Partial = {}, +): void { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 30, + clientY: y, + button: 0, + buttons: 1, + }); + Object.defineProperties(event, Object.fromEntries( + Object.entries({ pointerId: 9, pointerType: "mouse", ...options }) + .map(([key, value]) => [key, { value }]), + )); + target.dispatchEvent(event); +} + +function geometry(view: EditorView) { + for (const element of [view.dom, view.contentDOM, view.scrollDOM]) { + vi.spyOn(element, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 400, 300)); + } + vi.spyOn(view, "lineBlockAtHeight").mockImplementation((height) => + view.lineBlockAt(height < 80 ? 0 : view.state.doc.length), + ); + let measure = (): void => {}; + vi.spyOn(view, "requestMeasure").mockImplementation((request) => { + if (request) measure = () => request.write?.(request.read(view), view); + }); + pointer(view.dom, "pointermove", 5); + measure(); + const handle = view.dom.querySelector(".nexus-block-drag-handle")!; + let captured = false; + handle.setPointerCapture = () => { + captured = true; + }; + handle.hasPointerCapture = () => captured; + handle.releasePointerCapture = () => { + captured = false; + }; + return { handle, measure: () => measure() }; +} + +function start(view: EditorView) { + const controls = geometry(view); + expect(controls.handle.hidden).toBe(false); + pointer(controls.handle, "pointerdown", 5); + pointer(window, "pointermove", 240); + controls.measure(); + return controls; +} + +describe("block drag interaction", () => { + it.each([true, false])( + "moves only on release and restores content and selection (preview %s)", + (livePreview) => { + const { editor, view } = editorFor(undefined, { livePreview }); + editor.setSelection(2); + start(view); + expect(editor.getDocument()).toBe("First\n\nSecond\n\nThird"); + pointer(window, "pointerup", 240, { buttons: 0 }); + expect(editor.getDocument()).toBe("Second\n\nThird\n\nFirst"); + expect(editor.getSelection().head).toBe(17); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("First\n\nSecond\n\nThird"); + expect(editor.getSelection().head).toBe(2); + expect(editor.redo()).toBe(true); + }, + ); + + it.each([ + "Escape", "pointercancel", "blur", "outside", "stale", "readonly", "stop", + ])("cancels on %s", (reason) => { + const { editor, view, readonly } = editorFor(); + start(view); + if (reason === "Escape") { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + } + if (reason === "pointercancel") pointer(window, reason, 240); + if (reason === "blur") window.dispatchEvent(new Event("blur")); + if (reason === "stale") editor.replaceRange(0, 0, "New "); + if (reason === "readonly") { + view.dispatch({ effects: readonly.reconfigure(EditorState.readOnly.of(true)) }); + } + if (reason === "stop") stopBlockDrag(view); + pointer(window, "pointerup", 240, { + buttons: 0, + clientX: reason === "outside" ? 600 : 30, + }); + expect(editor.getDocument()).toBe(`${reason === "stale" ? "New " : ""}First\n\nSecond\n\nThird`); + expect(view.dom.querySelector(".is-dragging")).toBeNull(); + }); + + it("ignores touch, clicks below threshold, and unrelated pointer release", () => { + const { editor, view } = editorFor(); + const { handle } = geometry(view); + pointer(handle, "pointerdown", 5, { pointerType: "touch" }); + pointer(window, "pointermove", 240); + pointer(window, "pointerup", 240); + expect(editor.getDocument()).toBe("First\n\nSecond\n\nThird"); + pointer(handle, "pointerdown", 5); + pointer(window, "pointermove", 7); + pointer(window, "pointerup", 7); + expect(editor.undo()).toBe(false); + pointer(handle, "pointerdown", 5); + pointer(window, "pointermove", 240); + pointer(window, "pointerup", 240, { pointerId: 10 }); + expect(editor.getDocument()).toBe("First\n\nSecond\n\nThird"); + pointer(window, "pointerup", 240); + expect(editor.getDocument()).toBe("Second\n\nThird\n\nFirst"); + }); + + it("keeps each keyboard move separate from typing in undo history", () => { + const { editor, view } = editorFor(); + view.dispatch({ + changes: { from: 0, insert: "X" }, + userEvent: "input.type", + selection: { anchor: 1 }, + }); + expect(canMoveBlock(view, -1)).toBe(false); + expect(moveBlock(view, 1)).toBe(true); + moveBlock(view, 1); + editor.undo(); + expect(editor.getDocument()).toBe("Second\n\nXFirst\n\nThird"); + editor.undo(); + expect(editor.getDocument()).toBe("XFirst\n\nSecond\n\nThird"); + editor.undo(); + expect(editor.getDocument()).toBe("First\n\nSecond\n\nThird"); + }); + + it("reports host rejection without creating undo history", () => { + const { editor, view } = editorFor(); + editor.getContributionSink().registerTransactionFilter("deny", () => ({ action: "reject" })); + moveBlock(view, 1); + expect(view.dom.querySelector('[role="status"]')?.textContent).toBe("Block could not be moved"); + expect(editor.undo()).toBe(false); + }); + + it("does not handle keyboard moves in read-only, composing, folded, or stopped editors", () => { + const readonly = editorFor(undefined, { readOnly: true }); + expect(moveBlock(readonly.view, 1)).toBe(false); + const composing = editorFor(); + vi.spyOn(composing.view, "composing", "get").mockReturnValue(true); + expect(moveBlock(composing.view, 1)).toBe(false); + const folded = editorFor("# Title\n\nBody\n\n# End"); + folded.view.dispatch({ effects: foldEffect.of({ from: 7, to: 13 }) }); + expect(moveBlock(folded.view, 1)).toBe(false); + const stopped = editorFor(); + stopBlockDrag(stopped.view); + expect(moveBlock(stopped.view, 1)).toBe(false); + }); + + it("removes handles and keys on dynamic disposal", async () => { + const container = document.createElement("div"); + document.body.append(container); + const editor = createEditor({ container, initialValue: "First\n\nSecond" }); + cleanups.push(() => editor.destroy()); + const registration = editor.getContributionSink().registerExtension( + "optional-drag", + createBlockDragExtension(), + ); + await registration.ready; + expect(container.querySelectorAll(".nexus-block-drag")).toHaveLength(1); + await registration.dispose(); + expect(container.querySelector(".nexus-block-drag")).toBeNull(); + expect(container.querySelector("[data-block-reorder]")).toBeNull(); + }); + + it.each(["keyboard", "pointer"])("cancels the move when a table commit changes block identity (%s)", async (trigger) => { + const original = "| A | B |\n| --- | --- |\n| old | value |\n\nAfter\n\nTail"; + const { editor, view, container } = editorFor(original, { livePreview: true }); + const cell = container.querySelectorAll(".nexus-cell")[2]; + let rejectSave = true; + editor.getContributionSink().registerTransactionFilter("cell-guard", (context) => + rejectSave && context.changes.length > 0 ? { action: "reject" } : { action: "accept" }, + ); + cell.tabIndex = -1; + cell.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })); + document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0 })); + cell.focus(); + cell.textContent = "old\n\nnew block"; + cell.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste" })); + cell.blur(); + await Promise.resolve(); + expect(editor.getDocument()).toBe(original); + editor.setSelection(original.indexOf("After")); + view.focus(); + rejectSave = false; + + if (trigger === "keyboard") { + expect(moveBlock(view, 1)).toBe(true); + } else { + const controls = geometry(view); + vi.mocked(view.lineBlockAtHeight).mockImplementation((height) => view.lineBlockAt( + height < 80 ? 0 : height < 200 ? view.state.doc.toString().indexOf("After") : view.state.doc.length, + )); + pointer(view.dom, "pointermove", 120); + controls.measure(); + expect(controls.handle.hidden).toBe(false); + pointer(controls.handle, "pointerdown", 120); + pointer(window, "pointermove", 240); + controls.measure(); + pointer(window, "pointerup", 240, { buttons: 0 }); + } + + expect(editor.getDocument()).toBe(original.replace("old", "old\n\nnew block")); + expect(view.dom.querySelector('[role="status"]')?.textContent).toBe("Block could not be moved"); + expect(view.dom.querySelector(".is-dragging")).toBeNull(); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe(original); + }); + + it.each([false, true])("preserves table input and blocks rejected saves (reject %s)", (reject) => { + const original = "| A | B |\n| --- | --- |\n| old | value |\n\nEnd"; + const { editor, view, container } = editorFor(original, { livePreview: true }); + const cell = container.querySelectorAll("tr")[2] + .querySelector(".nexus-cell")!; + cell.tabIndex = -1; + cell.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })); + document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0 })); + cell.focus(); + cell.textContent = "updated"; + cell.dispatchEvent(new InputEvent("input", { + bubbles: true, + inputType: "insertText", + data: "updated", + })); + const registration = editor.getContributionSink().registerTransactionFilter( + "cell-guard", + (context) => reject && context.changes.some((change) => change.insert.includes("updated")) + ? { action: "reject" } + : { action: "accept" }, + ); + start(view); + pointer(window, "pointerup", 240); + if (reject) { + expect(editor.getDocument()).toBe(original); + expect(container.querySelectorAll("tr")[2].textContent).toContain("updated"); + registration.dispose(); + start(view); + pointer(window, "pointerup", 240); + } + expect(editor.getDocument()).toBe("End\n\n| A | B |\n| --- | --- |\n| updated | value |"); + editor.undo(); + expect(editor.getDocument()).toContain("updated"); + expect(editor.getDocument().startsWith("| A | B |")).toBe(true); + }); +}); + +describe("block handle navigation", () => { + it("keeps keyboard focus on the handle across repeated moves", () => { + const { editor, view } = editorFor(); + const { handle, measure } = geometry(view); + handle.focus(); + handle.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + measure(); + expect(document.activeElement).toBe(handle); + handle.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + expect(editor.getDocument()).toBe("Second\n\nThird\n\nFirst"); + }); + + it("does not insert a block into a collapsed heading's body", () => { + const { editor, view } = editorFor("# Heading\n\nHidden\n\nLast"); + view.dispatch({ effects: foldEffect.of({ from: 9, to: 17 }) }); + editor.setSelection(19); + expect(canMoveBlock(view, -1)).toBe(false); + moveBlock(view, -1); + expect(editor.getDocument()).toBe("# Heading\n\nHidden\n\nLast"); + }); +}); + +it("cancels if the source heading is folded during a drag", () => { + const source = "# Heading\n\nHidden\n\nLast"; + const { editor, view } = editorFor(source); + start(view); + view.dispatch({ effects: foldEffect.of({ from: 9, to: 17 }) }); + pointer(window, "pointerup", 240); + expect(editor.getDocument()).toBe(source); + expect(view.dom.querySelector(".is-dragging")).toBeNull(); +}); + +it("keeps the handle and drag indicator visible after horizontal scrolling", () => { + const { view } = editorFor(); + const { handle, measure } = geometry(view); + view.scrollDOM.scrollLeft = 180; + vi.mocked(view.contentDOM.getBoundingClientRect).mockReturnValue(new DOMRect(-180, 0, 1000, 300)); + measure(); + expect(handle.style.left).toBe("1px"); + pointer(handle, "pointerdown", 5); + pointer(window, "pointermove", 240); + measure(); + const indicator = view.dom.querySelector(".nexus-block-drag-indicator")!; + expect(indicator.hidden).toBe(false); + expect(indicator.style.left).toBe("28px"); + expect(indicator.style.width).toBe("372px"); +}); diff --git a/packages/plugin-block-drag/test/model.test.ts b/packages/plugin-block-drag/test/model.test.ts new file mode 100644 index 00000000..e8ecf3b8 --- /dev/null +++ b/packages/plugin-block-drag/test/model.test.ts @@ -0,0 +1,185 @@ +import { EditorSelection, EditorState, type Extension } from "@codemirror/state"; +import { describe, expect, it } from "vitest"; + +import { createMarkdownLanguageSupport } from "../../core/src/lezer-markdown"; +import { planBlockMove, readBlocks } from "../src/model"; + +function createState(doc: string, selection?: EditorSelection, extensions: Extension[] = []): EditorState { + return EditorState.create({ + doc, + selection, + extensions: [createMarkdownLanguageSupport(), EditorState.allowMultipleSelections.of(true), ...extensions] + }); +} + +function move(doc: string, source: number, gap: number): string | null { + const state = createState(doc); + const plan = planBlockMove(state, readBlocks(state), source, gap); + return plan?.changes.apply(state.doc).toString() ?? null; +} + +describe("Markdown block model", () => { + it("reads full outermost containers and retains unsupported blocks as barriers", () => { + const doc = [ + "# Heading", "paragraph", "- parent\n - child", "> quote\n>\n> - nested", + "```js\nconst a = 1;\n```", " indented", "| a | b |\n| --- | --- |\n| x | y |", + "***", "[ref]: https://example.com", "[^note]: footnote", "
html
" + ].join("\n\n"); + const state = createState(doc); + const blocks = readBlocks(state); + expect(blocks.map(block => block.type)).toEqual([ + "ATXHeading1", "Paragraph", "BulletList", "Blockquote", "FencedCode", "CodeBlock", "Table", + "HorizontalRule", "LinkReference", "LinkReference", "HTMLBlock" + ]); + expect(blocks.map(block => block.movable)).toEqual([ + true, true, true, true, true, true, true, true, false, false, false + ]); + expect(state.doc.sliceString(blocks[5].from, blocks[5].to)).toBe(" indented"); + }); + + it("has no handles for empty documents or missing Markdown language", () => { + expect(readBlocks(createState("\n \n"))).toEqual([]); + expect(readBlocks(EditorState.create({ doc: "paragraph" }))).toEqual([]); + }); + + it.each([ + [0, 3, "B\n\nC\n\nA"], + [2, 0, "C\n\nA\n\nB"], + [1, 0, "B\n\nA\n\nC"], + [1, 3, "A\n\nC\n\nB"], + [0, 2, "B\n\nA\n\nC"], + [2, 1, "A\n\nC\n\nB"] + ])("moves block %i to gap %i", (source, gap, expected) => { + expect(move("A\n\nB\n\nC", source, gap)).toBe(expected); + }); + + it.each([ + "# Title", "Title\n====", "---", "![logo](logo.png)", + "- [x] checked\n- [ ] open\n - nested", + "3. third\n4. fourth", "> first\n>\n> - nested", + "```ts\nconst x = `hello`;\n```", "~~~~ python\nprint('hi')\n~~~~", + " const x = 1;\n const y = 2;", "\tindented\n\tcode", + "| a | b |\n| :--- | ---: |\n| x\\|y | z |", + " **bold** and [link](url) \nnext line" + ])("preserves raw source when moving %j", (block) => { + expect(move(`before\n\n${block}\n\nafter`, 1, 0)).toBe(`${block}\n\nbefore\n\nafter`); + expect(move(`before\n\n${block}\n\nafter`, 1, 3)).toBe(`before\n\nafter\n\n${block}`); + }); + + it("keeps leading and trailing whitespace and all existing separator bytes", () => { + expect(move("\n \nA\n\n \nB\n\nC\n\n", 0, 3)).toBe("\n \nB\n\nC\n\n \nA\n\n"); + expect(move("A\n\nB\n\n", 1, 0)).toBe("B\n\nA\n\n"); + expect(move("A\n\nB", 1, 0)).toBe("B\n\nA"); + }); + + it("uses normalized document positions for CRLF input", () => { + const state = createState("A\r\n\r\nB\r\n"); + const plan = planBlockMove(state, readBlocks(state), 0, 2)!; + expect(plan.changes.apply(state.doc).toString()).toBe("B\n\nA\n"); + expect(plan.movedFrom).toBe(3); + }); + + it("adds a blank line if removal would merge paragraphs", () => { + expect(move("before\n# heading\nafter", 1, 0)).toBe("# heading\nbefore\n\nafter"); + }); + + it("adds a blank line if insertion would turn a thematic break into a setext heading", () => { + expect(move("# Heading\n---\ntext", 1, 3)).toBe("# Heading\ntext\n\n---"); + }); + + it("retains single newlines when the structure already stays intact", () => { + expect(move("# A\n## B\n### C", 0, 3)).toBe("## B\n### C\n# A"); + }); + + it("rejects an insertion that merges independent lists", () => { + expect(move("- first\n\nseparator\n\n- second", 2, 1)).toBeNull(); + }); + + it("rejects a removal that merges independent lists", () => { + expect(move("- first\n\nseparator\n\n- second\n\nlast", 1, 4)).toBeNull(); + }); + + it("keeps blockquotes separate, adding a blank line when required", () => { + expect(move("> first\n\nseparator\n\n> second", 2, 1)).toBe("> first\n\n> second\n\nseparator"); + expect(move("> first\n# heading\n> second", 1, 3)).toBe("> first\n\n> second\n# heading"); + }); + + it("rejects indented code being absorbed into a list even if root types look plausible", () => { + expect(move("- list\n\nseparator\n\n code", 2, 1)).toBeNull(); + }); + + it("rejects an unterminated fence swallowing neighboring content", () => { + expect(move("first\n\nsecond\n\n```js\nunclosed", 2, 0)).toBeNull(); + }); + + it("retains link definitions and their spelling when other blocks move", () => { + expect(move("[x]: /path 'title'\n\nparagraph [x]\n\nlast", 1, 3)) + .toBe("[x]: /path 'title'\n\nlast\n\nparagraph [x]"); + expect(move("[x]: /path\n\nparagraph", 0, 2)).toBeNull(); + }); + + it("uses the configured Markdown dialect instead of a second default parser", () => { + const state = createState("before\n\n| x |\n| --- |\n| ~~gone~~ |\n\nafter"); + const plan = planBlockMove(state, readBlocks(state), 0, 3)!; + expect(plan.changes.apply(state.doc).toString()).toBe("| x |\n| --- |\n| ~~gone~~ |\n\nafter\n\nbefore"); + }); + + it.each([[0, 0], [0, 1], [1, 1], [1, 2], [-1, 2], [3, 0], [0, -1], [0, 4], [0.5, 3], [0, NaN]])( + "rejects no-op or invalid indexes %s, %s", (source, gap) => { + expect(move("A\n\nB\n\nC", source, gap)).toBeNull(); + } + ); + + it("rejects stale ranges and synthetic unsupported-block overrides", () => { + const state = createState("A\n\nB\n\nC"); + const blocks = readBlocks(state); + const changed = state.update({ changes: { from: 0, insert: "more " } }).state; + expect(planBlockMove(changed, blocks, 0, 3)).toBeNull(); + const unsupported = createState("[x]: /path\n\nA"); + expect(planBlockMove(unsupported, readBlocks(unsupported).map(block => ({ ...block, movable: true })), 0, 2)) + .toBeNull(); + }); + + it("moves forward and backward selections with their source text", () => { + for (const range of [EditorSelection.range(1, 4), EditorSelection.range(4, 1)]) { + const state = createState("alpha\n\nbeta\n\ngamma", EditorSelection.create([range])); + const plan = planBlockMove(state, readBlocks(state), 0, 3)!; + expect(plan.selection.main.anchor).toBe(plan.movedFrom + range.anchor); + expect(plan.selection.main.head).toBe(plan.movedFrom + range.head); + expect(plan.changes.apply(state.doc).sliceString(plan.selection.main.from, plan.selection.main.to)).toBe("lph"); + } + }); + + it("maps all cursors and preserves the main range when order changes", () => { + const state = createState("alpha\n\nbeta\n\ngamma", EditorSelection.create([ + EditorSelection.cursor(2), EditorSelection.cursor(8), EditorSelection.range(14, 17) + ], 0)); + const plan = planBlockMove(state, readBlocks(state), 0, 3)!; + expect(plan.selection.ranges.map(range => [range.anchor, range.head])).toEqual([[1, 1], [7, 10], [15, 15]]); + expect(plan.selection.main.head).toBe(15); + }); + + it("maps the stationary content when a later block is inserted before it", () => { + const state = createState("alpha\n\nbeta", EditorSelection.single(1, 4)); + const plan = planBlockMove(state, readBlocks(state), 1, 0)!; + expect(plan.selection.main.anchor).toBe(7); + expect(plan.selection.main.head).toBe(10); + }); + + it("keeps source-end and source-start cursors with the moved block", () => { + const state = createState("alpha\n\nbeta", EditorSelection.create([ + EditorSelection.cursor(0), EditorSelection.cursor(5) + ])); + const plan = planBlockMove(state, readBlocks(state), 0, 2)!; + expect(plan.selection.ranges.map(range => range.head)).toEqual([6, 11]); + }); + + it("keeps stationary boundary cursors attached to their content", () => { + const atStart = createState("alpha\n\nbeta", EditorSelection.create([EditorSelection.cursor(0, -1)])); + const prepend = planBlockMove(atStart, readBlocks(atStart), 1, 0)!; + expect(prepend.selection.main.head).toBe(6); + const atEnd = createState("alpha\n\nbeta", EditorSelection.single(11)); + const append = planBlockMove(atEnd, readBlocks(atEnd), 0, 2)!; + expect(append.selection.main.head).toBe(4); + }); +}); diff --git a/packages/plugin-block-drag/test/runtime.test.ts b/packages/plugin-block-drag/test/runtime.test.ts new file mode 100644 index 00000000..66b47af1 --- /dev/null +++ b/packages/plugin-block-drag/test/runtime.test.ts @@ -0,0 +1,235 @@ +import { createEditor } from "@floatboat/nexus-core"; +import { createHistoryPlugin } from "@floatboat/nexus-plugin-history"; +import { + COMMANDS_CAPABILITY, + EDITOR_HOST_CAPABILITY, + type ManagedResource, + type NexusApp, + type ResourceOwner, +} from "@floatboat/nexus-plugin-api"; +import { + CommandRegistry, + ComponentLifecycleRuntime, + DiagnosticBus, + EditorHostRegistry, + HotkeyRegistry, + RuntimeCapabilityRegistry, + normalizeAuthorManifest, +} from "@floatboat/nexus-plugin-runtime"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + BlockDragLifecyclePlugin, + blockDragLifecyclePluginManifest, +} from "../src/runtime-plugin"; + +const cleanups: (() => void | Promise)[] = []; + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); + document.body.replaceChildren(); +}); + +function createHost() { + const capabilities = new RuntimeCapabilityRegistry(); + const commands = new CommandRegistry(); + const editors = new EditorHostRegistry(); + const hotkeys = new HotkeyRegistry(commands, { platform: "linux" }); + capabilities.registerOwnerBound(COMMANDS_CAPABILITY, ({ owner, registerResource }) => + commands.createService(owner, registerResource)); + capabilities.registerOwnerBound(EDITOR_HOST_CAPABILITY, ({ owner, registerResource }) => + editors.createService(owner, registerResource)); + + async function attach(initialValue = "first\n\nsecond", readOnly = false) { + const container = document.createElement("div"); + document.body.append(container); + const editor = createEditor({ + container, + initialValue, + readOnly, + parseDelayMs: 0, + plugins: [createHistoryPlugin()], + }); + const attachment = editors.attach({ + editor, + surface: { kind: "document", root: container }, + }); + await attachment.ready; + cleanups.push(async () => { + await attachment.detach(); + editor.destroy(); + container.remove(); + }); + return { editor, attachment, container }; + } + + async function enable() { + const result = normalizeAuthorManifest(blockDragLifecyclePluginManifest, { + source: { kind: "development", locator: "fixture:block-drag" }, + }); + if (!result.ok) throw new Error("Invalid block-drag manifest"); + const access = capabilities.createPluginAccess(result.manifest); + const app: NexusApp = { + apiVersion: "1.0.0", + host: { id: "block-drag-test", name: "Block Drag Test", version: "1.0.0", platform: "web" }, + capabilities: access, + diagnostics: new DiagnosticBus(), + }; + const plugin = new BlockDragLifecyclePlugin(app, result.manifest); + const controller = new ComponentLifecycleRuntime().manage(plugin, result.manifest.identity); + access.bindOwner(plugin); + await controller.load(); + let disabled = false; + const disable = async () => { + if (disabled) return; + disabled = true; + expect((await controller.unload()).clean).toBe(true); + await access.dispose(); + }; + cleanups.push(disable); + return { plugin, disable }; + } + + return { commands, editors, hotkeys, attach, enable }; +} + +function content(container: HTMLElement): HTMLElement { + const element = container.querySelector(".cm-content"); + if (!element) throw new Error("Missing editor content"); + return element; +} + +function key(value = "ArrowDown", init: KeyboardEventInit = {}) { + return new KeyboardEvent("keydown", { + key: value, + altKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + ...init, + }); +} + +describe("BlockDragLifecyclePlugin", () => { + it("routes commands to their editor, including editors attached after enable", async () => { + const host = createHost(); + const first = await host.attach(); + await host.enable(); + const second = await host.attach("alpha\n\nbeta"); + + expect(await host.commands.checkCommand("block-drag:move-down", { + editor: second.attachment.context, + })).toEqual({ status: "available" }); + expect(second.editor.getDocument()).toBe("alpha\n\nbeta"); + expect((await host.commands.executeCommand("block-drag:move-down", { + editor: second.attachment.context, + })).ok).toBe(true); + expect(second.editor.getDocument()).toBe("beta\n\nalpha"); + expect(first.editor.getDocument()).toBe("first\n\nsecond"); + expect(second.editor.undo()).toBe(true); + expect(second.editor.getDocument()).toBe("alpha\n\nbeta"); + }); + + it("disables commands at document boundaries and in read-only editors", async () => { + const host = createHost(); + const first = await host.attach(); + const readOnly = await host.attach(undefined, true); + await host.enable(); + expect(await host.commands.checkCommand("block-drag:move-up", { + editor: first.attachment.context, + })).toMatchObject({ status: "unavailable" }); + expect(await host.commands.checkCommand("block-drag:move-down", { + editor: readOnly.attachment.context, + })).toMatchObject({ status: "unavailable" }); + expect(await host.commands.checkCommand("block-drag:move-down")).toEqual({ status: "no-editor" }); + }); + + it("honors rebinding and clearing without leaving a fixed editor keymap", async () => { + const host = createHost(); + const fixture = await host.attach(); + await host.enable(); + const context = { editor: fixture.attachment.context }; + const down = "block-drag:move-down"; + const original = host.hotkeys.dispatchKeyboardEvent(key(), context); + expect(original.status).toBe("handled"); + if (original.status === "handled") expect((await original.completion).ok).toBe(true); + expect(fixture.editor.getDocument()).toBe("second\n\nfirst"); + fixture.editor.undo(); + + await host.hotkeys.setPreference(down, { + mode: "custom", + bindings: [{ key: "j", modifiers: ["Alt"] }], + }); + expect(host.hotkeys.dispatchKeyboardEvent(key(), context).status).toBe("pass"); + const rebound = host.hotkeys.dispatchKeyboardEvent(key("j", { shiftKey: false }), context); + expect(rebound.status).toBe("handled"); + if (rebound.status === "handled") expect((await rebound.completion).ok).toBe(true); + fixture.editor.undo(); + + await host.hotkeys.setPreference(down, { mode: "cleared" }); + expect(host.hotkeys.dispatchKeyboardEvent(key("j", { shiftKey: false }), context).status).toBe("pass"); + const event = key(); + content(fixture.container).dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + expect(fixture.editor.getDocument()).toBe("first\n\nsecond"); + }); + + it("leaves conflicting shortcuts to the host conflict policy", async () => { + const host = createHost(); + const fixture = await host.attach(); + await host.enable(); + const resources: ManagedResource[] = []; + const action = vi.fn(); + const owner = { pluginId: "another-plugin", componentId: "another-plugin/root" } as ResourceOwner; + host.commands.createService(owner, (resource) => resources.push(resource)).registerCommand({ + id: "move", + name: "Other movement", + callback: action, + defaultHotkeys: [{ key: "ArrowDown", modifiers: ["Alt", "Shift"] }], + }); + for (const resource of resources) await resource.activate?.(); + cleanups.push(async () => { + for (const resource of resources) await resource.dispose(); + }); + + const event = key(); + expect(host.hotkeys.dispatchKeyboardEvent(event, { + editor: fixture.attachment.context, + }).status).toBe("conflict"); + content(fixture.container).dispatchEvent(event); + expect(fixture.editor.getDocument()).toBe("first\n\nsecond"); + expect(action).not.toHaveBeenCalled(); + }); + + it("removes commands on disable and preserves hotkey preferences after re-enable", async () => { + const host = createHost(); + const fixture = await host.attach(); + const first = await host.enable(); + expect(fixture.container.querySelectorAll(".nexus-block-drag")).toHaveLength(1); + await host.hotkeys.setPreference("block-drag:move-down", { mode: "cleared" }); + await first.disable(); + expect(fixture.container.querySelectorAll(".nexus-block-drag")).toHaveLength(0); + expect(host.commands.listCommands()).toEqual([]); + expect(host.hotkeys.dispatchKeyboardEvent(key()).status).toBe("pass"); + await host.enable(); + expect(fixture.container.querySelectorAll(".nexus-block-drag")).toHaveLength(1); + expect(host.commands.listCommands()).toHaveLength(2); + expect(host.hotkeys.getBindings("block-drag:move-down")).toEqual([]); + expect((await host.commands.executeCommand("block-drag:move-down", { + editor: fixture.attachment.context, + })).ok).toBe(true); + expect(fixture.editor.getDocument()).toBe("second\n\nfirst"); + }); + + it("does not retain an editor after detach", async () => { + const host = createHost(); + const fixture = await host.attach(); + await host.enable(); + const context = fixture.attachment.context; + await fixture.attachment.detach(); + expect(await host.commands.checkCommand("block-drag:move-down", { + editor: context, + })).toMatchObject({ status: "unavailable" }); + expect(fixture.editor.getDocument()).toBe("first\n\nsecond"); + }); +}); diff --git a/packages/plugin-block-drag/tsconfig.json b/packages/plugin-block-drag/tsconfig.json new file mode 100644 index 00000000..6ece4d33 --- /dev/null +++ b/packages/plugin-block-drag/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/reference-plugins/api/public-consumer.ts b/packages/reference-plugins/api/public-consumer.ts index ac6589fe..605831d6 100644 --- a/packages/reference-plugins/api/public-consumer.ts +++ b/packages/reference-plugins/api/public-consumer.ts @@ -9,6 +9,7 @@ const flags: ReferencePluginFeatureFlags = { toolbar: true, slashMenu: true, wordCount: true, + blockDrag: true, }; const plan = createReferencePluginBootPlan(flags); diff --git a/packages/reference-plugins/package.json b/packages/reference-plugins/package.json index 1ec528fb..48cc0903 100644 --- a/packages/reference-plugins/package.json +++ b/packages/reference-plugins/package.json @@ -27,6 +27,7 @@ "dependencies": { "@floatboat/nexus-core": "workspace:*", "@floatboat/nexus-plugin-api": "workspace:*", + "@floatboat/nexus-plugin-block-drag": "workspace:*", "@floatboat/nexus-plugin-history": "workspace:*", "@floatboat/nexus-plugin-math": "workspace:*", "@floatboat/nexus-plugin-runtime": "workspace:*", diff --git a/packages/reference-plugins/src/bundled.ts b/packages/reference-plugins/src/bundled.ts index d18513eb..2fead354 100644 --- a/packages/reference-plugins/src/bundled.ts +++ b/packages/reference-plugins/src/bundled.ts @@ -2,6 +2,10 @@ import type { AuthorPluginManifest, NexusPluginConstructor, } from "@floatboat/nexus-plugin-api"; +import { + BlockDragLifecyclePlugin, + blockDragLifecyclePluginManifest, +} from "@floatboat/nexus-plugin-block-drag"; import { SlashLifecyclePlugin, slashLifecyclePluginManifest, @@ -30,6 +34,7 @@ export const bundledReferencePlugins: readonly BundledReferencePlugin[] = Object { manifest: wordCountLifecyclePluginManifest, Plugin: WordCountLifecyclePlugin }, { manifest: toolbarLifecyclePluginManifest, Plugin: ToolbarLifecyclePlugin }, { manifest: slashLifecyclePluginManifest, Plugin: SlashLifecyclePlugin }, + { manifest: blockDragLifecyclePluginManifest, Plugin: BlockDragLifecyclePlugin }, { manifest: obsidianSamplePortManifest, Plugin: ObsidianSamplePortPlugin }, ]); diff --git a/packages/reference-plugins/src/feature-flags.ts b/packages/reference-plugins/src/feature-flags.ts index 66319cfd..6bc99f14 100644 --- a/packages/reference-plugins/src/feature-flags.ts +++ b/packages/reference-plugins/src/feature-flags.ts @@ -1,4 +1,5 @@ import type { NexusPlugin } from "@floatboat/nexus-core"; +import { BlockDragLifecyclePlugin, createBlockDragPlugin } from "@floatboat/nexus-plugin-block-drag"; import type { NexusPluginConstructor } from "@floatboat/nexus-plugin-api"; import { SlashLifecyclePlugin } from "@floatboat/nexus-plugin-slash"; import { @@ -17,6 +18,8 @@ export interface ReferencePluginFeatureFlags { readonly toolbar: boolean; readonly slashMenu: boolean; readonly wordCount: boolean; + /** Opt in to block reordering; omitted flags preserve existing host behavior. */ + readonly blockDrag?: boolean; } export interface ReferencePluginBootPlan { @@ -45,6 +48,7 @@ export function createReferencePluginBootPlan( editorContributions: Object.freeze([ ...(flags.toolbar ? [createToolbarPlugin()] : []), ...(legacyWordCount ? [legacyWordCount] : []), + ...(flags.blockDrag ? [createBlockDragPlugin()] : []), ]), runtimePlugins: Object.freeze([]), legacyWordCount, @@ -65,6 +69,7 @@ export function createReferencePluginBootPlan( ...(flags.toolbar ? [ToolbarLifecyclePlugin] : []), ...(flags.slashMenu ? [SlashLifecyclePlugin] : []), ...(flags.wordCount ? [WordCountLifecyclePlugin] : []), + ...(flags.blockDrag ? [BlockDragLifecyclePlugin] : []), ] as NexusPluginConstructor[]), legacyWordCount: null, legacyUi: Object.freeze({ toolbar: false, slashMenu: false, wordCount: false }), diff --git a/packages/reference-plugins/test/feature-flags.test.ts b/packages/reference-plugins/test/feature-flags.test.ts index 216f565a..074a75c3 100644 --- a/packages/reference-plugins/test/feature-flags.test.ts +++ b/packages/reference-plugins/test/feature-flags.test.ts @@ -1,3 +1,4 @@ +import { BlockDragLifecyclePlugin } from "@floatboat/nexus-plugin-block-drag"; import { SlashLifecyclePlugin, } from "@floatboat/nexus-plugin-slash"; @@ -21,20 +22,22 @@ const BOOLEAN_VALUES = [false, true] as const; const FEATURE_FLAG_CASES = BOOLEAN_VALUES.flatMap((pluginPlatform) => BOOLEAN_VALUES.flatMap((toolbar) => BOOLEAN_VALUES.flatMap((slashMenu) => - BOOLEAN_VALUES.map((wordCount) => ({ + BOOLEAN_VALUES.flatMap((wordCount) => BOOLEAN_VALUES.map((blockDrag) => ({ label: [ `pluginPlatform=${pluginPlatform}`, `toolbar=${toolbar}`, `slashMenu=${slashMenu}`, `wordCount=${wordCount}`, + `blockDrag=${blockDrag}`, ].join(", "), flags: { pluginPlatform, toolbar, slashMenu, wordCount, + blockDrag, } satisfies ReferencePluginFeatureFlags, - })), + }))), ), ), ); @@ -54,6 +57,7 @@ describe("createReferencePluginBootPlan feature flags", () => { toolbar: true, slashMenu: true, wordCount: true, + blockDrag: true, }); const pairs = plan.runtimePlugins.map((Plugin) => @@ -64,6 +68,7 @@ describe("createReferencePluginBootPlan feature flags", () => { "toolbar", "slash-menu", "wordcount", + "block-drag", ]); }); @@ -83,11 +88,20 @@ describe("createReferencePluginBootPlan feature flags", () => { countOwners(plan, plan.legacyUi.wordCount, WordCountLifecyclePlugin), ).toBe(Number(flags.wordCount)); + expect( + countOwners( + plan, + plan.editorContributions.some((plugin) => plugin.name === "plugin-block-drag"), + BlockDragLifecyclePlugin, + ), + ).toBe(Number(flags.blockDrag)); + if (flags.pluginPlatform) { expect(plan.runtimePlugins).toEqual([ ...(flags.toolbar ? [ToolbarLifecyclePlugin] : []), ...(flags.slashMenu ? [SlashLifecyclePlugin] : []), ...(flags.wordCount ? [WordCountLifecyclePlugin] : []), + ...(flags.blockDrag ? [BlockDragLifecyclePlugin] : []), ]); expect(plan.legacyUi).toEqual({ toolbar: false, @@ -111,6 +125,7 @@ describe("createReferencePluginBootPlan feature flags", () => { expect(plan.editorContributions.map((plugin) => plugin.name)).toEqual([ ...(flags.toolbar ? ["plugin-toolbar"] : []), ...(flags.wordCount ? ["plugin-wordcount"] : []), + ...(flags.blockDrag ? ["plugin-block-drag"] : []), ]); }, ); @@ -125,7 +140,7 @@ describe("createReferencePluginBootPlan feature flags", () => { expect(plan.editorContributions).toHaveLength( flags.pluginPlatform ? Number(shouldExposeCatalogue) - : Number(flags.toolbar) + Number(flags.wordCount), + : Number(flags.toolbar) + Number(flags.wordCount) + Number(flags.blockDrag), ); if (!flags.pluginPlatform || !shouldExposeCatalogue) return; diff --git a/packages/reference-plugins/vitest.config.ts b/packages/reference-plugins/vitest.config.ts index 715ceb30..95930eff 100644 --- a/packages/reference-plugins/vitest.config.ts +++ b/packages/reference-plugins/vitest.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ resolve: { alias: { "@floatboat/nexus-core": resolveFromPackage("../core/src/index.ts"), + "@floatboat/nexus-plugin-block-drag": resolveFromPackage("../plugin-block-drag/src/index.ts"), "@floatboat/nexus-plugin-api": resolveFromPackage("../plugin-api/src/index.ts"), "@floatboat/nexus-plugin-runtime": resolveFromPackage("../plugin-runtime/src/index.ts"), "@floatboat/nexus-plugin-history": resolveFromPackage("../plugin-history/src/index.ts"), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0382fb79..c24a9783 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: jsdom: specifier: ^25.0.1 version: 25.0.1 + playwright: + specifier: 1.55.0 + version: 1.55.0 react: specifier: ^19.2.0 version: 19.2.5 @@ -56,6 +59,9 @@ importers: '@floatboat/nexus-plugin-api': specifier: workspace:* version: link:../../packages/plugin-api + '@floatboat/nexus-plugin-block-drag': + specifier: workspace:* + version: link:../../packages/plugin-block-drag '@floatboat/nexus-plugin-history': specifier: workspace:* version: link:../../packages/plugin-history @@ -154,6 +160,30 @@ importers: specifier: workspace:* version: link:../core + packages/plugin-block-drag: + dependencies: + '@codemirror/commands': + specifier: ^6.10.3 + version: 6.10.3 + '@codemirror/language': + specifier: ^6.12.3 + version: 6.12.3 + '@codemirror/state': + specifier: ^6.6.0 + version: 6.6.0 + '@codemirror/view': + specifier: ^6.41.0 + version: 6.41.0 + '@floatboat/nexus-core': + specifier: workspace:* + version: link:../core + '@floatboat/nexus-plugin-api': + specifier: workspace:* + version: link:../plugin-api + '@lezer/common': + specifier: ^1.5.1 + version: 1.5.2 + packages/plugin-history: dependencies: '@codemirror/commands': @@ -308,6 +338,9 @@ importers: '@floatboat/nexus-plugin-api': specifier: workspace:* version: link:../plugin-api + '@floatboat/nexus-plugin-block-drag': + specifier: workspace:* + version: link:../plugin-block-drag '@floatboat/nexus-plugin-history': specifier: workspace:* version: link:../plugin-history @@ -1428,6 +1461,7 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} @@ -2256,6 +2290,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3032,6 +3071,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.55.0: + resolution: {integrity: sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.55.0: + resolution: {integrity: sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==} + engines: {node: '>=18'} + hasBin: true + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -5817,6 +5866,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -6854,6 +6906,14 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 + playwright-core@1.55.0: {} + + playwright@1.55.0: + dependencies: + playwright-core: 1.55.0 + optionalDependencies: + fsevents: 2.3.2 + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.13 diff --git a/tsconfig.base.json b/tsconfig.base.json index 418ff47f..02bfc52b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -19,6 +19,7 @@ "@floatboat/nexus-plugin-math": ["packages/plugin-math/src/index.ts"], "@floatboat/nexus-plugin-vim": ["packages/plugin-vim/src/index.ts"], "@floatboat/nexus-plugin-wordcount": ["packages/plugin-wordcount/src/index.ts"], + "@floatboat/nexus-plugin-block-drag": ["packages/plugin-block-drag/src/index.ts"], "@floatboat/nexus-reference-plugins": ["packages/reference-plugins/src/index.ts"] }, "strict": true, diff --git a/vitest.config.ts b/vitest.config.ts index d7e39059..35ffb9c9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ "@floatboat/nexus-plugin-math": path.resolve(__dirname, "packages/plugin-math/src/index.ts"), "@floatboat/nexus-plugin-vim": path.resolve(__dirname, "packages/plugin-vim/src/index.ts"), "@floatboat/nexus-plugin-wordcount": path.resolve(__dirname, "packages/plugin-wordcount/src/index.ts"), + "@floatboat/nexus-plugin-block-drag": path.resolve(__dirname, "packages/plugin-block-drag/src/index.ts"), "@floatboat/nexus-reference-plugins": path.resolve(__dirname, "packages/reference-plugins/src/index.ts") } },