diff --git a/README.md b/README.md index 0e5f123..bfa588e 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ This package contains extracted UI components from the Open CRM frontend, design - **InputGroup** — Composite input with addons and buttons - **Combobox** — Searchable dropdown with chip support (based on Base UI) - **TagMultiSelect** — Multi-select tag picker with colored chips +- **MarkdownEditor** — WYSIWYG Markdown editor that round-trips all supported Markdown constructs without data loss +- **MarkdownView** — Read-only Markdown renderer with structural output (headings, lists, task lists, blockquotes, code) ## Usage diff --git a/docs/upgrade-to-0.10.md b/docs/upgrade-to-0.10.md new file mode 100644 index 0000000..642a4ce --- /dev/null +++ b/docs/upgrade-to-0.10.md @@ -0,0 +1,78 @@ +# Upgrade prompt: `@open-elements/ui` 0.9.x → 0.10.0 + +`@open-elements/ui` 0.10.0 fixes a **data-loss bug** in `MarkdownEditor` and a **rendering gap** in `MarkdownView`. Both components previously trimmed the TipTap schema down to paragraphs, a few marks and links, so every other Markdown construct (headings, bullet/ordered lists, task lists, blockquotes, code blocks, horizontal rules) was silently discarded when content was loaded. + +In `MarkdownEditor` this destroyed stored data: simply opening a form with such content was enough to corrupt the value passed back through `onChange`, without a single keystroke. In `MarkdownView` the same constructs were flattened to plain text. + +0.10.0 opens the schema so all of those constructs **round-trip untouched**. There is **no API change** — `MarkdownEditor` and `MarkdownView` keep the exact same props. What the editor toolbar lets a user *create* is unchanged (still Bold, Italic, Strikethrough, Link); task lists render but cannot be created. + +The consumer-visible effect is that content which used to appear as flat text now renders as real structure. This is the intended fix, but it means blocks that were previously invisible-as-structure now take up their natural space. + +This file is a self-contained prompt for an agent (Claude Code, etc.) to run inside a consumer repo. Paste it verbatim. + +--- + +## Prompt + +You are working inside an app that depends on `@open-elements/ui`. Goal: upgrade to `^0.10.0`. There is **no prop or import migration** — the change is behavioral, plus a documentation/visual verification pass. + +### What changed in 0.10.0 + +- **`MarkdownEditor` no longer corrupts content on open.** Loading a value containing headings, lists, task lists, blockquotes, code blocks or horizontal rules no longer strips those constructs and no longer fires a spurious `onChange`. If your app worked around this bug (e.g. by re-saving on load, or by pre-stripping Markdown before passing it in), remove that workaround. +- **`MarkdownView` now renders structure.** Content that previously appeared as a single flat paragraph now renders as headings, lists, task lists (as checkboxes), blockquotes, code blocks and rules. +- **Task lists render but stay non-creatable.** `- [x]` / `- [ ]` items display as checkboxes and survive editing. There is no toolbar button, keyboard shortcut, or `[ ] ` input rule to create one — this is deliberate. +- **Checkboxes in `MarkdownView` are read-only.** They reflect the stored state; clicking them does nothing (interactive toggling arrives in a later release). +- **Dependency manifest.** Internally the library dropped a duplicate `@tiptap/extension-link` and declared `@tiptap/extension-list`. These are the library's own `dependencies`, resolved transitively — you do **not** manage them in your app. + +### Steps + +1. **Find the consumer's frontend `package.json`** (repo root or under `frontend/`). Confirm `@open-elements/ui` is listed, then bump it to `^0.10.0` and run: + + ```bash + pnpm install + ``` + +2. **Confirm the library sources are in your Tailwind content globs.** The newly rendered blocks and the task-list styling rely on utility classes (`prose prose-sm`, `list-none`, `pl-0`, `flex`, …) that Tailwind must see in the library source. If you already render any `@open-elements/ui` component correctly, this is already the case — verify your `tailwind.config` `content` array includes the package, e.g.: + + ```js + content: ["./src/**/*.{ts,tsx}", "./node_modules/@open-elements/ui/src/**/*.{ts,tsx}"], + ``` + +3. **Visually check every screen that uses `MarkdownView` or `MarkdownEditor`.** Content that was silently flattened will now render as structure. In compact contexts (detail panels, cards, table cells) headings and code blocks may look oversized. If so, add local `prose` overrides *in your app* — do not edit the library. Example: + + ```html +
+ +
+ ``` + +4. **Remove any load-time corruption workarounds.** Search for code that pre-processes Markdown before handing it to `MarkdownEditor`, or that ignores the first `onChange` after mount. These existed to paper over the old bug and are now unnecessary. + +5. **Verify.** All three must pass: + + ```bash + pnpm exec tsc --noEmit + pnpm test + pnpm build + ``` + +6. **Commit** with a clear message: + + ``` + chore(deps): upgrade @open-elements/ui to 0.10.0 + + MarkdownEditor no longer corrupts stored Markdown on open and + MarkdownView now renders full structure. Verified prose styling. + ``` + +### Guard rails + +- **Do not** change how you call `MarkdownEditor` / `MarkdownView` — their props are identical to 0.9.0. +- **Do not** add `@tiptap/*` packages to your app's dependencies to "fix" the upgrade. They are the library's transitive dependencies. +- **Do not** edit `@open-elements/ui` from the consumer side. `prose` overrides belong in your app's markup or stylesheet. +- **Do not** bundle unrelated dependency bumps into the same change. + +### Don't do this + +- Do not try to re-add a way to *create* task lists — their non-creatability is intentional in this release. +- Do not treat the new rendering as a regression and revert the bump; flattened text was the bug. diff --git a/package.json b/package.json index daf84cb..c09dc38 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@tiptap/core": "^3.22.0", "@tiptap/react": "^3.22.0", "@tiptap/starter-kit": "^3.22.0", - "@tiptap/extension-link": "^3.22.0", + "@tiptap/extension-list": "^3.22.0", "@tiptap/extension-placeholder": "^3.22.0", "tiptap-markdown": "^0.9.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4401980..5a689dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,7 +11,7 @@ importers: '@tiptap/core': specifier: ^3.22.0 version: 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/extension-link': + '@tiptap/extension-list': specifier: ^3.22.0 version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) '@tiptap/extension-placeholder': @@ -1219,66 +1219,79 @@ packages: resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.2': resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.2': resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.2': resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.2': resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.2': resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.2': resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.2': resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.2': resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.2': resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.2': resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.2': resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.2': resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.2': resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} diff --git a/specs/001-markdown-schema-roundtrip/steps.md b/specs/001-markdown-schema-roundtrip/steps.md new file mode 100644 index 0000000..08a5348 --- /dev/null +++ b/specs/001-markdown-schema-roundtrip/steps.md @@ -0,0 +1,142 @@ +# Implementation Steps: Markdown schema round-trip + +## Step 1: Shared extension factory and dependency changes + +- [x] Add `@tiptap/extension-list` at `^3.22.0` to `dependencies` in `package.json` +- [x] Remove `@tiptap/extension-link` from `dependencies` in `package.json` +- [x] Run `pnpm install` so `@tiptap/extension-list` is hoisted as a direct dependency +- [x] Create `src/lib/markdown-extensions.ts` exporting `MarkdownExtensionsOptions` and `createMarkdownExtensions(options?)` +- [x] Open the schema: use `StarterKit.configure` without disabling `heading`, `codeBlock`, `blockquote`, `horizontalRule`, `bulletList`, `orderedList`, `listItem` +- [x] Set `underline: false` in the StarterKit config +- [x] Configure `link` **through** StarterKit (`openOnClick` from `options.openLinksOnClick`, default `false`; view adds `target`/`rel`) +- [x] Add `TaskList.extend({ addKeyboardShortcuts: () => ({}) })` configured with `HTMLAttributes: { class: "list-none pl-0" }` +- [x] Add `TaskItem.extend({ addInputRules: () => [] })` configured with `nested: true`, `HTMLAttributes: { class: "flex items-start gap-2" }` +- [x] Add `Placeholder.configure({ placeholder: options.placeholder ?? "" })` and `Markdown` + +**Acceptance criteria:** +- [x] `pnpm typecheck` passes +- [x] `pnpm build` succeeds +- [x] The factory is the single source of extension configuration + +**Related behaviors:** foundation for all scenarios + +--- + +## Step 2: Refactor `MarkdownEditor` to use the factory + +- [x] Replace the inline `extensions` array in `markdown-editor.tsx` with `createMarkdownExtensions({ placeholder, openLinksOnClick: false })` +- [x] Remove now-unused imports (`StarterKit`, `Link`, `Placeholder`, `Markdown`) +- [x] Leave `MarkdownEditorProps`, the toolbar, `onUpdate`, and the sync `useEffect` unchanged + +**Acceptance criteria:** +- [x] `pnpm typecheck` and `pnpm build` pass +- [x] Toolbar still renders Bold, Italic, Strikethrough, Link (and Unlink in a link) + +**Related behaviors:** Opening without editing leaves the value untouched; A real edit still reports the full document; An externally changed value replaces the document; The toolbar is unchanged + +--- + +## Step 3: Refactor `MarkdownView` to use the factory + +- [x] Replace the inline `extensions` array in `markdown-view.tsx` with `createMarkdownExtensions({ openLinksOnClick: true })` +- [x] Remove now-unused imports (`StarterKit`, `Link`, `Markdown`) +- [x] Leave `MarkdownViewProps`, `editable: false`, and the sync `useEffect` unchanged + +**Acceptance criteria:** +- [x] `pnpm typecheck` and `pnpm build` pass + +**Related behaviors:** Structural Markdown renders as structure; Task lists render as checkboxes reflecting their state; Clicking a checkbox has no effect; Checkboxes are not decorated with a list bullet + +--- + +## Step 4: Round-trip tests against a real `Editor` + +- [x] Create `src/lib/__tests__/markdown-extensions.test.ts` +- [x] Instantiate `new Editor({ extensions: createMarkdownExtensions(), content: input })` and assert `editor.storage.markdown.getMarkdown() === input` +- [x] Drive it from a table of Markdown inputs covering: task list, bullet list, ordered list, H1–H6, blockquote + fenced code block with language + `---`, nested task list, marks inside blocks (bold + link), mixed list, trailing-paragraph guard (doc ending in code block / hr), empty string, unchecked-only task list, plain text +- [x] If a trailing blank line appears, disable `trailingNode` in the factory and re-run + +**Acceptance criteria:** +- [x] `pnpm test` passes for all round-trip cases + +**Related behaviors:** Task list survives unchanged; Bullet list survives unchanged; Ordered list survives unchanged; Headings survive at every level; Blockquote, code block and horizontal rule survive; Nested task lists survive; Marks inside blocks survive; A mixed list keeps both kinds of items; The document does not grow a trailing paragraph; Empty content produces empty Markdown; An unchecked-only task list round-trips; Content that is already plain text is unaffected + +--- + +## Step 5: Editor behaviour tests (real editor, no React mock) + +- [x] Add tests that build a real `Editor` from the factory to verify creation paths are closed: + - [x] `Mod-Shift-9` creates no task list + - [x] typing `[ ] ` leaves literal text and creates no task item + - [x] underline command is unavailable (`editor.can().toggleUnderline()` is false / command absent) +- [x] Add editing tests for an existing task list: `Enter` splits into a new unchecked item (serializes to two lines); `Shift-Tab` lifts a nested item (reduced indentation) +- [x] Rewrite `markdown-editor.test.tsx` so the "no corruption on open" behaviours are testable: render a real `MarkdownEditor`, spy on `onChange`, assert it is not called after mount; assert a typed edit reports the full document; assert an external `value` change replaces the document +- [x] Keep a toolbar test asserting exactly Bold, Italic, Strikethrough, Link (and Unlink only inside a link) + +**Acceptance criteria:** +- [x] `pnpm test` passes + +**Related behaviors:** The keyboard shortcut does nothing; The input rule does nothing; Underline cannot be applied; Enter splits a task item; Shift-Tab lifts a nested item; Opening without editing leaves the value untouched; A real edit still reports the full document; An externally changed value replaces the document; The toolbar is unchanged + +--- + +## Step 6: View rendering tests (real editor, no React mock) + +- [x] Rewrite `markdown-view.test.tsx` to render a real `MarkdownView` +- [x] Assert structural Markdown renders as `h1`/`ul`/`blockquote` elements, not plain paragraphs +- [x] Assert `- [x] Done\n- [ ] Open` renders two checkbox inputs, first checked, second unchecked +- [x] Assert clicking a checkbox leaves its state unchanged and fires no callback +- [x] Assert the task list carries the `list-none pl-0` classes (bullet suppression) + +**Acceptance criteria:** +- [x] `pnpm test` passes + +**Related behaviors:** Structural Markdown renders as structure; Task lists render as checkboxes reflecting their state; Clicking a checkbox has no effect; Checkboxes are not decorated with a list bullet + +--- + +## Step 7: Documentation + +- [x] Create `docs/upgrade-to-0.10.md` documenting the rendering change (previously flattened constructs now render as structure) and the removal of `@tiptap/extension-link` from the public dependency set +- [x] Update `CLAUDE.md` Project Context (Features / Tech Stack / Structure / Architecture) if the markdown components or the new `markdown-extensions` module warrant it +- [x] Update `README.md` if user-facing behaviour or the dependency list is documented there + +**Acceptance criteria:** +- [x] `pnpm build`, `pnpm test`, `pnpm lint`, `pnpm typecheck` all pass +- [x] Docs reflect the change + +**Related behaviors:** none (documentation) + +--- + +## Behavior Coverage + +| Scenario | Layer | Covered in Step | +|----------|-------|-----------------| +| Task list survives unchanged | Frontend | 4 | +| Bullet list survives unchanged | Frontend | 4 | +| Ordered list survives unchanged | Frontend | 4 | +| Headings survive at every level | Frontend | 4 | +| Blockquote, code block and horizontal rule survive | Frontend | 4 | +| Nested task lists survive | Frontend | 4 | +| Marks inside blocks survive | Frontend | 4 | +| A mixed list keeps both kinds of items | Frontend | 4 | +| The document does not grow a trailing paragraph | Frontend | 4 | +| Opening without editing leaves the value untouched | Frontend | 5 | +| A real edit still reports the full document | Frontend | 5 | +| An externally changed value replaces the document | Frontend | 5 | +| Structural Markdown renders as structure | Frontend | 6 | +| Task lists render as checkboxes reflecting their state | Frontend | 6 | +| Clicking a checkbox has no effect | Frontend | 6 | +| Checkboxes are not decorated with a list bullet | Frontend | 6 | +| The keyboard shortcut does nothing | Frontend | 5 | +| The input rule does nothing | Frontend | 5 | +| The toolbar is unchanged | Frontend | 5 | +| Enter splits a task item | Frontend | 5 | +| Shift-Tab lifts a nested item | Frontend | 5 | +| Underline cannot be applied | Frontend | 5 | +| Empty content produces empty Markdown | Frontend | 4 | +| An unchecked-only task list round-trips | Frontend | 4 | +| Content that is already plain text is unaffected | Frontend | 4 | + +Every scenario is a frontend/library scenario and is assigned to a step. No backend layer exists. diff --git a/specs/INDEX.md b/specs/INDEX.md index 9a504f3..d08cdb1 100644 --- a/specs/INDEX.md +++ b/specs/INDEX.md @@ -2,6 +2,6 @@ | ID | Spec-Folder | Name | Areas | Description | GitHub Issue | Status | |-----|-------------|------|-------|-------------|--------------|--------| -| 001 | 001-markdown-schema-roundtrip | Markdown schema round-trip | frontend, api, testing | Stop destroying unsupported Markdown in MarkdownEditor/MarkdownView by teaching the schema everything Markdown can express | — | open | +| 001 | 001-markdown-schema-roundtrip | Markdown schema round-trip | frontend, api, testing | Stop destroying unsupported Markdown in MarkdownEditor/MarkdownView by teaching the schema everything Markdown can express | — | done | | 002 | 002-markdown-toolbar-actions | Markdown toolbar actions | frontend, api | Compose the MarkdownEditor toolbar per usage via an explicit action allowlist | — | open | | 003 | 003-markdown-view-checkboxes | Markdown view checkboxes | frontend, api | Tick task list checkboxes directly in MarkdownView with optimistic update and rollback | — | open | diff --git a/src/components/__tests__/markdown-editor.test.tsx b/src/components/__tests__/markdown-editor.test.tsx index 6869ef7..a6c140f 100644 --- a/src/components/__tests__/markdown-editor.test.tsx +++ b/src/components/__tests__/markdown-editor.test.tsx @@ -1,35 +1,91 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, cleanup } from "@testing-library/react"; - -// Mock TipTap before importing the component -vi.mock("@tiptap/react", () => ({ - useEditor: () => null, - EditorContent: () =>
, -})); -vi.mock("@tiptap/starter-kit", () => ({ default: { configure: () => ({}) } })); -vi.mock("@tiptap/extension-link", () => ({ default: { configure: () => ({}) } })); -vi.mock("@tiptap/extension-placeholder", () => ({ default: { configure: () => ({}) } })); -vi.mock("tiptap-markdown", () => ({ Markdown: {} })); - +import { render, cleanup, waitFor } from "@testing-library/react"; +import type { Editor } from "@tiptap/core"; import { MarkdownEditor } from "../markdown-editor.tsx"; afterEach(cleanup); -describe("MarkdownEditor", () => { - it("mounts without error", () => { - const { container } = render( {}} />); - expect(container).toBeTruthy(); +/** The rendered ProseMirror element carries a back-reference to the live editor. */ +function getEditor(container: HTMLElement): Editor { + const dom = container.querySelector(".ProseMirror") as (HTMLElement & { editor?: Editor }) | null; + if (!dom?.editor) throw new Error("editor not mounted yet"); + return dom.editor; +} + +async function waitForEditor(container: HTMLElement): Promise { + await waitFor(() => expect(container.querySelector(".ProseMirror")).toBeTruthy()); + return getEditor(container); +} + +describe("MarkdownEditor — no corruption on open", () => { + it("does not call onChange when opened with content it used to strip", async () => { + const onChange = vi.fn(); + const { container } = render( + , + ); + await waitForEditor(container); + // Give the sync effect a chance to run before asserting nothing fired. + await waitFor(() => { + expect(getEditor(container).storage.markdown.getMarkdown()).toBe( + "- [x] Write the press release", + ); + }); + expect(onChange).not.toHaveBeenCalled(); }); - it("renders editor content area", () => { - const { container } = render( {}} />); - expect(container.querySelector("[data-testid='editor-content']")).toBeTruthy(); + it("reports the full document, task list intact, when the paragraph is edited", async () => { + const onChange = vi.fn(); + const { container } = render( + , + ); + const editor = await waitForEditor(container); + onChange.mockClear(); + + // Type a character at the end of the paragraph — same onUpdate path as a keystroke. + editor.chain().focus("end").insertContent("X").run(); + + await waitFor(() => expect(onChange).toHaveBeenCalled()); + const reported = onChange.mock.calls.at(-1)?.[0] as string; + expect(reported).toContain("- [x] Task"); + expect(reported).toContain("HelloX"); + }); + + it("replaces the document when the parent passes a different value", async () => { + const onChange = vi.fn(); + const { container, rerender } = render( + , + ); + const editor = await waitForEditor(container); + await waitFor(() => expect(editor.storage.markdown.getMarkdown()).toBe("# First")); + + rerender(); + await waitFor(() => expect(editor.storage.markdown.getMarkdown()).toBe("# Second")); + }); +}); + +describe("MarkdownEditor — toolbar", () => { + it("offers exactly Bold, Italic, Strikethrough and Link by default", async () => { + const { container } = render( {}} />); + await waitForEditor(container); + const titles = Array.from(container.querySelectorAll("button[title]")).map((b) => + b.getAttribute("title"), + ); + expect(titles).toEqual(["Bold", "Italic", "Strikethrough", "Link"]); + expect(titles).not.toContain("Remove link"); }); - it("accepts placeholder prop without error", () => { + it("adds Unlink only while the cursor sits in a link", async () => { const { container } = render( - {}} placeholder="Type here..." />, + {}} />, ); - expect(container).toBeTruthy(); + const editor = await waitForEditor(container); + // Move the selection into the link text. + editor.chain().focus().setTextSelection(3).run(); + await waitFor(() => { + const titles = Array.from(container.querySelectorAll("button[title]")).map((b) => + b.getAttribute("title"), + ); + expect(titles).toContain("Remove link"); + }); }); }); diff --git a/src/components/__tests__/markdown-view.test.tsx b/src/components/__tests__/markdown-view.test.tsx index b62afca..36a1b9c 100644 --- a/src/components/__tests__/markdown-view.test.tsx +++ b/src/components/__tests__/markdown-view.test.tsx @@ -1,32 +1,47 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, cleanup } from "@testing-library/react"; - -// Mock TipTap before importing the component -vi.mock("@tiptap/react", () => ({ - useEditor: () => null, - EditorContent: () =>
, -})); -vi.mock("@tiptap/starter-kit", () => ({ default: { configure: () => ({}) } })); -vi.mock("@tiptap/extension-link", () => ({ default: { configure: () => ({}) } })); -vi.mock("tiptap-markdown", () => ({ Markdown: {} })); - +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup, waitFor, fireEvent } from "@testing-library/react"; import { MarkdownView } from "../markdown-view.tsx"; afterEach(cleanup); -describe("MarkdownView", () => { - it("mounts without error", () => { - const { container } = render(); - expect(container).toBeTruthy(); +async function renderView(content: string) { + const result = render(); + await waitFor(() => expect(result.container.querySelector(".ProseMirror")).toBeTruthy()); + return result; +} + +describe("MarkdownView — rendering", () => { + it("renders structural Markdown as structure, not plain paragraphs", async () => { + const { container } = await renderView("# Title\n\n- a\n- b\n\n> quote"); + expect(container.querySelector("h1")).toBeTruthy(); + expect(container.querySelector("ul")).toBeTruthy(); + expect(container.querySelector("blockquote")).toBeTruthy(); + }); + + it("renders a task list as checkboxes reflecting their state", async () => { + const { container } = await renderView("- [x] Done\n- [ ] Open"); + const checkboxes = container.querySelectorAll("input[type=checkbox]"); + expect(checkboxes).toHaveLength(2); + expect(checkboxes[0].checked).toBe(true); + expect(checkboxes[1].checked).toBe(false); }); - it("renders editor content area", () => { - const { container } = render(); - expect(container.querySelector("[data-testid='editor-content']")).toBeTruthy(); + it("does not change a checkbox when it is clicked", async () => { + const { container } = await renderView("- [x] Done\n- [ ] Open"); + const checkboxes = container.querySelectorAll("input[type=checkbox]"); + fireEvent.click(checkboxes[0]); + fireEvent.click(checkboxes[1]); + await waitFor(() => { + const after = container.querySelectorAll("input[type=checkbox]"); + expect(after[0].checked).toBe(true); + expect(after[1].checked).toBe(false); + }); }); - it("accepts markdown content without error", () => { - const { container } = render(); - expect(container).toBeTruthy(); + it("suppresses the prose bullet on the task list", async () => { + const { container } = await renderView("- [x] Done\n- [ ] Open"); + const list = container.querySelector('[data-type="taskList"]'); + expect(list?.classList.contains("list-none")).toBe(true); + expect(list?.classList.contains("pl-0")).toBe(true); }); }); diff --git a/src/components/markdown-editor.tsx b/src/components/markdown-editor.tsx index 852e899..d66bba4 100644 --- a/src/components/markdown-editor.tsx +++ b/src/components/markdown-editor.tsx @@ -1,13 +1,10 @@ "use client"; import { useEditor, EditorContent, type Editor } from "@tiptap/react"; -import StarterKit from "@tiptap/starter-kit"; -import Link from "@tiptap/extension-link"; -import Placeholder from "@tiptap/extension-placeholder"; -import { Markdown } from "tiptap-markdown"; import { Bold, Italic, Strikethrough, Link as LinkIcon, Unlink } from "lucide-react"; import { useEffect, useCallback } from "react"; import { cn } from "../lib/utils.ts"; +import { createMarkdownExtensions } from "../lib/markdown-extensions.ts"; import type { MarkdownEditorProps } from "../types/index.ts"; function ToolbarButton({ @@ -95,23 +92,7 @@ function Toolbar({ editor }: { readonly editor: Editor | null }) { export function MarkdownEditor({ value, onChange, placeholder }: MarkdownEditorProps) { const editor = useEditor({ - extensions: [ - StarterKit.configure({ - heading: false, - codeBlock: false, - blockquote: false, - horizontalRule: false, - bulletList: false, - orderedList: false, - listItem: false, - }), - Link.configure({ - openOnClick: false, - HTMLAttributes: { class: "text-blue-600 underline" }, - }), - Placeholder.configure({ placeholder: placeholder ?? "" }), - Markdown, - ], + extensions: createMarkdownExtensions({ placeholder, openLinksOnClick: false }), content: value, immediatelyRender: false, onUpdate: ({ editor: ed }) => { diff --git a/src/components/markdown-view.tsx b/src/components/markdown-view.tsx index 8711eba..ab28093 100644 --- a/src/components/markdown-view.tsx +++ b/src/components/markdown-view.tsx @@ -2,33 +2,12 @@ import { useEffect } from "react"; import { useEditor, EditorContent } from "@tiptap/react"; -import StarterKit from "@tiptap/starter-kit"; -import Link from "@tiptap/extension-link"; -import { Markdown } from "tiptap-markdown"; +import { createMarkdownExtensions } from "../lib/markdown-extensions.ts"; import type { MarkdownViewProps } from "../types/index.ts"; export function MarkdownView({ content }: MarkdownViewProps) { const editor = useEditor({ - extensions: [ - StarterKit.configure({ - heading: false, - codeBlock: false, - blockquote: false, - horizontalRule: false, - bulletList: false, - orderedList: false, - listItem: false, - }), - Link.configure({ - openOnClick: true, - HTMLAttributes: { - class: "text-blue-600 underline", - target: "_blank", - rel: "noopener noreferrer", - }, - }), - Markdown, - ], + extensions: createMarkdownExtensions({ openLinksOnClick: true }), content, immediatelyRender: false, editable: false, diff --git a/src/lib/__tests__/markdown-extensions.test.ts b/src/lib/__tests__/markdown-extensions.test.ts new file mode 100644 index 0000000..afb9810 --- /dev/null +++ b/src/lib/__tests__/markdown-extensions.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import { TaskList, TaskItem } from "@tiptap/extension-list"; +import { Markdown } from "tiptap-markdown"; +import { createMarkdownExtensions } from "../markdown-extensions.ts"; + +/** + * Serialize `input` through a real editor built from the shared factory and + * return the Markdown the document produces. This is exactly the pipeline the + * components use, so what these tests verify is what ships. + */ +function roundTrip(input: string): string { + const editor = new Editor({ extensions: createMarkdownExtensions(), content: input }); + const markdown = editor.storage.markdown.getMarkdown(); + editor.destroy(); + return markdown; +} + +let editors: Editor[] = []; + +afterEach(() => { + editors.forEach((e) => e.destroy()); + editors = []; +}); + +describe("createMarkdownExtensions — round-trip", () => { + it("keeps a task list unchanged, including the [x] and [ ] markers", () => { + const input = "- [x] Write the press release\n- [ ] Send invitations"; + expect(roundTrip(input)).toBe(input); + }); + + it("keeps a bullet list unchanged", () => { + const input = "- Milk\n- Bread"; + expect(roundTrip(input)).toBe(input); + }); + + it("keeps an ordered list unchanged", () => { + const input = "1. First\n2. Second"; + expect(roundTrip(input)).toBe(input); + }); + + it("keeps headings at every level H1–H6", () => { + const input = "# H1\n\n## H2\n\n### H3\n\n#### H4\n\n##### H5\n\n###### H6"; + expect(roundTrip(input)).toBe(input); + }); + + it("keeps a blockquote, a fenced code block with a language tag, and a rule", () => { + const input = "> A wise quote\n\n```js\nconst x = 1;\n```\n\n---"; + const out = roundTrip(input); + expect(out).toBe(input); + expect(out).toContain("> A wise quote"); + expect(out).toContain("```js\nconst x = 1;\n```"); + expect(out).toContain("---"); + }); + + it("keeps a nested task list, preserving indentation and nesting level", () => { + const input = "- [ ] parent\n - [ ] child"; + expect(roundTrip(input)).toBe(input); + }); + + it("keeps marks inside blocks — bold and links survive", () => { + const input = "- [x] Call **Anna** about [the offer](https://example.com)"; + expect(roundTrip(input)).toBe(input); + }); + + it("keeps both kinds of items in a mixed list", () => { + const out = roundTrip("- [x] Done\n- Just an item"); + // A checklist item and a plain item cannot share one node, so the serializer + // splits them; byte-identity is not promised, but nothing may be dropped. + expect(out).toContain("- [x] Done"); + expect(out).toContain("- Just an item"); + }); + + it("does not grow a trailing paragraph after a code block", () => { + const input = "intro\n\n```js\nconst y = 2;\n```"; + expect(roundTrip(input)).toBe(input); + }); + + it("does not grow a trailing paragraph after a horizontal rule", () => { + const input = "intro\n\n---"; + expect(roundTrip(input)).toBe(input); + }); + + it("produces empty Markdown for empty content", () => { + expect(roundTrip("")).toBe(""); + }); + + it("keeps an unchecked-only task list and does not normalise the marker", () => { + const input = "- [ ] Open item"; + const out = roundTrip(input); + expect(out).toBe(input); + expect(out).not.toContain("[x]"); + }); + + it("leaves plain text byte-identical", () => { + const input = "Just a plain paragraph with no markdown."; + expect(roundTrip(input)).toBe(input); + }); + + it("shows the placeholder when one is configured and the document is empty", () => { + const editor = new Editor({ + extensions: createMarkdownExtensions({ placeholder: "Type here..." }), + content: "", + }); + editors.push(editor); + const placeholderExt = editor.extensionManager.extensions.find((e) => e.name === "placeholder"); + expect(placeholderExt?.options.placeholder).toBe("Type here..."); + }); +}); + +describe("createMarkdownExtensions — marks Markdown cannot express", () => { + it("does not register underline, so the command is unavailable", () => { + const editor = new Editor({ extensions: createMarkdownExtensions(), content: "hello" }); + editors.push(editor); + const hasUnderline = editor.extensionManager.extensions.some((e) => e.name === "underline"); + expect(hasUnderline).toBe(false); + // The mark is absent from the schema, so no command can apply it — + // attempting to resolve the mark type throws rather than mutating the doc. + expect(editor.schema.marks.underline).toBeUndefined(); + editor.commands.selectAll(); + expect(() => editor.chain().setMark("underline").run()).toThrow(); + }); +}); + +/** Mount a real editor attached to the DOM so keyboard handling is active. */ +function mountEditor(content: string, extensions = createMarkdownExtensions()): Editor { + const element = document.createElement("div"); + document.body.appendChild(element); + const editor = new Editor({ element, extensions, content }); + editors.push(editor); + return editor; +} + +function pressKey(editor: Editor, init: KeyboardEventInit): void { + editor.view.dom.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }), + ); +} + +/** Simulate typing the trailing space of "[ ] " so a task-item input rule would fire. */ +function typeSpaceAfterBracket(editor: Editor): void { + editor.commands.setContent("

[ ]

"); + editor.commands.focus("end"); + const { from } = editor.state.selection; + editor.view.someProp("handleTextInput", (handler) => + handler(editor.view, from, from, " ", () => editor.state.tr), + ); +} + +function hasTaskList(editor: Editor): boolean { + return (editor.getJSON().content ?? []).some((node) => node.type === "taskList"); +} + +describe("createMarkdownExtensions — task list creation stays closed", () => { + it("does nothing when Mod-Shift-9 is pressed", () => { + const editor = mountEditor("

"); + editor.commands.focus(); + pressKey(editor, { key: "9", code: "Digit9", metaKey: true, ctrlKey: true, shiftKey: true }); + expect(hasTaskList(editor)).toBe(false); + }); + + it("does nothing when the user types '[ ] ' — the literal text remains", () => { + const editor = mountEditor("

"); + typeSpaceAfterBracket(editor); + expect(hasTaskList(editor)).toBe(false); + expect(editor.getText()).toContain("[ ]"); + }); + + it("guard: the same '[ ] ' input DOES create a task list with the default TaskItem", () => { + // Proves the simulated input rule genuinely fires, so the stripped-rule + // assertion above cannot silently pass if the trigger stops working. + const editor = mountEditor("

", [ + StarterKit.configure({ underline: false }), + TaskList, + TaskItem.configure({ nested: true }), + Markdown, + ]); + typeSpaceAfterBracket(editor); + expect(hasTaskList(editor)).toBe(true); + }); +}); + +describe("createMarkdownExtensions — editing an existing task list", () => { + it("splits a task item into a new unchecked item on Enter", () => { + const editor = mountEditor("- [x] Done"); + editor.commands.focus("end"); + pressKey(editor, { key: "Enter", code: "Enter" }); + const md = editor.storage.markdown.getMarkdown(); + expect(md).toContain("- [x] Done"); + expect(md.split("\n").some((line) => line.startsWith("- [ ]"))).toBe(true); + }); + + it("lifts a nested item one level on Shift-Tab", () => { + const editor = mountEditor("- [ ] parent\n - [ ] child"); + editor.commands.focus("end"); + pressKey(editor, { key: "Tab", code: "Tab", shiftKey: true }); + expect(editor.storage.markdown.getMarkdown()).toBe("- [ ] parent\n- [ ] child"); + }); +}); diff --git a/src/lib/markdown-extensions.ts b/src/lib/markdown-extensions.ts new file mode 100644 index 0000000..9959ea5 --- /dev/null +++ b/src/lib/markdown-extensions.ts @@ -0,0 +1,79 @@ +import type { Extensions } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import { TaskList, TaskItem } from "@tiptap/extension-list"; +import Placeholder from "@tiptap/extension-placeholder"; +import { Markdown } from "tiptap-markdown"; + +/** + * Options for {@link createMarkdownExtensions}. The two fields capture the only + * genuine differences between the editor and the read-only view. + */ +export interface MarkdownExtensionsOptions { + /** Placeholder text shown while the document is empty. Editor only. */ + readonly placeholder?: string; + /** Whether clicking a link opens it. `true` in the view, `false` in the editor. */ + readonly openLinksOnClick?: boolean; +} + +/** + * Builds the TipTap extension set shared by `MarkdownEditor`, `MarkdownView` + * and the round-trip tests. + * + * The schema intentionally covers everything Markdown can express and TipTap + * can model with the StarterKit plus task lists, so stored content round-trips + * untouched. Two marks are deliberately excluded: + * + * - `underline` — Markdown has no representation for it, so enabling it would + * reintroduce the data-loss bug this configuration exists to prevent. + * - a separate `Link` extension — StarterKit already ships `link`, so it is + * configured through StarterKit rather than registered twice. + * + * Task lists render but cannot be created: the `Mod-Shift-9` shortcut and the + * `[ ] ` input rule are stripped. Editing shortcuts inside an existing task + * list (Enter, Tab, Shift-Tab) are kept. + */ +export function createMarkdownExtensions(options?: MarkdownExtensionsOptions): Extensions { + const openLinksOnClick = options?.openLinksOnClick ?? false; + + // Strip the creation paths while keeping the in-list editing shortcuts. + // The `tight` attribute makes the Markdown serializer render task lists + // tightly (no blank line between items), matching how tiptap-markdown already + // treats bullet and ordered lists. Without it, prosemirror-markdown falls back + // to a loose list and a multi-item checklist no longer round-trips byte-for-byte. + // `rendered: false` keeps the attribute out of the DOM so it is not persisted. + const TaskListNode = TaskList.extend({ + addKeyboardShortcuts: () => ({}), + addAttributes() { + return { + ...this.parent?.(), + tight: { default: true, rendered: false }, + }; + }, + }); + const TaskItemNode = TaskItem.extend({ addInputRules: () => [] }); + + return [ + StarterKit.configure({ + underline: false, + link: { + openOnClick: openLinksOnClick, + HTMLAttributes: openLinksOnClick + ? { + class: "text-blue-600 underline", + target: "_blank", + rel: "noopener noreferrer", + } + : { class: "text-blue-600 underline" }, + }, + }), + // `list-none pl-0` suppresses the `prose` bullet so a checkbox does not also + // get a list marker; `flex items-start gap-2` aligns the box with its label. + TaskListNode.configure({ HTMLAttributes: { class: "list-none pl-0" } }), + TaskItemNode.configure({ + nested: true, + HTMLAttributes: { class: "flex items-start gap-2" }, + }), + Placeholder.configure({ placeholder: options?.placeholder ?? "" }), + Markdown, + ]; +}