@docs.plus/extension-placeholder 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to `@docs.plus/extension-placeholder` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/); the project adheres to [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## [2.1.0] — 2026-10-13
6
+
7
+ Aligned to docs.plus 2.1.0 family release. No behavioral changes since 2.0.0.
8
+
9
+ ### Changed
10
+
11
+ - The `@tiptap/core` and `@tiptap/pm` peer floor rises from `^3.22.3` to `^3.31.3`.
12
+
13
+ ### Documentation
14
+
15
+ - The README is a short start page. It covers why to use it, install,
16
+ a Quickstart that shows a result, the caveats that fail with no error,
17
+ and common tasks.
18
+ - The full reference moved to `docs/` on GitHub (`api.md`, `guide.md`, `migration.md`).
19
+ - The install line leads with `npm install`. `pnpm`, `yarn` and `bun`
20
+ follow on the next line.
21
+ - An **Open in StackBlitz** button runs the Quickstart in the browser.
22
+ The app lives in `examples/vanilla`, and preflight fails when it differs
23
+ from the README.
24
+ - A "For AI coding agents" row points agents at this README as plain
25
+ Markdown on jsDelivr.
26
+ - The Quickstart includes the CSS rule, so the hint shows on first run.
27
+
5
28
  ## [2.0.0] — 2026-08-11
6
29
 
7
30
  ### Highlights
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![Version](https://img.shields.io/npm/v/@docs.plus/extension-placeholder.svg?label=version)](https://www.npmjs.com/package/@docs.plus/extension-placeholder)
5
5
  [![Downloads](https://img.shields.io/npm/dm/@docs.plus/extension-placeholder.svg)](https://npmcharts.com/compare/@docs.plus/extension-placeholder)
6
6
  [![License](https://img.shields.io/npm/l/@docs.plus/extension-placeholder.svg)](https://www.npmjs.com/package/@docs.plus/extension-placeholder)
7
- [![Discord](https://img.shields.io/badge/discord-community-5865F2?logo=discord&logoColor=white)](https://discord.gg/25JPG38J59)
7
+ [![Discord](https://img.shields.io/badge/discord-community-5865F2?logo=discord&logoColor=white)](https://discord.gg/2EmAjmgZ8)
8
8
 
9
9
  <p align="center">
10
10
  <picture>
@@ -15,23 +15,42 @@
15
15
 
16
16
  Tiptap placeholder extension that shows hint text in the empty textblock at the cursor.
17
17
 
18
- A textblock is a node that holds inline text, such as a paragraph or a heading. Tiptap's built-in Placeholder scans every top-level block with `doc.descendants` on every editor update, and the whole tree when `includeChildren` is on. This package starts at the cursor and walks up, so the cost tracks the cursor depth, not the document length. It writes the hint text into a `data-placeholder` attribute on the empty textblock at the cursor. It also adds `emptyNodeClass` to every empty ancestor wrapper above that textblock. It accepts four of the built-in's seven options, and its callback form gains `parentName` and `doc`.
18
+ A textblock is a node that holds inline text, such as a paragraph or a heading.
19
+
20
+ [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/docs-plus/docs.plus/tree/main/extensions/extension-placeholder/examples/vanilla?file=main.ts)
21
+
22
+ ## Why use it
23
+
24
+ - **Cost tracks cursor depth, not document length.** Tiptap's built-in Placeholder scans every top-level block with `doc.descendants` on every editor update. This package walks up from the cursor instead.
25
+ - **Migration starts with one import swap.** Four of the built-in's seven options keep their names and defaults. The callback form also receives `parentName` and `doc`.
26
+ - **Nested blocks work with no extra option.** An empty paragraph inside a list item or blockquote gets the hint. The built-in needs `includeChildren` for that.
27
+ - **Metadata-only transactions skip the rebuild.** An awareness ping in a collaborative editor keeps the previous decoration set. The built-in re-evaluates the `placeholder` callback on every editor update.
19
28
 
20
29
  ## Install
21
30
 
22
31
  ```sh
23
- bun add @docs.plus/extension-placeholder
32
+ npm install @docs.plus/extension-placeholder
24
33
  ```
25
34
 
26
- Requires **`@tiptap/core` ^3.22.3** and **`@tiptap/pm` ^3.22.3** (Tiptap 3.x).
35
+ Or use `pnpm add @docs.plus/extension-placeholder`, `yarn add @docs.plus/extension-placeholder`, or `bun add @docs.plus/extension-placeholder`.
36
+
37
+ Requires **`@tiptap/core` ^3.31.3** and **`@tiptap/pm` ^3.31.3** (Tiptap 3.x).
38
+
39
+ This package imports no React, Vue, or Next.js code. Use it from a plain page, Vite, React, Vue, Svelte, Next.js, Nuxt, or SvelteKit. Create the editor in the browser.
40
+
41
+ React Native has no DOM. Load the editor in a web view.
27
42
 
28
43
  Installs with no runtime dependencies.
29
44
 
30
- To move from Tiptap's built-in Placeholder, see [Migrating from the built-in Placeholder](#migrating-from-the-built-in-placeholder).
45
+ To move from Tiptap's built-in Placeholder, see [Migrating from the built-in Placeholder](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-placeholder/docs/migration.md#migrating-from-the-built-in-placeholder).
31
46
 
32
47
  ## Quickstart
33
48
 
34
- Register `Placeholder` in the extensions array and set the hint text.
49
+ [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/docs-plus/docs.plus/tree/main/extensions/extension-placeholder/examples/vanilla?file=main.ts)
50
+
51
+ Run this Quickstart in your browser first, with nothing to install. The app lives in [`examples/vanilla`](https://github.com/docs-plus/docs.plus/tree/main/extensions/extension-placeholder/examples/vanilla).
52
+
53
+ Register `Placeholder` in the extensions array and set the hint text. The host page needs one mount point: `<div id="editor"></div>`. The snippet also imports `@tiptap/starter-kit`. Add it with `npm install @tiptap/starter-kit` when your app has none yet.
35
54
 
36
55
  ```ts
37
56
  import { Editor } from '@tiptap/core'
@@ -52,35 +71,34 @@ const editor = new Editor({
52
71
  })
53
72
  ```
54
73
 
55
- The snippet mounts the editor into `#editor`, so that element must exist on the page. The editor renders no hint yet, because the package ships no CSS — add the rule in [Styling](#styling).
56
-
57
- ## Options
74
+ The package ships no CSS, so add this rule to your page to render the hint:
58
75
 
59
- `.configure({ … })` accepts these four options and nothing else.
76
+ ```css
77
+ .ProseMirror [data-placeholder]::before {
78
+ content: attr(data-placeholder);
79
+ color: #9ca3af;
80
+ float: left;
81
+ height: 0;
82
+ pointer-events: none;
83
+ }
84
+ ```
60
85
 
61
- | Option | Type | Default | Description |
62
- | ---------------------- | ------------------------------------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------- |
63
- | `placeholder` | `((props: PlaceholderRenderProps) => string) \| string` | `'Write something …'` | Hint text, or a callback that returns it per textblock. See [`placeholder`](#placeholder). |
64
- | `emptyNodeClass` | `string` | `'is-empty'` | Class the extension adds to the empty textblock at the cursor, and to every empty ancestor wrapper above it. |
65
- | `emptyEditorClass` | `string` | `'is-editor-empty'` | Extra class next to `emptyNodeClass` while the whole document is empty. The extension never adds it to the editor root. |
66
- | `showOnlyWhenEditable` | `boolean` | `true` | Hides the hint and both classes while the editor is read-only. |
86
+ You should see the hint `Write something …` in the empty editor as soon as the page loads. Click in the editor and type a character, and the hint disappears. Press Enter, and the hint moves to the new empty paragraph.
67
87
 
68
- The extension reads `showOnlyWhenEditable` at render time. `editor.setEditable()` therefore updates the hint and both classes at once, without a transaction.
88
+ ## Caveats
69
89
 
70
- ### `placeholder`
90
+ - **Nothing renders without CSS.** The package ships no stylesheet, and the extension writes the hint text into a `data-placeholder` attribute. Add the rule in [Quickstart](#quickstart).
91
+ - **Two placeholder extensions collide.** This extension and Tiptap's built-in both register the name `placeholder`. Tiptap logs the warning `[tiptap warn]: Duplicate extension names found: ['placeholder']. This can lead to issues.`, and both plugins decorate the document.
92
+ - **`showOnlyCurrent`, `includeChildren` and `dataAttribute` do not exist.** TypeScript rejects them in a `.configure({ … })` literal. A plain JavaScript host gets no error, and the extension ignores the key. The option map in [Migrating from the built-in Placeholder](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-placeholder/docs/migration.md#migrating-from-the-built-in-placeholder) gives the replacement for each one.
93
+ - **The callback runs only on a document or selection change.** Hint text from outside the document, i18n text for example, does not refresh on its own. Force a rebuild with `editor.view.dispatch(editor.state.tr.setSelection(editor.state.selection))`.
71
94
 
72
- A string sets one hint for every empty textblock. A callback sets the hint per textblock, and receives one object:
95
+ The full list, with the reason for each, is in [Caveats](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-placeholder/docs/api.md#caveats).
73
96
 
74
- - `node` — the empty textblock at the cursor.
75
- - `parentName` — type name of the parent node, for example `doc`, `listItem` or `blockquote`.
76
- - `pos` — position of `node` inside `doc`.
77
- - `doc` — the document the extension builds the decoration from.
78
- - `hasAnchor` — always `true`, because the extension decorates only the empty textblock at the cursor. See the `hasAnchor` note in [Migrating from the built-in Placeholder](#migrating-from-the-built-in-placeholder).
79
- - `editor` — the editor instance.
97
+ ## Common tasks
80
98
 
81
- The callback runs inside the plugin's `init()` and `apply()`. During `apply()` it runs before the editor commits the transaction, so resolve `pos` against the supplied `doc`, never `editor.state.doc`.
99
+ ### Set a hint per textblock type
82
100
 
83
- To change the hint after the editor exists, use the callback form and read the new text inside it. Then force a rebuild with the dispatch in [Caveats](#caveats).
101
+ A string sets one hint for every empty textblock. A callback sets the hint per textblock:
84
102
 
85
103
  ```ts
86
104
  Placeholder.configure({
@@ -93,53 +111,11 @@ Placeholder.configure({
93
111
  })
94
112
  ```
95
113
 
96
- `node` names the empty textblock and `parentName` names its parent, so the two answer different questions. An empty paragraph inside a list item reports `node.type.name === 'paragraph'` and `parentName === 'listItem'`.
97
-
98
- A callback that returns `''` emits no decoration — see [Caveats](#caveats).
99
-
100
- ## Caveats
101
-
102
- The extension adds at most one `data-placeholder` decoration per state, and it treats a node as empty only when `isNodeEmpty(node)` from `@tiptap/core` returns true.
103
-
104
- - **Nothing renders without CSS.** The package ships no stylesheet, and the extension writes the hint text into a `data-placeholder` attribute. Add the rule in [Styling](#styling).
105
- - **Two placeholder extensions collide.** This extension and Tiptap's built-in both register the name `placeholder`. Tiptap logs the warning `[tiptap warn]: Duplicate extension names found: ['placeholder']. This can lead to issues.`, and both plugins decorate the document.
106
- - **A single space counts as content.** The check is `isNodeEmpty(node)` with the default `ignoreWhitespace: false`. A paragraph holding one space, or one hard break, shows no hint.
107
- - **`showOnlyCurrent`, `includeChildren` and `dataAttribute` do not exist.** TypeScript rejects them in a `.configure({ … })` literal. A plain JavaScript host gets no error, and the extension ignores the key. The option map in [Migrating from the built-in Placeholder](#migrating-from-the-built-in-placeholder) gives the replacement for each one.
108
- - **An empty hint removes the classes too.** A `placeholder` string or callback that resolves to `''` emits no decoration, so `emptyNodeClass` and `emptyEditorClass` disappear as well. Return a placeholder string of one space to keep the classes without visible text.
109
- - **A selection at document depth hides the hint.** ⌘A or Ctrl+A produces an `AllSelection` whose anchor sits at that depth. A node selection on a top-level node does the same. The hint returns when the selection moves back into a textblock.
110
- - **The hint stays after blur.** Nothing in the extension reads `editor.isFocused`, so the decoration survives a blur. Hide it with a CSS rule — see [Styling](#styling).
111
- - **Do not rely on the hint for accessibility.** It is generated content on a `::before` pseudo-element, outside the document. Set `aria-label` or `aria-placeholder` on the editable element yourself.
112
- - **The callback runs only on a document or selection change.** The extension rebuilds when a transaction changes the document or sets the selection. Hint text from outside the document, i18n text for example, does not refresh on its own. Force a rebuild with `editor.view.dispatch(editor.state.tr.setSelection(editor.state.selection))`. The same rule keeps the hint stable under [Collaborative editing](#collaborative-editing).
113
-
114
- ## Styling
115
-
116
- The package ships no CSS.
117
-
118
- The extension writes the resolved hint text into the `data-placeholder` attribute of the empty textblock at the cursor. Add this rule to render it:
119
-
120
- ```css
121
- .ProseMirror [data-placeholder]::before {
122
- content: attr(data-placeholder);
123
- color: #9ca3af;
124
- float: left;
125
- height: 0;
126
- pointer-events: none;
127
- }
128
- ```
129
-
130
- ### Class names
131
-
132
- | Name | Where the extension adds it | When |
133
- | -------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
134
- | `data-placeholder` (attribute) | the empty textblock at the cursor only | the cursor sits in an empty textblock, the resolved hint text is not empty, and the editor is editable or `showOnlyWhenEditable` is `false` |
135
- | `is-empty` (`emptyNodeClass`) | that same textblock, and every empty ancestor wrapper above it | the same three conditions as `data-placeholder` |
136
- | `is-editor-empty` (`emptyEditorClass`) | the same nodes as `is-empty`, next to it | the same three conditions, and the whole document is empty |
114
+ An empty paragraph inside a list item reports `node.type.name === 'paragraph'` and `parentName === 'listItem'`. The callback receives four more fields, listed in [`placeholder`](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-placeholder/docs/api.md#placeholder).
137
115
 
138
- Target `[data-placeholder]`, not `.is-empty`. The extension adds the empty class to an empty list item or blockquote, but not the attribute. A `.is-empty::before { content: attr(data-placeholder) }` rule therefore paints an empty pseudo-element on those wrappers as well.
116
+ ### Show the hint only in an empty document
139
117
 
140
- The extension never adds `is-editor-empty` to the editor root, because the ancestor walk stops below the document node. A `.ProseMirror.is-editor-empty` selector matches nothing.
141
-
142
- To show the hint only while the document is fully empty, swap the selector of the rule above for the selector below:
118
+ Swap the selector of the Quickstart rule for the selector below:
143
119
 
144
120
  ```css
145
121
  .ProseMirror .is-editor-empty[data-placeholder]::before {
@@ -151,14 +127,7 @@ To show the hint only while the document is fully empty, swap the selector of th
151
127
  }
152
128
  ```
153
129
 
154
- Select the empty ancestor wrappers on their own. This rule dims an empty blockquote or list item. Replace the declaration as needed:
155
-
156
- ```css
157
- .ProseMirror blockquote.is-empty,
158
- .ProseMirror li.is-empty {
159
- opacity: 0.6;
160
- }
161
- ```
130
+ ### Hide the hint when the editor has no focus
162
131
 
163
132
  The extension has no focus option. To hide the hint while the editor has no focus, add this rule:
164
133
 
@@ -168,70 +137,15 @@ The extension has no focus option. To hide the hint while the editor has no focu
168
137
  }
169
138
  ```
170
139
 
171
- ## Collaborative editing
172
-
173
- A remote edit that leaves the local selection alone keeps the hint, and moves it with the document.
174
-
175
- The plugin rebuilds its decorations when a transaction changes the document or sets the selection. A Yjs update arrives as a document change, so the rebuild runs and the decoration follows the new position. A transaction that carries only metadata, such as an awareness ping, skips the rebuild and keeps the previous decoration set.
176
-
177
- The decoration is local to one editor view. It never enters the document, so it never syncs to another client, and each client sees the hint at its own cursor.
178
-
179
- The clean-room spec [cypress/e2e/external-edit.cy.ts](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-placeholder/cypress/e2e/external-edit.cy.ts) covers both shapes: text inserted elsewhere, and a paragraph inserted before the empty textblock.
180
-
181
- ## Migrating from the built-in Placeholder
182
-
183
- Tiptap 3.x ships the built-in Placeholder from `@tiptap/extensions`, subpath `./placeholder`. `@tiptap/extension-placeholder` is the older name for the same extension.
184
-
185
- Swap the import and keep the same configuration. The options this package accepts carry the built-in's names and defaults.
186
-
187
- ```diff
188
- - import { Placeholder } from '@tiptap/extensions'
189
- + import { Placeholder } from '@docs.plus/extension-placeholder'
190
- ```
191
-
192
- Remove the built-in from the extensions array in the same change. Both register the name `placeholder`, so leaving both in place makes both decorate.
193
-
194
- | Built-in option | This package |
195
- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
196
- | `placeholder` | Same name, same default `'Write something …'`. The callback also receives `parentName` and `doc`. |
197
- | `emptyNodeClass` | Same name, same default `'is-empty'`. The extension also adds it to every empty ancestor wrapper. |
198
- | `emptyEditorClass` | Same name, same default `'is-editor-empty'`. Unchanged: the built-in also adds it to the decorated nodes. |
199
- | `showOnlyWhenEditable` | Same name, same default `true`. Read at render time, so `editor.setEditable()` updates the hint and both classes at once. |
200
- | `showOnlyCurrent` | No option. The extension decorates only the empty textblock at the cursor. |
201
- | `includeChildren` | No option needed. The extension adds `data-placeholder` to the empty textblock at the cursor inside a list item or blockquote too, and adds the empty class to its empty wrappers. |
202
- | `dataAttribute` | No option. The attribute is always `data-placeholder`. |
203
-
204
- Five behavior differences remain after the swap:
205
-
206
- - **Cost.** The built-in scans every top-level block with `doc.descendants` on every editor update, and the whole tree when `includeChildren` is on. This package walks from the cursor up to the first non-empty ancestor.
207
- - **Rebuild timing.** The built-in re-evaluates the `placeholder` callback on every editor update. This package rebuilds only when a transaction changes the document or sets the selection.
208
- - **Empty hint.** A resolved hint of `''` removes the decoration and both classes here. The built-in still emits the classes.
209
- - **Document-depth selection.** ⌘A or Ctrl+A hides the hint here. The built-in keeps it.
210
- - **`hasAnchor`.** Always `true` here, because the extension decorates only the empty textblock at the cursor. The built-in passes `false` when `showOnlyCurrent: false` decorates a node away from the cursor.
211
-
212
- Class-based CSS keeps working, with one change: the extension now adds the empty class to an empty list item or blockquote as well. Add `[data-placeholder]` to the selector to reach only the textblock that holds the hint. See [Styling](#styling).
213
-
214
- Full breaking-change list: [CHANGELOG.md](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-placeholder/CHANGELOG.md).
215
-
216
- ## TypeScript
217
-
218
- `dist/index.d.ts` exports three symbols, and the options in [Options](#options) are the whole API.
219
-
220
- Extension:
221
-
222
- - `Placeholder` — `Extension<PlaceholderOptions, any>`, registered under the name `placeholder`.
223
-
224
- Types:
225
-
226
- - `PlaceholderOptions` — the four option fields.
227
- - `PlaceholderRenderProps` — the argument the callback form of `placeholder` receives.
228
-
229
- Nothing else exists, so this README skips four sections:
140
+ ## Documentation
230
141
 
231
- - No `addCommands`. `editor.commands` gains nothing, so there is no Commands section.
232
- - No `addKeyboardShortcuts` and no keydown handler. The extension binds no keys, so there is no Keyboard shortcuts section.
233
- - No `addStorage`. `editor.storage.placeholder` holds the empty object Tiptap creates for every extension.
234
- - No option and no attribute holds a URL, and the source calls no `window.open`, so there is no Security section.
142
+ | Guide | What it covers |
143
+ | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
144
+ | [API reference](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-placeholder/docs/api.md) | Options, the `placeholder` callback, the full caveats list, and TypeScript exports |
145
+ | [Guide](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-placeholder/docs/guide.md) | How it works, Styling and class names, and collaborative editing |
146
+ | [Migrating from the built-in Placeholder](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-placeholder/docs/migration.md) | Moving from Tiptap's built-in Placeholder: option map and behavior differences |
147
+ | [Changelog](https://github.com/docs-plus/docs.plus/blob/main/extensions/extension-placeholder/CHANGELOG.md) | Every release and its breaking changes |
148
+ | [For AI coding agents](https://cdn.jsdelivr.net/npm/@docs.plus/extension-placeholder/README.md) | This README as plain Markdown for your agent. The docs are also on [Context7](https://context7.com/docs-plus/docs.plus) |
235
149
 
236
150
  ## Part of docs.plus
237
151
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/placeholder.ts"],"sourcesContent":["export type { PlaceholderOptions, PlaceholderRenderProps } from './placeholder'\nexport { Placeholder } from './placeholder'\n","import type { Editor } from '@tiptap/core'\nimport { Extension, isNodeEmpty } from '@tiptap/core'\nimport type { Node as ProseMirrorNode } from '@tiptap/pm/model'\nimport type { EditorState, Transaction } from '@tiptap/pm/state'\nimport { Plugin, PluginKey } from '@tiptap/pm/state'\nimport { Decoration, DecorationSet } from '@tiptap/pm/view'\n\n/** Arguments passed to the `placeholder` callback when it is a function. */\nexport interface PlaceholderRenderProps {\n editor: Editor\n node: ProseMirrorNode\n pos: number\n hasAnchor: boolean\n /** Parent node type name (e.g. 'doc', 'listItem', 'blockquote'). Safe during apply(). */\n parentName: string\n /** Doc that `pos` resolves against. Use this — `editor.state.doc` is stale during apply(). */\n doc: ProseMirrorNode\n}\n\nexport interface PlaceholderOptions {\n emptyEditorClass: string\n emptyNodeClass: string\n placeholder: ((props: PlaceholderRenderProps) => string) | string\n showOnlyWhenEditable: boolean\n}\n\nconst PLACEHOLDER_KEY = new PluginKey('placeholder')\n\n/**\n * At most one placeholder decoration at the cursor's empty parent, plus\n * empty-class decorations on empty ancestor wrappers. Cost: O(depth).\n */\nfunction buildFromCursor(\n state: EditorState,\n editor: Editor,\n options: PlaceholderOptions\n): DecorationSet {\n const { $anchor } = state.selection\n if ($anchor.depth === 0) return DecorationSet.empty\n\n const node = $anchor.parent\n if (!node.type.isTextblock || !isNodeEmpty(node)) return DecorationSet.empty\n\n const pos = $anchor.before($anchor.depth)\n const parentName = $anchor.node($anchor.depth - 1).type.name\n const placeholderText =\n typeof options.placeholder === 'function'\n ? options.placeholder({ editor, node, pos, hasAnchor: true, parentName, doc: state.doc })\n : options.placeholder\n\n if (!placeholderText) return DecorationSet.empty\n\n const classes = [options.emptyNodeClass]\n if (isNodeEmpty(state.doc)) {\n classes.push(options.emptyEditorClass)\n }\n\n const classStr = classes.join(' ')\n const decorations: Decoration[] = [\n Decoration.node(pos, pos + node.nodeSize, {\n class: classStr,\n 'data-placeholder': placeholderText\n })\n ]\n\n // Propagate empty class to ancestor wrappers (list items, blockquotes, etc.)\n for (let d = $anchor.depth - 1; d >= 1; d--) {\n const ancestor = $anchor.node(d)\n if (!isNodeEmpty(ancestor)) break\n const ancestorPos = $anchor.before(d)\n decorations.push(\n Decoration.node(ancestorPos, ancestorPos + ancestor.nodeSize, {\n class: classStr\n })\n )\n }\n\n return DecorationSet.create(state.doc, decorations)\n}\n\n/**\n * Drop-in replacement for the built-in Placeholder with O(depth) cursor-based\n * state init/apply instead of the built-in's O(N) doc.descendants scan.\n * Decorates the empty node at the cursor plus empty ancestor wrappers —\n * inherently showOnlyCurrent + includeChildren.\n */\nexport const Placeholder = Extension.create<PlaceholderOptions>({\n name: 'placeholder',\n\n addOptions() {\n return {\n emptyEditorClass: 'is-editor-empty',\n emptyNodeClass: 'is-empty',\n placeholder: 'Write something …',\n showOnlyWhenEditable: true\n }\n },\n\n addProseMirrorPlugins() {\n const editor = this.editor\n const options = this.options\n\n return [\n new Plugin({\n key: PLACEHOLDER_KEY,\n\n state: {\n init(_: unknown, state: EditorState): DecorationSet {\n return buildFromCursor(state, editor, options)\n },\n\n apply(\n tr: Transaction,\n old: DecorationSet,\n _oldState: EditorState,\n newState: EditorState\n ): DecorationSet {\n // Decorations derive solely from doc + selection, so meta-only\n // transactions (collab pings, plugin meta) cannot change them.\n if (!tr.docChanged && !tr.selectionSet) return old\n\n return buildFromCursor(newState, editor, options)\n }\n },\n\n props: {\n // Gate editability here, not in apply. `setEditable()` refreshes props via\n // view.updateState without dispatching a transaction, so the placeholder\n // toggles immediately when the editor flips to/from read-only.\n decorations(state: EditorState): DecorationSet | null {\n if (options.showOnlyWhenEditable && !editor.isEditable) return null\n return this.getState(state) ?? null\n }\n }\n })\n ]\n }\n})\n"],"mappings":"yaAAA,IAAAA,EAAA,GAAAC,EAAAD,EAAA,iBAAAE,IAAA,eAAAC,EAAAH,GCCA,IAAAI,EAAuC,wBAGvCC,EAAkC,4BAClCC,EAA0C,2BAqBpCC,EAAkB,IAAI,YAAU,aAAa,EAMnD,SAASC,EACPC,EACAC,EACAC,EACe,CACf,GAAM,CAAE,QAAAC,CAAQ,EAAIH,EAAM,UAC1B,GAAIG,EAAQ,QAAU,EAAG,OAAO,gBAAc,MAE9C,IAAMC,EAAOD,EAAQ,OACrB,GAAI,CAACC,EAAK,KAAK,aAAe,IAAC,eAAYA,CAAI,EAAG,OAAO,gBAAc,MAEvE,IAAMC,EAAMF,EAAQ,OAAOA,EAAQ,KAAK,EAClCG,EAAaH,EAAQ,KAAKA,EAAQ,MAAQ,CAAC,EAAE,KAAK,KAClDI,EACJ,OAAOL,EAAQ,aAAgB,WAC3BA,EAAQ,YAAY,CAAE,OAAAD,EAAQ,KAAAG,EAAM,IAAAC,EAAK,UAAW,GAAM,WAAAC,EAAY,IAAKN,EAAM,GAAI,CAAC,EACtFE,EAAQ,YAEd,GAAI,CAACK,EAAiB,OAAO,gBAAc,MAE3C,IAAMC,EAAU,CAACN,EAAQ,cAAc,KACnC,eAAYF,EAAM,GAAG,GACvBQ,EAAQ,KAAKN,EAAQ,gBAAgB,EAGvC,IAAMO,EAAWD,EAAQ,KAAK,GAAG,EAC3BE,EAA4B,CAChC,aAAW,KAAKL,EAAKA,EAAMD,EAAK,SAAU,CACxC,MAAOK,EACP,mBAAoBF,CACtB,CAAC,CACH,EAGA,QAAS,EAAIJ,EAAQ,MAAQ,EAAG,GAAK,EAAG,IAAK,CAC3C,IAAMQ,EAAWR,EAAQ,KAAK,CAAC,EAC/B,GAAI,IAAC,eAAYQ,CAAQ,EAAG,MAC5B,IAAMC,EAAcT,EAAQ,OAAO,CAAC,EACpCO,EAAY,KACV,aAAW,KAAKE,EAAaA,EAAcD,EAAS,SAAU,CAC5D,MAAOF,CACT,CAAC,CACH,CACF,CAEA,OAAO,gBAAc,OAAOT,EAAM,IAAKU,CAAW,CACpD,CAQO,IAAMG,EAAc,YAAU,OAA2B,CAC9D,KAAM,cAEN,YAAa,CACX,MAAO,CACL,iBAAkB,kBAClB,eAAgB,WAChB,YAAa,yBACb,qBAAsB,EACxB,CACF,EAEA,uBAAwB,CACtB,IAAMZ,EAAS,KAAK,OACdC,EAAU,KAAK,QAErB,MAAO,CACL,IAAI,SAAO,CACT,IAAKJ,EAEL,MAAO,CACL,KAAKgB,EAAYd,EAAmC,CAClD,OAAOD,EAAgBC,EAAOC,EAAQC,CAAO,CAC/C,EAEA,MACEa,EACAC,EACAC,EACAC,EACe,CAGf,MAAI,CAACH,EAAG,YAAc,CAACA,EAAG,aAAqBC,EAExCjB,EAAgBmB,EAAUjB,EAAQC,CAAO,CAClD,CACF,EAEA,MAAO,CAIL,YAAYF,EAA0C,CACpD,OAAIE,EAAQ,sBAAwB,CAACD,EAAO,WAAmB,KACxD,KAAK,SAASD,CAAK,GAAK,IACjC,CACF,CACF,CAAC,CACH,CACF,CACF,CAAC","names":["index_exports","__export","Placeholder","__toCommonJS","import_core","import_state","import_view","PLACEHOLDER_KEY","buildFromCursor","state","editor","options","$anchor","node","pos","parentName","placeholderText","classes","classStr","decorations","ancestor","ancestorPos","Placeholder","_","tr","old","_oldState","newState"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/placeholder.ts"],"sourcesContent":["export type { PlaceholderOptions, PlaceholderRenderProps } from './placeholder'\nexport { Placeholder } from './placeholder'\n","import type { Editor } from '@tiptap/core'\nimport { Extension, isNodeEmpty } from '@tiptap/core'\nimport type { Node as ProseMirrorNode } from '@tiptap/pm/model'\nimport type { EditorState, Transaction } from '@tiptap/pm/state'\nimport { Plugin, PluginKey } from '@tiptap/pm/state'\nimport { Decoration, DecorationSet } from '@tiptap/pm/view'\n\nexport interface PlaceholderRenderProps {\n editor: Editor\n node: ProseMirrorNode\n pos: number\n hasAnchor: boolean\n /** Parent type name. Safe during apply(). */\n parentName: string\n /** Use this — `editor.state.doc` is stale during apply(). */\n doc: ProseMirrorNode\n}\n\nexport interface PlaceholderOptions {\n emptyEditorClass: string\n emptyNodeClass: string\n placeholder: ((props: PlaceholderRenderProps) => string) | string\n showOnlyWhenEditable: boolean\n}\n\nconst PLACEHOLDER_KEY = new PluginKey('placeholder')\n\n/**\n * At most one placeholder decoration at the cursor's empty parent, plus\n * empty-class decorations on empty ancestor wrappers. Cost: O(depth).\n */\nfunction buildFromCursor(\n state: EditorState,\n editor: Editor,\n options: PlaceholderOptions\n): DecorationSet {\n const { $anchor } = state.selection\n if ($anchor.depth === 0) return DecorationSet.empty\n\n const node = $anchor.parent\n if (!node.type.isTextblock || !isNodeEmpty(node)) return DecorationSet.empty\n\n const pos = $anchor.before($anchor.depth)\n const parentName = $anchor.node($anchor.depth - 1).type.name\n const placeholderText =\n typeof options.placeholder === 'function'\n ? options.placeholder({ editor, node, pos, hasAnchor: true, parentName, doc: state.doc })\n : options.placeholder\n\n if (!placeholderText) return DecorationSet.empty\n\n const classes = [options.emptyNodeClass]\n if (isNodeEmpty(state.doc)) {\n classes.push(options.emptyEditorClass)\n }\n\n const classStr = classes.join(' ')\n const decorations: Decoration[] = [\n Decoration.node(pos, pos + node.nodeSize, {\n class: classStr,\n 'data-placeholder': placeholderText\n })\n ]\n\n for (let d = $anchor.depth - 1; d >= 1; d--) {\n const ancestor = $anchor.node(d)\n if (!isNodeEmpty(ancestor)) break\n const ancestorPos = $anchor.before(d)\n decorations.push(\n Decoration.node(ancestorPos, ancestorPos + ancestor.nodeSize, {\n class: classStr\n })\n )\n }\n\n return DecorationSet.create(state.doc, decorations)\n}\n\n/**\n * Drop-in replacement for the built-in Placeholder with O(depth) cursor-based\n * state init/apply instead of the built-in's O(N) doc.descendants scan.\n * Decorates the empty node at the cursor plus empty ancestor wrappers —\n * inherently showOnlyCurrent + includeChildren.\n */\nexport const Placeholder = Extension.create<PlaceholderOptions>({\n name: 'placeholder',\n\n addOptions() {\n return {\n emptyEditorClass: 'is-editor-empty',\n emptyNodeClass: 'is-empty',\n placeholder: 'Write something …',\n showOnlyWhenEditable: true\n }\n },\n\n addProseMirrorPlugins() {\n const editor = this.editor\n const options = this.options\n\n return [\n new Plugin({\n key: PLACEHOLDER_KEY,\n\n state: {\n init(_: unknown, state: EditorState): DecorationSet {\n return buildFromCursor(state, editor, options)\n },\n\n apply(\n tr: Transaction,\n old: DecorationSet,\n _oldState: EditorState,\n newState: EditorState\n ): DecorationSet {\n // Decorations derive solely from doc + selection, so meta-only\n // transactions (collab pings, plugin meta) cannot change them.\n if (!tr.docChanged && !tr.selectionSet) return old\n\n return buildFromCursor(newState, editor, options)\n }\n },\n\n props: {\n // Gate editability here, not in apply. `setEditable()` refreshes props via\n // view.updateState without dispatching a transaction, so the placeholder\n // toggles immediately when the editor flips to/from read-only.\n decorations(state: EditorState): DecorationSet | null {\n if (options.showOnlyWhenEditable && !editor.isEditable) return null\n return this.getState(state) ?? null\n }\n }\n })\n ]\n }\n})\n"],"mappings":"yaAAA,IAAAA,EAAA,GAAAC,EAAAD,EAAA,iBAAAE,IAAA,eAAAC,EAAAH,GCCA,IAAAI,EAAuC,wBAGvCC,EAAkC,4BAClCC,EAA0C,2BAoBpCC,EAAkB,IAAI,YAAU,aAAa,EAMnD,SAASC,EACPC,EACAC,EACAC,EACe,CACf,GAAM,CAAE,QAAAC,CAAQ,EAAIH,EAAM,UAC1B,GAAIG,EAAQ,QAAU,EAAG,OAAO,gBAAc,MAE9C,IAAMC,EAAOD,EAAQ,OACrB,GAAI,CAACC,EAAK,KAAK,aAAe,IAAC,eAAYA,CAAI,EAAG,OAAO,gBAAc,MAEvE,IAAMC,EAAMF,EAAQ,OAAOA,EAAQ,KAAK,EAClCG,EAAaH,EAAQ,KAAKA,EAAQ,MAAQ,CAAC,EAAE,KAAK,KAClDI,EACJ,OAAOL,EAAQ,aAAgB,WAC3BA,EAAQ,YAAY,CAAE,OAAAD,EAAQ,KAAAG,EAAM,IAAAC,EAAK,UAAW,GAAM,WAAAC,EAAY,IAAKN,EAAM,GAAI,CAAC,EACtFE,EAAQ,YAEd,GAAI,CAACK,EAAiB,OAAO,gBAAc,MAE3C,IAAMC,EAAU,CAACN,EAAQ,cAAc,KACnC,eAAYF,EAAM,GAAG,GACvBQ,EAAQ,KAAKN,EAAQ,gBAAgB,EAGvC,IAAMO,EAAWD,EAAQ,KAAK,GAAG,EAC3BE,EAA4B,CAChC,aAAW,KAAKL,EAAKA,EAAMD,EAAK,SAAU,CACxC,MAAOK,EACP,mBAAoBF,CACtB,CAAC,CACH,EAEA,QAAS,EAAIJ,EAAQ,MAAQ,EAAG,GAAK,EAAG,IAAK,CAC3C,IAAMQ,EAAWR,EAAQ,KAAK,CAAC,EAC/B,GAAI,IAAC,eAAYQ,CAAQ,EAAG,MAC5B,IAAMC,EAAcT,EAAQ,OAAO,CAAC,EACpCO,EAAY,KACV,aAAW,KAAKE,EAAaA,EAAcD,EAAS,SAAU,CAC5D,MAAOF,CACT,CAAC,CACH,CACF,CAEA,OAAO,gBAAc,OAAOT,EAAM,IAAKU,CAAW,CACpD,CAQO,IAAMG,EAAc,YAAU,OAA2B,CAC9D,KAAM,cAEN,YAAa,CACX,MAAO,CACL,iBAAkB,kBAClB,eAAgB,WAChB,YAAa,yBACb,qBAAsB,EACxB,CACF,EAEA,uBAAwB,CACtB,IAAMZ,EAAS,KAAK,OACdC,EAAU,KAAK,QAErB,MAAO,CACL,IAAI,SAAO,CACT,IAAKJ,EAEL,MAAO,CACL,KAAKgB,EAAYd,EAAmC,CAClD,OAAOD,EAAgBC,EAAOC,EAAQC,CAAO,CAC/C,EAEA,MACEa,EACAC,EACAC,EACAC,EACe,CAGf,MAAI,CAACH,EAAG,YAAc,CAACA,EAAG,aAAqBC,EAExCjB,EAAgBmB,EAAUjB,EAAQC,CAAO,CAClD,CACF,EAEA,MAAO,CAIL,YAAYF,EAA0C,CACpD,OAAIE,EAAQ,sBAAwB,CAACD,EAAO,WAAmB,KACxD,KAAK,SAASD,CAAK,GAAK,IACjC,CACF,CACF,CAAC,CACH,CACF,CACF,CAAC","names":["index_exports","__export","Placeholder","__toCommonJS","import_core","import_state","import_view","PLACEHOLDER_KEY","buildFromCursor","state","editor","options","$anchor","node","pos","parentName","placeholderText","classes","classStr","decorations","ancestor","ancestorPos","Placeholder","_","tr","old","_oldState","newState"]}
package/dist/index.d.cts CHANGED
@@ -1,15 +1,14 @@
1
1
  import { Extension, Editor } from '@tiptap/core';
2
2
  import { Node } from '@tiptap/pm/model';
3
3
 
4
- /** Arguments passed to the `placeholder` callback when it is a function. */
5
4
  interface PlaceholderRenderProps {
6
5
  editor: Editor;
7
6
  node: Node;
8
7
  pos: number;
9
8
  hasAnchor: boolean;
10
- /** Parent node type name (e.g. 'doc', 'listItem', 'blockquote'). Safe during apply(). */
9
+ /** Parent type name. Safe during apply(). */
11
10
  parentName: string;
12
- /** Doc that `pos` resolves against. Use this — `editor.state.doc` is stale during apply(). */
11
+ /** Use this — `editor.state.doc` is stale during apply(). */
13
12
  doc: Node;
14
13
  }
15
14
  interface PlaceholderOptions {
package/dist/index.d.ts CHANGED
@@ -1,15 +1,14 @@
1
1
  import { Extension, Editor } from '@tiptap/core';
2
2
  import { Node } from '@tiptap/pm/model';
3
3
 
4
- /** Arguments passed to the `placeholder` callback when it is a function. */
5
4
  interface PlaceholderRenderProps {
6
5
  editor: Editor;
7
6
  node: Node;
8
7
  pos: number;
9
8
  hasAnchor: boolean;
10
- /** Parent node type name (e.g. 'doc', 'listItem', 'blockquote'). Safe during apply(). */
9
+ /** Parent type name. Safe during apply(). */
11
10
  parentName: string;
12
- /** Doc that `pos` resolves against. Use this — `editor.state.doc` is stale during apply(). */
11
+ /** Use this — `editor.state.doc` is stale during apply(). */
13
12
  doc: Node;
14
13
  }
15
14
  interface PlaceholderOptions {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/placeholder.ts"],"sourcesContent":["import type { Editor } from '@tiptap/core'\nimport { Extension, isNodeEmpty } from '@tiptap/core'\nimport type { Node as ProseMirrorNode } from '@tiptap/pm/model'\nimport type { EditorState, Transaction } from '@tiptap/pm/state'\nimport { Plugin, PluginKey } from '@tiptap/pm/state'\nimport { Decoration, DecorationSet } from '@tiptap/pm/view'\n\n/** Arguments passed to the `placeholder` callback when it is a function. */\nexport interface PlaceholderRenderProps {\n editor: Editor\n node: ProseMirrorNode\n pos: number\n hasAnchor: boolean\n /** Parent node type name (e.g. 'doc', 'listItem', 'blockquote'). Safe during apply(). */\n parentName: string\n /** Doc that `pos` resolves against. Use this — `editor.state.doc` is stale during apply(). */\n doc: ProseMirrorNode\n}\n\nexport interface PlaceholderOptions {\n emptyEditorClass: string\n emptyNodeClass: string\n placeholder: ((props: PlaceholderRenderProps) => string) | string\n showOnlyWhenEditable: boolean\n}\n\nconst PLACEHOLDER_KEY = new PluginKey('placeholder')\n\n/**\n * At most one placeholder decoration at the cursor's empty parent, plus\n * empty-class decorations on empty ancestor wrappers. Cost: O(depth).\n */\nfunction buildFromCursor(\n state: EditorState,\n editor: Editor,\n options: PlaceholderOptions\n): DecorationSet {\n const { $anchor } = state.selection\n if ($anchor.depth === 0) return DecorationSet.empty\n\n const node = $anchor.parent\n if (!node.type.isTextblock || !isNodeEmpty(node)) return DecorationSet.empty\n\n const pos = $anchor.before($anchor.depth)\n const parentName = $anchor.node($anchor.depth - 1).type.name\n const placeholderText =\n typeof options.placeholder === 'function'\n ? options.placeholder({ editor, node, pos, hasAnchor: true, parentName, doc: state.doc })\n : options.placeholder\n\n if (!placeholderText) return DecorationSet.empty\n\n const classes = [options.emptyNodeClass]\n if (isNodeEmpty(state.doc)) {\n classes.push(options.emptyEditorClass)\n }\n\n const classStr = classes.join(' ')\n const decorations: Decoration[] = [\n Decoration.node(pos, pos + node.nodeSize, {\n class: classStr,\n 'data-placeholder': placeholderText\n })\n ]\n\n // Propagate empty class to ancestor wrappers (list items, blockquotes, etc.)\n for (let d = $anchor.depth - 1; d >= 1; d--) {\n const ancestor = $anchor.node(d)\n if (!isNodeEmpty(ancestor)) break\n const ancestorPos = $anchor.before(d)\n decorations.push(\n Decoration.node(ancestorPos, ancestorPos + ancestor.nodeSize, {\n class: classStr\n })\n )\n }\n\n return DecorationSet.create(state.doc, decorations)\n}\n\n/**\n * Drop-in replacement for the built-in Placeholder with O(depth) cursor-based\n * state init/apply instead of the built-in's O(N) doc.descendants scan.\n * Decorates the empty node at the cursor plus empty ancestor wrappers —\n * inherently showOnlyCurrent + includeChildren.\n */\nexport const Placeholder = Extension.create<PlaceholderOptions>({\n name: 'placeholder',\n\n addOptions() {\n return {\n emptyEditorClass: 'is-editor-empty',\n emptyNodeClass: 'is-empty',\n placeholder: 'Write something …',\n showOnlyWhenEditable: true\n }\n },\n\n addProseMirrorPlugins() {\n const editor = this.editor\n const options = this.options\n\n return [\n new Plugin({\n key: PLACEHOLDER_KEY,\n\n state: {\n init(_: unknown, state: EditorState): DecorationSet {\n return buildFromCursor(state, editor, options)\n },\n\n apply(\n tr: Transaction,\n old: DecorationSet,\n _oldState: EditorState,\n newState: EditorState\n ): DecorationSet {\n // Decorations derive solely from doc + selection, so meta-only\n // transactions (collab pings, plugin meta) cannot change them.\n if (!tr.docChanged && !tr.selectionSet) return old\n\n return buildFromCursor(newState, editor, options)\n }\n },\n\n props: {\n // Gate editability here, not in apply. `setEditable()` refreshes props via\n // view.updateState without dispatching a transaction, so the placeholder\n // toggles immediately when the editor flips to/from read-only.\n decorations(state: EditorState): DecorationSet | null {\n if (options.showOnlyWhenEditable && !editor.isEditable) return null\n return this.getState(state) ?? null\n }\n }\n })\n ]\n }\n})\n"],"mappings":"AACA,OAAS,aAAAA,EAAW,eAAAC,MAAmB,eAGvC,OAAS,UAAAC,EAAQ,aAAAC,MAAiB,mBAClC,OAAS,cAAAC,EAAY,iBAAAC,MAAqB,kBAqB1C,IAAMC,EAAkB,IAAIH,EAAU,aAAa,EAMnD,SAASI,EACPC,EACAC,EACAC,EACe,CACf,GAAM,CAAE,QAAAC,CAAQ,EAAIH,EAAM,UAC1B,GAAIG,EAAQ,QAAU,EAAG,OAAON,EAAc,MAE9C,IAAMO,EAAOD,EAAQ,OACrB,GAAI,CAACC,EAAK,KAAK,aAAe,CAACX,EAAYW,CAAI,EAAG,OAAOP,EAAc,MAEvE,IAAMQ,EAAMF,EAAQ,OAAOA,EAAQ,KAAK,EAClCG,EAAaH,EAAQ,KAAKA,EAAQ,MAAQ,CAAC,EAAE,KAAK,KAClDI,EACJ,OAAOL,EAAQ,aAAgB,WAC3BA,EAAQ,YAAY,CAAE,OAAAD,EAAQ,KAAAG,EAAM,IAAAC,EAAK,UAAW,GAAM,WAAAC,EAAY,IAAKN,EAAM,GAAI,CAAC,EACtFE,EAAQ,YAEd,GAAI,CAACK,EAAiB,OAAOV,EAAc,MAE3C,IAAMW,EAAU,CAACN,EAAQ,cAAc,EACnCT,EAAYO,EAAM,GAAG,GACvBQ,EAAQ,KAAKN,EAAQ,gBAAgB,EAGvC,IAAMO,EAAWD,EAAQ,KAAK,GAAG,EAC3BE,EAA4B,CAChCd,EAAW,KAAKS,EAAKA,EAAMD,EAAK,SAAU,CACxC,MAAOK,EACP,mBAAoBF,CACtB,CAAC,CACH,EAGA,QAASI,EAAIR,EAAQ,MAAQ,EAAGQ,GAAK,EAAGA,IAAK,CAC3C,IAAMC,EAAWT,EAAQ,KAAKQ,CAAC,EAC/B,GAAI,CAAClB,EAAYmB,CAAQ,EAAG,MAC5B,IAAMC,EAAcV,EAAQ,OAAOQ,CAAC,EACpCD,EAAY,KACVd,EAAW,KAAKiB,EAAaA,EAAcD,EAAS,SAAU,CAC5D,MAAOH,CACT,CAAC,CACH,CACF,CAEA,OAAOZ,EAAc,OAAOG,EAAM,IAAKU,CAAW,CACpD,CAQO,IAAMI,EAActB,EAAU,OAA2B,CAC9D,KAAM,cAEN,YAAa,CACX,MAAO,CACL,iBAAkB,kBAClB,eAAgB,WAChB,YAAa,yBACb,qBAAsB,EACxB,CACF,EAEA,uBAAwB,CACtB,IAAMS,EAAS,KAAK,OACdC,EAAU,KAAK,QAErB,MAAO,CACL,IAAIR,EAAO,CACT,IAAKI,EAEL,MAAO,CACL,KAAKiB,EAAYf,EAAmC,CAClD,OAAOD,EAAgBC,EAAOC,EAAQC,CAAO,CAC/C,EAEA,MACEc,EACAC,EACAC,EACAC,EACe,CAGf,MAAI,CAACH,EAAG,YAAc,CAACA,EAAG,aAAqBC,EAExClB,EAAgBoB,EAAUlB,EAAQC,CAAO,CAClD,CACF,EAEA,MAAO,CAIL,YAAYF,EAA0C,CACpD,OAAIE,EAAQ,sBAAwB,CAACD,EAAO,WAAmB,KACxD,KAAK,SAASD,CAAK,GAAK,IACjC,CACF,CACF,CAAC,CACH,CACF,CACF,CAAC","names":["Extension","isNodeEmpty","Plugin","PluginKey","Decoration","DecorationSet","PLACEHOLDER_KEY","buildFromCursor","state","editor","options","$anchor","node","pos","parentName","placeholderText","classes","classStr","decorations","d","ancestor","ancestorPos","Placeholder","_","tr","old","_oldState","newState"]}
1
+ {"version":3,"sources":["../src/placeholder.ts"],"sourcesContent":["import type { Editor } from '@tiptap/core'\nimport { Extension, isNodeEmpty } from '@tiptap/core'\nimport type { Node as ProseMirrorNode } from '@tiptap/pm/model'\nimport type { EditorState, Transaction } from '@tiptap/pm/state'\nimport { Plugin, PluginKey } from '@tiptap/pm/state'\nimport { Decoration, DecorationSet } from '@tiptap/pm/view'\n\nexport interface PlaceholderRenderProps {\n editor: Editor\n node: ProseMirrorNode\n pos: number\n hasAnchor: boolean\n /** Parent type name. Safe during apply(). */\n parentName: string\n /** Use this — `editor.state.doc` is stale during apply(). */\n doc: ProseMirrorNode\n}\n\nexport interface PlaceholderOptions {\n emptyEditorClass: string\n emptyNodeClass: string\n placeholder: ((props: PlaceholderRenderProps) => string) | string\n showOnlyWhenEditable: boolean\n}\n\nconst PLACEHOLDER_KEY = new PluginKey('placeholder')\n\n/**\n * At most one placeholder decoration at the cursor's empty parent, plus\n * empty-class decorations on empty ancestor wrappers. Cost: O(depth).\n */\nfunction buildFromCursor(\n state: EditorState,\n editor: Editor,\n options: PlaceholderOptions\n): DecorationSet {\n const { $anchor } = state.selection\n if ($anchor.depth === 0) return DecorationSet.empty\n\n const node = $anchor.parent\n if (!node.type.isTextblock || !isNodeEmpty(node)) return DecorationSet.empty\n\n const pos = $anchor.before($anchor.depth)\n const parentName = $anchor.node($anchor.depth - 1).type.name\n const placeholderText =\n typeof options.placeholder === 'function'\n ? options.placeholder({ editor, node, pos, hasAnchor: true, parentName, doc: state.doc })\n : options.placeholder\n\n if (!placeholderText) return DecorationSet.empty\n\n const classes = [options.emptyNodeClass]\n if (isNodeEmpty(state.doc)) {\n classes.push(options.emptyEditorClass)\n }\n\n const classStr = classes.join(' ')\n const decorations: Decoration[] = [\n Decoration.node(pos, pos + node.nodeSize, {\n class: classStr,\n 'data-placeholder': placeholderText\n })\n ]\n\n for (let d = $anchor.depth - 1; d >= 1; d--) {\n const ancestor = $anchor.node(d)\n if (!isNodeEmpty(ancestor)) break\n const ancestorPos = $anchor.before(d)\n decorations.push(\n Decoration.node(ancestorPos, ancestorPos + ancestor.nodeSize, {\n class: classStr\n })\n )\n }\n\n return DecorationSet.create(state.doc, decorations)\n}\n\n/**\n * Drop-in replacement for the built-in Placeholder with O(depth) cursor-based\n * state init/apply instead of the built-in's O(N) doc.descendants scan.\n * Decorates the empty node at the cursor plus empty ancestor wrappers —\n * inherently showOnlyCurrent + includeChildren.\n */\nexport const Placeholder = Extension.create<PlaceholderOptions>({\n name: 'placeholder',\n\n addOptions() {\n return {\n emptyEditorClass: 'is-editor-empty',\n emptyNodeClass: 'is-empty',\n placeholder: 'Write something …',\n showOnlyWhenEditable: true\n }\n },\n\n addProseMirrorPlugins() {\n const editor = this.editor\n const options = this.options\n\n return [\n new Plugin({\n key: PLACEHOLDER_KEY,\n\n state: {\n init(_: unknown, state: EditorState): DecorationSet {\n return buildFromCursor(state, editor, options)\n },\n\n apply(\n tr: Transaction,\n old: DecorationSet,\n _oldState: EditorState,\n newState: EditorState\n ): DecorationSet {\n // Decorations derive solely from doc + selection, so meta-only\n // transactions (collab pings, plugin meta) cannot change them.\n if (!tr.docChanged && !tr.selectionSet) return old\n\n return buildFromCursor(newState, editor, options)\n }\n },\n\n props: {\n // Gate editability here, not in apply. `setEditable()` refreshes props via\n // view.updateState without dispatching a transaction, so the placeholder\n // toggles immediately when the editor flips to/from read-only.\n decorations(state: EditorState): DecorationSet | null {\n if (options.showOnlyWhenEditable && !editor.isEditable) return null\n return this.getState(state) ?? null\n }\n }\n })\n ]\n }\n})\n"],"mappings":"AACA,OAAS,aAAAA,EAAW,eAAAC,MAAmB,eAGvC,OAAS,UAAAC,EAAQ,aAAAC,MAAiB,mBAClC,OAAS,cAAAC,EAAY,iBAAAC,MAAqB,kBAoB1C,IAAMC,EAAkB,IAAIH,EAAU,aAAa,EAMnD,SAASI,EACPC,EACAC,EACAC,EACe,CACf,GAAM,CAAE,QAAAC,CAAQ,EAAIH,EAAM,UAC1B,GAAIG,EAAQ,QAAU,EAAG,OAAON,EAAc,MAE9C,IAAMO,EAAOD,EAAQ,OACrB,GAAI,CAACC,EAAK,KAAK,aAAe,CAACX,EAAYW,CAAI,EAAG,OAAOP,EAAc,MAEvE,IAAMQ,EAAMF,EAAQ,OAAOA,EAAQ,KAAK,EAClCG,EAAaH,EAAQ,KAAKA,EAAQ,MAAQ,CAAC,EAAE,KAAK,KAClDI,EACJ,OAAOL,EAAQ,aAAgB,WAC3BA,EAAQ,YAAY,CAAE,OAAAD,EAAQ,KAAAG,EAAM,IAAAC,EAAK,UAAW,GAAM,WAAAC,EAAY,IAAKN,EAAM,GAAI,CAAC,EACtFE,EAAQ,YAEd,GAAI,CAACK,EAAiB,OAAOV,EAAc,MAE3C,IAAMW,EAAU,CAACN,EAAQ,cAAc,EACnCT,EAAYO,EAAM,GAAG,GACvBQ,EAAQ,KAAKN,EAAQ,gBAAgB,EAGvC,IAAMO,EAAWD,EAAQ,KAAK,GAAG,EAC3BE,EAA4B,CAChCd,EAAW,KAAKS,EAAKA,EAAMD,EAAK,SAAU,CACxC,MAAOK,EACP,mBAAoBF,CACtB,CAAC,CACH,EAEA,QAASI,EAAIR,EAAQ,MAAQ,EAAGQ,GAAK,EAAGA,IAAK,CAC3C,IAAMC,EAAWT,EAAQ,KAAKQ,CAAC,EAC/B,GAAI,CAAClB,EAAYmB,CAAQ,EAAG,MAC5B,IAAMC,EAAcV,EAAQ,OAAOQ,CAAC,EACpCD,EAAY,KACVd,EAAW,KAAKiB,EAAaA,EAAcD,EAAS,SAAU,CAC5D,MAAOH,CACT,CAAC,CACH,CACF,CAEA,OAAOZ,EAAc,OAAOG,EAAM,IAAKU,CAAW,CACpD,CAQO,IAAMI,EAActB,EAAU,OAA2B,CAC9D,KAAM,cAEN,YAAa,CACX,MAAO,CACL,iBAAkB,kBAClB,eAAgB,WAChB,YAAa,yBACb,qBAAsB,EACxB,CACF,EAEA,uBAAwB,CACtB,IAAMS,EAAS,KAAK,OACdC,EAAU,KAAK,QAErB,MAAO,CACL,IAAIR,EAAO,CACT,IAAKI,EAEL,MAAO,CACL,KAAKiB,EAAYf,EAAmC,CAClD,OAAOD,EAAgBC,EAAOC,EAAQC,CAAO,CAC/C,EAEA,MACEc,EACAC,EACAC,EACAC,EACe,CAGf,MAAI,CAACH,EAAG,YAAc,CAACA,EAAG,aAAqBC,EAExClB,EAAgBoB,EAAUlB,EAAQC,CAAO,CAClD,CACF,EAEA,MAAO,CAIL,YAAYF,EAA0C,CACpD,OAAIE,EAAQ,sBAAwB,CAACD,EAAO,WAAmB,KACxD,KAAK,SAASD,CAAK,GAAK,IACjC,CACF,CACF,CAAC,CACH,CACF,CACF,CAAC","names":["Extension","isNodeEmpty","Plugin","PluginKey","Decoration","DecorationSet","PLACEHOLDER_KEY","buildFromCursor","state","editor","options","$anchor","node","pos","parentName","placeholderText","classes","classStr","decorations","d","ancestor","ancestorPos","Placeholder","_","tr","old","_oldState","newState"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docs.plus/extension-placeholder",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "A performance-optimized Tiptap placeholder extension using O(depth) cursor-based decoration",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
@@ -55,26 +55,26 @@
55
55
  },
56
56
  "keywords": [
57
57
  "tiptap",
58
- "tiptap extension",
58
+ "tiptap-extension",
59
59
  "placeholder",
60
60
  "rich-text-editor",
61
61
  "prosemirror",
62
62
  "docs.plus"
63
63
  ],
64
64
  "peerDependencies": {
65
- "@tiptap/core": "^3.22.3",
66
- "@tiptap/pm": "^3.22.3"
65
+ "@tiptap/core": "^3.31.3",
66
+ "@tiptap/pm": "^3.31.3"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@docs.plus/playground": "0.0.0",
70
70
  "@docs.plus/release-tooling": "0.1.0",
71
- "@tiptap/core": "^3.22.3",
72
- "@tiptap/pm": "^3.22.3",
73
- "@tiptap/starter-kit": "^3.22.3",
74
- "cypress": "^15.13.1",
71
+ "@tiptap/core": "^3.31.3",
72
+ "@tiptap/pm": "^3.31.3",
73
+ "@tiptap/starter-kit": "^3.31.3",
74
+ "cypress": "^15.21.1",
75
75
  "cypress-real-events": "^1.15.0",
76
- "eslint": "^9.39.4",
77
- "start-server-and-test": "^2.1.0",
76
+ "eslint": "^9.39.5",
77
+ "start-server-and-test": "^2.1.5",
78
78
  "tsup": "^8.5.1",
79
79
  "typescript": "^5.9.3"
80
80
  }