Skip to content
Merged
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
23 changes: 15 additions & 8 deletions packages/muya/src/editor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type BlockNode = {
remove?: (source: string) => void;
replaceWith?: (newBlock: BlockNode, source: string) => void;
insertBefore?: (newBlock: BlockNode, ref: BlockNode, source: string) => void;
append?: (newBlock: BlockNode, source: string) => void;
update?: (value?: unknown, source?: string) => void;
blockName?: string;
align?: string;
Expand Down Expand Up @@ -152,8 +153,12 @@ function drop(root: BlockNode, descent: JSONOpList, muya: Muya): BlockNode {
if (typeof key === 'number') {
const insertedState = comp.i as { name: string };
const newBlock = ScrollPage.loadBlock(insertedState.name).create(muya, insertedState) as BlockNode;
if (cur && ref && newBlock)
cur.insertBefore?.(newBlock, ref, 'api');
if (cur && newBlock) {
if (ref)
cur.insertBefore?.(newBlock, ref, 'api');
else
cur.append?.(newBlock, 'api');
}

subDoc = newBlock;
}
Expand Down Expand Up @@ -442,13 +447,15 @@ export class Editor {
return;
}

// Incremental (updateContents) path: blocks are still attached. Clone the
// paths so `queryBlock(path)` can't drain the caller's arrays — notably
// the selection object stored in the undo stack.
const anchorBlock = anchor.block ?? this.scrollPage?.queryBlock([...anchor.path]);
const focusBlock = focus.block ?? this.scrollPage?.queryBlock([...focus.path]);
if (!anchorBlock || !anchorBlock.isContent() || !focusBlock || !focusBlock.isContent())
// Incremental (updateContents) path. Clone the paths so
// `queryBlock(path)` can't drain the caller's arrays — notably the
// selection object stored in the undo stack.
const anchorBlock = this.scrollPage?.queryBlock([...anchor.path]);
const focusBlock = this.scrollPage?.queryBlock([...focus.path]);
if (!anchorBlock || !anchorBlock.isContent() || !focusBlock || !focusBlock.isContent()) {
this.focus();
return;
}

this.selection.setSelection(
{ offset: anchor.offset, block: anchorBlock, path: [...anchor.path] },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// @vitest-environment happy-dom

import type Format from '../../block/base/format';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Muya } from '../../muya';

const bootedHosts: HTMLElement[] = [];

beforeEach(() => {
window.MUYA_VERSION = 'test';
});

afterEach(() => {
while (bootedHosts.length)
bootedHosts.pop()!.remove();
delete (window as Partial<Window>).MUYA_VERSION;
});

function bootMuya(markdown: string): Muya {
const host = document.createElement('div');
document.body.appendChild(host);
const muya = new Muya(host, { markdown } as ConstructorParameters<typeof Muya>[1]);
muya.init();
bootedHosts.push(muya.domNode);
return muya;
}

function placeCursorOnFirstContent(muya: Muya): void {
const first = muya.editor.scrollPage!.firstContentInDescendant()!;
muya.editor.activeContentBlock = first;
first.setCursor(0, 0, true);
}

function secondBlockContent(muya: Muya): Format {
const blocks: { firstContentInDescendant: () => Format }[] = [];
(muya.editor.scrollPage as unknown as {
children: { forEach: (cb: (b: { firstContentInDescendant: () => Format }) => void) => void };
}).children.forEach(b => blocks.push(b));
return blocks[1].firstContentInDescendant();
}

function undoDepth(muya: Muya): number {
// @ts-expect-error — reach into the private stack for test assertions.
return muya.editor.history._stack.undo.length;
}

describe('undo/redo of a coalesced paragraph→list + text edit', () => {
it('restores the typed list-item text on redo', async () => {
const muya = bootMuya('# anchor\n\nseed\n');

const para = secondBlockContent(muya);
para.text = '- ';
para.checkInlineUpdate();
await vi.waitFor(() => {
expect(muya.getMarkdown()).toContain('- ');
expect(undoDepth(muya)).toBe(1);
});

const listContent = secondBlockContent(muya);
listContent.text = 'foo';
await vi.waitFor(() => {
expect(muya.getMarkdown()).toContain('foo');
});
expect(undoDepth(muya)).toBe(1);

placeCursorOnFirstContent(muya);
muya.undo();
await vi.waitFor(() => {
const md = muya.getMarkdown();
expect(md).toContain('seed');
expect(md).not.toContain('foo');
});
expect(muya.getMarkdown()).not.toContain('- ');

placeCursorOnFirstContent(muya);
muya.redo();
await vi.waitFor(() => {
expect(muya.getMarkdown()).toContain('foo');
});
});

it('undo/redo with the caret left inside the converted block does not crash', async () => {
const muya = bootMuya('hello world\n\nx\n');

const para = secondBlockContent(muya);
para.setCursor(0, 0, true);
para.text = '- ';
para.checkInlineUpdate();
await vi.waitFor(() => {
expect(muya.getMarkdown()).toContain('- ');
});

const listContent = secondBlockContent(muya);
listContent.setCursor(0, 0, true);
listContent.text = 'foo';
await vi.waitFor(() => {
expect(muya.getMarkdown()).toContain('foo');
});

muya.editor.history.cutoff();
const listContent2 = secondBlockContent(muya);
listContent2.setCursor(3, 3, true);
listContent2.text = 'foo bar';
await vi.waitFor(() => {
expect(muya.getMarkdown()).toContain('foo bar');
});

muya.undo();
await vi.waitFor(() => expect(muya.getMarkdown()).not.toContain(' bar'));
muya.undo();
await vi.waitFor(() => expect(muya.getMarkdown()).not.toContain('foo'));

muya.redo();
await vi.waitFor(() => expect(muya.getMarkdown()).toContain('foo'));
muya.redo();
await vi.waitFor(() => expect(muya.getMarkdown()).toContain('foo bar'));

const live = secondBlockContent(muya);
expect((live as unknown as { text: string }).text).toBe('foo bar');
expect((live as unknown as { blockName: string }).blockName).toContain('paragraph');
});
});
5 changes: 4 additions & 1 deletion packages/muya/src/history/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,10 @@ class History {
return;

const { operation, selection, rebuild } = this._stack[source].pop()!;
const inverseOperation = json1.type.invert(operation);
const inverseOperation = json1.type.invertWithDoc(
operation,
asDoc(this._muya.editor.jsonState.getState()),
);

this._stack[dest].push({
operation: inverseOperation as JSONOpList,
Expand Down
3 changes: 3 additions & 0 deletions packages/muya/src/selection/TextSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,9 @@ class TextSelection {
if (!anchorBlock || !focusBlock)
return null;

if (!anchorBlock.outMostBlock || !focusBlock.outMostBlock)
return null;

const anchorPath = anchorBlock.path;
const focusPath = focusBlock.path;

Expand Down
Loading