# NotionEditor

Notion-style WYSIWYG markdown editor built on **TipTap v3**.

Lazy-loaded sibling of [`MarkdownEditor`](../MarkdownEditor/) — same
markdown round-trip, but with a heavier extension stack and the
floating menus (slash / bubble / drag handle) that users expect from a
"document" editor.

```tsx
import { NotionEditor } from '@djangocfg/ui-tools/notion-editor';

<NotionEditor
  value={markdown}
  onChange={setMarkdown}
  onSave={(md) => writeFile(md)}
  autoFocus
  placeholder="Type '/' for commands…"
/>
```

## When to use this vs `MarkdownEditor`

| Use case                                | Pick                |
| --------------------------------------- | ------------------- |
| Chat composer, single-paragraph input   | `MarkdownEditor`    |
| Note / doc surface, multi-paragraph     | `NotionEditor`      |
| `.md` file viewer with full editing     | `NotionEditor`      |
| Slim mention dropdown, no tables/tasks  | `MarkdownEditor`    |
| Slash menu, drag handle, tables, hljs   | `NotionEditor`      |

The two coexist intentionally — `NotionEditor` is ~350 KB minified
(lowlight common pack + tables + drag handle); `MarkdownEditor` is
~200 KB. Don't mount NotionEditor inside a chat composer just to get
features the user will never use.

## Features

- **Slash menu (`/`)** — 11 commands: Text, H1-H3, Bullet/Numbered/Todo
  lists, Quote, Code block, Divider, Table. Each item shows its
  markdown shorthand (`#`, `- [ ]`, etc.) as a kbd hint. Filtering by
  title + aliases. `Esc` / click-outside close.
- **Bubble menu** — floating selection toolbar with Bold, Italic,
  Underline, Strike, Code, Highlight, Link. Each button has a Tooltip
  with the keyboard shortcut. Auto-hides inside code blocks and on
  empty selections.
- **Cmd+K link prompt** — opens a Radix Dialog with URL input and
  Save/Remove/Cancel. Snapshots and restores selection across the
  dialog mount. Rejects unsafe schemes (`javascript:`, `data:`).
- **Drag handle** — Notion-style ⠿ grip appears on the left of every
  block on hover (powered by `tiptap-extension-global-drag-handle`).
  The icon is rendered as a `mask-image` of `lucide`'s `GripVertical`
  so it matches the rest of the app at the pixel level.
- **Smart Cmd+A** — first press selects the current text block, second
  press selects the whole document (Notion convention).
- **Heading-aware placeholder** — empty H1/H2/H3 show "Heading 1/2/3";
  empty paragraphs show the supplied placeholder.
- **Forced-dark code blocks** — code blocks render on a fixed One Dark
  Pro surface regardless of host theme. Avoids the washed-out
  "light syntax on light page" look every editor hits.
- **Task list with ui-core `<Checkbox>`** — `taskItem` uses a React
  `NodeView` so the checkbox is the same component used everywhere
  else in the app (theme-aware, focus ring, etc.). Aligned to the
  text cap-height via `align-items: baseline`.
- **Cmd+S save hook** — `onSave?: (md: string) => void` bound to
  Cmd/Ctrl+S via `useHotkey`, scoped to the editor DOM so it doesn't
  collide with global palettes higher up.
- **autoFocus** — declarative prop. Pair with `key={path}` upstream
  when the parent wants a fresh focus per file change.
- **Markdown shortcuts** — StarterKit's input rules cover `#`, `>`,
  `-`, `1.`, `` ``` ``, `---` → matching block on type.

## Extension stack

Built once per editor instance (see `extensions.ts`). Order matters
for markdown serialisation — `Markdown` is registered before
`Highlight`/`TaskList` so the latter use the markdown extension's
node spec when serialising.

- `StarterKit` — paragraph, headings 1-4, bold/italic/code/strike,
  blockquote, ordered+unordered list, link, underline, horizontal
  rule, history. CodeBlock is **disabled** here — replaced by
  `CodeBlockLowlight` for syntax highlighting.
- `Placeholder` — heading-aware ghost text.
- `Markdown` — bidirectional serialisation via `@tiptap/markdown`.
  Handles tables, task lists (`- [ ]`), highlight (`==text==`).
- `Highlight` — `==text==` mark.
- `TaskList` + `TaskItem.extend({ addNodeView: TaskItemView })`.
- `Table` + `TableRow` + `TableHeader` + `TableCell`.
- `CodeBlockLowlight` — uses `lowlight`'s `common` language pack
  (~25 languages). Add more by importing them and registering on the
  shared `lowlight` instance.
- `GlobalDragHandle` — block grabber. **One handle per page** — see
  the "Known limitations" section below.
- `SlashExtension` — wraps `@tiptap/suggestion` for the `/` menu.
- `CustomKeymap` — smart Cmd+A.

## Files

| File                          | Purpose                                                     |
| ----------------------------- | ----------------------------------------------------------- |
| `lazy.tsx`                    | Subpath entry — `React.lazy` + `forwardRef` for the handle  |
| `NotionEditor.tsx`            | Main component + `BubbleSelectionToolbar`                   |
| `extensions.ts`               | Assembled TipTap stack (factory `notionExtensions()`)       |
| `types.ts`                    | `NotionEditorProps`, `NotionEditorHandle`                   |
| `SlashExtension.ts`           | TipTap extension wrapping `@tiptap/suggestion`              |
| `createSlashSuggestion.ts`    | Suggestion config — floating-ui popup mount/update/teardown |
| `SlashList.tsx`               | Popover content (listbox + keyboard nav)                    |
| `slashItems.ts`               | Command list + `filterSlashItems` helper                    |
| `CustomKeymap.ts`             | Smart Cmd+A extension                                       |
| `TaskItemView.tsx`            | React NodeView for `taskItem` (mounts ui-core Checkbox)     |
| `LinkDialog.tsx`              | Cmd+K link prompt (ui-core Dialog)                          |
| `styles.css`                  | Typography + slash/bubble menu + drag handle + lowlight     |

## Imperative API

```tsx
const ref = useRef<NotionEditorHandle>(null);

ref.current?.focus();             // focus the editor surface
ref.current?.moveCursorToEnd();   // focus + caret at end
ref.current?.getEditor();         // raw TipTap Editor (escape hatch)
```

`NotionEditorHandle` is structurally compatible with `ComposerHandle`
from `@djangocfg/ui-tools/composer-registry` — the chat suite can
register a NotionEditor as a composer if you ever want to swap them in
the chat input (unusual, but allowed).

## Saving — dirty / pristine handling

The editor is **uncontrolled-ish**: it calls `onChange` on every
keystroke with the freshly-serialised markdown. The host owns state
and detects "dirty" however it likes.

Two gotchas the host needs to know about:

1. **TipTap re-serialises markdown on mount** (trailing newline,
   CRLF → LF normalisation). The first `onChange` after
   `setContent(value)` is byte-different from `value` even though
   nobody typed. If you compare `draft !== savedContent` literally,
   the dirty indicator lights up on every file open. **Fix in the
   host**: normalise both sides before comparing.

   ```ts
   const norm = (s: string) => s.replace(/\r\n/g, '\n').trimEnd();
   const isDirty = norm(draft) !== norm(saved);
   ```

2. **Cmd+S only fires when focus is inside the editor**. If the user
   clicked outside (header, sidebar) and presses Cmd+S, this hook
   doesn't fire. Add a window-level `useHotkey('mod+s', save)` in the
   host as a fallback — both paths call the same save fn.

See `cmdop/.../document-preview/viewers/text-viewer.tsx` for the
canonical wiring.

## Markdown round-trip

| Input            | Round-trips? | Notes                              |
| ---------------- | ------------ | ---------------------------------- |
| Headings         | ✓            | H1-H4 only (StarterKit config)     |
| Bold/italic/etc. | ✓            |                                    |
| Inline code      | ✓            |                                    |
| Code blocks      | ✓            | Language fence preserved by hljs   |
| Bullet/ordered   | ✓            | Nested too                         |
| Task lists       | ✓            | GFM `- [x]` / `- [ ]`              |
| Blockquote       | ✓            |                                    |
| Horizontal rule  | ✓            | `---`                              |
| Tables           | ✓            | GFM tables — alignment via `:--:`  |
| Links            | ✓            | autolinks too                      |
| Highlight        | ✓            | `==text==` extension syntax        |
| Images           | mostly       | Round-trip URLs, no upload yet     |
| HTML passthrough | ✗            | Stripped on parse                  |
| Footnotes        | ✗            | Not in StarterKit                  |

## Known limitations

- **One editor per page (drag handle).**
  `tiptap-extension-global-drag-handle` mints a single DOM grabber
  attached to `document.body`. Mounting two `NotionEditor` instances
  on the same page (e.g. a side-by-side diff) makes them fight over
  the handle. If you need multiple editors, render only one with
  drag-handle support.

- **Slash popup is portal'd to `document.body`.**
  If the parent unmounts the editor mid-suggestion, the popup gets
  cleaned up by ProseMirror's `onExit` — but a hostile race (route
  change while menu is open) can leave an orphan div. Rare; visible
  only via the inspector.

- **No image upload pipeline (yet).**
  `Image` extension is not registered. Pasting an image from the
  clipboard inserts the data-URL into the document (browser default)
  but there's no upload integration. Add `@tiptap/extension-image` +
  a custom paste handler when you have a target storage. See Novel's
  `plugins/upload-images.tsx` for the canonical pattern.

- **Bubble menu uses `@tiptap/react/menus`'s `BubbleMenu`** (Tippy
  under the hood). Slash menu uses `floating-ui` directly. Two
  positioning libraries for one editor — kept this way because
  `BubbleMenu` is opinionated about anchor invalidation and Tippy
  handles it correctly; reimplementing on floating-ui would save
  ~5 KB but cost a week of edge cases.

## Storybook

Stories live at `apps/storybook/stories/ui-tools/markdown/NotionEditor.stories.tsx`:

- Default (empty editor with placeholder)
- With sample content (every supported block + serialised output panel)
- Auto-focus on mount
- Read-only
- Compact (240px min-height)

Each story renders a right-side panel showing the current serialised
markdown so you can watch the round-trip live as you type.

## Wiring inside a host

The canonical host is `cmdop`'s document-preview text viewer:

```tsx
// .md → NotionEditor only (it's already WYSIWYG, no Preview/Source split)
<div className="mx-auto w-full max-w-[820px] px-6 py-6">
  <NotionEditor
    value={draft}
    onChange={handleChange}
    onSave={save}
    autoFocus
    minHeight={420}
  />
</div>
```

Use `key={path}` on the wrapper if you remount per file — that's what
makes `autoFocus` fire on each file open.
