Gap cursor for block-edge positions (#335) #340

Merged
fable-5 merged 1 commits from issue-335-gap-cursor into main 2026-08-16 05:21:06 +02:00
4 changed files with 94 additions and 0 deletions
Showing only changes of commit 69563348ca - Show all commits

View File

@ -60,6 +60,42 @@ test('typing persists across reload and undo/redo work', async ({ browser }) =>
await context.close();
});
test('gap cursor reaches positions before and after a lone table (issue #335)', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E Gapcursor ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
const status = page.locator('.editor-connection');
await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 });
const content = page.locator('.ProseMirror');
await content.click();
await page.getByRole('button', { name: /insert table|tabelle einfügen/i }).click();
await expect(content.locator('table')).toBeVisible();
// Inserting into the empty page replaces the placeholder paragraph — the
// table really is the only block, which is the situation of issue #335.
await expect(content.locator(':scope > p')).toHaveCount(0);
// Keyboard only: ArrowUp from the first cell lands on the gap cursor
// before the table; typing there materializes a paragraph.
await content.locator('th').first().click();
await page.keyboard.press('ArrowUp');
await page.keyboard.type('above');
await expect(content.locator(':scope > :first-child')).toHaveText('above');
// Same for the position after the table.
await content.locator('td').last().click();
await page.keyboard.press('ArrowDown');
await page.keyboard.type('below');
await expect(content.locator(':scope > :last-child')).toHaveText('below');
await context.close();
});
test('edit mode hides the sidebar; leaving edit mode restores it', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E Sidebar ${Date.now()}`);

View File

@ -1,5 +1,6 @@
import type { AnyExtension } from '@tiptap/core';
import { GapCursor } from './gap-cursor';
import { MarkdownClipboard } from './markdown-clipboard';
import { Bold, CodeMark, Italic, LinkMark, Strikethrough } from './marks';
import { Image } from './nodes/image';
@ -63,4 +64,5 @@ export const documentExtensions: AnyExtension[] = [
Strikethrough,
LinkMark,
MarkdownClipboard,
GapCursor,
];

View File

@ -0,0 +1,20 @@
import { Extension } from '@tiptap/core';
import { gapCursor } from '@tiptap/pm/gapcursor';
/**
* Cursor position adjacent to block nodes that offer no text position of
* their own without it a table (or code block, image, ) as the page's
* first, last, or only block is unreachable from before/after, and no
* paragraph can be created there (issue #335). Wraps prosemirror-gapcursor,
* which also handles the arrow-key navigation into the gap positions; the
* bar itself is styled in `styles/base.css` (`.ProseMirror-gapcursor`)
* because the upstream package does not ship its stylesheet through this
* entry point.
*/
export const GapCursor = Extension.create({
name: 'gapCursor',
addProseMirrorPlugins() {
return [gapCursor()];
},
});

View File

@ -1732,6 +1732,42 @@ button {
outline: none;
}
/* Gap cursor (issue #335): the blinking bar prosemirror-gapcursor renders at
positions adjacent to block nodes without a text position of their own
(e.g. a table as the page's only block). The upstream package does not
ship its stylesheet through our import path, so these rules replace it. */
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
}
.ProseMirror-gapcursor::after {
content: '';
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid var(--color-text);
animation: dt-gapcursor-blink 1.1s steps(2, start) infinite;
}
@keyframes dt-gapcursor-blink {
to {
visibility: hidden;
}
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
@media (prefers-reduced-motion: reduce) {
.ProseMirror-gapcursor::after {
animation: none;
}
}
.editor-content h1,
.editor-content h2,
.editor-content h3,