Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/electron-demo/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ const MAIN_PLUGIN_PERMISSION_POLICIES = new Map<string, MainOwnedPluginPermissio
["wordcount", { declaredPermissions: [], grantedPermissions: [] }],
["toolbar", { declaredPermissions: [], grantedPermissions: [] }],
["slash-menu", { declaredPermissions: [], grantedPermissions: [] }],
["block-drag", { declaredPermissions: [], grantedPermissions: [] }],
["obsidian-sample-port", { declaredPermissions: [], grantedPermissions: [] }],
]);

Expand Down
1 change: 1 addition & 0 deletions apps/electron-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,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-runtime": "workspace:*",
"@floatboat/nexus-plugin-search": "workspace:*",
Expand Down
5 changes: 5 additions & 0 deletions apps/electron-demo/src/renderer/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export function resolveReferencePluginFeatureFlags(
toolbar: true,
slashMenu: true,
wordCount: true,
blockDrag: true,
...rendererFeatures,
});
}
Expand Down Expand Up @@ -857,6 +858,10 @@ export async function boot(options: BootOptions = {}): Promise<ElectronDemoApp>
? 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
Expand Down
5 changes: 4 additions & 1 deletion apps/electron-demo/src/renderer/editor-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -138,6 +140,7 @@ export function createEditorShell(options: EditorShellOptions): EditorShell {
plugins: [
createGfmPreset(),
createHistoryPlugin(),
...(!runtimeManaged && contributionFeatures.blockDrag ? [createBlockDragPlugin()] : []),
...(!runtimeManaged && contributionFeatures.toolbar ? [createToolbarPlugin()] : []),
createSearchPlugin(),
wikilinksPlugin,
Expand Down
18 changes: 18 additions & 0 deletions apps/electron-demo/test/app-runtime-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
26 changes: 26 additions & 0 deletions apps/electron-demo/test/editor-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);

Expand Down
1 change: 1 addition & 0 deletions apps/electron-demo/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
4 changes: 4 additions & 0 deletions apps/electron-demo/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions docs/plugins/native-plugin-api.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 字段速查

| 字段 | 必填 | 约束 |
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
61 changes: 61 additions & 0 deletions openspec/changes/add-markdown-block-reordering/design.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions openspec/changes/add-markdown-block-reordering/proposal.md
Original file line number Diff line number Diff line change
@@ -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`.
Loading