@tiptap/extensions 3.27.0 → 3.27.1
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/dist/index.cjs +33 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +33 -10
- package/dist/index.js.map +1 -1
- package/dist/placeholder/index.cjs +33 -10
- package/dist/placeholder/index.cjs.map +1 -1
- package/dist/placeholder/index.js +33 -10
- package/dist/placeholder/index.js.map +1 -1
- package/package.json +5 -5
- package/src/placeholder/utils/getViewportBoundaryPositions.ts +40 -15
- package/src/placeholder/utils/viewportTracking.ts +20 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/placeholder/constants.ts","../../src/placeholder/placeholder.ts","../../src/placeholder/plugins/PlaceholderPlugin.ts","../../src/placeholder/utils/buildPlaceholderDecorations.ts","../../src/placeholder/utils/createPlaceholderDecoration.ts","../../src/placeholder/utils/preparePlaceholderAttribute.ts","../../src/placeholder/utils/findScrollParent.ts","../../src/placeholder/utils/getViewportBoundaryPositions.ts","../../src/placeholder/utils/viewportTracking.ts"],"sourcesContent":["import { PluginKey } from '@tiptap/pm/state'\n\nimport type { ViewportState } from './types.js'\n\n/** The default data attribute label */\nexport const DEFAULT_DATA_ATTRIBUTE = 'placeholder'\n\n/** The plugin key used to store and read the placeholder viewport state */\nexport const PLUGIN_KEY = new PluginKey<ViewportState>('tiptap__placeholder')\n\n/**\n * Extra pixels added above and below the visible viewport when computing the\n * range of nodes to decorate. Decorating slightly beyond the fold means a\n * node already has its placeholder before it scrolls into view, which hides\n * the latency introduced by the throttled viewport recompute.\n */\nexport const VIEWPORT_OVERSCAN_PX = 200\n","import { Extension } from '@tiptap/core'\n\nimport { DEFAULT_DATA_ATTRIBUTE } from './constants.js'\nimport { createPlaceholderPlugin } from './plugins/PlaceholderPlugin.js'\nimport type { PlaceholderOptions } from './types.js'\n\n/**\n * This extension allows you to add a placeholder to your editor.\n * A placeholder is a text that appears when the editor or a node is empty.\n * @see https://www.tiptap.dev/api/extensions/placeholder\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 dataAttribute: DEFAULT_DATA_ATTRIBUTE,\n placeholder: 'Write something …',\n showOnlyWhenEditable: true,\n showOnlyCurrent: true,\n includeChildren: false,\n }\n },\n\n addProseMirrorPlugins() {\n return [createPlaceholderPlugin({ editor: this.editor, options: this.options })]\n },\n})\n","import type { Editor } from '@tiptap/core'\nimport { Plugin } from '@tiptap/pm/state'\n\nimport { DEFAULT_DATA_ATTRIBUTE, PLUGIN_KEY } from '../constants.js'\nimport type { PlaceholderOptions } from '../types.js'\nimport { buildPlaceholderDecorations } from '../utils/buildPlaceholderDecorations.js'\nimport { preparePlaceholderAttribute } from '../utils/preparePlaceholderAttribute.js'\nimport { createViewportPluginView, viewportPluginState } from '../utils/viewportTracking.js'\n\nexport type CreatePluginOptions = {\n editor: Editor\n options: PlaceholderOptions\n}\n\n/**\n * Creates the ProseMirror plugin that renders placeholder decorations.\n * @param options.editor - The editor instance.\n * @param options.options - The resolved placeholder options.\n * @returns The configured placeholder plugin.\n */\nexport function createPlaceholderPlugin({ editor, options }: CreatePluginOptions) {\n const dataAttribute = options.dataAttribute\n ? `data-${preparePlaceholderAttribute(options.dataAttribute)}`\n : `data-${DEFAULT_DATA_ATTRIBUTE}`\n\n return new Plugin({\n key: PLUGIN_KEY,\n state: viewportPluginState,\n view: createViewportPluginView,\n props: {\n decorations: ({ doc, selection }) =>\n buildPlaceholderDecorations({ editor, options, dataAttribute, doc, selection }),\n },\n })\n}\n","import type { Editor } from '@tiptap/core'\nimport { isNodeEmpty } from '@tiptap/core'\nimport type { Node } from '@tiptap/pm/model'\nimport type { Selection } from '@tiptap/pm/state'\nimport type { Decoration } from '@tiptap/pm/view'\nimport { DecorationSet } from '@tiptap/pm/view'\n\nimport { PLUGIN_KEY } from '../constants.js'\nimport type { PlaceholderOptions } from '../types.js'\nimport { createPlaceholderDecoration } from './createPlaceholderDecoration.js'\n\nfunction resolveEmptyNodeClass(\n emptyNodeClass: PlaceholderOptions['emptyNodeClass'],\n props: { editor: Editor; node: Node; pos: number; hasAnchor: boolean },\n): string {\n return typeof emptyNodeClass === 'function' ? emptyNodeClass(props) : emptyNodeClass\n}\n\n/**\n * Builds the placeholder decorations for the current document state.\n * @param options.editor - The editor instance.\n * @param options.options - The resolved placeholder options.\n * @param options.dataAttribute - The prepared `data-*` attribute name.\n * @param options.doc - The current document node.\n * @param options.selection - The current selection.\n * @returns A decoration set, or `null` when no placeholders should be shown.\n */\nexport function buildPlaceholderDecorations({\n editor,\n options,\n dataAttribute,\n doc,\n selection,\n}: {\n editor: Editor\n options: PlaceholderOptions\n dataAttribute: string\n doc: Node\n selection: Selection\n}): DecorationSet | null {\n const active = editor.isEditable || !options.showOnlyWhenEditable\n\n if (!active) {\n return null\n }\n\n const { anchor } = selection\n const decorations: Decoration[] = []\n const isEmptyDoc = editor.isEmpty\n\n const useResolvedPath = options.showOnlyCurrent && !options.includeChildren\n\n if (useResolvedPath) {\n const resolved = doc.resolve(anchor)\n\n // When the selection spans the whole document (e.g. an `AllSelection`\n // after Cmd+A), the anchor resolves to the document level (depth 0). In\n // that case the relevant textblock is the node directly after the\n // position rather than an ancestor. otherwise the placeholder would\n // disappear after selecting all and deleting.\n const node = resolved.depth > 0 ? resolved.node(1) : resolved.nodeAfter\n const nodeStart = resolved.depth > 0 ? resolved.before(1) : anchor\n\n if (node && node.type.isTextblock && isNodeEmpty(node)) {\n const hasAnchor = anchor >= nodeStart && anchor <= nodeStart + node.nodeSize\n\n decorations.push(\n createPlaceholderDecoration({\n editor,\n isEmptyDoc,\n dataAttribute,\n hasAnchor,\n placeholder: options.placeholder,\n classes: {\n emptyEditor: options.emptyEditorClass,\n emptyNode: resolveEmptyNodeClass(options.emptyNodeClass, {\n editor,\n node,\n pos: nodeStart,\n hasAnchor,\n }),\n },\n node,\n pos: nodeStart,\n }),\n )\n }\n } else {\n const pluginState = PLUGIN_KEY.getState(editor.state)\n const from = pluginState?.topPos ?? 0\n const to = pluginState?.bottomPos ?? doc.content.size\n\n doc.nodesBetween(from, to, (node, pos) => {\n const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize\n const isEmpty = !node.isLeaf && isNodeEmpty(node)\n\n if (!node.type.isTextblock) {\n return options.includeChildren\n }\n\n if ((hasAnchor || !options.showOnlyCurrent) && isEmpty) {\n decorations.push(\n createPlaceholderDecoration({\n editor,\n isEmptyDoc,\n dataAttribute,\n hasAnchor,\n placeholder: options.placeholder,\n classes: {\n emptyEditor: options.emptyEditorClass,\n emptyNode: resolveEmptyNodeClass(options.emptyNodeClass, {\n editor,\n node,\n pos,\n hasAnchor,\n }),\n },\n node,\n pos,\n }),\n )\n }\n\n return options.includeChildren\n })\n }\n\n return DecorationSet.create(doc, decorations)\n}\n","import type { Editor } from '@tiptap/core'\nimport type { Node } from '@tiptap/pm/model'\nimport { Decoration } from '@tiptap/pm/view'\n\nimport type { PlaceholderOptions } from '../types.js'\n\n/**\n * Creates a ProseMirror node decoration that applies a placeholder\n * CSS class and data attribute to an empty node.\n * @param options.editor - The editor instance\n * @param options.pos - The position of the node in the document\n * @param options.node - The ProseMirror node\n * @param options.isEmptyDoc - Whether the entire document is empty\n * @param options.hasAnchor - Whether the selection anchor is within the node\n * @param options.dataAttribute - The data attribute name (e.g. `data-placeholder`)\n * @param options.classes - CSS classes for empty nodes and the empty editor\n * @param options.placeholder - The placeholder text or a function that returns it\n * @returns A ProseMirror node decoration with placeholder classes and data attribute\n */\nexport function createPlaceholderDecoration(options: {\n editor: Editor\n pos: number\n node: Node\n isEmptyDoc: boolean\n hasAnchor: boolean\n dataAttribute: string\n classes: {\n emptyEditor: PlaceholderOptions['emptyEditorClass']\n emptyNode: string\n }\n placeholder: PlaceholderOptions['placeholder']\n}) {\n const {\n editor,\n placeholder,\n dataAttribute,\n pos,\n node,\n isEmptyDoc,\n hasAnchor,\n classes: { emptyNode, emptyEditor },\n } = options\n const classes = [emptyNode]\n\n if (isEmptyDoc) {\n classes.push(emptyEditor)\n }\n\n return Decoration.node(pos, pos + node.nodeSize, {\n class: classes.join(' '),\n [dataAttribute]:\n typeof placeholder === 'function'\n ? placeholder({\n editor,\n node,\n pos,\n hasAnchor,\n })\n : placeholder,\n })\n}\n","/**\n * Prepares the placeholder attribute by ensuring it is properly formatted.\n * @param attr - The placeholder attribute string.\n * @returns The prepared placeholder attribute string.\n */\nexport function preparePlaceholderAttribute(attr: string): string {\n return (\n attr\n // replace whitespace with dashes\n .replace(/\\s+/g, '-')\n // replace non-alphanumeric characters\n // or special chars like $, %, &, etc.\n // but not dashes\n .replace(/[^a-zA-Z0-9-]/g, '')\n // and replace any numeric character at the start\n .replace(/^[0-9-]+/, '')\n // and finally replace any stray, leading dashes\n .replace(/^-+/, '')\n .toLowerCase()\n )\n}\n","/**\n * Checks if an element is scrollable by testing its overflow properties.\n * Elements with `overflow: hidden` or `overflow: clip` are intentionally\n * excluded — they clip content but don't emit scroll events.\n */\nfunction isScrollable(el: HTMLElement): boolean {\n const style = getComputedStyle(el)\n const overflow = `${style.overflow} ${style.overflowY} ${style.overflowX}`\n\n return /auto|scroll|overlay/.test(overflow)\n}\n\nexport function findScrollParent(element: HTMLElement): HTMLElement | Window {\n let el: HTMLElement | null = element\n\n while (el) {\n if (isScrollable(el)) {\n return el\n }\n\n // Check if we hit a Shadow DOM boundary. If so, jump to the shadow host\n // and continue traversing the light DOM.\n const parent = el.parentElement\n if (!parent) {\n const root = el.getRootNode()\n if (root instanceof ShadowRoot) {\n el = root.host as HTMLElement\n continue\n }\n\n return window\n }\n\n el = parent\n }\n\n return window\n}\n","import type { Node } from '@tiptap/pm/model'\nimport type { EditorView } from '@tiptap/pm/view'\n\nimport { VIEWPORT_OVERSCAN_PX } from '../constants.js'\n\nfunction getContainerRect(container: HTMLElement | Window): { top: number; bottom: number } {\n if (container === window) {\n return { top: 0, bottom: window.innerHeight }\n }\n\n return (container as HTMLElement).getBoundingClientRect()\n}\n\nexport function getViewportBoundaryPositions({\n doc,\n view,\n scrollContainer,\n}: {\n doc: Node\n view: EditorView\n scrollContainer?: HTMLElement | Window\n}) {\n const editorRect = view.dom.getBoundingClientRect()\n const containerRect = scrollContainer\n ? getContainerRect(scrollContainer)\n : { top: 0, bottom: window.innerHeight }\n\n const visibleTop = Math.max(editorRect.top, containerRect.top) - VIEWPORT_OVERSCAN_PX\n const visibleBottom = Math.min(editorRect.bottom, containerRect.bottom) + VIEWPORT_OVERSCAN_PX\n\n if (visibleTop >= visibleBottom) {\n // Editor is not visible — fall back to full document range\n return { top: 0, bottom: doc.content.size }\n }\n\n // Pick the x-coordinate based on text direction. In LTR the content\n // starts at the left edge; in RTL it starts at the right edge.\n // Clamp to ensure the coordinate stays inside the editor bounds.\n const isRTL = getComputedStyle(view.dom).direction === 'rtl'\n const x = isRTL ? Math.max(editorRect.right - 2, editorRect.left + 2) : editorRect.left + 2\n\n const topPos = view.posAtCoords({ left: x, top: visibleTop + 2 })\n const bottomPos = view.posAtCoords({ left: x, top: visibleBottom - 2 })\n\n return {\n top: topPos ? topPos.pos : 0,\n bottom: bottomPos ? bottomPos.pos : doc.content.size,\n }\n}\n","import type { EditorState, PluginView, StateField, Transaction } from '@tiptap/pm/state'\nimport type { EditorView } from '@tiptap/pm/view'\n\nimport { PLUGIN_KEY } from '../constants.js'\nimport type { ViewportState } from '../types.js'\nimport { findScrollParent } from './findScrollParent.js'\nimport { getViewportBoundaryPositions } from './getViewportBoundaryPositions.js'\n\n/**\n * The plugin `state` config that tracks the visible viewport boundaries so the\n * decoration callback only scans the nodes currently on screen.\n */\nexport const viewportPluginState: StateField<ViewportState> = {\n /**\n * Initialises the viewport state with no known positions.\n * @returns The initial viewport state.\n */\n init(): ViewportState {\n return { topPos: null, bottomPos: null }\n },\n\n /**\n * Updates the viewport state from incoming transactions.\n * @param tr - The transaction being applied.\n * @param prev - The previous viewport state.\n * @returns The next viewport state.\n */\n apply(tr: Transaction, prev: ViewportState): ViewportState {\n const meta = tr.getMeta(PLUGIN_KEY) as\n | { positions?: { top: number; bottom: number } }\n | undefined\n\n if (meta?.positions) {\n return { topPos: meta.positions.top, bottomPos: meta.positions.bottom }\n }\n\n if (!tr.docChanged) {\n return prev\n }\n\n // Preserve last known viewport positions across transactions.\n // Without this, every keystroke resets back to a full document\n // scan, defeating the viewport optimisation.\n // Only map when we have actual positions — null means \"no viewport\n // info yet\" and should stay null to fall back to full doc scan.\n return {\n topPos: prev.topPos !== null ? tr.mapping.map(prev.topPos) : null,\n bottomPos: prev.bottomPos !== null ? tr.mapping.map(prev.bottomPos) : null,\n }\n },\n}\n\n/**\n * Creates the plugin `view` that recomputes the visible viewport on scroll and\n * document size changes, dispatching the result into the plugin state.\n * @param view - The editor view the plugin is attached to.\n * @returns A plugin view with `update` and `destroy` handlers.\n */\nexport function createViewportPluginView(view: EditorView): PluginView {\n const scrollContainer = findScrollParent(view.dom)\n\n const computeAndDispatch = () => {\n const positions = getViewportBoundaryPositions({\n view,\n doc: view.state.doc,\n scrollContainer,\n })\n\n const prev = PLUGIN_KEY.getState(view.state)\n if (prev?.topPos === positions.top && prev?.bottomPos === positions.bottom) {\n return\n }\n\n const tr = view.state.tr.setMeta(PLUGIN_KEY, { positions })\n view.dispatch(tr)\n }\n\n // rAF-based scheduler with interval guard (150ms → ~6–7 Hz) used by\n // scroll events and update(). The overscan margin hides the extra\n // latency. When the guard blocks, the callback reschedules itself so\n // the trailing position is always captured.\n let frame: number | null = null\n let lastCompute = 0\n const MIN_SCROLL_INTERVAL = 150\n\n const scheduleFrame = () => {\n if (frame !== null) return\n frame = requestAnimationFrame(() => {\n frame = null\n const now = performance.now()\n if (now - lastCompute >= MIN_SCROLL_INTERVAL) {\n lastCompute = now\n computeAndDispatch()\n } else {\n scheduleFrame()\n }\n })\n }\n\n scrollContainer.addEventListener('scroll', scheduleFrame, { passive: true })\n\n // Fire once to populate initial viewport\n computeAndDispatch()\n\n return {\n update(_view: EditorView, prevState: EditorState) {\n if (view.state.doc.content.size !== prevState.doc.content.size) {\n scheduleFrame()\n }\n },\n destroy: () => {\n if (frame !== null) {\n cancelAnimationFrame(frame)\n }\n scrollContainer.removeEventListener('scroll', scheduleFrame)\n },\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/placeholder/constants.ts","../../src/placeholder/placeholder.ts","../../src/placeholder/plugins/PlaceholderPlugin.ts","../../src/placeholder/utils/buildPlaceholderDecorations.ts","../../src/placeholder/utils/createPlaceholderDecoration.ts","../../src/placeholder/utils/preparePlaceholderAttribute.ts","../../src/placeholder/utils/findScrollParent.ts","../../src/placeholder/utils/getViewportBoundaryPositions.ts","../../src/placeholder/utils/viewportTracking.ts"],"sourcesContent":["import { PluginKey } from '@tiptap/pm/state'\n\nimport type { ViewportState } from './types.js'\n\n/** The default data attribute label */\nexport const DEFAULT_DATA_ATTRIBUTE = 'placeholder'\n\n/** The plugin key used to store and read the placeholder viewport state */\nexport const PLUGIN_KEY = new PluginKey<ViewportState>('tiptap__placeholder')\n\n/**\n * Extra pixels added above and below the visible viewport when computing the\n * range of nodes to decorate. Decorating slightly beyond the fold means a\n * node already has its placeholder before it scrolls into view, which hides\n * the latency introduced by the throttled viewport recompute.\n */\nexport const VIEWPORT_OVERSCAN_PX = 200\n","import { Extension } from '@tiptap/core'\n\nimport { DEFAULT_DATA_ATTRIBUTE } from './constants.js'\nimport { createPlaceholderPlugin } from './plugins/PlaceholderPlugin.js'\nimport type { PlaceholderOptions } from './types.js'\n\n/**\n * This extension allows you to add a placeholder to your editor.\n * A placeholder is a text that appears when the editor or a node is empty.\n * @see https://www.tiptap.dev/api/extensions/placeholder\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 dataAttribute: DEFAULT_DATA_ATTRIBUTE,\n placeholder: 'Write something …',\n showOnlyWhenEditable: true,\n showOnlyCurrent: true,\n includeChildren: false,\n }\n },\n\n addProseMirrorPlugins() {\n return [createPlaceholderPlugin({ editor: this.editor, options: this.options })]\n },\n})\n","import type { Editor } from '@tiptap/core'\nimport { Plugin } from '@tiptap/pm/state'\n\nimport { DEFAULT_DATA_ATTRIBUTE, PLUGIN_KEY } from '../constants.js'\nimport type { PlaceholderOptions } from '../types.js'\nimport { buildPlaceholderDecorations } from '../utils/buildPlaceholderDecorations.js'\nimport { preparePlaceholderAttribute } from '../utils/preparePlaceholderAttribute.js'\nimport { createViewportPluginView, viewportPluginState } from '../utils/viewportTracking.js'\n\nexport type CreatePluginOptions = {\n editor: Editor\n options: PlaceholderOptions\n}\n\n/**\n * Creates the ProseMirror plugin that renders placeholder decorations.\n * @param options.editor - The editor instance.\n * @param options.options - The resolved placeholder options.\n * @returns The configured placeholder plugin.\n */\nexport function createPlaceholderPlugin({ editor, options }: CreatePluginOptions) {\n const dataAttribute = options.dataAttribute\n ? `data-${preparePlaceholderAttribute(options.dataAttribute)}`\n : `data-${DEFAULT_DATA_ATTRIBUTE}`\n\n return new Plugin({\n key: PLUGIN_KEY,\n state: viewportPluginState,\n view: createViewportPluginView,\n props: {\n decorations: ({ doc, selection }) =>\n buildPlaceholderDecorations({ editor, options, dataAttribute, doc, selection }),\n },\n })\n}\n","import type { Editor } from '@tiptap/core'\nimport { isNodeEmpty } from '@tiptap/core'\nimport type { Node } from '@tiptap/pm/model'\nimport type { Selection } from '@tiptap/pm/state'\nimport type { Decoration } from '@tiptap/pm/view'\nimport { DecorationSet } from '@tiptap/pm/view'\n\nimport { PLUGIN_KEY } from '../constants.js'\nimport type { PlaceholderOptions } from '../types.js'\nimport { createPlaceholderDecoration } from './createPlaceholderDecoration.js'\n\nfunction resolveEmptyNodeClass(\n emptyNodeClass: PlaceholderOptions['emptyNodeClass'],\n props: { editor: Editor; node: Node; pos: number; hasAnchor: boolean },\n): string {\n return typeof emptyNodeClass === 'function' ? emptyNodeClass(props) : emptyNodeClass\n}\n\n/**\n * Builds the placeholder decorations for the current document state.\n * @param options.editor - The editor instance.\n * @param options.options - The resolved placeholder options.\n * @param options.dataAttribute - The prepared `data-*` attribute name.\n * @param options.doc - The current document node.\n * @param options.selection - The current selection.\n * @returns A decoration set, or `null` when no placeholders should be shown.\n */\nexport function buildPlaceholderDecorations({\n editor,\n options,\n dataAttribute,\n doc,\n selection,\n}: {\n editor: Editor\n options: PlaceholderOptions\n dataAttribute: string\n doc: Node\n selection: Selection\n}): DecorationSet | null {\n const active = editor.isEditable || !options.showOnlyWhenEditable\n\n if (!active) {\n return null\n }\n\n const { anchor } = selection\n const decorations: Decoration[] = []\n const isEmptyDoc = editor.isEmpty\n\n const useResolvedPath = options.showOnlyCurrent && !options.includeChildren\n\n if (useResolvedPath) {\n const resolved = doc.resolve(anchor)\n\n // When the selection spans the whole document (e.g. an `AllSelection`\n // after Cmd+A), the anchor resolves to the document level (depth 0). In\n // that case the relevant textblock is the node directly after the\n // position rather than an ancestor. otherwise the placeholder would\n // disappear after selecting all and deleting.\n const node = resolved.depth > 0 ? resolved.node(1) : resolved.nodeAfter\n const nodeStart = resolved.depth > 0 ? resolved.before(1) : anchor\n\n if (node && node.type.isTextblock && isNodeEmpty(node)) {\n const hasAnchor = anchor >= nodeStart && anchor <= nodeStart + node.nodeSize\n\n decorations.push(\n createPlaceholderDecoration({\n editor,\n isEmptyDoc,\n dataAttribute,\n hasAnchor,\n placeholder: options.placeholder,\n classes: {\n emptyEditor: options.emptyEditorClass,\n emptyNode: resolveEmptyNodeClass(options.emptyNodeClass, {\n editor,\n node,\n pos: nodeStart,\n hasAnchor,\n }),\n },\n node,\n pos: nodeStart,\n }),\n )\n }\n } else {\n const pluginState = PLUGIN_KEY.getState(editor.state)\n const from = pluginState?.topPos ?? 0\n const to = pluginState?.bottomPos ?? doc.content.size\n\n doc.nodesBetween(from, to, (node, pos) => {\n const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize\n const isEmpty = !node.isLeaf && isNodeEmpty(node)\n\n if (!node.type.isTextblock) {\n return options.includeChildren\n }\n\n if ((hasAnchor || !options.showOnlyCurrent) && isEmpty) {\n decorations.push(\n createPlaceholderDecoration({\n editor,\n isEmptyDoc,\n dataAttribute,\n hasAnchor,\n placeholder: options.placeholder,\n classes: {\n emptyEditor: options.emptyEditorClass,\n emptyNode: resolveEmptyNodeClass(options.emptyNodeClass, {\n editor,\n node,\n pos,\n hasAnchor,\n }),\n },\n node,\n pos,\n }),\n )\n }\n\n return options.includeChildren\n })\n }\n\n return DecorationSet.create(doc, decorations)\n}\n","import type { Editor } from '@tiptap/core'\nimport type { Node } from '@tiptap/pm/model'\nimport { Decoration } from '@tiptap/pm/view'\n\nimport type { PlaceholderOptions } from '../types.js'\n\n/**\n * Creates a ProseMirror node decoration that applies a placeholder\n * CSS class and data attribute to an empty node.\n * @param options.editor - The editor instance\n * @param options.pos - The position of the node in the document\n * @param options.node - The ProseMirror node\n * @param options.isEmptyDoc - Whether the entire document is empty\n * @param options.hasAnchor - Whether the selection anchor is within the node\n * @param options.dataAttribute - The data attribute name (e.g. `data-placeholder`)\n * @param options.classes - CSS classes for empty nodes and the empty editor\n * @param options.placeholder - The placeholder text or a function that returns it\n * @returns A ProseMirror node decoration with placeholder classes and data attribute\n */\nexport function createPlaceholderDecoration(options: {\n editor: Editor\n pos: number\n node: Node\n isEmptyDoc: boolean\n hasAnchor: boolean\n dataAttribute: string\n classes: {\n emptyEditor: PlaceholderOptions['emptyEditorClass']\n emptyNode: string\n }\n placeholder: PlaceholderOptions['placeholder']\n}) {\n const {\n editor,\n placeholder,\n dataAttribute,\n pos,\n node,\n isEmptyDoc,\n hasAnchor,\n classes: { emptyNode, emptyEditor },\n } = options\n const classes = [emptyNode]\n\n if (isEmptyDoc) {\n classes.push(emptyEditor)\n }\n\n return Decoration.node(pos, pos + node.nodeSize, {\n class: classes.join(' '),\n [dataAttribute]:\n typeof placeholder === 'function'\n ? placeholder({\n editor,\n node,\n pos,\n hasAnchor,\n })\n : placeholder,\n })\n}\n","/**\n * Prepares the placeholder attribute by ensuring it is properly formatted.\n * @param attr - The placeholder attribute string.\n * @returns The prepared placeholder attribute string.\n */\nexport function preparePlaceholderAttribute(attr: string): string {\n return (\n attr\n // replace whitespace with dashes\n .replace(/\\s+/g, '-')\n // replace non-alphanumeric characters\n // or special chars like $, %, &, etc.\n // but not dashes\n .replace(/[^a-zA-Z0-9-]/g, '')\n // and replace any numeric character at the start\n .replace(/^[0-9-]+/, '')\n // and finally replace any stray, leading dashes\n .replace(/^-+/, '')\n .toLowerCase()\n )\n}\n","/**\n * Checks if an element is scrollable by testing its overflow properties.\n * Elements with `overflow: hidden` or `overflow: clip` are intentionally\n * excluded — they clip content but don't emit scroll events.\n */\nfunction isScrollable(el: HTMLElement): boolean {\n const style = getComputedStyle(el)\n const overflow = `${style.overflow} ${style.overflowY} ${style.overflowX}`\n\n return /auto|scroll|overlay/.test(overflow)\n}\n\nexport function findScrollParent(element: HTMLElement): HTMLElement | Window {\n let el: HTMLElement | null = element\n\n while (el) {\n if (isScrollable(el)) {\n return el\n }\n\n // Check if we hit a Shadow DOM boundary. If so, jump to the shadow host\n // and continue traversing the light DOM.\n const parent = el.parentElement\n if (!parent) {\n const root = el.getRootNode()\n if (root instanceof ShadowRoot) {\n el = root.host as HTMLElement\n continue\n }\n\n return window\n }\n\n el = parent\n }\n\n return window\n}\n","import type { EditorView } from '@tiptap/pm/view'\n\nimport { VIEWPORT_OVERSCAN_PX } from '../constants.js'\n\nfunction getContainerRect(container: HTMLElement | Window): { top: number; bottom: number } {\n if (container === window) {\n return { top: 0, bottom: window.innerHeight }\n }\n\n return (container as HTMLElement).getBoundingClientRect()\n}\n\n/**\n * Computes the document positions bounding the currently visible viewport.\n */\nexport function getViewportBoundaryPositions({\n view,\n scrollContainer,\n}: {\n view: EditorView\n scrollContainer?: HTMLElement | Window\n}): { top: number; bottom: number } | null {\n const editorRect = view.dom.getBoundingClientRect()\n\n // No usable layout — can't probe a coordinate inside the box.\n if (editorRect.width <= 0 || editorRect.height <= 0) {\n return null\n }\n\n const containerRect = scrollContainer\n ? getContainerRect(scrollContainer)\n : { top: 0, bottom: window.innerHeight }\n\n const visibleTop = Math.max(editorRect.top, containerRect.top) - VIEWPORT_OVERSCAN_PX\n const visibleBottom = Math.min(editorRect.bottom, containerRect.bottom) + VIEWPORT_OVERSCAN_PX\n\n if (visibleTop >= visibleBottom) {\n // Editor is scrolled fully out of its container so not measurable.\n return null\n }\n\n // Probe the start edge (left in LTR, right in RTL), clamped inside the box\n // so a too-narrow editor doesn't push the probe out.\n const minX = editorRect.left + 1\n const maxX = editorRect.right - 1\n\n if (minX > maxX) {\n return null\n }\n\n const isRTL = getComputedStyle(view.dom).direction === 'rtl'\n const targetX = isRTL ? editorRect.right - 2 : editorRect.left + 2\n const x = Math.min(Math.max(targetX, minX), maxX)\n\n // Clamp the probe y inside the box so the overscan doesn't push it past the\n // editor's own content (which would make `posAtCoords` null for no reason).\n const probeTop = Math.max(visibleTop + 2, editorRect.top + 1)\n const probeBottom = Math.min(visibleBottom - 2, editorRect.bottom - 1)\n\n if (probeTop > probeBottom) {\n return null\n }\n\n const topPos = view.posAtCoords({ left: x, top: probeTop })\n const bottomPos = view.posAtCoords({ left: x, top: probeBottom })\n\n // Null usually means occlusion (but can be benign). Either way it's\n // unreliable, so freeze the previous window instead of decorating the whole doc.\n if (!topPos || !bottomPos) {\n return null\n }\n\n return { top: topPos.pos, bottom: bottomPos.pos }\n}\n","import type { EditorState, PluginView, StateField, Transaction } from '@tiptap/pm/state'\nimport type { EditorView } from '@tiptap/pm/view'\n\nimport { PLUGIN_KEY } from '../constants.js'\nimport type { ViewportState } from '../types.js'\nimport { findScrollParent } from './findScrollParent.js'\nimport { getViewportBoundaryPositions } from './getViewportBoundaryPositions.js'\n\n/**\n * The plugin `state` config that tracks the visible viewport boundaries so the\n * decoration callback only scans the nodes currently on screen.\n */\nexport const viewportPluginState: StateField<ViewportState> = {\n /**\n * Initialises the viewport state with no known positions.\n * @returns The initial viewport state.\n */\n init(): ViewportState {\n return { topPos: null, bottomPos: null }\n },\n\n /**\n * Updates the viewport state from incoming transactions.\n * @param tr - The transaction being applied.\n * @param prev - The previous viewport state.\n * @returns The next viewport state.\n */\n apply(tr: Transaction, prev: ViewportState): ViewportState {\n const meta = tr.getMeta(PLUGIN_KEY) as\n | { positions?: { top: number; bottom: number } }\n | undefined\n\n if (meta?.positions) {\n return { topPos: meta.positions.top, bottomPos: meta.positions.bottom }\n }\n\n if (!tr.docChanged) {\n return prev\n }\n\n // Preserve last known viewport positions across transactions.\n // Without this, every keystroke resets back to a full document\n // scan, defeating the viewport optimisation.\n // Only map when we have actual positions — null means \"no viewport\n // info yet\" and should stay null to fall back to full doc scan.\n return {\n topPos: prev.topPos !== null ? tr.mapping.map(prev.topPos) : null,\n bottomPos: prev.bottomPos !== null ? tr.mapping.map(prev.bottomPos) : null,\n }\n },\n}\n\n/**\n * Creates the plugin `view` that recomputes the visible viewport on scroll and\n * document size changes, dispatching the result into the plugin state.\n * @param view - The editor view the plugin is attached to.\n * @returns A plugin view with `update` and `destroy` handlers.\n */\nexport function createViewportPluginView(view: EditorView): PluginView {\n const scrollContainer = findScrollParent(view.dom)\n\n const computeAndDispatch = () => {\n const positions = getViewportBoundaryPositions({\n view,\n scrollContainer,\n })\n\n // Not measurable, keep the last good window frozen\n if (positions === null) {\n return\n }\n\n const prev = PLUGIN_KEY.getState(view.state)\n if (prev?.topPos === positions.top && prev?.bottomPos === positions.bottom) {\n return\n }\n\n const tr = view.state.tr.setMeta(PLUGIN_KEY, { positions })\n view.dispatch(tr)\n }\n\n // rAF-based scheduler with interval guard (150ms → ~6–7 Hz) used by\n // scroll events and update(). The overscan margin hides the extra\n // latency. When the guard blocks, the callback reschedules itself so\n // the trailing position is always captured.\n let frame: number | null = null\n let lastCompute = 0\n const MIN_SCROLL_INTERVAL = 150\n\n const scheduleFrame = () => {\n if (frame !== null) return\n frame = requestAnimationFrame(() => {\n frame = null\n const now = performance.now()\n if (now - lastCompute >= MIN_SCROLL_INTERVAL) {\n lastCompute = now\n computeAndDispatch()\n } else {\n scheduleFrame()\n }\n })\n }\n\n scrollContainer.addEventListener('scroll', scheduleFrame, { passive: true })\n\n // Recover a frozen window once the editor becomes usable again (e.g. a modal\n // closes): re-measure on size/visibility changes and when focus returns.\n const resizeObserver =\n typeof ResizeObserver !== 'undefined' ? new ResizeObserver(scheduleFrame) : null\n resizeObserver?.observe(view.dom)\n\n const intersectionObserver =\n typeof IntersectionObserver !== 'undefined' ? new IntersectionObserver(scheduleFrame) : null\n intersectionObserver?.observe(view.dom)\n\n view.dom.addEventListener('focus', scheduleFrame)\n\n // Fire once to populate initial viewport\n computeAndDispatch()\n\n return {\n update(_view: EditorView, prevState: EditorState) {\n if (view.state.doc.content.size !== prevState.doc.content.size) {\n scheduleFrame()\n }\n },\n destroy: () => {\n if (frame !== null) {\n cancelAnimationFrame(frame)\n }\n scrollContainer.removeEventListener('scroll', scheduleFrame)\n resizeObserver?.disconnect()\n intersectionObserver?.disconnect()\n view.dom.removeEventListener('focus', scheduleFrame)\n },\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiptap/extensions",
|
|
3
|
-
"version": "3.27.
|
|
3
|
+
"version": "3.27.1",
|
|
4
4
|
"description": "various extensions for tiptap",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"tiptap",
|
|
@@ -103,12 +103,12 @@
|
|
|
103
103
|
}
|
|
104
104
|
},
|
|
105
105
|
"devDependencies": {
|
|
106
|
-
"@tiptap/core": "^3.27.
|
|
107
|
-
"@tiptap/pm": "^3.27.
|
|
106
|
+
"@tiptap/core": "^3.27.1",
|
|
107
|
+
"@tiptap/pm": "^3.27.1"
|
|
108
108
|
},
|
|
109
109
|
"peerDependencies": {
|
|
110
|
-
"@tiptap/core": "3.27.
|
|
111
|
-
"@tiptap/pm": "3.27.
|
|
110
|
+
"@tiptap/core": "3.27.1",
|
|
111
|
+
"@tiptap/pm": "3.27.1"
|
|
112
112
|
},
|
|
113
113
|
"scripts": {
|
|
114
114
|
"build": "tsup"
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { Node } from '@tiptap/pm/model'
|
|
2
1
|
import type { EditorView } from '@tiptap/pm/view'
|
|
3
2
|
|
|
4
3
|
import { VIEWPORT_OVERSCAN_PX } from '../constants.js'
|
|
@@ -11,16 +10,23 @@ function getContainerRect(container: HTMLElement | Window): { top: number; botto
|
|
|
11
10
|
return (container as HTMLElement).getBoundingClientRect()
|
|
12
11
|
}
|
|
13
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Computes the document positions bounding the currently visible viewport.
|
|
15
|
+
*/
|
|
14
16
|
export function getViewportBoundaryPositions({
|
|
15
|
-
doc,
|
|
16
17
|
view,
|
|
17
18
|
scrollContainer,
|
|
18
19
|
}: {
|
|
19
|
-
doc: Node
|
|
20
20
|
view: EditorView
|
|
21
21
|
scrollContainer?: HTMLElement | Window
|
|
22
|
-
}) {
|
|
22
|
+
}): { top: number; bottom: number } | null {
|
|
23
23
|
const editorRect = view.dom.getBoundingClientRect()
|
|
24
|
+
|
|
25
|
+
// No usable layout — can't probe a coordinate inside the box.
|
|
26
|
+
if (editorRect.width <= 0 || editorRect.height <= 0) {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
const containerRect = scrollContainer
|
|
25
31
|
? getContainerRect(scrollContainer)
|
|
26
32
|
: { top: 0, bottom: window.innerHeight }
|
|
@@ -29,21 +35,40 @@ export function getViewportBoundaryPositions({
|
|
|
29
35
|
const visibleBottom = Math.min(editorRect.bottom, containerRect.bottom) + VIEWPORT_OVERSCAN_PX
|
|
30
36
|
|
|
31
37
|
if (visibleTop >= visibleBottom) {
|
|
32
|
-
// Editor is
|
|
33
|
-
return
|
|
38
|
+
// Editor is scrolled fully out of its container so not measurable.
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Probe the start edge (left in LTR, right in RTL), clamped inside the box
|
|
43
|
+
// so a too-narrow editor doesn't push the probe out.
|
|
44
|
+
const minX = editorRect.left + 1
|
|
45
|
+
const maxX = editorRect.right - 1
|
|
46
|
+
|
|
47
|
+
if (minX > maxX) {
|
|
48
|
+
return null
|
|
34
49
|
}
|
|
35
50
|
|
|
36
|
-
// Pick the x-coordinate based on text direction. In LTR the content
|
|
37
|
-
// starts at the left edge; in RTL it starts at the right edge.
|
|
38
|
-
// Clamp to ensure the coordinate stays inside the editor bounds.
|
|
39
51
|
const isRTL = getComputedStyle(view.dom).direction === 'rtl'
|
|
40
|
-
const
|
|
52
|
+
const targetX = isRTL ? editorRect.right - 2 : editorRect.left + 2
|
|
53
|
+
const x = Math.min(Math.max(targetX, minX), maxX)
|
|
41
54
|
|
|
42
|
-
|
|
43
|
-
|
|
55
|
+
// Clamp the probe y inside the box so the overscan doesn't push it past the
|
|
56
|
+
// editor's own content (which would make `posAtCoords` null for no reason).
|
|
57
|
+
const probeTop = Math.max(visibleTop + 2, editorRect.top + 1)
|
|
58
|
+
const probeBottom = Math.min(visibleBottom - 2, editorRect.bottom - 1)
|
|
44
59
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
bottom: bottomPos ? bottomPos.pos : doc.content.size,
|
|
60
|
+
if (probeTop > probeBottom) {
|
|
61
|
+
return null
|
|
48
62
|
}
|
|
63
|
+
|
|
64
|
+
const topPos = view.posAtCoords({ left: x, top: probeTop })
|
|
65
|
+
const bottomPos = view.posAtCoords({ left: x, top: probeBottom })
|
|
66
|
+
|
|
67
|
+
// Null usually means occlusion (but can be benign). Either way it's
|
|
68
|
+
// unreliable, so freeze the previous window instead of decorating the whole doc.
|
|
69
|
+
if (!topPos || !bottomPos) {
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { top: topPos.pos, bottom: bottomPos.pos }
|
|
49
74
|
}
|
|
@@ -62,10 +62,14 @@ export function createViewportPluginView(view: EditorView): PluginView {
|
|
|
62
62
|
const computeAndDispatch = () => {
|
|
63
63
|
const positions = getViewportBoundaryPositions({
|
|
64
64
|
view,
|
|
65
|
-
doc: view.state.doc,
|
|
66
65
|
scrollContainer,
|
|
67
66
|
})
|
|
68
67
|
|
|
68
|
+
// Not measurable, keep the last good window frozen
|
|
69
|
+
if (positions === null) {
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
69
73
|
const prev = PLUGIN_KEY.getState(view.state)
|
|
70
74
|
if (prev?.topPos === positions.top && prev?.bottomPos === positions.bottom) {
|
|
71
75
|
return
|
|
@@ -99,6 +103,18 @@ export function createViewportPluginView(view: EditorView): PluginView {
|
|
|
99
103
|
|
|
100
104
|
scrollContainer.addEventListener('scroll', scheduleFrame, { passive: true })
|
|
101
105
|
|
|
106
|
+
// Recover a frozen window once the editor becomes usable again (e.g. a modal
|
|
107
|
+
// closes): re-measure on size/visibility changes and when focus returns.
|
|
108
|
+
const resizeObserver =
|
|
109
|
+
typeof ResizeObserver !== 'undefined' ? new ResizeObserver(scheduleFrame) : null
|
|
110
|
+
resizeObserver?.observe(view.dom)
|
|
111
|
+
|
|
112
|
+
const intersectionObserver =
|
|
113
|
+
typeof IntersectionObserver !== 'undefined' ? new IntersectionObserver(scheduleFrame) : null
|
|
114
|
+
intersectionObserver?.observe(view.dom)
|
|
115
|
+
|
|
116
|
+
view.dom.addEventListener('focus', scheduleFrame)
|
|
117
|
+
|
|
102
118
|
// Fire once to populate initial viewport
|
|
103
119
|
computeAndDispatch()
|
|
104
120
|
|
|
@@ -113,6 +129,9 @@ export function createViewportPluginView(view: EditorView): PluginView {
|
|
|
113
129
|
cancelAnimationFrame(frame)
|
|
114
130
|
}
|
|
115
131
|
scrollContainer.removeEventListener('scroll', scheduleFrame)
|
|
132
|
+
resizeObserver?.disconnect()
|
|
133
|
+
intersectionObserver?.disconnect()
|
|
134
|
+
view.dom.removeEventListener('focus', scheduleFrame)
|
|
116
135
|
},
|
|
117
136
|
}
|
|
118
137
|
}
|