Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1d08f70
test(editor): define imperative history handle RED
seonghobae Aug 10, 2026
ce8ba58
feat(editor): expose imperative history commands
seonghobae Aug 10, 2026
d2539b9
feat(editor): type imperative history controls
seonghobae Aug 10, 2026
fed2fc1
test(editor): bind history handle to collaborative undo manager
seonghobae Aug 10, 2026
45e4686
test(editor): bind history proof to public handle type
seonghobae Aug 10, 2026
6c6229f
test(editor): fail closed after editor destruction
seonghobae Aug 10, 2026
fba20b7
fix(editor): fail closed history after destruction
seonghobae Aug 10, 2026
a178de3
test(editor): require null editor after destruction
seonghobae Aug 10, 2026
ced6b14
fix(editor): hide destroyed editor from host handle
seonghobae Aug 10, 2026
2fc4d9f
test(editor): fail closed after handle editor destruction
seonghobae Aug 10, 2026
023841d
fix(editor): make retained handle reads lifecycle-safe
seonghobae Aug 10, 2026
554140d
test(editor): cover full retained-handle lifecycle boundary
seonghobae Aug 10, 2026
895d207
fix(editor): isolate retained handles after editor destruction
seonghobae Aug 10, 2026
8715af1
test(editor): prove history capability transitions
seonghobae Aug 10, 2026
a5732c7
docs(editor): document bounded imperative history control
seonghobae Aug 10, 2026
275efd2
test(package): require history handle in packed consumer
seonghobae Aug 10, 2026
836a722
test(package): compile imperative history handle consumer
seonghobae Aug 10, 2026
08d3708
test(package): verify public history handle declarations
seonghobae Aug 10, 2026
d7ead25
test(editor): keep imperative history inert when read-only
seonghobae Aug 10, 2026
e752891
fix(editor): keep imperative history inert when read-only
seonghobae Aug 10, 2026
8194154
docs(editor): define read-only imperative history semantics
seonghobae Aug 10, 2026
e627828
test(editor): preserve history across read-only transitions
seonghobae Aug 10, 2026
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
60 changes: 60 additions & 0 deletions docs/imperative-history-control.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Imperative undo and redo control

Status: `implemented_on_active_pr`

Inkspan exposes a narrow history-control surface on `CwlEditorHandle` for hosts that replace the built-in toolbar, provide an application command palette, or map desktop menu actions to the editor. This active-PR contract is not protected-main authority until the implementing pull request is integrated.

## Public handle contract

`CwlEditorHandle` provides four boolean-returning operations:

```ts
const handle = editorRef.current;

if (handle?.canUndo()) {
handle.undo();
}

if (handle?.canRedo()) {
handle.redo();
}
```

- `canUndo()` reports whether the currently registered editor history can undo.
- `undo()` asks that history implementation to undo and returns whether the command ran.
- `canRedo()` reports whether the currently registered editor history can redo.
- `redo()` asks that history implementation to redo and returns whether the command ran.

A newly created editor with no history reports both capabilities as `false`. After a document-changing edit, undo capability becomes available. A successful undo exposes redo capability, and a successful redo restores undo capability when history remains.

When the editor surface is read-only (`editable={false}`), all four history operations return `false` and no history entry is consumed. Restoring `editable={true}` exposes the same still-available history again. This read-only guard applies specifically to user-facing history commands; explicit host-control mutators such as `setValue()` and `restoreDocumentEnvelope()` retain their existing programmatic authority so a host can update a read-only presentation from its own trusted state transition.

## Lifecycle safety

A retained host handle must not keep a destroyed TipTap editor authoritative. Before an editor exists or after its editor has been destroyed, all four history methods return `false` without throwing. The same active-editor guard is used by the rest of the retained-handle read, mutation, envelope, revision, selection, and restore surface so stale destroyed-editor state cannot be presented as current Inkspan state.

Hosts should still dispose of retained refs normally; fail-closed behavior is a defensive boundary, not a replacement for application lifecycle management.

## Standalone and collaborative history

Standalone `CwlEditor` delegates to the history commands already registered by Inkspan's TipTap/StarterKit configuration. `CollaborativeCwlEditor` delegates through the same public handle to the collaboration-aware history commands registered with Yjs. Inkspan does not re-enable a second StarterKit local history engine in collaboration mode.

This means a host can wire one stable command surface without reaching into TipTap internals while preserving the history semantics selected by the active Inkspan editor configuration.

## Authority boundary

The four methods are local editor commands only. A successful `undo()` or `redo()` does **not** prove a durable save, actor identity, authorization, timestamp, audit event, persistence result, collaboration-provider acknowledgement, or server-side revision transition. Normal document-change callbacks and host persistence workflows remain responsible for whatever durable consequences the embedding application requires.

Inkspan does not acquire transport, authentication, authorization, tenancy, durable persistence, retention, provider-room, model-use, or audit authority through this API.

## Acceptance evidence

The implementing branch is expected to prove:

- initial, post-edit, post-undo, and post-redo capability transitions on a real editor;
- read-only history commands are inert without consuming history and become available again when editability is restored;
- no-active-editor and destroyed-editor fail-closed behavior;
- collaborative convergence through two in-memory `Y.Doc` editors without duplicate local history;
- unchanged package consumers, accessibility/browser evidence, Office evidence, security scans, and exact 100% owned-production statement/branch/function/line coverage.

Until the branch reaches protected `main`, downstream products must treat this document as active-PR guidance rather than a shipped-version guarantee.
15 changes: 13 additions & 2 deletions src/collaboration/CollaborativeCwlEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ describe('CollaborativeCwlEditor contract', () => {
});

describe('CollaborativeCwlEditor convergence and lifecycle', () => {
it('converges rich text, shares undo, merges concurrent edits, and preserves host-owned documents across remount', async () => {
it('converges rich text, shares imperative undo/redo, merges concurrent edits, and preserves host-owned documents across remount', async () => {
const leftDocument = new Y.Doc();
const rightDocument = new Y.Doc();
let disconnect = connectDocuments(leftDocument, rightDocument);
Expand Down Expand Up @@ -307,7 +307,18 @@ describe('CollaborativeCwlEditor convergence and lifecycle', () => {
await waitFor(() =>
expect(rightRef.current!.getHTML()).toContain('Undo this sentence'),
);
act(() => leftRef.current!.getEditor()!.commands.undo());
expect(leftRef.current!.canUndo()).toBe(true);
act(() => expect(leftRef.current!.undo()).toBe(true));
await waitFor(() =>
expect(rightRef.current!.getHTML()).not.toContain('Undo this sentence'),
);
expect(leftRef.current!.canRedo()).toBe(true);
act(() => expect(leftRef.current!.redo()).toBe(true));
await waitFor(() =>
expect(rightRef.current!.getHTML()).toContain('Undo this sentence'),
);
expect(leftRef.current!.canUndo()).toBe(true);
act(() => expect(leftRef.current!.undo()).toBe(true));
await waitFor(() =>
expect(rightRef.current!.getHTML()).not.toContain('Undo this sentence'),
);
Expand Down
167 changes: 167 additions & 0 deletions src/components/CwlEditorHistoryHandle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { act, render, waitFor } from '@testing-library/react';
import { forwardRef, createRef, useRef } from 'react';
import { describe, expect, it } from 'vitest';
import type { CwlEditorHandle, EditorMode } from '../types.js';
import { CwlEditor } from './CwlEditor.js';
import { useEditorHandle } from './useEditorHandle.js';

const NullEditorHandleHarness = forwardRef<CwlEditorHandle>((_, ref) => {
const modeRef = useRef<EditorMode>('markdown');
useEditorHandle(ref, null, modeRef);
return null;
});

describe('CwlEditor imperative history control', () => {
it('exposes host-safe undo/redo capability and execution on a real editor', async () => {
const editorRef = createRef<CwlEditorHandle>();
render(<CwlEditor ref={editorRef} defaultValue="Original" />);
await waitFor(() => expect(editorRef.current?.getEditor()).toBeTruthy());

const handle = editorRef.current!;
expect(typeof handle.canUndo).toBe('function');
expect(typeof handle.undo).toBe('function');
expect(typeof handle.canRedo).toBe('function');
expect(typeof handle.redo).toBe('function');
expect(handle.canUndo()).toBe(false);
expect(handle.canRedo()).toBe(false);

await act(async () => {
handle.getEditor()!.chain().focus('end').insertContent(' updated').run();
});
expect(handle.getMarkdown()).toBe('Original updated');
expect(handle.canUndo()).toBe(true);
expect(handle.canRedo()).toBe(false);

await act(async () => {
expect(handle.undo()).toBe(true);
});
expect(handle.getMarkdown()).toBe('Original');
expect(handle.canUndo()).toBe(false);
expect(handle.canRedo()).toBe(true);

await act(async () => {
expect(handle.redo()).toBe(true);
});
expect(handle.getMarkdown()).toBe('Original updated');
expect(handle.canUndo()).toBe(true);
expect(handle.canRedo()).toBe(false);
});

it('keeps imperative history inert while read-only without consuming it', async () => {
const editorRef = createRef<CwlEditorHandle>();
const { rerender } = render(
<CwlEditor ref={editorRef} defaultValue="Original" editable />,
);
await waitFor(() => expect(editorRef.current?.getEditor()).toBeTruthy());

const handle = editorRef.current!;
await act(async () => {
handle.getEditor()!.chain().focus('end').insertContent(' updated').run();
});
expect(handle.getMarkdown()).toBe('Original updated');
expect(handle.canUndo()).toBe(true);

rerender(<CwlEditor ref={editorRef} defaultValue="Original" editable={false} />);
await waitFor(() => expect(handle.getEditor()?.isEditable).toBe(false));

expect(handle.canUndo()).toBe(false);
expect(handle.undo()).toBe(false);
expect(handle.canRedo()).toBe(false);
expect(handle.redo()).toBe(false);
expect(handle.getMarkdown()).toBe('Original updated');

rerender(<CwlEditor ref={editorRef} defaultValue="Original" editable />);
await waitFor(() => expect(handle.getEditor()?.isEditable).toBe(true));

expect(handle.canUndo()).toBe(true);
await act(async () => {
expect(handle.undo()).toBe(true);
});
expect(handle.getMarkdown()).toBe('Original');
expect(handle.canRedo()).toBe(true);
});

it('fails closed when the shared handle has no active editor instance', () => {
const editorRef = createRef<CwlEditorHandle>();
render(<NullEditorHandleHarness ref={editorRef} />);

const handle = editorRef.current!;
expect(typeof handle.canUndo).toBe('function');
expect(typeof handle.undo).toBe('function');
expect(typeof handle.canRedo).toBe('function');
expect(typeof handle.redo).toBe('function');
expect(handle.canUndo()).toBe(false);
expect(handle.undo()).toBe(false);
expect(handle.canRedo()).toBe(false);
expect(handle.redo()).toBe(false);
});

it('fails closed on a retained handle after its editor is destroyed', async () => {
const editorRef = createRef<CwlEditorHandle>();
render(<CwlEditor ref={editorRef} defaultValue="Original" />);
await waitFor(() => expect(editorRef.current?.getEditor()).toBeTruthy());

const handle = editorRef.current!;
const editor = handle.getEditor()!;
await act(async () => {
editor.chain().focus('end').insertContent(' updated').run();
});
expect(handle.canUndo()).toBe(true);

act(() => editor.destroy());
expect(editor.isDestroyed).toBe(true);
expect(handle.getEditor()).toBeNull();
expect(handle.canUndo()).toBe(false);
expect(handle.undo()).toBe(false);
expect(handle.canRedo()).toBe(false);
expect(handle.redo()).toBe(false);

expect(handle.getValue()).toBe('');
expect(handle.getHTML()).toBe('');
expect(handle.getMarkdown()).toBe('');
expect(handle.getSnapshot()).toEqual({
mode: 'markdown',
value: '',
html: '',
markdown: '',
plainText: '',
documentJson: null,
isEmpty: true,
});
expect(handle.getDocumentEnvelope()).toBeNull();
expect(handle.getDocumentEnvelopeJson()).toBe('');
expect(handle.getDocumentEnvelopeBytes()).toEqual(new Uint8Array());
await expect(handle.getDocumentEnvelopeRevision()).resolves.toBeNull();
await expect(
handle.getDocumentEnvelopeRevisionEvidence(),
).resolves.toBeNull();
await expect(handle.getSelectionRevisionEvidence()).resolves.toBeNull();
await expect(handle.getTextPositionSelectorEvidence()).resolves.toBeNull();
expect(handle.validateDocumentEnvelope({})).toBe(false);
expect(handle.validateDocumentEnvelopeBytes(new Uint8Array())).toBe(false);
expect(handle.restoreDocumentEnvelope({})).toBeNull();
expect(handle.restoreDocumentEnvelopeBytes(new Uint8Array())).toBeNull();
await expect(
handle.restoreDocumentEnvelopeIfMatch('"sha256-deadbeef"', {}),
).resolves.toBeNull();
await expect(
handle.restoreDocumentEnvelopeBytesIfMatch(
'"sha256-deadbeef"',
new Uint8Array(),
),
).resolves.toBeNull();
expect(handle.validateDocumentJson({ type: 'doc', content: [] })).toBe(false);
expect(handle.isEmpty()).toBe(true);
expect(() => handle.focus()).not.toThrow();
expect(() => handle.blur()).not.toThrow();
expect(() => handle.setValue('ignored')).not.toThrow();
expect(() =>
handle.setDocumentJson({ type: 'doc', content: [] }),
).not.toThrow();
expect(() => handle.insertValue('ignored')).not.toThrow();
expect(() =>
handle.insertDocumentJson({ type: 'paragraph' }),
).not.toThrow();
expect(() => handle.clear()).not.toThrow();
});
});
Loading
Loading