diff --git a/CHANGELOG.md b/CHANGELOG.md index aafe003..895a5ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this package will be documented in this file. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [SemVer](https://semver.org) (pre-1.0: minors may break). +## [0.5.0] + +### Changed + +- `@plannotator/atomic-editor` peer range widened to `^0.8.0 || ^0.9.0`; the dev engine is + `^0.9.0`. Engine 0.9.0 adds `linkWidgets()` and `refreshLinkWidgets`, a host seam that draws a + single-line `[text](url)` link as the host's own widget (a chip, a mention) while the markdown + stays the link byte for byte. It composes through the `extensions` prop with no wrapper code + changes; `test/link-widgets.test.tsx` guards that path and the fidelity rule. Links inside table + cells keep the link look in engine 0.9.0. + ## [0.4.0] ### Added diff --git a/README.md b/README.md index 751a164..16912ea 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,15 @@ How it behaves: `slashCommands()` is a Notion-style insert menu on `/` at the start of a line; `selectionToolbar()` is a floating bold/italic/strike/code/link bar over selected text (works multi-line and inside table cells). Both are themeable via the `--atomic-editor-menu-*` CSS variables and documented in the [atomic-editor changelog](https://github.com/plannotator/atomic-editor/blob/main/CHANGELOG.md). + Engine 0.9.0 adds `linkWidgets()`, which draws a single-line `[text](url)` link as your own CM6 widget while the markdown stays the link. The engine decides when the widget yields to the raw source (caret inside or at an edge, focus, pointer-press freeze, diff changes); dispatch `refreshLinkWidgets` when your answer changes without a document edit: + + ```tsx + import { linkWidgets, refreshLinkWidgets } from "@plannotator/atomic-editor"; + + chipFor(url, text) })]} ... /> + // later: view.dispatch({ effects: refreshLinkWidgets.of(null) }) + ``` + - **`className` / `cardClassName`**: extra classes on the wrapper and inner card, for stacking, shadows, or padding your app needs. ## Theming diff --git a/package.json b/package.json index 855755b..c7adf0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@plannotator/markdown-editor", - "version": "0.4.0", + "version": "0.5.0", "description": "Live-preview markdown editor for React (CodeMirror 6, atomic-editor). Raw markdown is the source of truth: rendering is decoration only, edits round-trip byte-identical.", "keywords": [ "codemirror", @@ -59,7 +59,7 @@ "@codemirror/legacy-modes": "^6.5.3", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.43.0", - "@plannotator/atomic-editor": "^0.8.0", + "@plannotator/atomic-editor": "^0.9.0", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "happy-dom": "^20.0.0", @@ -78,7 +78,7 @@ "@codemirror/language": "^6.0.0", "@codemirror/legacy-modes": "^6.0.0", "@codemirror/state": "^6.0.0", - "@plannotator/atomic-editor": "^0.8.0", + "@plannotator/atomic-editor": "^0.8.0 || ^0.9.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21d305c..af3b4ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,8 +33,8 @@ importers: specifier: ^6.43.0 version: 6.43.4 '@plannotator/atomic-editor': - specifier: ^0.8.0 - version: 0.8.0(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.4)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.11)(@codemirror/lang-javascript@6.2.5)(@codemirror/lang-json@6.0.2)(@codemirror/lang-markdown@6.5.0)(@codemirror/lang-python@6.2.1)(@codemirror/lang-yaml@6.1.3)(@codemirror/language@6.12.4)(@codemirror/legacy-modes@6.5.3)(@codemirror/merge@6.12.2)(@codemirror/search@6.7.1)(@codemirror/state@6.7.0)(@codemirror/view@6.43.4)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/markdown@1.6.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.2) + specifier: ^0.9.0 + version: 0.9.0(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.4)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.11)(@codemirror/lang-javascript@6.2.5)(@codemirror/lang-json@6.0.2)(@codemirror/lang-markdown@6.5.0)(@codemirror/lang-python@6.2.1)(@codemirror/lang-yaml@6.1.3)(@codemirror/language@6.12.4)(@codemirror/legacy-modes@6.5.3)(@codemirror/merge@6.12.2)(@codemirror/search@6.7.1)(@codemirror/state@6.7.0)(@codemirror/view@6.43.4)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/markdown@1.6.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.2) '@types/react': specifier: ^19.2.0 version: 19.2.17 @@ -533,8 +533,8 @@ packages: cpu: [x64] os: [win32] - '@plannotator/atomic-editor@0.8.0': - resolution: {integrity: sha512-s4IaRrlFjluYvMVRS/XJjAaHVR5I/4yAxAFWlUHk74RE7W9BQO8lazWgYgNyRSYQtdlEZsJiPgzaDeOuocErWg==} + '@plannotator/atomic-editor@0.9.0': + resolution: {integrity: sha512-TWiv9zkMUPpsw5gIX0zWNM9xxS7LTqgs0FC5Dpg6kl+cushddaWzrRHO8Z7WaMfoyMvSg2kiQYiUvZMSxba+dQ==} engines: {node: '>=18'} peerDependencies: '@codemirror/autocomplete': ^6.0.0 @@ -1443,7 +1443,7 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.72.0': optional: true - '@plannotator/atomic-editor@0.8.0(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.4)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.11)(@codemirror/lang-javascript@6.2.5)(@codemirror/lang-json@6.0.2)(@codemirror/lang-markdown@6.5.0)(@codemirror/lang-python@6.2.1)(@codemirror/lang-yaml@6.1.3)(@codemirror/language@6.12.4)(@codemirror/legacy-modes@6.5.3)(@codemirror/merge@6.12.2)(@codemirror/search@6.7.1)(@codemirror/state@6.7.0)(@codemirror/view@6.43.4)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/markdown@1.6.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.2)': + '@plannotator/atomic-editor@0.9.0(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.4)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.11)(@codemirror/lang-javascript@6.2.5)(@codemirror/lang-json@6.0.2)(@codemirror/lang-markdown@6.5.0)(@codemirror/lang-python@6.2.1)(@codemirror/lang-yaml@6.1.3)(@codemirror/language@6.12.4)(@codemirror/legacy-modes@6.5.3)(@codemirror/merge@6.12.2)(@codemirror/search@6.7.1)(@codemirror/state@6.7.0)(@codemirror/view@6.43.4)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/markdown@1.6.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.2)': dependencies: '@codemirror/autocomplete': 6.20.3 '@codemirror/commands': 6.10.4 diff --git a/test/link-widgets.test.tsx b/test/link-widgets.test.tsx new file mode 100644 index 0000000..b60fad0 --- /dev/null +++ b/test/link-widgets.test.tsx @@ -0,0 +1,84 @@ +/** + * Engine 0.9.0's `linkWidgets()` seam composes through the wrapper's + * `extensions` prop: a host widget draws in place of a `[text](url)` link, + * the document bytes are untouched, and `refreshLinkWidgets` re-asks the + * host without a document change. The engine owns the reveal rules and + * tests them itself; this guards the wrapper path and the fidelity rule. + */ +import { describe, expect, test } from "vitest"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { EditorView, WidgetType } from "@codemirror/view"; +import { linkWidgets, refreshLinkWidgets } from "@plannotator/atomic-editor"; +import type { LinkWidgetLink } from "@plannotator/atomic-editor"; +import { MarkdownEditor } from "../src/MarkdownEditor.js"; +import type { MarkdownEditorHandle } from "../src/MarkdownEditor.js"; + +const DOC = + "# Decisions\n\nDecided: [Ship on Friday](dec://42) today.\n\nSee [docs](https://example.com).\n"; + +class ChipWidget extends WidgetType { + constructor(readonly text: string) { + super(); + } + + override eq(other: ChipWidget): boolean { + return other.text === this.text; + } + + override toDOM(): HTMLElement { + const span = document.createElement("span"); + span.className = "test-chip"; + span.textContent = this.text; + return span; + } +} + +describe("linkWidgets through the wrapper", () => { + test("draws the host widget, keeps the bytes, and re-asks on refresh", async () => { + const known = new Set(); + const match = (link: LinkWidgetLink) => + known.has(link.url) ? new ChipWidget(link.text) : null; + const host = document.createElement("div"); + host.style.width = "600px"; + host.style.height = "400px"; + document.body.appendChild(host); + const handleRef: { current: MarkdownEditorHandle | null } = { current: null }; + const root = createRoot(host); + await act(async () => { + root.render( + , + ); + }); + + // Nothing known yet: the engine's own link look. + expect(host.querySelector(".test-chip")).toBeNull(); + + known.add("dec://42"); + const editorDom = host.querySelector(".cm-editor"); + const view = editorDom ? EditorView.findFromDOM(editorDom) : null; + expect(view).not.toBeNull(); + await act(async () => { + view?.dispatch({ effects: refreshLinkWidgets.of(null) }); + }); + + const chips = host.querySelectorAll(".test-chip"); + expect(chips).toHaveLength(1); + expect(chips[0]?.textContent).toBe("Ship on Friday"); + expect(chips[0]?.closest(".cm-atomic-link")).toBeNull(); + // The unmatched link keeps the engine look. + expect(host.querySelector(".cm-atomic-link")?.textContent).toBe("docs"); + // The one inviolable rule: drawing a widget never touches the bytes. + expect(handleRef.current?.getMarkdown()).toBe(DOC); + + await act(async () => { + root.unmount(); + }); + host.remove(); + }); +});