diff --git a/apps/web/e2e/editor.spec.ts b/apps/web/e2e/editor.spec.ts index f63a0d4..025f9a3 100644 --- a/apps/web/e2e/editor.spec.ts +++ b/apps/web/e2e/editor.spec.ts @@ -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()}`); diff --git a/apps/web/src/editor/document-extensions.ts b/apps/web/src/editor/document-extensions.ts index eb67fd8..e4f90f8 100644 --- a/apps/web/src/editor/document-extensions.ts +++ b/apps/web/src/editor/document-extensions.ts @@ -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, ]; diff --git a/apps/web/src/editor/gap-cursor.ts b/apps/web/src/editor/gap-cursor.ts new file mode 100644 index 0000000..b45f516 --- /dev/null +++ b/apps/web/src/editor/gap-cursor.ts @@ -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()]; + }, +}); diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 6dbe329..3a6b455 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -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,