commit 083a46e93bc260b26e842609089def15bc431962
Author: upstream-mirror
Date: Tue Sep 15 21:11:02 2026 +0500
snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
diff --git a/.changeset/2026-08-20-ordered-list-markdown-indent.md b/.changeset/2026-08-20-ordered-list-markdown-indent.md
new file mode 100644
index 0000000..3324ff3
--- /dev/null
+++ b/.changeset/2026-08-20-ordered-list-markdown-indent.md
@@ -0,0 +1,6 @@
+---
+'@tiptap/core': patch
+'@tiptap/extension-list': patch
+---
+
+Nested lists exported to Markdown now keep their hierarchy when the file is read back by other Markdown tools.
diff --git a/.changeset/2026-08-23-youtube-missing-src.md b/.changeset/2026-08-23-youtube-missing-src.md
new file mode 100644
index 0000000..0b0b5ac
--- /dev/null
+++ b/.changeset/2026-08-23-youtube-missing-src.md
@@ -0,0 +1,5 @@
+---
+'@tiptap/extension-youtube': patch
+---
+
+Pasting a YouTube iframe without a `src` attribute no longer crashes the editor. The embed is kept without a source.
diff --git a/.changeset/2026-08-25-remove-deprecated-packages.md b/.changeset/2026-08-25-remove-deprecated-packages.md
new file mode 100644
index 0000000..b099ded
--- /dev/null
+++ b/.changeset/2026-08-25-remove-deprecated-packages.md
@@ -0,0 +1,7 @@
+---
+'@tiptap/starter-kit': major
+---
+
+StarterKit no longer installs the deprecated `@tiptap/extension-dropcursor`, `@tiptap/extension-gapcursor`, `@tiptap/extension-list-item`, and `@tiptap/extension-list-keymap` packages. We will not publish further updates to these packages. StarterKit still includes their extensions, so you do not need to change your configuration.
+
+If you import these extensions directly, switch to the new packages: `Dropcursor` and `Gapcursor` from `@tiptap/extensions`, and `ListItem` and `ListKeymap` from `@tiptap/extension-list`.
diff --git a/.changeset/2026-08-26-fix-markdown-whitespace-only-marked-text.md b/.changeset/2026-08-26-fix-markdown-whitespace-only-marked-text.md
new file mode 100644
index 0000000..fa4671f
--- /dev/null
+++ b/.changeset/2026-08-26-fix-markdown-whitespace-only-marked-text.md
@@ -0,0 +1,5 @@
+---
+'@tiptap/markdown': patch
+---
+
+Fix Markdown serialization of whitespace-only marked text.
diff --git a/.changeset/2026-08-26-markdown-attribute-redos.md b/.changeset/2026-08-26-markdown-attribute-redos.md
new file mode 100644
index 0000000..fcc34e4
--- /dev/null
+++ b/.changeset/2026-08-26-markdown-attribute-redos.md
@@ -0,0 +1,6 @@
+---
+'@tiptap/core': patch
+'@tiptap/extension-mention': patch
+---
+
+Fix a denial-of-service risk where crafted block or inline Markdown attributes could consume excessive CPU and block the browser or server event loop.
diff --git a/.changeset/2026-08-26-validate-collaboration-caret-colors.md b/.changeset/2026-08-26-validate-collaboration-caret-colors.md
new file mode 100644
index 0000000..f2f4bb7
--- /dev/null
+++ b/.changeset/2026-08-26-validate-collaboration-caret-colors.md
@@ -0,0 +1,5 @@
+---
+'@tiptap/extension-collaboration-caret': patch
+---
+
+Fixed a bug which allowed potentially unsafe color values being sent by other clients. Those unsafe colors received from collaboration users are now ignored.
diff --git a/.changeset/2026-08-28-react-shared-node-view-selection.md b/.changeset/2026-08-28-react-shared-node-view-selection.md
new file mode 100644
index 0000000..959a60d
--- /dev/null
+++ b/.changeset/2026-08-28-react-shared-node-view-selection.md
@@ -0,0 +1,5 @@
+---
+'@tiptap/react': patch
+---
+
+Reduce overhead in React node views on documents with many nodes.
diff --git a/.changeset/2026-08-31-bump-prosemirror-view.md b/.changeset/2026-08-31-bump-prosemirror-view.md
new file mode 100644
index 0000000..0f7551d
--- /dev/null
+++ b/.changeset/2026-08-31-bump-prosemirror-view.md
@@ -0,0 +1,5 @@
+---
+'@tiptap/pm': patch
+---
+
+Bump `prosemirror-view` to `^1.42.3`, which fixes an XSS vulnerability where pasting crafted HTML could run arbitrary JavaScript (GHSA-c8x8-7fp4-3x9w).
diff --git a/.changeset/2026-08-31-react-nodeview-selection-state.md b/.changeset/2026-08-31-react-nodeview-selection-state.md
new file mode 100644
index 0000000..ad5b62a
--- /dev/null
+++ b/.changeset/2026-08-31-react-nodeview-selection-state.md
@@ -0,0 +1,5 @@
+---
+'@tiptap/react': minor
+---
+
+Align `selected` with ProseMirror node selections by default, expose text selections through `selectionInside`, and keep `selectedOnTextSelection` compatible.
diff --git a/.changeset/2026-09-04-react-editor-namespace-ts2694.md b/.changeset/2026-09-04-react-editor-namespace-ts2694.md
new file mode 100644
index 0000000..bb34826
--- /dev/null
+++ b/.changeset/2026-09-04-react-editor-namespace-ts2694.md
@@ -0,0 +1,5 @@
+---
+'@tiptap/react': patch
+---
+
+Fix a TypeScript error (TS2694) in the shipped type declarations when `skipLibCheck` is turned off.
diff --git a/.changeset/README.md b/.changeset/README.md
new file mode 100644
index 0000000..e5b6d8d
--- /dev/null
+++ b/.changeset/README.md
@@ -0,0 +1,8 @@
+# Changesets
+
+Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
+with multi-package repos, or single-package repos to help you version and publish your code. You can
+find the full documentation for it [in our repository](https://github.com/changesets/changesets)
+
+We have a quick list of common questions to get you started engaging with this project in
+[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
diff --git a/.changeset/config.json b/.changeset/config.json
new file mode 100644
index 0000000..a9336fa
--- /dev/null
+++ b/.changeset/config.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://unpkg.com/@changesets/config@3.0.1/schema.json",
+ "changelog": "@changesets/cli/changelog",
+ "commit": false,
+ "fixed": [["@tiptap/*", "!@tiptap/ai-toolkit"]],
+ "linked": [],
+ "access": "public",
+ "baseBranch": "main",
+ "updateInternalDependencies": "patch",
+ "ignore": [],
+ "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
+ "onlyUpdatePeerDependentsWhenOutOfRange": true
+ }
+}
diff --git a/.changeset/fix-ios-ignore-mutation-contentdom.md b/.changeset/fix-ios-ignore-mutation-contentdom.md
new file mode 100644
index 0000000..ba55bd0
--- /dev/null
+++ b/.changeset/fix-ios-ignore-mutation-contentdom.md
@@ -0,0 +1,5 @@
+---
+'@tiptap/core': patch
+---
+
+Fix freezes in framework-based node views on iOS and Android
diff --git a/.changeset/fix-merge-attributes-prototype.md b/.changeset/fix-merge-attributes-prototype.md
new file mode 100644
index 0000000..4b43dd7
--- /dev/null
+++ b/.changeset/fix-merge-attributes-prototype.md
@@ -0,0 +1,5 @@
+---
+'@tiptap/core': patch
+---
+
+Prevent untrusted HTML attributes from changing an object's prototype when merged with `mergeAttributes`.
diff --git a/.changeset/pre.json b/.changeset/pre.json
new file mode 100644
index 0000000..e7317ad
--- /dev/null
+++ b/.changeset/pre.json
@@ -0,0 +1,68 @@
+{
+ "mode": "pre",
+ "tag": "next",
+ "initialVersions": {
+ "tiptap-demos": "3.0.3",
+ "@tiptap/ai-toolkit": "0.4.0",
+ "@tiptap/core": "3.30.3",
+ "@tiptap/extension-audio": "3.30.3",
+ "@tiptap/extension-blockquote": "3.30.3",
+ "@tiptap/extension-bold": "3.30.3",
+ "@tiptap/extension-bubble-menu": "3.30.3",
+ "@tiptap/extension-bullet-list": "3.30.3",
+ "@tiptap/extension-code": "3.30.3",
+ "@tiptap/extension-code-block": "3.30.3",
+ "@tiptap/extension-code-block-lowlight": "3.30.3",
+ "@tiptap/extension-collaboration": "3.30.3",
+ "@tiptap/extension-collaboration-caret": "3.30.3",
+ "@tiptap/extension-color": "3.30.3",
+ "@tiptap/extension-details": "3.30.3",
+ "@tiptap/extension-document": "3.30.3",
+ "@tiptap/extension-drag-handle": "3.30.3",
+ "@tiptap/extension-drag-handle-react": "3.30.3",
+ "@tiptap/extension-drag-handle-vue": "3.30.3",
+ "@tiptap/extension-emoji": "3.30.3",
+ "@tiptap/extension-file-handler": "3.30.3",
+ "@tiptap/extension-find-and-replace": "3.30.3",
+ "@tiptap/extension-floating-menu": "3.30.3",
+ "@tiptap/extension-font-family": "3.30.3",
+ "@tiptap/extension-hard-break": "3.30.3",
+ "@tiptap/extension-heading": "3.30.3",
+ "@tiptap/extension-highlight": "3.30.3",
+ "@tiptap/extension-horizontal-rule": "3.30.3",
+ "@tiptap/extension-image": "3.30.3",
+ "@tiptap/extension-invisible-characters": "3.30.3",
+ "@tiptap/extension-italic": "3.30.3",
+ "@tiptap/extension-link": "3.30.3",
+ "@tiptap/extension-list": "3.30.3",
+ "@tiptap/extension-mathematics": "3.30.3",
+ "@tiptap/extension-mention": "3.30.3",
+ "@tiptap/extension-node-range": "3.30.3",
+ "@tiptap/extension-ordered-list": "3.30.3",
+ "@tiptap/extension-paragraph": "3.30.3",
+ "@tiptap/extension-ruby-text": "3.30.3",
+ "@tiptap/extension-strike": "3.30.3",
+ "@tiptap/extension-subscript": "3.30.3",
+ "@tiptap/extension-superscript": "3.30.3",
+ "@tiptap/extension-table": "3.30.3",
+ "@tiptap/extension-table-of-contents": "3.30.3",
+ "@tiptap/extension-text": "3.30.3",
+ "@tiptap/extension-text-align": "3.30.3",
+ "@tiptap/extension-text-style": "3.30.3",
+ "@tiptap/extension-twitch": "3.30.3",
+ "@tiptap/extension-typography": "3.30.3",
+ "@tiptap/extension-underline": "3.30.3",
+ "@tiptap/extension-unique-id": "3.30.3",
+ "@tiptap/extension-youtube": "3.30.3",
+ "@tiptap/extensions": "3.30.3",
+ "@tiptap/html": "3.30.3",
+ "@tiptap/markdown": "3.30.3",
+ "@tiptap/pm": "3.30.3",
+ "@tiptap/react": "3.30.3",
+ "@tiptap/starter-kit": "3.30.3",
+ "@tiptap/static-renderer": "3.30.3",
+ "@tiptap/suggestion": "3.30.3",
+ "@tiptap/vue": "3.30.3"
+ },
+ "changesets": []
+}
diff --git a/.changeset/remove-vue-2.md b/.changeset/remove-vue-2.md
new file mode 100644
index 0000000..efe17f2
--- /dev/null
+++ b/.changeset/remove-vue-2.md
@@ -0,0 +1,18 @@
+---
+'@tiptap/vue': major
+'@tiptap/extension-drag-handle-vue': major
+---
+
+Vue 2 support is removed. `@tiptap/vue-3` is now `@tiptap/vue`, and `@tiptap/extension-drag-handle-vue-3` is now `@tiptap/extension-drag-handle-vue`.
+
+Update imports and dependencies:
+
+```diff
+- import { EditorContent } from '@tiptap/vue-3'
++ import { EditorContent } from '@tiptap/vue'
+
+- import DragHandle from '@tiptap/extension-drag-handle-vue-3'
++ import DragHandle from '@tiptap/extension-drag-handle-vue'
+```
+
+Vue 2 users must migrate to Vue 3 before using the new packages. The planned Codemod Registry package `@tiptap/codemod-v3-to-v4` updates package imports and dependencies.
diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 0000000..86e81db
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,123 @@
+inheritance: true
+
+reviews:
+ request_changes_workflow: true
+ high_level_summary_instructions:
+ Short and simple, keep it cohesive, no complex wording, assume everyone including the
+ PR reviewer is new to the project or to code in general - simple english so even non-english native speakers
+ understand everything just fine.
+ high_level_summary_in_walkthrough: true
+ in_progress_fortune: false
+ slop_detection:
+ label: slop
+ suggested_labels: true
+ auto_apply_labels: true
+ labeling_instructions:
+ - label: 'area: ai'
+ instructions: Apply this label if the PR changes AI features, AI integrations, prompts, or AI-related dependencies.
+ - label: 'area: ci'
+ instructions: Apply this label if the PR changes CI workflows, build pipelines, checks, or release automation.
+ - label: 'area: cloud'
+ instructions: Apply this label if the PR changes Tiptap Cloud features, integrations, or configuration.
+ - label: 'area: convert'
+ instructions: Apply this label if the PR changes Tiptap Convert or document conversion behavior.
+ - label: 'area: core'
+ instructions: Apply this label if the PR changes Tiptap Core APIs, commands, extensions, or editor behavior.
+ - label: 'area: docs'
+ instructions: Apply this label if the PR changes documentation, examples, guides, or API reference content.
+ - label: 'area: editor'
+ instructions: Apply this label if the PR changes the Tiptap Editor or its user-facing editing behavior.
+ - label: 'area: markdown'
+ instructions: Apply this label if the PR changes Markdown parsing, serialization, or Markdown-related extensions.
+ - label: 'area: pages'
+ instructions: Apply this label if the PR changes Tiptap Pages, pagination, or Word-like UI behavior.
+ - label: 'area: react'
+ instructions: Apply this label if the PR changes the Tiptap React implementation, hooks, or React examples.
+ - label: 'area: ui-components'
+ instructions: Apply this label if the PR changes Tiptap UI Components, their styles, or component behavior.
+ - label: 'area: vue'
+ instructions: Apply this label if the PR changes the Tiptap Vue implementation, composables, or Vue examples.
+ - label: 'complexity: easy'
+ instructions: Apply this label if the PR makes a clearly scoped change in one isolated component or package.
+ - label: 'complexity: expert'
+ instructions: Apply this label if the PR changes fundamental architecture, core APIs across extensions, or collaboration behavior.
+ - label: 'complexity: hard'
+ instructions: Apply this label if the PR changes ProseMirror internals, transaction handling, or node view rendering.
+ - label: 'complexity: medium'
+ instructions: Apply this label if the PR changes multiple components or the ProseMirror/Tiptap integration with a clear implementation path.
+ - label: 'complexity: trivial'
+ instructions: Apply this label if the PR is an obvious change under 10 lines, such as a documentation typo or missing type check.
+ - label: good first issue
+ instructions: Apply this label if the PR is a small, self-contained change that is easy for a first-time contributor to understand.
+ - label: 'impact: critical'
+ instructions: Apply this label if the PR fixes data loss, a security vulnerability, broken core functionality, or a problem affecting nearly all users of a feature.
+ - label: 'impact: high'
+ instructions: Apply this label if the PR fixes behavior affecting a large portion of users or a common workflow.
+ - label: 'impact: low'
+ instructions: Apply this label if the PR fixes an obscure edge case, a specific browser condition, or a rarely used feature.
+ - label: 'impact: medium'
+ instructions: Apply this label if the PR fixes a common feature usage or a workflow shared by a group of users.
+ - label: 'status: needs-info'
+ instructions: Apply this label if the PR lacks the context needed to understand or validate the changes.
+ - label: 'status: needs-reproduction'
+ instructions: Apply this label if the PR fixes a bug without a reproduction or regression test for the reported behavior.
+ auto_review:
+ ignore_usernames:
+ - github-actions[bot]
+
+issue_enrichment:
+ auto_enrich:
+ enabled: true
+ labeling:
+ auto_apply_labels: true
+ labeling_instructions:
+ - label: 'area: ai'
+ instructions: Add this label if an item is related to AI features
+ - label: 'area: ci'
+ instructions: Add this label if an item is related to the CI
+ - label: 'area: cloud'
+ instructions: Add this label if an item is related to Tiptap Cloud
+ - label: 'area: convert'
+ instructions: Apply this label if the item is related to Tiptap Convert / Conversion
+ - label: 'area: core'
+ instructions: Apply this label if an item is related to the Tiptap Core
+ - label: 'area: docs'
+ instructions: Apply this label if an item is related to documentation
+ - label: 'area: editor'
+ instructions: Add this label if an item is related to the Tiptap Editor
+ - label: 'area: markdown'
+ instructions: Apply this if the item relates to Markdown Parsing or Serialization
+ - label: 'area: pages'
+ instructions: Apply this label if an issue is related to Tiptap Pages / Pagination (Word-like UI)
+ - label: 'area: react'
+ instructions: Apply this label if the item is related to the Tiptap React implementation
+ - label: 'area: ui-components'
+ instructions: Apply this label if the item is related to Tiptap UI Components
+ - label: 'area: vue'
+ instructions: Apply this label if an item is related to the Tiptap Vue implementation
+ - label: 'complexity: easy'
+ instructions: 'clearly described, affects one isolated component, fix localized to a single package'
+ - label: 'complexity: expert'
+ instructions: 'fundamental architecture conflict, core API changes affecting all extensions, or concurrent/collaboration behavior'
+ - label: 'complexity: hard'
+ instructions: 'requires deep understanding of ProseMirror internals, transaction handling, or node view rendering; no obvious solution'
+ - label: 'complexity: medium'
+ instructions: 'touches multiple components or requires understanding ProseMirror/Tiptap interaction, but solution path is clear'
+ - label: 'complexity: trivial'
+ instructions: 'typo in docs, missing type check, obvious fix in <10 lines'
+ - label: good first issue
+ instructions:
+ Apply this label if the issue is a good ticket to pick up as a first-time contributor or beginner at
+ ProseMirror or Tiptap.
+ - label: 'impact: critical'
+ instructions: 'data loss, security vulnerability, completely broken core functionality, or affects nearly everyone using a specific feature'
+ - label: 'impact: high'
+ instructions: 'affects a large portion of users, blocks a common use case, or causes noticeable UX problem many will run into'
+ - label: 'impact: low'
+ instructions: 'obscure edge case, specific browser under specific conditions, or feature request very few would use'
+ - label: 'impact: medium'
+ instructions: 'affects a common usage of a feature (e.g. specific extension combination) or handful of users with similar workflows'
+ - label: 'status: needs-info'
+ instructions: Apply this label if the item needs more info to become actionable.
+ - label: 'status: needs-reproduction'
+ instructions: Add this label to an item if it needs more reproduction to become actionable.
diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml
new file mode 100644
index 0000000..d0f13e1
--- /dev/null
+++ b/.codex/environments/environment.toml
@@ -0,0 +1,6 @@
+# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
+version = 1
+name = "tiptap"
+
+[setup]
+script = "pnpm install --frozen-lockfile"
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..efb4918
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,12 @@
+# EditorConfig is awesome: http://EditorConfig.org
+
+root = true
+
+[*]
+end_of_line = lf
+charset = utf-8
+insert_final_newline = true
+max_line_length = 100
+trim_trailing_whitespace = true
+indent_style = space
+indent_size = 2
diff --git a/.fallowrc.json b/.fallowrc.json
new file mode 100644
index 0000000..14440cc
--- /dev/null
+++ b/.fallowrc.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
+ "ignorePatterns": ["**/*.spec.ts"],
+ "ignoreExports": [{ "file": "**/*", "exports": ["default"] }],
+ "dynamicallyLoaded": ["demos/src/**/index.*"]
+}
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..c324011
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,5 @@
+# Repository-wide ownership
+* @ueberdosis/editor-maintainers
+
+# LICENSE
+LICENSE.md @philipisik @ueberdosis/editor-maintainers
diff --git a/.github/DISCUSSION_TEMPLATE/community-extensions.yml b/.github/DISCUSSION_TEMPLATE/community-extensions.yml
new file mode 100644
index 0000000..81af9fc
--- /dev/null
+++ b/.github/DISCUSSION_TEMPLATE/community-extensions.yml
@@ -0,0 +1,51 @@
+title: 'Community Extension: '
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Hey! Thanks for your time and effort to create a new community extension! Please make sure to fill out the form below.
+ - type: textarea
+ id: description
+ attributes:
+ label: Description
+ description: Please describe how your extension works and what it does.
+ placeholder: 'My extension does …'
+ validations:
+ required: true
+ - type: textarea
+ id: installation
+ attributes:
+ label: Installation
+ description: Please describe how users can install your extension.
+ placeholder: 'npm install …'
+ validations:
+ required: true
+ - type: textarea
+ id: usage
+ attributes:
+ label: Usage
+ description: Please describe how users can use your extension in their editor.
+ placeholder: 'To use my extension you have to …'
+ validations:
+ required: true
+ - type: dropdown
+ id: type
+ attributes:
+ label: Type
+ description: Please select the type of this extension.
+ options:
+ - 'Node'
+ - 'Mark'
+ - 'Prosemirror plugin'
+ - 'Package or Kit'
+ - 'Other'
+ validations:
+ required: true
+ - type: textarea
+ id: other
+ attributes:
+ label: Other
+ description: Feel free to add any other information about your extension.
+ placeholder: 'I hope you like …'
+ validations:
+ required: false
diff --git a/.github/DISCUSSION_TEMPLATE/feature-requests.yml b/.github/DISCUSSION_TEMPLATE/feature-requests.yml
new file mode 100644
index 0000000..e5e99c0
--- /dev/null
+++ b/.github/DISCUSSION_TEMPLATE/feature-requests.yml
@@ -0,0 +1,36 @@
+title: 'Feature Request: '
+labels:
+ - 'Type: Feature Request'
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for your time to create a new feature request! Please make sure to fill out the form below.
+ - type: textarea
+ id: description
+ attributes:
+ label: Description
+ description: Please describe the feature you would like to see in Tiptap.
+ placeholder: 'I wish there was an extension for …'
+ validations:
+ required: true
+ - type: textarea
+ id: use-case
+ attributes:
+ label: Use Case
+ description: Please describe the use case for this feature.
+ placeholder: 'I want to use this feature for …'
+ validations:
+ required: true
+ - type: dropdown
+ id: type
+ attributes:
+ label: Type
+ description: Please select the type of this feature.
+ options:
+ - 'New extension'
+ - 'New feature'
+ - 'New Tiptap API'
+ - 'Other'
+ validations:
+ required: true
diff --git a/.github/DISCUSSION_TEMPLATE/showcase.yml b/.github/DISCUSSION_TEMPLATE/showcase.yml
new file mode 100644
index 0000000..6f4b367
--- /dev/null
+++ b/.github/DISCUSSION_TEMPLATE/showcase.yml
@@ -0,0 +1,52 @@
+title: 'Community Extension: '
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Hey! Thanks for using Tiptap in your project. We hope you had a great experience. Please take a moment to share your project with us. We would love to see what you built with Tiptap.
+ - type: textarea
+ id: description
+ attributes:
+ label: Description
+ description: Please describe what your project is about
+ placeholder: 'My project is about …'
+ validations:
+ required: true
+ - type: input
+ id: url
+ attributes:
+ label: URL
+ description: If possible share the URL of your project.
+ placeholder: 'https://example.com'
+ validations:
+ required: false
+ - type: textarea
+ id: about
+ attributes:
+ label: About
+ description: Feel free to talk about how you used Tiptap in your project, what you liked about it, what you didn't like about it, and what you would like to see in the future.
+ placeholder: 'If used Tiptap to …'
+ validations:
+ required: true
+ - type: dropdown
+ id: type
+ attributes:
+ label: Type
+ description: Please select the type of your project.
+ options:
+ - 'Chat Application'
+ - 'Commenting Application'
+ - 'Content Management System'
+ - 'Document Editor'
+ - 'Document Editor with Collaboration'
+ - 'Other'
+ validations:
+ required: true
+ - type: textarea
+ id: other
+ attributes:
+ label: Other
+ description: Feel free to add any other information about your project.
+ placeholder: 'I hope you like …'
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..dd7db54
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,81 @@
+name: Bug Report
+description: Found a bug in the editor core or one of the extensions? Report it here to help us improve.
+
+labels: ['status: triage']
+type: 'Bug'
+
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Please use simple, human-written English. Keep your answers short and clear.
+ Small code examples that show the problem are welcome. Do not include a code fix or a complete implementation in an issue. If you already have a fix, please open a pull request instead.
+ - type: input
+ id: packages
+ attributes:
+ label: Affected Packages
+ description: List the Tiptap packages involved in the problem.
+ placeholder: core, extension-mention, react
+ validations:
+ required: true
+ - type: input
+ id: version
+ attributes:
+ label: Tiptap Version
+ description: Which Tiptap version or package versions are affected?
+ placeholder: 3.0.0
+ validations:
+ required: true
+ - type: dropdown
+ id: browser
+ attributes:
+ label: Browser Used
+ description: Select the browser where you saw the problem.
+ options:
+ - Chrome
+ - Firefox
+ - Safari
+ - Edge
+ - Other
+ - Not browser-specific
+ validations:
+ required: true
+ - type: textarea
+ id: body
+ attributes:
+ label: What happened?
+ description: Describe what happens, when it happens, and why it is a problem. Use short sentences and simple English. Do not write a wall of text, use complicated language, or include a complete code fix.
+ placeholder: The issue happens when...
+ validations:
+ required: true
+ - type: textarea
+ id: expectation
+ attributes:
+ label: Expected Behavior
+ description: Describe what you expected to happen.
+ placeholder: I expected...
+ validations:
+ required: true
+ - type: markdown
+ attributes:
+ value: |
+ A small reproducible example is the best way to help us investigate the issue. Please add one if you can.
+
+ CodeSandbox templates:
+ * JavaScript: [JS Template](https://codesandbox.io/s/tiptap-js-fv1lyo)
+ * React: [React Template](https://codesandbox.io/s/tiptap-react-qidlsv)
+ * Vue 3: [Vue 3 Template](https://codesandbox.io/p/sandbox/tiptap-vue-3-ci7q9h)
+ - type: input
+ id: sandbox
+ attributes:
+ label: Reproducible Example URL (Optional)
+ description: Link a CodeSandbox, StackBlitz, GitHub repository, or similar example if you have one.
+ placeholder: https://codesandbox.io/s/example
+ - type: textarea
+ id: context
+ attributes:
+ label: Additional Context (Optional)
+ description: Add useful screenshots, videos, or other short details. Avoid long explanations.
+ - type: markdown
+ attributes:
+ value: 'Thank you for helping us improve our open-source projects by reporting this issue!'
diff --git a/.github/ISSUE_TEMPLATE/bug_report_pro.yml b/.github/ISSUE_TEMPLATE/bug_report_pro.yml
new file mode 100644
index 0000000..caefa3c
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report_pro.yml
@@ -0,0 +1,81 @@
+name: Bug Report (Tiptap Pro)
+description: If you've encountered a bug with Tiptap Pro features, please report it here.
+
+labels: ['tier: pro', 'status: triage']
+type: 'Bug'
+
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Please use simple, human-written English. Keep your answers short and clear.
+ Small code examples that show the problem are welcome. Do not include a code fix or a complete implementation in an issue. If you already have a fix, please open a pull request instead.
+ - type: input
+ id: packages
+ attributes:
+ label: Affected Packages
+ description: List the Tiptap Pro packages involved in the problem.
+ placeholder: pro-extension, pro-collaboration
+ validations:
+ required: true
+ - type: input
+ id: version
+ attributes:
+ label: Tiptap Version
+ description: Which Tiptap or Tiptap Pro version or package versions are affected?
+ placeholder: 3.0.0
+ validations:
+ required: true
+ - type: dropdown
+ id: browser
+ attributes:
+ label: Browser Used
+ description: Select the browser where you saw the problem.
+ options:
+ - Chrome
+ - Firefox
+ - Safari
+ - Edge
+ - Other
+ - Not browser-specific
+ validations:
+ required: true
+ - type: textarea
+ id: body
+ attributes:
+ label: What happened?
+ description: Describe what happens, when it happens, and why it is a problem. Use short sentences and simple English. Do not write a wall of text, use complicated language, or include a complete code fix.
+ placeholder: The issue happens when...
+ validations:
+ required: true
+ - type: textarea
+ id: expectation
+ attributes:
+ label: Expected Behavior
+ description: Describe what you expected to happen.
+ placeholder: I expected...
+ validations:
+ required: true
+ - type: markdown
+ attributes:
+ value: |
+ A small reproducible example is the best way to help us investigate the issue. Please add one if you can.
+
+ CodeSandbox templates:
+ - JavaScript: [Template](https://codesandbox.io/s/tiptap-js-fv1lyo)
+ - React: [Template](https://codesandbox.io/s/tiptap-react-qidlsv)
+ - Vue 3: [Template](https://codesandbox.io/p/sandbox/tiptap-vue-3-ci7q9h)
+ - type: input
+ id: sandbox
+ attributes:
+ label: Reproducible Example URL (Optional)
+ description: Link a CodeSandbox, StackBlitz, GitHub repository, or similar example if you have one.
+ placeholder: https://codesandbox.io/s/example
+ - type: textarea
+ id: context
+ attributes:
+ label: Additional Context (Optional)
+ description: Add useful screenshots, videos, or other short details. Avoid long explanations.
+ - type: markdown
+ attributes:
+ value: 'Thank you for contributing to Tiptap Pro by reporting this issue!'
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..2c17251
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,17 @@
+blank_issues_enabled: false
+contact_links:
+ - name: New Feature Request
+ url: https://github.com/ueberdosis/tiptap/discussions/new?category=feature-requests
+ about: Interested in proposing a new feature for Tiptap? Submit your feature request here.
+ - name: Help & Support
+ url: https://github.com/ueberdosis/tiptap/discussions/new?category=questions-help
+ about: Require assistance or have inquiries about using Tiptap? Ask your questions here.
+ - name: Join our Discord
+ url: https://discord.gg/WtJ49jGshW
+ about: Interested in engaging with the Tiptap community? Join our Discord server.
+ - name: Present your project
+ url: https://github.com/ueberdosis/tiptap/discussions/new?category=showcase
+ about: Developed something impressive using Tiptap? Share your project with the community here.
+ - name: Present your Tiptap extensions
+ url: https://github.com/ueberdosis/tiptap/discussions/new?category=community-extensions
+ about: Created a Tiptap extension? Showcase your work to the community here.
diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml
new file mode 100644
index 0000000..b9eeda0
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/documentation.yml
@@ -0,0 +1,45 @@
+name: Documentation feedback
+description: Share what we need to explain better.
+title: '[Documentation]: '
+labels:
+ - 'Type: Documentation'
+ - 'Category: Open Source'
+ - 'Status: New'
+body:
+ - type: input
+ id: url
+ attributes:
+ label: What’s the URL to the page you’re sending feedback for?
+ placeholder: https://tiptap.dev/example
+ validations:
+ required: true
+ - type: textarea
+ id: part-of-the-documentation
+ attributes:
+ label: What part of the documentation needs improvement?
+ placeholder: 'I’ve read the following page of the documentation …'
+ validations:
+ required: true
+ - type: textarea
+ id: good-parts
+ attributes:
+ label: What is helpful about that part?
+ placeholder: 'I think this part is really good: …'
+ validations:
+ required: true
+ - type: textarea
+ id: bad-parts
+ attributes:
+ label: What is hard to understand, missing or misleading?
+ placeholder: 'But you really need to improve …'
+ validations:
+ required: true
+ - type: textarea
+ id: context
+ attributes:
+ label: Anything to add? (optional)
+ description: 'Add any other context or screenshots here.'
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to send us feedback!
diff --git a/.github/actions/restore-dependencies/action.yml b/.github/actions/restore-dependencies/action.yml
new file mode 100644
index 0000000..6a45cf7
--- /dev/null
+++ b/.github/actions/restore-dependencies/action.yml
@@ -0,0 +1,9 @@
+name: Restore dependencies
+description: Install dependencies from the cached package manager data
+
+runs:
+ using: composite
+ steps:
+ - name: Restore dependencies
+ shell: bash
+ run: vp install --offline --frozen-lockfile -- --strict-peer-dependencies
diff --git a/.github/actions/setup-env/action.yml b/.github/actions/setup-env/action.yml
new file mode 100644
index 0000000..db1549a
--- /dev/null
+++ b/.github/actions/setup-env/action.yml
@@ -0,0 +1,20 @@
+name: Setup Vite+ environment
+description: Install Vite+ and set up Node.js and dependency caching
+
+inputs:
+ node-version:
+ description: Node.js version
+ required: true
+ cache:
+ description: Whether to cache package manager data
+ default: 'false'
+ required: false
+
+runs:
+ using: composite
+ steps:
+ - uses: voidzero-dev/setup-vp@v1.18.0
+ with:
+ node-version: ${{ inputs.node-version }}
+ cache: ${{ inputs.cache == 'true' && 'true' || 'false' }}
+ run-install: false
diff --git a/.github/actions/setup-playwright/action.yml b/.github/actions/setup-playwright/action.yml
new file mode 100644
index 0000000..8927c60
--- /dev/null
+++ b/.github/actions/setup-playwright/action.yml
@@ -0,0 +1,27 @@
+name: Setup Playwright
+description: Cache and install Playwright browsers with system dependencies
+
+runs:
+ using: composite
+ steps:
+ - name: Get Playwright version
+ id: playwright-version
+ shell: bash
+ run: echo "version=$(node -p 'require("@playwright/test/package.json").version')" >> "$GITHUB_OUTPUT"
+
+ - name: Cache Playwright browsers
+ id: playwright-cache
+ uses: actions/cache@v6
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
+
+ - name: Install Playwright browsers
+ if: steps.playwright-cache.outputs.cache-hit != 'true'
+ shell: bash
+ run: vp exec playwright install --with-deps chromium
+
+ - name: Install Playwright system deps
+ if: steps.playwright-cache.outputs.cache-hit == 'true'
+ shell: bash
+ run: vp exec playwright install-deps chromium
diff --git a/.github/assets/cover.png b/.github/assets/cover.png
new file mode 100644
index 0000000..2bd9053
Binary files /dev/null and b/.github/assets/cover.png differ
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..7000b35
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,13 @@
+# Dependabot creates pull requests to keep your dependencies secure and up-to-date.
+# Documentation: https://docs.github.com/en/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+ - package-ecosystem: 'github-actions'
+ directory: '/'
+ open-pull-requests-limit: 10
+ schedule:
+ interval: 'weekly'
+ day: 'monday'
+ reviewers:
+ - 'bdbch'
diff --git a/.github/instructions/PR.instructions.md b/.github/instructions/PR.instructions.md
new file mode 100644
index 0000000..45ca6ce
--- /dev/null
+++ b/.github/instructions/PR.instructions.md
@@ -0,0 +1,43 @@
+---
+applyTo: '**'
+---
+
+When asked to write a pull request description, use the following template:
+
+```
+## Changes Overview
+
+
+
+## Implementation Approach
+
+
+
+## Testing Done
+
+
+
+## Verification Steps
+
+
+
+## Additional Notes
+
+
+
+## Checklist
+
+- [ ] I have created a [changeset](https://github.com/changesets/changesets) for this PR if necessary.
+- [ ] My changes do not break the library.
+- [ ] I have added tests where applicable.
+- [ ] I have followed the project guidelines.
+- [ ] I have fixed any lint issues.
+
+## Related Issues
+
+
+```
+
+When generating the pull request description, ensure it is clear, concise, and follows the provided template. Focus on the key aspects of the changes made, how they were implemented, and how they can be verified.
+
+Make it as minimal as possible to convey the necessary information effectively without causing to much noise and making it hard to read.
diff --git a/.github/instructions/changeset.instructions.md b/.github/instructions/changeset.instructions.md
new file mode 100644
index 0000000..0c22a4d
--- /dev/null
+++ b/.github/instructions/changeset.instructions.md
@@ -0,0 +1,9 @@
+---
+applyTo: '**'
+---
+
+When a user asks for a changeset to be generated, follow the following rules:
+
+Create a good changeset file for the changes in the diff. Don't include non-frontfacing changes, as the changeset file will be used for the changelog. Our users don't care about deep logic that they'll not interact with so we only want to generate changelog entries for front-facing/user-facing changes and API changes our users will need to know about.
+
+Make sure that the changeset file stays minimal and short but includes important information that may be important for our users to understand what actually changed.
diff --git a/.github/instructions/tiptap.instructions.md b/.github/instructions/tiptap.instructions.md
new file mode 100644
index 0000000..0ad70bb
--- /dev/null
+++ b/.github/instructions/tiptap.instructions.md
@@ -0,0 +1,261 @@
+---
+applyTo: '**'
+---
+
+# Tiptap
+
+This document explains how to work on the Tiptap monorepo in VS Code. It covers repo layout, local dev, linting and formatting, tests, docs, and release workflow. It is written to be friendly for both humans and AI coding assistants.
+
+---
+
+## What is Tiptap
+
+Tiptap is a headless rich text editor toolkit built on ProseMirror. It ships a small Core and many opt-in Extensions so you can compose exactly the editor you need for React, Vue, or vanilla apps. The project is optimized for user experience and developer experience. APIs are predictable, behavior is testable, and everything should be documented with JSDoc and runnable examples so we can generate API docs automatically.
+
+Key points for AI assistants:
+
+- Treat Tiptap as a collection of focused packages that together form an editor system.
+- Do not assume a single framework. Many packages are framework agnostic, with separate bindings for React and Vue.
+- Favor small pure utilities and deterministic code. Side effects should be explicit.
+
+---
+
+## Repository layout
+
+```
+.
+├─ packages/ # Core and all first-party extensions
+│ ├─ core/ # Editor core (@tiptap/core)
+│ ├─ extension-*/ # Individual extensions
+│ ├─ pm/ # ProseMirror related internals and helpers
+│ └─ ... # Shared utilities, framework bindings, etc.
+├─ demos/ # Vite app for live examples and colocated e2e specs
+│ ├─ src/
+│ │ ├─ react/ # React demos
+│ │ └─ vue/ # Vue demos
+│ └─ test/ # Playwright helpers (getEditor, setEditorContent, ...)
+├─ .changeset/ # Changesets for versioning and changelogs
+└─ .github/ # Workflows and docs like this file
+```
+
+Notes:
+
+- All packages we publish or use live under `packages/*`.
+- The `demos/` folder contains a Vite app. It automatically discovers and parses React and Vue demos so they appear in the UI without manual wiring.
+- Playwright e2e specs live alongside their demos as `demos/src/**/index.spec.ts`. `playwright.config.ts` auto-starts the Vite dev server on `http://127.0.0.1:4080` — no need to launch it manually.
+
+## NPM scripts
+
+Scripts defined at the repo root. Run them with `vp run `
+
+ const expected = `{{ '@tiptap/vue-3' }}
+`
+
+ assert.equal(transformVueSource(source), expected)
+})
+
+test('does not scan past local exports', () => {
+ const source = ``
+
+ const expected = ``
+
+ assert.equal(transformVueSource(source), expected)
+})
diff --git a/codemods/tiptap-v4-migration/tsconfig.json b/codemods/tiptap-v4-migration/tsconfig.json
new file mode 100644
index 0000000..0264e93
--- /dev/null
+++ b/codemods/tiptap-v4-migration/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "types": ["@codemod.com/jssg-types"],
+ "allowImportingTsExtensions": true,
+ "noEmit": true,
+ "verbatimModuleSyntax": true,
+ "erasableSyntaxOnly": true,
+ "strict": true,
+ "strictNullChecks": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "skipLibCheck": true
+ },
+ "exclude": ["tests"]
+}
diff --git a/codemods/tiptap-v4-migration/workflow.yaml b/codemods/tiptap-v4-migration/workflow.yaml
new file mode 100644
index 0000000..c0490bf
--- /dev/null
+++ b/codemods/tiptap-v4-migration/workflow.yaml
@@ -0,0 +1,51 @@
+# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json
+
+version: '1'
+
+params:
+ schema:
+ vue_target_path:
+ name: Vue target path
+ description: Directory containing Vue SFC files
+ type: string
+ default: '.'
+
+nodes:
+ - id: migrate-package-specifiers
+ name: Migrate Vue package specifiers
+ type: automatic
+ steps:
+ - name: Migrate JavaScript imports
+ js-ast-grep:
+ js_file: scripts/vue-package-renames.ts
+ base_path: '.'
+ language: 'javascript'
+ include: ['**/*.{js,mjs,cjs}']
+ exclude: ['**/node_modules/**', '**/dist/**', '**/build/**']
+
+ - name: Migrate TypeScript imports
+ js-ast-grep:
+ js_file: scripts/vue-package-renames.ts
+ base_path: '.'
+ language: 'typescript'
+ include: ['**/*.{ts,mts,cts}']
+ exclude: ['**/node_modules/**', '**/dist/**', '**/build/**']
+
+ - name: Migrate TSX imports
+ js-ast-grep:
+ js_file: scripts/vue-package-renames.ts
+ base_path: '.'
+ language: 'tsx'
+ include: ['**/*.{tsx,jsx}']
+ exclude: ['**/node_modules/**', '**/dist/**', '**/build/**']
+
+ - name: Migrate package manifests
+ js-ast-grep:
+ js_file: scripts/vue-package-renames.ts
+ base_path: '.'
+ language: 'json'
+ include: ['**/package.json']
+ exclude: ['**/node_modules/**']
+
+ - name: Migrate Vue SFC imports
+ run: node scripts/vue-sfc-imports.mjs "${{ params.vue_target_path }}"
diff --git a/commitlint.config.mjs b/commitlint.config.mjs
new file mode 100644
index 0000000..7c4ff4d
--- /dev/null
+++ b/commitlint.config.mjs
@@ -0,0 +1 @@
+export default { extends: ['@commitlint/config-conventional'] }
diff --git a/demos/CHANGELOG.md b/demos/CHANGELOG.md
new file mode 100644
index 0000000..8cc65a1
--- /dev/null
+++ b/demos/CHANGELOG.md
@@ -0,0 +1,571 @@
+# Change Log
+
+## 3.0.3
+
+### Patch Changes
+
+- a1c6243: Allow KaTeX 0.17
+
+## 3.0.2
+
+### Patch Changes
+
+- 75e8404: Fix the `Selection` extension highlighting beyond the selected text on multi-line selections: the native browser selection is now hidden while the editor is blurred, so only the styled `.selection` decoration is shown.
+
+## 3.0.1
+
+### Major Changes
+
+- a92f4a6: We are now building packages with tsup which does not support UMD builds, please repackage if you require UMD builds
+
+### Patch Changes
+
+- 89bd9c7: Enforce type imports so that the bundler ignores TypeScript type imports when generating the index.js file of the dist directory
+- 8c69002: Synced beta with stable features
+
+## 3.0.0-beta.1
+
+### Patch Changes
+
+- 8c69002: Synced beta with stable features
+
+## 3.0.0-next.2
+
+### Patch Changes
+
+- 89bd9c7: Enforce type imports so that the bundler ignores TypeScript type imports when generating the index.js file of the dist directory
+
+## 3.0.0-next.1
+
+### Major Changes
+
+- a92f4a6: We are now building packages with tsup which does not support UMD builds, please repackage if you require UMD builds
+
+## 3.0.0-next.0
+
+### Major Changes
+
+- a92f4a6: We are now building packages with tsup which does not support UMD builds, please repackage if you require UMD builds
+
+## 2.5.2
+
+### Patch Changes
+
+- 98fffbb: Upgraded prosemirror-tables to 1.6.3 to fix cells being resizable while the editor is uneditable
+
+## 2.5.1
+
+### Patch Changes
+
+- 7619215: The link extension's `validate` option now applies to both auto-linking and XSS mitigation. While, the new `shouldAutoLink` option is used to disable auto linking on an otherwise valid url.
+
+## 2.5.0
+
+### Minor Changes
+
+- 6834a7f: Bundling of packages no longer includes tiptap dependency type definitions
+
+## 2.4.2
+
+### Patch Changes
+
+- d6e56c4: declare lowlight to be a peer dep of extension-code-block-lowlight, update usage to v3
+
+## 2.4.1
+
+### Patch Changes
+
+- 85d21ca: Updated demos and reverted vue specific performance enhancements until we know they work
+
+ > in commit ff04353b3ee0e6fc63733a673e2b27d2272a3355 revert: "fix(vue-3): faster component rendering (#5206)"
+ > This reverts commit 31f37464912b7b21f3a565ca63222b9f5b6cce00.
+
+ and
+
+ > in commit dbab8e42eac893a0237566fb30c14b4ed0f3674a revert: "fix(vue-3): fix editor.state updating too late during a transaction due to reactiveState fixes #4870 (#5252)"
+ > This reverts commit 509676ed4a63b84b904a98c1e34d18449d25c2a7.
+
+All notable changes to this project will be documented in this file.
+See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
+
+# [2.4.0](https://github.com/ueberdosis/tiptap/compare/v2.3.2...v2.4.0) (2024-05-14)
+
+**Note:** Version bump only for package tiptap-demos
+
+## [2.3.2](https://github.com/ueberdosis/tiptap/compare/v2.3.1...v2.3.2) (2024-05-08)
+
+### Bug Fixes
+
+- NodePos querySelectorAll function ([#5094](https://github.com/ueberdosis/tiptap/issues/5094)) ([4900a27](https://github.com/ueberdosis/tiptap/commit/4900a27c5389d9a2d0d69f407ca3db0155304315))
+
+## [2.3.1](https://github.com/ueberdosis/tiptap/compare/v2.3.0...v2.3.1) (2024-04-30)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.3.0](https://github.com/ueberdosis/tiptap/compare/v2.2.6...v2.3.0) (2024-04-09)
+
+### Bug Fixes
+
+- **core:** fix nodepos child lookup ([#5038](https://github.com/ueberdosis/tiptap/issues/5038)) ([22ced31](https://github.com/ueberdosis/tiptap/commit/22ced318723003365fbfd8f59b8dac79c7563017))
+
+### Features
+
+- **core:** apply input and paste rules when using insertContent methods ([#5046](https://github.com/ueberdosis/tiptap/issues/5046)) ([96b6abc](https://github.com/ueberdosis/tiptap/commit/96b6abcf6edbc6cac03a391130d9feebb6de3a04))
+
+## [2.2.6](https://github.com/ueberdosis/tiptap/compare/v2.2.5...v2.2.6) (2024-04-06)
+
+**Note:** Version bump only for package tiptap-demos
+
+## [2.2.5](https://github.com/ueberdosis/tiptap/compare/v2.2.4...v2.2.5) (2024-04-05)
+
+**Note:** Version bump only for package tiptap-demos
+
+## [2.2.4](https://github.com/ueberdosis/tiptap/compare/v2.2.3...v2.2.4) (2024-02-23)
+
+**Note:** Version bump only for package tiptap-demos
+
+## [2.2.3](https://github.com/ueberdosis/tiptap/compare/v2.2.2...v2.2.3) (2024-02-15)
+
+### Bug Fixes
+
+- fix test path ([21aa96d](https://github.com/ueberdosis/tiptap/commit/21aa96dee8deab1f439b7f655b8ed266a516a4cd))
+
+## [2.2.2](https://github.com/ueberdosis/tiptap/compare/v2.2.1...v2.2.2) (2024-02-07)
+
+**Note:** Version bump only for package tiptap-demos
+
+## [2.2.1](https://github.com/ueberdosis/tiptap/compare/v2.2.0...v2.2.1) (2024-01-31)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.2.0](https://github.com/ueberdosis/tiptap/compare/v2.1.16...v2.2.0) (2024-01-29)
+
+### Bug Fixes
+
+- **core:** fix new lines being added via elementFromString ([#4767](https://github.com/ueberdosis/tiptap/issues/4767)) ([b7a2504](https://github.com/ueberdosis/tiptap/commit/b7a2504f16f46563537c890930cb2c332c256175))
+- fix imports, fix demos, unpin y-prosemirror ([681aa57](https://github.com/ueberdosis/tiptap/commit/681aa577bff500015c3f925e300c55a71c73efaf))
+- fix newline stripping via insertContent ([8954007](https://github.com/ueberdosis/tiptap/commit/8954007b2b92b040d69b26a0866ae58fabf5e512))
+
+# [2.2.0-rc.8](https://github.com/ueberdosis/tiptap/compare/v2.1.14...v2.2.0-rc.8) (2024-01-08)
+
+# [2.2.0-rc.7](https://github.com/ueberdosis/tiptap/compare/v2.2.0-rc.6...v2.2.0-rc.7) (2023-11-27)
+
+# [2.2.0-rc.6](https://github.com/ueberdosis/tiptap/compare/v2.2.0-rc.5...v2.2.0-rc.6) (2023-11-23)
+
+# [2.2.0-rc.4](https://github.com/ueberdosis/tiptap/compare/v2.1.11...v2.2.0-rc.4) (2023-10-10)
+
+# [2.2.0-rc.3](https://github.com/ueberdosis/tiptap/compare/v2.2.0-rc.2...v2.2.0-rc.3) (2023-08-18)
+
+# [2.2.0-rc.1](https://github.com/ueberdosis/tiptap/compare/v2.2.0-rc.0...v2.2.0-rc.1) (2023-08-18)
+
+# [2.2.0-rc.0](https://github.com/ueberdosis/tiptap/compare/v2.1.5...v2.2.0-rc.0) (2023-08-18)
+
+### Features
+
+- **placeholder:** allow editor-is-empty class on any node ([#4335](https://github.com/ueberdosis/tiptap/issues/4335)) ([ff929b1](https://github.com/ueberdosis/tiptap/commit/ff929b179de930619005a773bb4186ae2aa2ec58))
+
+## [2.1.16](https://github.com/ueberdosis/tiptap/compare/v2.1.15...v2.1.16) (2024-01-10)
+
+### Bug Fixes
+
+- **core:** fix new lines being added via elementFromString ([#4767](https://github.com/ueberdosis/tiptap/issues/4767)) ([2235908](https://github.com/ueberdosis/tiptap/commit/2235908c28f388eda041d1d5d017554d513fe909))
+
+## [2.1.15](https://github.com/ueberdosis/tiptap/compare/v2.1.14...v2.1.15) (2024-01-08)
+
+### Bug Fixes
+
+- **core:** fix insertContentAt keeping new lines in html content ([#4465](https://github.com/ueberdosis/tiptap/issues/4465)) ([135a12f](https://github.com/ueberdosis/tiptap/commit/135a12f7aa2df839a0b619704110a360b980c738))
+- **link:** fix tests ([d495d92](https://github.com/ueberdosis/tiptap/commit/d495d92a1f7b1c51e09ac8f4934e15a2d1cf070d))
+
+## [2.1.14](https://github.com/ueberdosis/tiptap/compare/v2.1.13...v2.1.14) (2024-01-08)
+
+### Bug Fixes
+
+- **typography:** require spaces after divisions to not break date formats ([#4696](https://github.com/ueberdosis/tiptap/issues/4696)) ([f6d7e00](https://github.com/ueberdosis/tiptap/commit/f6d7e00a746a67fa440a3fa0f5362295959873d2))
+
+## [2.1.13](https://github.com/ueberdosis/tiptap/compare/v2.1.12...v2.1.13) (2023-11-30)
+
+**Note:** Version bump only for package tiptap-demos
+
+## [2.1.12](https://github.com/ueberdosis/tiptap/compare/v2.1.11...v2.1.12) (2023-10-11)
+
+**Note:** Version bump only for package tiptap-demos
+
+## [2.1.11](https://github.com/ueberdosis/tiptap/compare/v2.1.10...v2.1.11) (2023-09-20)
+
+### Reverts
+
+- Revert "v2.2.11" ([6aa755a](https://github.com/ueberdosis/tiptap/commit/6aa755a04b9955fc175c7ab33dee527d0d5deef0))
+
+## [2.1.10](https://github.com/ueberdosis/tiptap/compare/v2.1.9...v2.1.10) (2023-09-15)
+
+**Note:** Version bump only for package tiptap-demos
+
+## [2.1.9](https://github.com/ueberdosis/tiptap/compare/v2.1.8...v2.1.9) (2023-09-14)
+
+**Note:** Version bump only for package tiptap-demos
+
+## [2.1.8](https://github.com/ueberdosis/tiptap-workspace/compare/v2.1.7...v2.1.8) (2023-09-04)
+
+**Note:** Version bump only for package tiptap-demos
+
+## [2.1.7](https://github.com/ueberdosis/tiptap-workspace/compare/v2.1.6...v2.1.7) (2023-09-04)
+
+**Note:** Version bump only for package tiptap-demos
+
+## [2.1.6](https://github.com/ueberdosis/tiptap/compare/v2.1.5...v2.1.6) (2023-08-18)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.2.0-rc.2](https://github.com/ueberdosis/tiptap/compare/v2.1.6...v2.2.0-rc.2) (2023-08-18)
+
+# [2.2.0-rc.1](https://github.com/ueberdosis/tiptap/compare/v2.2.0-rc.0...v2.2.0-rc.1) (2023-08-18)
+
+# [2.2.0-rc.0](https://github.com/ueberdosis/tiptap/compare/v2.1.5...v2.2.0-rc.0) (2023-08-18)
+
+### Features
+
+- **placeholder:** allow editor-is-empty class on any node ([#4335](https://github.com/ueberdosis/tiptap/issues/4335)) ([ff929b1](https://github.com/ueberdosis/tiptap/commit/ff929b179de930619005a773bb4186ae2aa2ec58))
+
+# [2.2.0-rc.1](https://github.com/ueberdosis/tiptap/compare/v2.2.0-rc.0...v2.2.0-rc.1) (2023-08-18)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.2.0-rc.0](https://github.com/ueberdosis/tiptap/compare/v2.1.5...v2.2.0-rc.0) (2023-08-18)
+
+### Features
+
+- **placeholder:** allow editor-is-empty class on any node ([#4335](https://github.com/ueberdosis/tiptap/issues/4335)) ([ff929b1](https://github.com/ueberdosis/tiptap/commit/ff929b179de930619005a773bb4186ae2aa2ec58))
+
+## [2.1.6](https://github.com/ueberdosis/tiptap/compare/v2.1.5...v2.1.6) (2023-08-18)
+
+**Note:** Version bump only for package tiptap-demos
+
+## [2.1.5](https://github.com/ueberdosis/tiptap/compare/v2.1.4...v2.1.5) (2023-08-18)
+
+**Note:** Version bump only for package tiptap-demos
+
+## [2.1.4](https://github.com/ueberdosis/tiptap/compare/v2.1.3...v2.1.4) (2023-08-18)
+
+**Note:** Version bump only for package tiptap-demos
+
+## [2.1.3](https://github.com/ueberdosis/tiptap/compare/v2.1.2...v2.1.3) (2023-08-18)
+
+**Note:** Version bump only for package tiptap-demos
+
+## [2.1.2](https://github.com/ueberdosis/tiptap/compare/v2.1.1...v2.1.2) (2023-08-17)
+
+### Bug Fixes
+
+- **core:** fix error when merging class attributes ([#4340](https://github.com/ueberdosis/tiptap/issues/4340)) ([a251946](https://github.com/ueberdosis/tiptap/commit/a2519468589e2baa44901a66a3a06b24dc8626d6))
+
+## [2.1.1](https://github.com/ueberdosis/tiptap/compare/v2.1.0...v2.1.1) (2023-08-16)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.1.0](https://github.com/ueberdosis/tiptap-workspace/compare/v2.1.0-rc.14...v2.1.0) (2023-08-16)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.1.0-rc.14](https://github.com/ueberdosis/tiptap-workspace/compare/v2.1.0-rc.13...v2.1.0-rc.14) (2023-08-11)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.1.0-rc.13](https://github.com/ueberdosis/tiptap-workspace/compare/v2.0.4...v2.1.0-rc.13) (2023-08-11)
+
+### Bug Fixes
+
+- **demos:** add missing extensions ([6383fd5](https://github.com/ueberdosis/tiptap-workspace/commit/6383fd54080b2ad555286cd0e7c4ad880200200f))
+- **demos:** update deps ([05a2edf](https://github.com/ueberdosis/tiptap-workspace/commit/05a2edfc16e297effa86d1583fb1680be0320f25))
+- **strikethrough:** update strikethrough shortcut ([#4288](https://github.com/ueberdosis/tiptap-workspace/issues/4288)) ([fd35db4](https://github.com/ueberdosis/tiptap-workspace/commit/fd35db4d090d9fdfef1196fb1f6f858f13cf53d1))
+
+# [2.1.0-rc.12](https://github.com/ueberdosis/tiptap-workspace/compare/v2.1.0-rc.11...v2.1.0-rc.12) (2023-07-14)
+
+# [2.1.0-rc.11](https://github.com/ueberdosis/tiptap-workspace/compare/v2.1.0-rc.10...v2.1.0-rc.11) (2023-07-07)
+
+### Bug Fixes
+
+- **tests:** fix link rel tests ([c1d1854](https://github.com/ueberdosis/tiptap-workspace/commit/c1d18543b03b1fb6b99a2f3546aa5da10c919920))
+
+# [2.1.0-rc.10](https://github.com/ueberdosis/tiptap-workspace/compare/v2.1.0-rc.9...v2.1.0-rc.10) (2023-07-07)
+
+### Bug Fixes
+
+- **react:** check props.clientRect before creating ReactRenderer ([#4138](https://github.com/ueberdosis/tiptap-workspace/issues/4138)) ([d710846](https://github.com/ueberdosis/tiptap-workspace/commit/d710846ecb6a3059dfbc21300b9a4b887a8defa3))
+
+# [2.1.0-rc.9](https://github.com/ueberdosis/tiptap-workspace/compare/v2.1.0-rc.8...v2.1.0-rc.9) (2023-06-15)
+
+# [2.1.0-rc.8](https://github.com/ueberdosis/tiptap-workspace/compare/v2.1.0-rc.7...v2.1.0-rc.8) (2023-05-25)
+
+# [2.1.0-rc.5](https://github.com/ueberdosis/tiptap-workspace/compare/v2.1.0-rc.4...v2.1.0-rc.5) (2023-05-25)
+
+### Features
+
+- add tiptap class ([614fc80](https://github.com/ueberdosis/tiptap-workspace/commit/614fc8082c376bf3c40a05c23ceda6b4a6fbf8d0))
+
+# [2.1.0-rc.4](https://github.com/ueberdosis/tiptap-workspace/compare/v2.1.0-rc.3...v2.1.0-rc.4) (2023-04-27)
+
+# [2.1.0-rc.3](https://github.com/ueberdosis/tiptap-workspace/compare/v2.1.0-rc.2...v2.1.0-rc.3) (2023-04-26)
+
+# [2.1.0-rc.2](https://github.com/ueberdosis/tiptap-workspace/compare/v2.0.3...v2.1.0-rc.2) (2023-04-26)
+
+### Bug Fixes
+
+- **extension-link:** fix link not being kept when pasting url with link ([#3975](https://github.com/ueberdosis/tiptap-workspace/issues/3975)) ([e7d7d49](https://github.com/ueberdosis/tiptap-workspace/commit/e7d7d496376c8c11e24c342e20bd179a6ea7dcee))
+
+# [2.1.0-rc.1](https://github.com/ueberdosis/tiptap-workspace/compare/v2.1.0-rc.0...v2.1.0-rc.1) (2023-04-12)
+
+### Bug Fixes
+
+- **bubble-menu:** fix debounce not working with collab/collaboration cursor ([#3956](https://github.com/ueberdosis/tiptap-workspace/issues/3956)) ([e8cef04](https://github.com/ueberdosis/tiptap-workspace/commit/e8cef0404b5039ec2657536976b8b31931afd337))
+
+# [2.1.0-rc.0](https://github.com/ueberdosis/tiptap-workspace/compare/v2.0.2...v2.1.0-rc.0) (2023-04-05)
+
+### Bug Fixes
+
+- clear nodes when cursor at start of empty isolating parent ([#3943](https://github.com/ueberdosis/tiptap-workspace/issues/3943)) ([7278ee2](https://github.com/ueberdosis/tiptap-workspace/commit/7278ee2b05de2f96efddf3b1dc3bfd3d52262cbb))
+- Update peerDependencies to fix lerna version tasks ([#3914](https://github.com/ueberdosis/tiptap-workspace/issues/3914)) ([0c1bba3](https://github.com/ueberdosis/tiptap-workspace/commit/0c1bba3137b535776bcef95ff3c55e13f5a2db46))
+
+# [2.1.0-rc.12](https://github.com/ueberdosis/tiptap-workspace/compare/v2.1.0-rc.11...v2.1.0-rc.12) (2023-07-14)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.1.0-rc.11](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.10...v2.1.0-rc.11) (2023-07-07)
+
+### Bug Fixes
+
+- **tests:** fix link rel tests ([c1d1854](https://github.com/ueberdosis/tiptap/commit/c1d18543b03b1fb6b99a2f3546aa5da10c919920))
+
+# [2.1.0-rc.10](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.9...v2.1.0-rc.10) (2023-07-07)
+
+### Bug Fixes
+
+- **react:** check props.clientRect before creating ReactRenderer ([#4138](https://github.com/ueberdosis/tiptap/issues/4138)) ([d710846](https://github.com/ueberdosis/tiptap/commit/d710846ecb6a3059dfbc21300b9a4b887a8defa3))
+
+# [2.1.0-rc.9](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.8...v2.1.0-rc.9) (2023-06-15)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.1.0-rc.8](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.7...v2.1.0-rc.8) (2023-05-25)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.1.0-rc.7](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.6...v2.1.0-rc.7) (2023-05-25)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.1.0-rc.6](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.5...v2.1.0-rc.6) (2023-05-25)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.1.0-rc.5](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.4...v2.1.0-rc.5) (2023-05-25)
+
+### Features
+
+- add tiptap class ([614fc80](https://github.com/ueberdosis/tiptap/commit/614fc8082c376bf3c40a05c23ceda6b4a6fbf8d0))
+
+# [2.1.0-rc.4](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.3...v2.1.0-rc.4) (2023-04-27)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.1.0-rc.3](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.2...v2.1.0-rc.3) (2023-04-26)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.1.0-rc.2](https://github.com/ueberdosis/tiptap/compare/v2.0.3...v2.1.0-rc.2) (2023-04-26)
+
+### Bug Fixes
+
+- **extension-link:** fix link not being kept when pasting url with link ([#3975](https://github.com/ueberdosis/tiptap/issues/3975)) ([e7d7d49](https://github.com/ueberdosis/tiptap/commit/e7d7d496376c8c11e24c342e20bd179a6ea7dcee))
+
+# [2.1.0-rc.1](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.0...v2.1.0-rc.1) (2023-04-12)
+
+### Bug Fixes
+
+- **bubble-menu:** fix debounce not working with collab/collaboration cursor ([#3956](https://github.com/ueberdosis/tiptap/issues/3956)) ([e8cef04](https://github.com/ueberdosis/tiptap/commit/e8cef0404b5039ec2657536976b8b31931afd337))
+
+# [2.1.0-rc.0](https://github.com/ueberdosis/tiptap/compare/v2.0.2...v2.1.0-rc.0) (2023-04-05)
+
+### Bug Fixes
+
+- clear nodes when cursor at start of empty isolating parent ([#3943](https://github.com/ueberdosis/tiptap/issues/3943)) ([7278ee2](https://github.com/ueberdosis/tiptap/commit/7278ee2b05de2f96efddf3b1dc3bfd3d52262cbb))
+- Update peerDependencies to fix lerna version tasks ([#3914](https://github.com/ueberdosis/tiptap/issues/3914)) ([0c1bba3](https://github.com/ueberdosis/tiptap/commit/0c1bba3137b535776bcef95ff3c55e13f5a2db46))
+
+# [2.1.0-rc.0](https://github.com/ueberdosis/tiptap/compare/v2.0.2...v2.1.0-rc.0) (2023-04-05)
+
+### Bug Fixes
+
+- clear nodes when cursor at start of empty isolating parent ([#3943](https://github.com/ueberdosis/tiptap/issues/3943)) ([7278ee2](https://github.com/ueberdosis/tiptap/commit/7278ee2b05de2f96efddf3b1dc3bfd3d52262cbb))
+- **bubble-menu:** fix debounce not working with collab/collaboration cursor ([#3956](https://github.com/ueberdosis/tiptap/issues/3956)) ([a78f8cd](https://github.com/ueberdosis/tiptap/commit/a78f8cd9646008e4db938fa3c22b0714c8bb5849))
+
+## [2.0.3](https://github.com/ueberdosis/tiptap/compare/v2.0.2...v2.0.3) (2023-04-13)
+
+### Bug Fixes
+
+- **bubble-menu:** fix debounce not working with collab/collaboration cursor ([#3956](https://github.com/ueberdosis/tiptap/issues/3956)) ([e8cef04](https://github.com/ueberdosis/tiptap/commit/e8cef0404b5039ec2657536976b8b31931afd337))
+
+## [2.0.2](https://github.com/ueberdosis/tiptap/compare/v2.0.1...v2.0.2) (2023-04-03)
+
+### Features
+
+- add box-shadow to collab demo ([c5496c1](https://github.com/ueberdosis/tiptap/commit/c5496c1b27783150dafb5ebdf6bda43648a46316))
+- landingpage demo ([#3925](https://github.com/ueberdosis/tiptap/issues/3925)) ([958925f](https://github.com/ueberdosis/tiptap/commit/958925f2560ca786cd0cf52b83b7ae51deb7dd77))
+- Tiptap collab demo styling ([87840b0](https://github.com/ueberdosis/tiptap/commit/87840b0f0821ca65d9f104d9c90512021aa70113))
+
+## [2.0.1](https://github.com/ueberdosis/tiptap/compare/v2.0.0...v2.0.1) (2023-03-30)
+
+### Bug Fixes
+
+- Update peerDependencies to fix lerna version tasks ([#3914](https://github.com/ueberdosis/tiptap/issues/3914)) ([0534f76](https://github.com/ueberdosis/tiptap/commit/0534f76401bf5399c01ca7f39d87f7221d91b4f7))
+
+# [2.0.0-beta.220](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.219...v2.0.0-beta.220) (2023-02-28)
+
+### Bug Fixes
+
+- **core:** fix destroyed view causing errors on dispatchTransaction ([#3799](https://github.com/ueberdosis/tiptap/issues/3799)) ([3c07ca0](https://github.com/ueberdosis/tiptap/commit/3c07ca0b9c48cef60d56acdd44812e20e05fc928))
+- **tests:** fix tests for lists ([02eec8a](https://github.com/ueberdosis/tiptap/commit/02eec8aaefc2709dc20f91c3c8f9eca84cddc12d))
+
+# [2.0.0-beta.219](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.218...v2.0.0-beta.219) (2023-02-27)
+
+### Bug Fixes
+
+- **core:** allow insertContentAt and insertContent text node arrays ([#3790](https://github.com/ueberdosis/tiptap/issues/3790)) ([0300630](https://github.com/ueberdosis/tiptap/commit/0300630a5b04b61d4eef8155f24ca0ef2d683966))
+
+### Features
+
+- [#3540](https://github.com/ueberdosis/tiptap/issues/3540) Ability to preserve marks on lists ([#3541](https://github.com/ueberdosis/tiptap/issues/3541)) ([36bb1e1](https://github.com/ueberdosis/tiptap/commit/36bb1e1041f91da6437272e7196702df868eae0f))
+
+# [2.0.0-beta.218](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.217...v2.0.0-beta.218) (2023-02-18)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.0.0-beta.217](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.216...v2.0.0-beta.217) (2023-02-09)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.0.0-beta.216](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.215...v2.0.0-beta.216) (2023-02-08)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.0.0-beta.215](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.214...v2.0.0-beta.215) (2023-02-08)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.0.0-beta.214](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.213...v2.0.0-beta.214) (2023-02-08)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.0.0-beta.213](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.212...v2.0.0-beta.213) (2023-02-07)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.0.0-beta.212](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.211...v2.0.0-beta.212) (2023-02-03)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.0.0-beta.211](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.210...v2.0.0-beta.211) (2023-02-02)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.0.0-beta.210](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.209...v2.0.0-beta.210) (2023-02-02)
+
+### Features
+
+- **pm:** new prosemirror package for dependency resolving ([f387ad3](https://github.com/ueberdosis/tiptap/commit/f387ad3dd4c2b30eaea33fb0ba0b42e0cd39263b))
+
+# [2.0.0-beta.209](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.208...v2.0.0-beta.209) (2022-12-16)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.0.0-beta.208](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.207...v2.0.0-beta.208) (2022-12-16)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.0.0-beta.207](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.206...v2.0.0-beta.207) (2022-12-08)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.0.0-beta.206](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.205...v2.0.0-beta.206) (2022-12-08)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.0.0-beta.205](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.204...v2.0.0-beta.205) (2022-12-05)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.0.0-beta.204](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.203...v2.0.0-beta.204) (2022-11-25)
+
+### Bug Fixes
+
+- **tests:** fix autolink validation test ([5150095](https://github.com/ueberdosis/tiptap/commit/5150095c6b510c080f4aa35f54d2387543f86da8))
+
+# [2.0.0-beta.203](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.202...v2.0.0-beta.203) (2022-11-24)
+
+### Bug Fixes
+
+- **extension/table:** move dependency from @\_ueberdosis to [@tiptap](https://github.com/tiptap) ([#3448](https://github.com/ueberdosis/tiptap/issues/3448)) ([31c3a9a](https://github.com/ueberdosis/tiptap/commit/31c3a9aad9eb37f445eadcd27135611291178ca6))
+
+# [2.0.0-beta.202](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.201...v2.0.0-beta.202) (2022-11-04)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.0.0-beta.201](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.200...v2.0.0-beta.201) (2022-11-04)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.0.0-beta.200](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.199...v2.0.0-beta.200) (2022-11-04)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.0.0-beta.199](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.198...v2.0.0-beta.199) (2022-09-30)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.0.0-beta.198](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.197...v2.0.0-beta.198) (2022-09-29)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.0.0-beta.197](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.196...v2.0.0-beta.197) (2022-09-26)
+
+### Bug Fixes
+
+- **core:** Can() does not work for setting marks ([#3223](https://github.com/ueberdosis/tiptap/issues/3223)) ([17a41da](https://github.com/ueberdosis/tiptap/commit/17a41da5a7a14879cf490c81914084791c4c494c))
+
+# [2.0.0-beta.196](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.195...v2.0.0-beta.196) (2022-09-20)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.0.0-beta.195](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.194...v2.0.0-beta.195) (2022-09-14)
+
+### Bug Fixes
+
+- **extension/bubble-menu:** :bug: fix bubble menu and floating menu being available when editor not editable ([#3195](https://github.com/ueberdosis/tiptap/issues/3195)) ([fa96749](https://github.com/ueberdosis/tiptap/commit/fa96749ce22ec67125da491cfeeb38623b9f0d6e))
+
+# [2.0.0-beta.194](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.193...v2.0.0-beta.194) (2022-09-11)
+
+**Note:** Version bump only for package tiptap-demos
+
+# [2.0.0-beta.193](https://github.com/ueberdosis/tiptap/compare/v0.1.2...v2.0.0-beta.193) (2022-09-10)
+
+### Bug Fixes
+
+- bump documents ([43611ea](https://github.com/ueberdosis/tiptap/commit/43611ea2e70d3dc66ff907ba7ca377bf74814543))
+- disable broken tests for experiements with further todo message ([b8ae9e2](https://github.com/ueberdosis/tiptap/commit/b8ae9e27622857093c6ca539901956da5cc291e5))
+- don’t check for active node in wrapIn command, fix [#1059](https://github.com/ueberdosis/tiptap/issues/1059) ([170ec4b](https://github.com/ueberdosis/tiptap/commit/170ec4be5b3c8362890ca3100a223b505f788381))
+- **extension/collaboration:** :ambulance: pin y-prosemirror version to 1.0.20 to fix broken functionality with vue ([5989f3b](https://github.com/ueberdosis/tiptap/commit/5989f3b780bb64b2884d81dcd41a95d98a0714b2))
+- fix RangeError bug when selecting all text, fix [#2490](https://github.com/ueberdosis/tiptap/issues/2490) ([70422dd](https://github.com/ueberdosis/tiptap/commit/70422dd107ed1ecdd8dfe41a8a93297124d2f1e0))
+- **maintainment:** fix cjs issues with prosemirror-tables ([eb92597](https://github.com/ueberdosis/tiptap/commit/eb925976038fbf59f6ba333ccc57ea84113da00e))
+- remove some magic strings ([6c34dec](https://github.com/ueberdosis/tiptap/commit/6c34dec33ac39c9f037a0a72e4525f3fc6d422bf))
+- **suggestion:** :bug: make clientrect prop optional as it can potentially be undefined ([#2813](https://github.com/ueberdosis/tiptap/issues/2813)) ([f019f70](https://github.com/ueberdosis/tiptap/commit/f019f70a19c34715e2d5c3921d348e11c7ac51a3)), closes [#2795](https://github.com/ueberdosis/tiptap/issues/2795)
+- temp fix collaboration demo ([4528756](https://github.com/ueberdosis/tiptap/commit/45287563f3cfb389095a2794cb2001d65e56d633))
+
+### Features
+
+- Add extension storage ([#2069](https://github.com/ueberdosis/tiptap/issues/2069)) ([7ffabf2](https://github.com/ueberdosis/tiptap/commit/7ffabf251c408a652eec1931cc78a8bd43cccb67))
+- add getText() and generateText() methods (fix [#1428](https://github.com/ueberdosis/tiptap/issues/1428)) ([#1875](https://github.com/ueberdosis/tiptap/issues/1875)) ([fe6a3e7](https://github.com/ueberdosis/tiptap/commit/fe6a3e7491f6a42123d3d8a92ab588f2a40d7799))
+- add some improvements to `CharacterCount` extension ([#2256](https://github.com/ueberdosis/tiptap/issues/2256)), fix [#1049](https://github.com/ueberdosis/tiptap/issues/1049), fix [#1550](https://github.com/ueberdosis/tiptap/issues/1550), fix [#1839](https://github.com/ueberdosis/tiptap/issues/1839), fix [#2245](https://github.com/ueberdosis/tiptap/issues/2245) ([5daa870](https://github.com/ueberdosis/tiptap/commit/5daa870b0906f0387fe07041681bc6f5b3774617))
+- Add support for autolink ([#2226](https://github.com/ueberdosis/tiptap/issues/2226)) ([3d68981](https://github.com/ueberdosis/tiptap/commit/3d68981b47d087fff40549d2143eb952fc9e0a50))
+- **extension-link:** :sparkles: add validate option to link extension ([23e67ad](https://github.com/ueberdosis/tiptap/commit/23e67adfa730df7364bc31220d0ed0e8ea522593)), closes [#2779](https://github.com/ueberdosis/tiptap/issues/2779)
+- **extension/youtube:** :sparkles: new youtube embed extension ([#2814](https://github.com/ueberdosis/tiptap/issues/2814)) ([1c0554b](https://github.com/ueberdosis/tiptap/commit/1c0554b7c06d80145274353e58d56608b097fbe4))
+- Integrate input rules and paste rules into the core ([#1997](https://github.com/ueberdosis/tiptap/issues/1997)) ([723b955](https://github.com/ueberdosis/tiptap/commit/723b955cecc5c92c8aad897ce16c60fb62976571))
+- parseHTML for attributes should return the value instead of an object now, fix [#1863](https://github.com/ueberdosis/tiptap/issues/1863) ([8a3b47a](https://github.com/ueberdosis/tiptap/commit/8a3b47a529d28b28b50d634c6ff69b8e5aad3080))
diff --git a/demos/dedupeDeps.txt b/demos/dedupeDeps.txt
new file mode 100644
index 0000000..e5c2daa
--- /dev/null
+++ b/demos/dedupeDeps.txt
@@ -0,0 +1,39 @@
+d3
+highlight.js/lib/languages/css
+highlight.js/lib/languages/javascript
+highlight.js/lib/languages/typescript
+highlight.js/lib/languages/xml
+highlight.js/lib/core
+linkifyjs
+lowlight
+react
+react-dom
+react-dom/client
+use-sync-external-store/shim
+use-sync-external-store/shim/with-selector
+shiki
+@floating-ui/dom
+uuid
+y-webrtc
+yjs
+prosemirror-changeset
+prosemirror-collab
+prosemirror-commands
+prosemirror-dropcursor
+prosemirror-gapcursor
+prosemirror-history
+prosemirror-inputrules
+prosemirror-keymap
+prosemirror-markdown
+prosemirror-menu
+prosemirror-model
+prosemirror-schema-basic
+prosemirror-schema-list
+prosemirror-state
+prosemirror-tables
+prosemirror-trailing-node
+prosemirror-transform
+prosemirror-view
+@hocuspocus/provider
+@lifeomic/attempt
+@tiptap/y-tiptap
diff --git a/demos/index.html b/demos/index.html
new file mode 100644
index 0000000..297cea7
--- /dev/null
+++ b/demos/index.html
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/demos/package.json b/demos/package.json
new file mode 100644
index 0000000..6880339
--- /dev/null
+++ b/demos/package.json
@@ -0,0 +1,71 @@
+{
+ "name": "tiptap-demos",
+ "version": "3.0.3",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "start": "vp dev --host",
+ "start:e2e": "vp dev --host --port 4080",
+ "build:demos": "vp build",
+ "preview": "vp preview"
+ },
+ "dependencies": {
+ "@floating-ui/dom": "^1.6.13",
+ "@hocuspocus/provider": "^2.15.0",
+ "@hocuspocus/transformer": "^2.15.0",
+ "@lexical/react": "^0.36.2",
+ "@lifeomic/attempt": "3.1.0",
+ "@shikijs/core": "1.10.3",
+ "@tiptap/y-tiptap": "^3.0.7",
+ "d3": "^7.9.0",
+ "fast-glob": "^3.3.2",
+ "highlight.js": "^11.11.1",
+ "katex": "^0.18.0",
+ "lexical": "^0.36.2",
+ "linkifyjs": "^4.3.2",
+ "lowlight": "^3.3.0",
+ "prosemirror-changeset": "^2.4.1",
+ "prosemirror-collab": "^1.3.1",
+ "prosemirror-commands": "^1.7.1",
+ "prosemirror-dropcursor": "^1.8.2",
+ "prosemirror-gapcursor": "^1.4.1",
+ "prosemirror-history": "^1.5.0",
+ "prosemirror-inputrules": "^1.5.1",
+ "prosemirror-keymap": "^1.2.3",
+ "prosemirror-markdown": "^1.13.4",
+ "prosemirror-menu": "^1.3.2",
+ "prosemirror-model": "^1.25.9",
+ "prosemirror-schema-basic": "^1.2.4",
+ "prosemirror-schema-list": "^1.5.1",
+ "prosemirror-state": "^1.4.4",
+ "prosemirror-tables": "^1.8.5",
+ "prosemirror-trailing-node": "^3.0.0",
+ "prosemirror-transform": "^1.12.0",
+ "prosemirror-view": "^1.42.3",
+ "remixicon": "^2.5.0",
+ "shiki": "^1.25.1",
+ "y-webrtc": "10.3.0",
+ "yjs": "13.6.23"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "7.2.0",
+ "@types/node": "^22.12.0",
+ "@vitejs/plugin-react": "6.0.5",
+ "@vitejs/plugin-vue": "6.0.8",
+ "autoprefixer": "^10.4.20",
+ "iframe-resizer": "^4.4.5",
+ "postcss": "^8.5.23",
+ "postcss-import": "^15.1.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "sass-embedded": "^1.99.0",
+ "svelte": "^5.56.8",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.7.2",
+ "uuid": "^14.0.0",
+ "vite": "catalog:",
+ "vite-plus": "catalog:",
+ "vue": "^3.5.13",
+ "vue-router": "^4.5.0"
+ }
+}
diff --git a/demos/postcss.config.cjs b/demos/postcss.config.cjs
new file mode 100644
index 0000000..825cb18
--- /dev/null
+++ b/demos/postcss.config.cjs
@@ -0,0 +1,8 @@
+module.exports = {
+ plugins: {
+ 'postcss-import': {},
+ 'tailwindcss/nesting': {},
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/demos/preview/Demo.vue b/demos/preview/Demo.vue
new file mode 100644
index 0000000..d3309df
--- /dev/null
+++ b/demos/preview/Demo.vue
@@ -0,0 +1,233 @@
+
+
+
+
+ {{ language.name }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ file.name }}
+
+
+
+ Inspect
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/preview/DemoFrame.vue b/demos/preview/DemoFrame.vue
new file mode 100644
index 0000000..d9f0c3b
--- /dev/null
+++ b/demos/preview/DemoFrame.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
diff --git a/demos/preview/Shiki.vue b/demos/preview/Shiki.vue
new file mode 100644
index 0000000..d2ec375
--- /dev/null
+++ b/demos/preview/Shiki.vue
@@ -0,0 +1,73 @@
+
+
+ {{ code }}
+
+
+
+
+
diff --git a/demos/preview/index.html b/demos/preview/index.html
new file mode 100644
index 0000000..ac30152
--- /dev/null
+++ b/demos/preview/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Preview
+
+
+
+
+
+
diff --git a/demos/preview/index.js b/demos/preview/index.js
new file mode 100644
index 0000000..7c73ff0
--- /dev/null
+++ b/demos/preview/index.js
@@ -0,0 +1,61 @@
+import 'iframe-resizer/js/iframeResizer.contentWindow'
+import './style.css'
+
+import { demos } from '@demos'
+import iframeResize from 'iframe-resizer/js/iframeResizer'
+import { createApp } from 'vue'
+import { createRouter, createWebHistory } from 'vue-router'
+
+import Demo from './Demo.vue'
+import App from './index.vue'
+
+const routes = demos.map(({ name, tabs }) => {
+ return {
+ path: `/${name}`,
+ component: Demo,
+ props: {
+ name,
+ tabs,
+ },
+ }
+})
+
+const router = createRouter({
+ history: createWebHistory('preview'),
+ routes,
+})
+
+createApp(App)
+ .directive('resize', {
+ beforeMount: (el, { value = {} }) => {
+ el.addEventListener('load', () => {
+ iframeResize(
+ {
+ ...value,
+ // messageCallback(messageData) {
+ // if (messageData.message.type !== 'resize') {
+ // return
+ // }
+
+ // const style = window.getComputedStyle(el.parentElement)
+ // const maxHeight = parseInt(style.getPropertyValue('max-height'), 10)
+
+ // if (messageData.message.height > maxHeight) {
+ // el.setAttribute('scrolling', 'auto')
+ // } else {
+ // el.setAttribute('scrolling', 'no')
+ // }
+
+ // el?.iFrameResizer?.resize?.()
+ // },
+ },
+ el,
+ )
+ })
+ },
+ unmounted(el) {
+ el?.iFrameResizer?.removeListeners?.()
+ },
+ })
+ .use(router)
+ .mount('#app')
diff --git a/demos/preview/index.vue b/demos/preview/index.vue
new file mode 100644
index 0000000..3b603d4
--- /dev/null
+++ b/demos/preview/index.vue
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+ {{ route.props.name }}
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/preview/shiki.worker.js b/demos/preview/shiki.worker.js
new file mode 100644
index 0000000..b1d3314
--- /dev/null
+++ b/demos/preview/shiki.worker.js
@@ -0,0 +1,30 @@
+import * as shiki from 'shiki'
+
+let highlighter = null
+
+async function init() {
+ if (highlighter) {
+ return highlighter
+ }
+
+ highlighter = await shiki.createHighlighter({
+ themes: ['material-theme-darker'],
+ langs: ['html', 'js', 'jsx', 'ts', 'tsx', 'css', 'vue-html', 'vue', 'scss'],
+ })
+
+ return highlighter
+}
+
+// oxlint-disable-next-line
+self.addEventListener('message', async event => {
+ init().then(async () => {
+ const { code, language } = event.data
+
+ await highlighter.loadLanguage(language)
+
+ const html = highlighter.codeToHtml(code, { lang: language, theme: 'material-theme-darker' })
+
+ // oxlint-disable-next-line
+ self.postMessage({ code, language, html })
+ })
+})
diff --git a/demos/preview/style.css b/demos/preview/style.css
new file mode 100644
index 0000000..1128572
--- /dev/null
+++ b/demos/preview/style.css
@@ -0,0 +1,148 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@font-face {
+ font-family: 'Inter';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src:
+ url('https://rsms.me/inter/font-files/Inter-Regular.woff2?v=3.19') format('woff2'),
+ url('https://rsms.me/inter/font-files/Inter-Regular.woff?v=3.19') format('woff');
+}
+@font-face {
+ font-family: 'Inter';
+ font-style: italic;
+ font-weight: 400;
+ font-display: swap;
+ src:
+ url('https://rsms.me/inter/font-files/Inter-Italic.woff2?v=3.19') format('woff2'),
+ url('https://rsms.me/inter/font-files/Inter-Italic.woff?v=3.19') format('woff');
+}
+
+@font-face {
+ font-family: 'Inter';
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src:
+ url('https://rsms.me/inter/font-files/Inter-Medium.woff2?v=3.19') format('woff2'),
+ url('https://rsms.me/inter/font-files/Inter-Medium.woff?v=3.19') format('woff');
+}
+@font-face {
+ font-family: 'Inter';
+ font-style: italic;
+ font-weight: 500;
+ font-display: swap;
+ src:
+ url('https://rsms.me/inter/font-files/Inter-MediumItalic.woff2?v=3.19') format('woff2'),
+ url('https://rsms.me/inter/font-files/Inter-MediumItalic.woff?v=3.19') format('woff');
+}
+
+@font-face {
+ font-family: 'Inter';
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src:
+ url('https://rsms.me/inter/font-files/Inter-SemiBold.woff2?v=3.19') format('woff2'),
+ url('https://rsms.me/inter/font-files/Inter-SemiBold.woff?v=3.19') format('woff');
+}
+@font-face {
+ font-family: 'Inter';
+ font-style: italic;
+ font-weight: 600;
+ font-display: swap;
+ src:
+ url('https://rsms.me/inter/font-files/Inter-SemiBoldItalic.woff2?v=3.19') format('woff2'),
+ url('https://rsms.me/inter/font-files/Inter-SemiBoldItalic.woff?v=3.19') format('woff');
+}
+
+@font-face {
+ font-family: 'Inter';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src:
+ url('https://rsms.me/inter/font-files/Inter-Bold.woff2?v=3.19') format('woff2'),
+ url('https://rsms.me/inter/font-files/Inter-Bold.woff?v=3.19') format('woff');
+}
+@font-face {
+ font-family: 'Inter';
+ font-style: italic;
+ font-weight: 700;
+ font-display: swap;
+ src:
+ url('https://rsms.me/inter/font-files/Inter-BoldItalic.woff2?v=3.19') format('woff2'),
+ url('https://rsms.me/inter/font-files/Inter-BoldItalic.woff?v=3.19') format('woff');
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src:
+ url('https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff2/JetBrainsMono-Regular.woff2')
+ format('woff2'),
+ url('https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff2/JetBrainsMono-Regular.woff')
+ format('woff');
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src:
+ url('https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff2/JetBrainsMono-Bold.woff2')
+ format('woff2'),
+ url('https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff2/JetBrainsMono-Bold.woff')
+ format('woff');
+}
+
+::-webkit-scrollbar {
+ width: 14px;
+ height: 14px;
+}
+
+::-webkit-scrollbar-track {
+ border: 4px solid transparent;
+ background-clip: padding-box;
+ border-radius: 8px;
+ background-color: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ border: 4px solid rgba(0, 0, 0, 0);
+ background-clip: padding-box;
+ border-radius: 8px;
+ background-color: rgba(0, 0, 0, 0);
+}
+
+:hover::-webkit-scrollbar-thumb {
+ background-color: rgba(0, 0, 0, 0.1);
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background-color: rgba(0, 0, 0, 0.15);
+}
+
+.overflow-dark:hover::-webkit-scrollbar-thumb {
+ background-color: rgba(255, 255, 255, 0.2);
+}
+
+.overflow-dark::-webkit-scrollbar-thumb:hover {
+ background-color: rgba(255, 255, 255, 0.3);
+}
+
+::-webkit-scrollbar-button {
+ display: none;
+ width: 0;
+ height: 0;
+}
+
+::-webkit-scrollbar-corner {
+ background-color: transparent;
+}
diff --git a/demos/public/_redirects b/demos/public/_redirects
new file mode 100644
index 0000000..740095a
--- /dev/null
+++ b/demos/public/_redirects
@@ -0,0 +1 @@
+/preview/* /preview/index.html 200
diff --git a/demos/setup/helper.ts b/demos/setup/helper.ts
new file mode 100644
index 0000000..a8b928f
--- /dev/null
+++ b/demos/setup/helper.ts
@@ -0,0 +1,40 @@
+const waitUntilElementExists = (selector: any, callback: (element: Element) => void) => {
+ const element = document.querySelector(selector)
+
+ if (element) {
+ return callback(element)
+ }
+
+ setTimeout(() => waitUntilElementExists(selector, callback), 500)
+}
+
+const sendData = (eventName: string, data: any) => {
+ const event = new CustomEvent(eventName, { detail: data })
+
+ window.parent.document.dispatchEvent(event)
+}
+
+export function splitName(name: string) {
+ const parts = name.split('/')
+
+ if (parts.length !== 3) {
+ throw Error(
+ 'Demos must always be within exactly one category. Nested categories are not supported.',
+ )
+ }
+
+ return parts
+}
+
+export function debug() {
+ sendData('editor', null)
+ // @ts-ignore
+ sendData('source', window.source)
+
+ waitUntilElementExists('.tiptap', element => {
+ // @ts-ignore
+ const editor = element.editor
+
+ sendData('editor', editor)
+ })
+}
diff --git a/demos/setup/js.ts b/demos/setup/js.ts
new file mode 100644
index 0000000..0aa85cf
--- /dev/null
+++ b/demos/setup/js.ts
@@ -0,0 +1,12 @@
+import 'iframe-resizer/js/iframeResizer.contentWindow'
+import './style.scss'
+
+import { debug } from './helper.js'
+
+export default function init(name: string, source: any) {
+ // @ts-ignore
+ window.source = source
+ document.title = name
+
+ debug()
+}
diff --git a/demos/setup/react.ts b/demos/setup/react.ts
new file mode 100644
index 0000000..7e60efa
--- /dev/null
+++ b/demos/setup/react.ts
@@ -0,0 +1,35 @@
+import 'iframe-resizer/js/iframeResizer.contentWindow'
+import './style.scss'
+
+import React from 'react'
+import { createRoot } from 'react-dom/client'
+
+import { debug, splitName } from './helper.js'
+
+export default function init(name: string, source: any) {
+ // @ts-ignore
+ window.source = source
+ document.title = name
+
+ const [demoCategory, demoName, frameworkName] = splitName(name)
+
+ import(`../src/${demoCategory}/${demoName}/${frameworkName}/index.tsx`)
+ .then(module => {
+ const root = document.getElementById('app')
+
+ if (root) {
+ createRoot(root).render(React.createElement(module.default))
+ }
+ debug()
+ })
+ .catch(() => {
+ import(`../src/${demoCategory}/${demoName}/${frameworkName}/index.jsx`).then(module => {
+ const root = document.getElementById('app')
+
+ if (root) {
+ createRoot(root).render(React.createElement(module.default))
+ }
+ debug()
+ })
+ })
+}
diff --git a/demos/setup/style.scss b/demos/setup/style.scss
new file mode 100644
index 0000000..d1e4843
--- /dev/null
+++ b/demos/setup/style.scss
@@ -0,0 +1,500 @@
+/* Base HTML and global element styles*/
+:root {
+ --white: #fff;
+ --black: #2e2b29;
+ --black-contrast: #110f0e;
+ --gray-1: rgba(61, 37, 20, 0.05);
+ --gray-2: rgba(61, 37, 20, 0.08);
+ --gray-3: rgba(61, 37, 20, 0.12);
+ --gray-4: rgba(53, 38, 28, 0.3);
+ --gray-5: rgba(28, 25, 23, 0.6);
+ --green: #22c55e;
+ --purple: #6a00f5;
+ --purple-contrast: #5800cc;
+ --purple-light: rgba(88, 5, 255, 0.05);
+ --yellow-contrast: #facc15;
+ --yellow: rgba(250, 204, 21, 0.4);
+ --yellow-light: #fffae5;
+ --red: #ff5c33;
+ --red-light: #ffebe5;
+ --shadow: 0px 12px 33px 0px rgba(0, 0, 0, 0.06), 0px 3.618px 9.949px 0px rgba(0, 0, 0, 0.04);
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ font-family:
+ Inter,
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ Roboto,
+ 'Helvetica Neue',
+ Arial,
+ 'Noto Sans',
+ sans-serif,
+ 'Apple Color Emoji',
+ 'Segoe UI Emoji',
+ 'Segoe UI Symbol',
+ 'Noto Color Emoji';
+ line-height: 1.5;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-font-smoothing: antialiased;
+}
+
+body {
+ min-height: 25rem;
+ margin: 0;
+}
+
+:first-child {
+ margin-top: 0;
+}
+
+.tiptap {
+ caret-color: var(--purple);
+ margin: 1.5rem;
+ padding-bottom: 2rem;
+
+ &:focus {
+ outline: none;
+ }
+}
+
+/* Custom scrollbar styles */
+::-webkit-scrollbar {
+ height: 14px;
+ width: 14px;
+}
+
+::-webkit-scrollbar-track {
+ background-clip: padding-box;
+ background-color: transparent;
+ border: 4px solid transparent;
+ border-radius: 8px;
+}
+
+::-webkit-scrollbar-thumb {
+ background-clip: padding-box;
+ background-color: rgba(0, 0, 0, 0);
+ border: 4px solid rgba(0, 0, 0, 0);
+ border-radius: 8px;
+}
+
+:hover::-webkit-scrollbar-thumb {
+ background-color: rgba(0, 0, 0, 0.1);
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background-color: rgba(0, 0, 0, 0.15);
+}
+
+::-webkit-scrollbar-button {
+ display: none;
+ height: 0;
+ width: 0;
+}
+
+::-webkit-scrollbar-corner {
+ background-color: transparent;
+}
+
+/* Specific element and component styles */
+button,
+input,
+select,
+textarea {
+ background: var(--gray-2);
+ border-radius: 0.5rem;
+ border: none;
+ color: var(--black);
+ font-family: inherit;
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: 1.15;
+ margin: none;
+ padding: 0.375rem 0.625rem;
+ transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1);
+
+ &:hover {
+ background-color: var(--gray-3);
+ color: var(--black-contrast);
+ }
+
+ &[disabled] {
+ background: var(--gray-1);
+ color: var(--gray-4);
+ }
+
+ &:checked {
+ accent-color: var(--purple);
+ }
+
+ &.primary {
+ background: var(--black);
+ color: var(--white);
+
+ &:hover {
+ background-color: var(--black-contrast);
+ }
+
+ &[disabled] {
+ background: var(--gray-1);
+ color: var(--gray-4);
+ }
+ }
+
+ &.is-active {
+ background: var(--purple);
+ color: var(--white);
+
+ &:hover {
+ background-color: var(--purple-contrast);
+ color: var(--white);
+ }
+ }
+}
+
+[popover] {
+ background-color: var(--white);
+ box-shadow: var(--shadow);
+ max-height: 18rem;
+ overflow-y: auto;
+ scrollbar-color: var(--gray-4) transparent;
+}
+
+[popover]::-webkit-scrollbar-thumb {
+ background-color: var(--gray-4);
+}
+
+/* Details */
+[data-type='details'] {
+ display: flex;
+ gap: 0.25rem;
+ margin: 1.5rem 0;
+ border: 1px solid var(--gray-3);
+ border-radius: 0.5rem;
+ padding: 0.5rem;
+
+ summary {
+ font-weight: 700;
+ }
+
+ > button {
+ align-items: center;
+ background: transparent;
+ border-radius: 4px;
+ display: flex;
+ font-size: 0.625rem;
+ height: 1.25rem;
+ justify-content: center;
+ line-height: 1;
+ margin-top: 0.1rem;
+ padding: 0;
+ width: 1.25rem;
+
+ &:hover {
+ background-color: var(--gray-3);
+ }
+
+ &::before {
+ content: '\25B6';
+ }
+ }
+
+ &.is-open > button::before {
+ transform: rotate(90deg);
+ }
+
+ > div {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ width: 100%;
+
+ > [data-type='detailsContent'] > :last-child {
+ margin-bottom: 0.5rem;
+ }
+ }
+
+ .details {
+ margin: 0.5rem 0;
+ }
+}
+
+[data-type='taskList'] {
+ list-style: none;
+ padding: 0;
+
+ [data-type='taskList'] {
+ padding-left: 1.5rem;
+ }
+
+ li {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.25rem;
+ }
+
+ > li > div > p:first-child {
+ margin: 0;
+ }
+
+ > li {
+ &:not(:first-child) {
+ margin-top: 0.125rem;
+ }
+
+ &:not(:last-child) {
+ margin-bottom: 0.125rem;
+ }
+ }
+
+ [data-checked='true'] > div > p:first-child {
+ text-decoration: line-through;
+ color: var(--gray-4);
+ }
+}
+
+button:not([disabled]),
+select:not([disabled]) {
+ cursor: pointer;
+}
+
+input[type='text'],
+textarea {
+ background-color: unset;
+ border: 1px solid var(--gray-3);
+ border-radius: 0.5rem;
+ color: var(--black);
+
+ &::placeholder {
+ color: var(--gray-4);
+ }
+
+ &:hover {
+ background-color: unset;
+ border-color: var(--gray-4);
+ }
+
+ &:focus-visible,
+ &:focus {
+ border-color: var(--purple);
+ outline: none;
+ }
+}
+
+select {
+ /* reset */
+ appearance: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ background-image: url('data:image/svg+xml;utf8, ');
+ background-repeat: no-repeat;
+ background-position: right 0.1rem center;
+ background-size: 1.25rem 1.25rem;
+ padding-right: 1.25rem;
+
+ &:focus {
+ outline: 0;
+ }
+}
+
+form {
+ align-items: flex-start;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.hint {
+ align-items: center;
+ background-color: var(--yellow-light);
+ border-radius: 0.5rem;
+ border: 1px solid var(--gray-2);
+ display: flex;
+ flex-direction: row;
+ font-size: 0.75rem;
+ gap: 0.25rem;
+ line-height: 1.15;
+ padding: 0.3rem 0.5rem;
+
+ &.purple-spinner,
+ &.error {
+ justify-content: center;
+ text-align: center;
+ width: 100%;
+ }
+
+ .badge {
+ background-color: var(--gray-1);
+ border: 1px solid var(--gray-3);
+ border-radius: 2rem;
+ color: var(--gray-5);
+ font-size: 0.625rem;
+ font-weight: 700;
+ line-height: 1;
+ padding: 0.25rem 0.5rem;
+ }
+
+ &.purple-spinner {
+ background-color: var(--purple-light);
+
+ &::after {
+ content: '';
+ background-image: url("data:image/svg+xml;utf8, ");
+ background-size: cover;
+ background-repeat: no-repeat;
+ background-position: center;
+ height: 1rem;
+ width: 1rem;
+ }
+ }
+
+ &.error {
+ background-color: var(--red-light);
+ }
+}
+
+.label,
+.label-small,
+.label-large {
+ color: var(--black);
+ font-size: 0.8125rem;
+ font-weight: 500;
+ line-height: 1.15;
+}
+
+.label-small {
+ color: var(--gray-5);
+ font-size: 0.75rem;
+ font-weight: 400;
+}
+
+.label-large {
+ font-size: 0.875rem;
+ font-weight: 700;
+}
+
+hr {
+ border: none;
+ border-top: 1px solid var(--gray-3);
+ margin: 0;
+ width: 100%;
+}
+
+kbd {
+ background-color: var(--gray-2);
+ border: 1px solid var(--gray-2);
+ border-radius: 0.25rem;
+ font-size: 0.6rem;
+ line-height: 1.15;
+ padding: 0.1rem 0.25rem;
+ text-transform: uppercase;
+}
+
+/* Layout and structure */
+#app,
+.container {
+ display: flex;
+ flex-direction: column;
+}
+
+.button-group {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+}
+
+.control-group {
+ align-items: flex-start;
+ background-color: var(--white);
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ padding: 1.5rem;
+
+ .sticky {
+ position: sticky;
+ top: 0;
+ }
+}
+
+[data-node-view-wrapper] > .control-group {
+ padding: 0;
+}
+
+.flex-row {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 1rem;
+ justify-content: space-between;
+ width: 100%;
+}
+
+.switch-group {
+ align-items: center;
+ background: var(--gray-2);
+ border-radius: 0.5rem;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ flex: 0 1 auto;
+ justify-content: flex-start;
+ padding: 0.125rem;
+
+ label {
+ align-items: center;
+ border-radius: 0.375rem;
+ color: var(--gray-5);
+ cursor: pointer;
+ display: flex;
+ flex-direction: row;
+ font-size: 0.75rem;
+ font-weight: 500;
+ gap: 0.25rem;
+ line-height: 1.15;
+ min-height: 1.5rem;
+ padding: 0 0.375rem;
+ transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1);
+
+ &:has(input:checked) {
+ background-color: var(--white);
+ color: var(--black-contrast);
+ }
+
+ &:hover {
+ color: var(--black);
+ }
+
+ input {
+ display: none;
+ margin: unset;
+ }
+ }
+}
+
+.output-group {
+ background-color: var(--gray-1);
+ display: flex;
+ flex-direction: column;
+ font-family: 'JetBrainsMono', monospace;
+ font-size: 0.75rem;
+ gap: 1rem;
+ margin-top: 2.5rem;
+ padding: 1.5rem;
+
+ label {
+ color: var(--black);
+ font-size: 0.875rem;
+ font-weight: 700;
+ line-height: 1.15;
+ }
+}
diff --git a/demos/setup/svelte.ts b/demos/setup/svelte.ts
new file mode 100644
index 0000000..21edfad
--- /dev/null
+++ b/demos/setup/svelte.ts
@@ -0,0 +1,21 @@
+import 'iframe-resizer/js/iframeResizer.contentWindow'
+import './style.scss'
+
+import { mount } from 'svelte'
+import { debug, splitName } from './helper.js'
+
+export default function init(name: string, source: any) {
+ // @ts-ignore
+ window.source = source
+ document.title = name
+
+ const [demoCategory, demoName, frameworkName] = splitName(name)
+
+ import(`../src/${demoCategory}/${demoName}/${frameworkName}/index.svelte`).then(Module => {
+ const Component = Module.default
+
+ mount(Component, { target: document.querySelector('#app')! })
+
+ debug()
+ })
+}
diff --git a/demos/setup/vue.ts b/demos/setup/vue.ts
new file mode 100644
index 0000000..4c8ccc1
--- /dev/null
+++ b/demos/setup/vue.ts
@@ -0,0 +1,24 @@
+import 'iframe-resizer/js/iframeResizer.contentWindow'
+import './style.scss'
+
+import { createApp } from 'vue'
+
+import { debug, splitName } from './helper.js'
+
+export default function init(name: string, source: any) {
+ // @ts-ignore
+ window.source = source
+ document.title = name
+
+ const [demoCategory, demoName, frameworkName] = splitName(name)
+
+ import(`../src/${demoCategory}/${demoName}/${frameworkName}/index.vue`).then(module => {
+ const app = createApp(module.default)
+
+ if (typeof module.configureApp === 'function') {
+ module.configureApp(app)
+ }
+ app.mount('#app')
+ debug()
+ })
+}
diff --git a/demos/src/Commands/Cut/React/index.html b/demos/src/Commands/Cut/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Commands/Cut/React/index.jsx b/demos/src/Commands/Cut/React/index.jsx
new file mode 100644
index 0000000..9245326
--- /dev/null
+++ b/demos/src/Commands/Cut/React/index.jsx
@@ -0,0 +1,81 @@
+import './styles.scss'
+
+import { Color, TextStyle } from '@tiptap/extension-text-style'
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React, { useCallback } from 'react'
+
+const MenuBar = ({ editor }) => {
+ const onCutToStart = useCallback(() => {
+ editor
+ .chain()
+ .cut({ from: editor.state.selection.$from.pos, to: editor.state.selection.$to.pos }, 1)
+ .run()
+ }, [editor])
+
+ const onCutToEnd = useCallback(() => {
+ editor
+ .chain()
+ .cut(
+ { from: editor.state.selection.$from.pos, to: editor.state.selection.$to.pos },
+ editor.state.doc.nodeSize - 2,
+ )
+ .run()
+ }, [editor])
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+
+
+ Cut content to start of document
+ Cut content to end of document
+
+
+ )
+}
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Color, TextStyle, StarterKit],
+ content: `
+
+ Hi there,
+
+
+ this is a basic example of Tiptap . Sure, there are all kind of basic text styles you’d probably expect from a text editor. But wait until you see the lists:
+
+
+
+ That’s a bullet list with one …
+
+
+ … or two list items.
+
+
+
+ Isn’t that great? And all of that is editable. But wait, there’s more. Let’s try a code block:
+
+ body {
+ display: none;
+}
+
+ I know, I know, this is impressive. It’s only the tip of the iceberg though. Give it a try and click a little bit around. Don’t forget to check the other examples too.
+
+
+ Wow, that’s amazing. Good work, boy! 👏
+
+ — Mom
+
+ `,
+ })
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/demos/src/Commands/Cut/React/styles.scss b/demos/src/Commands/Cut/React/styles.scss
new file mode 100644
index 0000000..15283b1
--- /dev/null
+++ b/demos/src/Commands/Cut/React/styles.scss
@@ -0,0 +1,91 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
diff --git a/demos/src/Commands/InsertContent/React/index.html b/demos/src/Commands/InsertContent/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Commands/InsertContent/React/index.jsx b/demos/src/Commands/InsertContent/React/index.jsx
new file mode 100644
index 0000000..7d70a84
--- /dev/null
+++ b/demos/src/Commands/InsertContent/React/index.jsx
@@ -0,0 +1,80 @@
+import './styles.scss'
+
+import { Image } from '@tiptap/extension-image'
+import Link from '@tiptap/extension-link'
+import { Color, TextStyle } from '@tiptap/extension-text-style'
+import { EditorProvider, useCurrentEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React, { useCallback } from 'react'
+
+const htmlContent = `
+
+ Hello World
+ This is a paragraph with a break.
+ And this is some additional string content.
+`
+
+const textContent = `Hello World
+This is content with a new line. Is this working?
+
+Lets see if multiple new lines are inserted correctly
+
+And more lines`
+
+const MenuBar = () => {
+ const { editor } = useCurrentEditor()
+
+ const insertImage = useCallback(() => {
+ const url = prompt('Enter an image URL')
+
+ editor.chain().insertContent(` `).focus().run()
+ }, [editor])
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+
+
+
editor.chain().insertContent(htmlContent).focus().run()}
+ >
+ Insert HTML content
+
+
+ editor.chain().insertContent('Hello World
').focus().run()
+ }
+ >
+ Insert HTML with span tags content
+
+
editor.chain().insertContent(textContent).focus().run()}
+ >
+ Insert text content
+
+
+ Insert image
+
+
+
+ )
+}
+
+const extensions = [Image, Color, TextStyle, Link, StarterKit]
+
+const content = ''
+
+export default () => {
+ return (
+ }
+ extensions={extensions}
+ content={content}
+ >
+ )
+}
diff --git a/demos/src/Commands/InsertContent/React/styles.scss b/demos/src/Commands/InsertContent/React/styles.scss
new file mode 100644
index 0000000..15283b1
--- /dev/null
+++ b/demos/src/Commands/InsertContent/React/styles.scss
@@ -0,0 +1,91 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
diff --git a/demos/src/Commands/InsertContentApplyingRules/React/index.html b/demos/src/Commands/InsertContentApplyingRules/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Commands/InsertContentApplyingRules/React/index.jsx b/demos/src/Commands/InsertContentApplyingRules/React/index.jsx
new file mode 100644
index 0000000..3d044ac
--- /dev/null
+++ b/demos/src/Commands/InsertContentApplyingRules/React/index.jsx
@@ -0,0 +1,178 @@
+import './styles.scss'
+
+import { EditorProvider, useCurrentEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React, { useState } from 'react'
+
+const MenuBar = () => {
+ const { editor } = useCurrentEditor()
+ const [useInputRules, setUseInputRules] = useState(true)
+ const [usePasteRules, setUsePasteRules] = useState(false)
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+
+
+
+ setUseInputRules(prev => !prev)}
+ />
+ Apply input rules
+
+
+ setUsePasteRules(prev => !prev)}
+ />
+ Apply paste rules
+
+
+
+
+ {
+ editor
+ .chain()
+ .insertContent('-', {
+ applyInputRules: useInputRules,
+ applyPasteRules: usePasteRules,
+ })
+ .focus()
+ .run()
+ }}
+ data-test-1
+ >
+ Insert "-"
+
+ {
+ editor
+ .chain()
+ .insertContent(' ', {
+ applyInputRules: useInputRules,
+ applyPasteRules: usePasteRules,
+ })
+ .focus()
+ .run()
+ }}
+ data-test-2
+ >
+ Insert " "
+
+ {
+ editor
+ .chain()
+ .insertContent('A', {
+ applyInputRules: useInputRules,
+ applyPasteRules: usePasteRules,
+ })
+ .focus()
+ .run()
+ }}
+ data-test-3
+ >
+ Insert "A"
+
+
+
+
+ {
+ editor
+ .chain()
+ .insertContent('*this is', {
+ applyInputRules: useInputRules,
+ applyPasteRules: usePasteRules,
+ })
+ .focus()
+ .run()
+ }}
+ data-test-4
+ >
+ Insert "*this is"
+
+ {
+ editor
+ .chain()
+ .insertContent(' a test*', {
+ applyInputRules: useInputRules,
+ applyPasteRules: usePasteRules,
+ })
+ .focus()
+ .run()
+ }}
+ data-test-5
+ >
+ Insert " a test*"
+
+ {
+ editor
+ .chain()
+ .insertContent(' a test*, whooho', {
+ applyInputRules: useInputRules,
+ applyPasteRules: usePasteRules,
+ })
+ .focus()
+ .run()
+ }}
+ data-test-6
+ >
+ Insert " a test*, whooho."
+
+ {
+ editor
+ .chain()
+ .insertContent('*this is a test*, whooho.', {
+ applyInputRules: useInputRules,
+ applyPasteRules: usePasteRules,
+ })
+ .focus()
+ .run()
+ }}
+ data-test-7
+ >
+ Insert "*this is a test*, whooho."
+
+ {
+ editor
+ .chain()
+ .insertContent('*this is a test*', {
+ applyInputRules: useInputRules,
+ applyPasteRules: usePasteRules,
+ })
+ .focus()
+ .run()
+ }}
+ data-test-8
+ >
+ Insert "*this is a test*"
+
+
+
+ )
+}
+
+const extensions = [StarterKit]
+
+const content = ''
+
+export default () => {
+ return (
+ }
+ extensions={extensions}
+ content={content}
+ >
+ )
+}
diff --git a/demos/src/Commands/InsertContentApplyingRules/React/styles.scss b/demos/src/Commands/InsertContentApplyingRules/React/styles.scss
new file mode 100644
index 0000000..15283b1
--- /dev/null
+++ b/demos/src/Commands/InsertContentApplyingRules/React/styles.scss
@@ -0,0 +1,91 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
diff --git a/demos/src/Commands/SetContent/React/index.html b/demos/src/Commands/SetContent/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Commands/SetContent/React/index.jsx b/demos/src/Commands/SetContent/React/index.jsx
new file mode 100644
index 0000000..7cda592
--- /dev/null
+++ b/demos/src/Commands/SetContent/React/index.jsx
@@ -0,0 +1,15 @@
+import './styles.scss'
+
+import Mentions from '@tiptap/extension-mention'
+import { Color, TextStyle } from '@tiptap/extension-text-style'
+import { EditorProvider } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+const extensions = [Color, TextStyle, StarterKit, Mentions]
+
+const content = ''
+
+export default () => {
+ return
+}
diff --git a/demos/src/Commands/SetContent/React/styles.scss b/demos/src/Commands/SetContent/React/styles.scss
new file mode 100644
index 0000000..88bedde
--- /dev/null
+++ b/demos/src/Commands/SetContent/React/styles.scss
@@ -0,0 +1,56 @@
+/* Basic editor styles */
+.tiptap {
+ > * + * {
+ margin-top: 0.75em;
+ }
+
+ ul,
+ ol {
+ padding: 0 1rem;
+ }
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ }
+
+ code {
+ background-color: rgba(#616161, 0.1);
+ color: #616161;
+ }
+
+ pre {
+ background: #0d0d0d;
+ color: #fff;
+ font-family: 'JetBrainsMono', monospace;
+ padding: 0.75rem 1rem;
+ border-radius: 0.5rem;
+
+ code {
+ color: inherit;
+ padding: 0;
+ background: none;
+ font-size: 0.8rem;
+ }
+ }
+
+ img {
+ max-width: 100%;
+ height: auto;
+ }
+
+ blockquote {
+ padding-left: 1rem;
+ border-left: 2px solid rgba(#0d0d0d, 0.1);
+ }
+
+ hr {
+ border: none;
+ border-top: 2px solid rgba(#0d0d0d, 0.1);
+ margin: 2rem 0;
+ }
+}
diff --git a/demos/src/Demos/CollaborationSplitPane/React/Editor.jsx b/demos/src/Demos/CollaborationSplitPane/React/Editor.jsx
new file mode 100644
index 0000000..c8c9d99
--- /dev/null
+++ b/demos/src/Demos/CollaborationSplitPane/React/Editor.jsx
@@ -0,0 +1,233 @@
+import Collaboration from '@tiptap/extension-collaboration'
+import CollaborationCaret from '@tiptap/extension-collaboration-caret'
+import Highlight from '@tiptap/extension-highlight'
+import { TaskItem, TaskList } from '@tiptap/extension-list'
+import { CharacterCount } from '@tiptap/extensions'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React, { useCallback, useEffect, useState } from 'react'
+
+const colors = [
+ '#958DF1',
+ '#F98181',
+ '#FBBC88',
+ '#FAF594',
+ '#70CFF8',
+ '#94FADB',
+ '#B9F18D',
+ '#C3E2C2',
+ '#EAECCC',
+ '#AFC8AD',
+ '#EEC759',
+ '#9BB8CD',
+ '#FF90BC',
+ '#FFC0D9',
+ '#DC8686',
+ '#7ED7C1',
+ '#F3EEEA',
+ '#89B9AD',
+ '#D0BFFF',
+ '#FFF8C9',
+ '#CBFFA9',
+ '#9BABB8',
+ '#E3F4F4',
+]
+const names = [
+ 'Lea Thompson',
+ 'Cyndi Lauper',
+ 'Tom Cruise',
+ 'Madonna',
+ 'Jerry Hall',
+ 'Joan Collins',
+ 'Winona Ryder',
+ 'Christina Applegate',
+ 'Alyssa Milano',
+ 'Molly Ringwald',
+ 'Ally Sheedy',
+ 'Debbie Harry',
+ 'Olivia Newton-John',
+ 'Elton John',
+ 'Michael J. Fox',
+ 'Axl Rose',
+ 'Emilio Estevez',
+ 'Ralph Macchio',
+ 'Rob Lowe',
+ 'Jennifer Grey',
+ 'Mickey Rourke',
+ 'John Cusack',
+ 'Matthew Broderick',
+ 'Justine Bateman',
+ 'Lisa Bonet',
+]
+
+const defaultContent = `
+ Hi 👋, this is a collaborative document.
+ Feel free to edit and collaborate in real-time!
+`
+
+const getRandomElement = list => list[Math.floor(Math.random() * list.length)]
+
+const getRandomColor = () => getRandomElement(colors)
+const getRandomName = () => getRandomElement(names)
+
+const getInitialUser = () => {
+ return {
+ name: getRandomName(),
+ color: getRandomColor(),
+ }
+}
+
+const Editor = ({ ydoc, provider, room }) => {
+ const [status, setStatus] = useState('connecting')
+ const [currentUser, setCurrentUser] = useState(getInitialUser)
+ const [userCount, setUserCount] = useState(0)
+
+ const editor = useEditor({
+ enableContentCheck: true,
+ onContentError: ({ disableCollaboration }) => {
+ disableCollaboration()
+ },
+ onCreate: ({ editor: currentEditor }) => {
+ provider.on('synced', () => {
+ if (currentEditor.isEmpty) {
+ currentEditor.commands.setContent(defaultContent)
+ }
+ })
+ },
+ extensions: [
+ StarterKit.configure({
+ undoRedo: false,
+ }),
+ Highlight,
+ TaskList,
+ TaskItem,
+ CharacterCount.extend().configure({
+ limit: 10000,
+ }),
+ Collaboration.extend().configure({
+ document: ydoc,
+ }),
+ CollaborationCaret.extend().configure({
+ provider,
+ }),
+ ],
+ })
+
+ const { isBold, isItalic, isStrike, isBulletList, isCode } = useEditorState({
+ editor,
+ selector: ctx => ({
+ isBold: ctx.editor.isActive('bold') ?? false,
+ isItalic: ctx.editor.isActive('italic') ?? false,
+ isStrike: ctx.editor.isActive('strike') ?? false,
+ isBulletList: ctx.editor.isActive('bulletList') ?? false,
+ isCode: ctx.editor.isActive('code') ?? false,
+ }),
+ })
+
+ useEffect(() => {
+ // Update status changes
+ const statusHandler = event => {
+ setStatus(event.status)
+ }
+
+ provider.on('status', statusHandler)
+
+ return () => {
+ provider.off('status', statusHandler)
+ }
+ }, [provider])
+
+ useEffect(() => {
+ if (!editor) {
+ return undefined
+ }
+
+ const updateUserCount = () => {
+ setUserCount(editor.storage.collaborationCaret.users.length)
+ }
+
+ updateUserCount()
+ provider.awareness.on('update', updateUserCount)
+
+ return () => {
+ provider.awareness.off('update', updateUserCount)
+ }
+ }, [editor, provider])
+
+ // Save current user to localStorage and emit to editor
+ useEffect(() => {
+ if (editor && currentUser) {
+ localStorage.setItem('currentUser', JSON.stringify(currentUser))
+ editor.chain().focus().updateUser(currentUser).run()
+ }
+ }, [editor, currentUser])
+
+ const setName = useCallback(() => {
+ const name = (window.prompt('Name', currentUser.name) || '').trim().substring(0, 32)
+
+ if (name) {
+ return setCurrentUser({ ...currentUser, name })
+ }
+ }, [currentUser])
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+
+
+
+ editor.chain().focus().toggleBold().run()}
+ className={isBold ? 'is-active' : ''}
+ >
+ Bold
+
+ editor.chain().focus().toggleItalic().run()}
+ className={isItalic ? 'is-active' : ''}
+ >
+ Italic
+
+ editor.chain().focus().toggleStrike().run()}
+ className={isStrike ? 'is-active' : ''}
+ >
+ Strike
+
+ editor.chain().focus().toggleBulletList().run()}
+ className={isBulletList ? 'is-active' : ''}
+ >
+ Bullet list
+
+ editor.chain().focus().toggleCode().run()}
+ className={isCode ? 'is-active' : ''}
+ >
+ Code
+
+
+
+
+
+
+
+
+ {status === 'connected'
+ ? `${userCount} user${userCount === 1 ? '' : 's'} online in ${room}`
+ : 'offline'}
+
+
+ ✎ {currentUser.name}
+
+
+
+ )
+}
+
+export default Editor
diff --git a/demos/src/Demos/CollaborationSplitPane/React/index.html b/demos/src/Demos/CollaborationSplitPane/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Demos/CollaborationSplitPane/React/index.jsx b/demos/src/Demos/CollaborationSplitPane/React/index.jsx
new file mode 100644
index 0000000..4464403
--- /dev/null
+++ b/demos/src/Demos/CollaborationSplitPane/React/index.jsx
@@ -0,0 +1,39 @@
+import './styles.scss'
+
+import { TiptapCollabProvider } from '@hocuspocus/provider'
+import * as Y from 'yjs'
+
+import Editor from './Editor.jsx'
+
+const appId = '7j9y6m10'
+const room = `room.${new Date()
+ .getFullYear()
+ .toString()
+ .slice(-2)}${new Date().getMonth() + 1}${new Date().getDate()}-ok`
+
+// ydoc and provider for Editor A
+const ydocA = new Y.Doc()
+const providerA = new TiptapCollabProvider({
+ appId,
+ name: room,
+ document: ydocA,
+})
+
+// ydoc and provider for Editor B
+const ydocB = new Y.Doc()
+const providerB = new TiptapCollabProvider({
+ appId,
+ name: room,
+ document: ydocB,
+})
+
+const App = () => {
+ return (
+
+
+
+
+ )
+}
+
+export default App
diff --git a/demos/src/Demos/CollaborationSplitPane/React/styles.scss b/demos/src/Demos/CollaborationSplitPane/React/styles.scss
new file mode 100644
index 0000000..fcb8e2c
--- /dev/null
+++ b/demos/src/Demos/CollaborationSplitPane/React/styles.scss
@@ -0,0 +1,280 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+
+ /* Highlight specific styles */
+ mark {
+ background-color: #faf594;
+ border-radius: 0.4rem;
+ box-decoration-break: clone;
+ padding: 0.1rem 0.3rem;
+ }
+
+ /* Task list specific styles */
+ ul[data-type='taskList'] {
+ list-style: none;
+ margin-left: 0;
+ padding: 0;
+
+ li {
+ align-items: flex-start;
+ display: flex;
+
+ > label {
+ flex: 0 0 auto;
+ margin-right: 0.5rem;
+ user-select: none;
+ }
+
+ > div {
+ flex: 1 1 auto;
+ }
+ }
+
+ input[type='checkbox'] {
+ cursor: pointer;
+ }
+
+ ul[data-type='taskList'] {
+ margin: 0;
+ }
+ }
+
+ p {
+ word-break: break-all;
+ }
+
+ /* Give a remote user a caret */
+ .collaboration-carets__caret {
+ border-left: 1px solid #0d0d0d;
+ border-right: 1px solid #0d0d0d;
+ margin-left: -1px;
+ margin-right: -1px;
+ pointer-events: none;
+ position: relative;
+ word-break: normal;
+ }
+
+ /* Render the username above the caret */
+ .collaboration-carets__label {
+ border-radius: 3px 3px 3px 0;
+ color: #0d0d0d;
+ font-size: 12px;
+ font-style: normal;
+ font-weight: 600;
+ left: -1px;
+ line-height: normal;
+ padding: 0.1rem 0.3rem;
+ position: absolute;
+ top: -1.4em;
+ user-select: none;
+ white-space: nowrap;
+ }
+}
+
+.col-group {
+ display: flex;
+ flex-direction: row;
+ height: 100vh;
+
+ @media (max-width: 540px) {
+ flex-direction: column;
+ }
+}
+
+/* Column-half */
+body {
+ overflow: hidden;
+}
+
+.column-half {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ overflow: auto;
+
+ &:last-child {
+ border-left: 1px solid var(--gray-3);
+
+ @media (max-width: 540px) {
+ border-left: none;
+ border-top: 1px solid var(--gray-3);
+ }
+ }
+
+ & > .main-group {
+ flex-grow: 1;
+ }
+}
+
+/* Collaboration status */
+.collab-status-group {
+ align-items: center;
+ background-color: var(--white);
+ border-top: 1px solid var(--gray-3);
+ bottom: 0;
+ color: var(--gray-5);
+ display: flex;
+ flex-direction: row;
+ font-size: 0.75rem;
+ font-weight: 400;
+ gap: 1rem;
+ justify-content: space-between;
+ padding: 0.375rem 0.5rem 0.375rem 1rem;
+ position: sticky;
+ width: 100%;
+ z-index: 100;
+
+ button {
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 1;
+ align-self: stretch;
+ background: none;
+ display: -webkit-box;
+ flex-shrink: 1;
+ font-size: 0.75rem;
+ max-width: 100%;
+ padding: 0.25rem 0.375rem;
+ overflow: hidden;
+ position: relative;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ &::before {
+ background-color: var(--color);
+ border-radius: 0.375rem;
+ content: '';
+ height: 100%;
+ left: 0;
+ opacity: 0.5;
+ position: absolute;
+ top: 0;
+ transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1);
+ width: 100%;
+ z-index: -1;
+ }
+
+ &:hover::before {
+ opacity: 1;
+ }
+ }
+
+ label {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ flex-shrink: 0;
+ gap: 0.375rem;
+ line-height: 1.1;
+
+ &::before {
+ border-radius: 50%;
+ content: ' ';
+ height: 0.35rem;
+ width: 0.35rem;
+ }
+ }
+
+ &[data-state='online'] {
+ label {
+ &::before {
+ background-color: var(--green);
+ }
+ }
+ }
+
+ &[data-state='offline'] {
+ label {
+ &::before {
+ background-color: var(--red);
+ }
+ }
+ }
+}
diff --git a/demos/src/Demos/CollaborationSplitPane/index.spec.ts b/demos/src/Demos/CollaborationSplitPane/index.spec.ts
new file mode 100644
index 0000000..bbbc5cd
--- /dev/null
+++ b/demos/src/Demos/CollaborationSplitPane/index.spec.ts
@@ -0,0 +1,41 @@
+import { expect, test } from '@playwright/test'
+
+const demoName = 'CollaborationSplitPane'
+const frameworkPaths = ['React']
+const demoPath = '/src/Demos'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test('should have a working tiptap instance', async ({ page }) => {
+ const editor = page.locator('.tiptap').first()
+ await editor.waitFor()
+
+ const hasEditor = await editor.evaluate((el: any) => !!el.editor)
+
+ expect(hasEditor).toBe(true)
+ })
+
+ test('should have a ydoc', async ({ page }) => {
+ const editor = page.locator('.tiptap').first()
+ await editor.waitFor()
+
+ const hasYDoc = await editor.evaluate((el: any) => {
+ const extension = el.editor.extensionManager.extensions.find(
+ (a: any) => a.name === 'collaboration',
+ )
+
+ return !!extension?.options.document
+ })
+
+ expect(hasYDoc).toBe(true)
+ })
+ })
+ })
+})
diff --git a/demos/src/Demos/SingleRoomCollab/React/index.html b/demos/src/Demos/SingleRoomCollab/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Demos/SingleRoomCollab/React/index.jsx b/demos/src/Demos/SingleRoomCollab/React/index.jsx
new file mode 100644
index 0000000..f470dba
--- /dev/null
+++ b/demos/src/Demos/SingleRoomCollab/React/index.jsx
@@ -0,0 +1,147 @@
+import './styles.scss'
+
+import { TiptapCollabProvider } from '@hocuspocus/provider'
+import Collaboration from '@tiptap/extension-collaboration'
+import CollaborationCaret from '@tiptap/extension-collaboration-caret'
+import { CharacterCount } from '@tiptap/extensions'
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React, { useCallback, useEffect, useState } from 'react'
+import * as Y from 'yjs'
+
+const room = 'room-1'
+const colors = ['#958DF1', '#F98181', '#FBBC88', '#FAF594', '#70CFF8', '#94FADB', '#B9F18D']
+const names = [
+ 'Lea Thompson',
+ 'Cyndi Lauper',
+ 'Tom Cruise',
+ 'Madonna',
+ 'Jerry Hall',
+ 'Joan Collins',
+ 'Winona Ryder',
+ 'Christina Applegate',
+ 'Alyssa Milano',
+ 'Molly Ringwald',
+ 'Ally Sheedy',
+ 'Debbie Harry',
+ 'Olivia Newton-John',
+ 'Elton John',
+ 'Michael J. Fox',
+ 'Axl Rose',
+ 'Emilio Estevez',
+ 'Ralph Macchio',
+ 'Rob Lowe',
+ 'Jennifer Grey',
+ 'Mickey Rourke',
+ 'John Cusack',
+ 'Matthew Broderick',
+ 'Justine Bateman',
+ 'Lisa Bonet',
+]
+
+const getRandomElement = list => list[Math.floor(Math.random() * list.length)]
+
+const getRandomColor = () => getRandomElement(colors)
+const getRandomName = () => getRandomElement(names)
+
+const ydoc = new Y.Doc()
+const websocketProvider = new TiptapCollabProvider({
+ appId: '7j9y6m10',
+ name: room,
+ document: ydoc,
+})
+
+const getInitialUser = () => {
+ return (
+ JSON.parse(localStorage.getItem('currentUser')) || {
+ name: getRandomName(),
+ color: getRandomColor(),
+ }
+ )
+}
+
+export default () => {
+ const [status, setStatus] = useState('connecting')
+ const [currentUser, setCurrentUser] = useState(getInitialUser)
+ const [userCount, setUserCount] = useState(0)
+
+ const editor = useEditor({
+ extensions: [
+ StarterKit.configure({
+ undoRedo: false,
+ }),
+ CharacterCount.configure({
+ limit: 10000,
+ }),
+ Collaboration.configure({
+ document: ydoc,
+ }),
+ CollaborationCaret.configure({
+ provider: websocketProvider,
+ }),
+ ],
+ })
+
+ useEffect(() => {
+ // Update status changes
+ websocketProvider.on('status', event => {
+ setStatus(event.status)
+ })
+ }, [])
+
+ useEffect(() => {
+ if (!editor) {
+ return undefined
+ }
+
+ const updateUserCount = () => {
+ setUserCount(editor.storage.collaborationCaret.users.length)
+ }
+
+ updateUserCount()
+ websocketProvider.awareness.on('update', updateUserCount)
+
+ return () => {
+ websocketProvider.awareness.off('update', updateUserCount)
+ }
+ }, [editor])
+
+ // Save current user to localStorage and emit to editor
+ useEffect(() => {
+ if (editor && currentUser) {
+ localStorage.setItem('currentUser', JSON.stringify(currentUser))
+ editor.chain().focus().updateUser(currentUser).run()
+ }
+ }, [editor, currentUser])
+
+ const setName = useCallback(() => {
+ const name = (window.prompt('Name') || '').trim().substring(0, 32)
+
+ if (name) {
+ return setCurrentUser({ ...currentUser, name })
+ }
+ }, [currentUser])
+
+ return (
+
+
+
+
+
+
+
+
+
+ {status === 'connected'
+ ? `${userCount} user${userCount === 1 ? '' : 's'} online in ${room}`
+ : 'offline'}
+
+
+ {currentUser.name}
+
+
+
+
+
+ )
+}
diff --git a/demos/src/Demos/SingleRoomCollab/React/styles.scss b/demos/src/Demos/SingleRoomCollab/React/styles.scss
new file mode 100644
index 0000000..39d96f8
--- /dev/null
+++ b/demos/src/Demos/SingleRoomCollab/React/styles.scss
@@ -0,0 +1,199 @@
+/* Basic editor styles */
+.tiptap {
+ > * + * {
+ margin-top: 0.75em;
+ }
+
+ ul,
+ ol {
+ padding: 0 1rem;
+ }
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ }
+
+ code {
+ background-color: rgba(#616161, 0.1);
+ color: #616161;
+ }
+
+ pre {
+ background: #0d0d0d;
+ border-radius: 0.5rem;
+ color: #fff;
+ font-family: 'JetBrainsMono', monospace;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ mark {
+ background-color: #faf594;
+ }
+
+ img {
+ height: auto;
+ max-width: 100%;
+ }
+
+ hr {
+ margin: 1rem 0;
+ }
+
+ blockquote {
+ border-left: 2px solid rgba(#0d0d0d, 0.1);
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 2px solid rgba(#0d0d0d, 0.1);
+ margin: 2rem 0;
+ }
+
+ ul[data-type='taskList'] {
+ list-style: none;
+ padding: 0;
+
+ li {
+ align-items: center;
+ display: flex;
+
+ > label {
+ flex: 0 0 auto;
+ margin-right: 0.5rem;
+ user-select: none;
+ }
+
+ > div {
+ flex: 1 1 auto;
+ }
+ }
+ }
+}
+
+.editor {
+ background-color: #fff;
+ border: 2px solid #0d0d0d;
+ border-radius: 0.75rem;
+ box-shadow: 5px 5px #000;
+ color: #0d0d0d;
+ display: flex;
+ flex-direction: column;
+ max-height: 26rem;
+
+ &__header {
+ align-items: center;
+ border-bottom: 2px solid #0d0d0d;
+ border-top-left-radius: 0.25rem;
+ border-top-right-radius: 0.25rem;
+ display: flex;
+ justify-content: space-between;
+ padding: 0.5rem 1rem;
+ }
+
+ &__users {
+ color: rgba(#000, 0.8);
+ display: flex;
+ font-size: 0.85rem;
+ gap: 1rem;
+ }
+
+ &__content {
+ flex: 1 1 auto;
+ overflow-x: hidden;
+ overflow-y: auto;
+ padding: 1.25rem 1rem;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ /* Some information about the status */
+ &__status {
+ align-items: center;
+ border-radius: 5px;
+ display: flex;
+
+ &::before {
+ background: rgba(#0d0d0d, 0.5);
+ border-radius: 50%;
+ content: ' ';
+ display: inline-block;
+ flex: 0 0 auto;
+ height: 0.5rem;
+ margin-right: 0.5rem;
+ width: 0.5rem;
+ }
+
+ &--connecting::before {
+ background: #616161;
+ }
+
+ &--connected::before {
+ background: #b9f18d;
+ }
+ }
+
+ &__name button {
+ appearance: none;
+ background: transparent;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ font: inherit;
+ line-height: normal;
+ margin: 0;
+ padding: 0;
+ overflow: visible;
+ width: auto;
+ }
+}
+
+/* Give a remote user a caret */
+.collaboration-carets__caret {
+ border-left: 1px solid #0d0d0d;
+ border-right: 1px solid #0d0d0d;
+ margin-left: -1px;
+ margin-right: -1px;
+ pointer-events: none;
+ position: relative;
+ word-break: normal;
+}
+
+/* Render the username above the caret */
+.collaboration-carets__label {
+ border-radius: 3px 3px 3px 0;
+ color: #0d0d0d;
+ font-size: 0.75rem;
+ font-style: normal;
+ font-weight: 600;
+ left: -1px;
+ line-height: normal;
+ padding: 0.1rem 0.3rem;
+ position: absolute;
+ top: -1.4em;
+ user-select: none;
+ white-space: nowrap;
+}
+
+.dots {
+ display: flex;
+ gap: 6px;
+}
+
+.dot {
+ background: #000;
+ border-radius: 100%;
+ height: 0.625rem;
+ width: 0.625rem;
+}
diff --git a/demos/src/Demos/SingleRoomCollab/index.spec.ts b/demos/src/Demos/SingleRoomCollab/index.spec.ts
new file mode 100644
index 0000000..1a6bd5e
--- /dev/null
+++ b/demos/src/Demos/SingleRoomCollab/index.spec.ts
@@ -0,0 +1,26 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'SingleRoomCollab'
+const frameworkPaths = ['React']
+const demoPath = '/src/Demos'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test('should have a working tiptap instance', async ({ page }) => {
+ const editor = await getEditor(page)
+ const hasEditor = await editor.evaluate((el: any) => !!el.editor)
+
+ expect(hasEditor).toBe(true)
+ })
+ })
+ })
+})
diff --git a/demos/src/Dev/.gitkeep b/demos/src/Dev/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Accessibility/React/InsertMenu.tsx b/demos/src/Examples/Accessibility/React/InsertMenu.tsx
new file mode 100644
index 0000000..1846cbd
--- /dev/null
+++ b/demos/src/Examples/Accessibility/React/InsertMenu.tsx
@@ -0,0 +1,89 @@
+import type { Editor } from '@tiptap/react'
+import { useEditorState } from '@tiptap/react'
+import { FloatingMenu } from '@tiptap/react/menus'
+import React, { useRef } from 'react'
+
+import { useMenubarNav } from './useMenubarNav.js'
+
+/**
+ * A floating menu for inserting new elements like lists, horizontal rules, etc.
+ */
+export function InsertMenu({ editor }: { editor: Editor }) {
+ const containerRef = useRef(null)
+
+ const { activeNodeType } = useEditorState({
+ editor,
+ selector: ctx => {
+ const activeNode = ctx.editor.state.selection.$from.node(1)
+
+ return {
+ activeNodeType: activeNode?.type.name ?? 'paragraph',
+ }
+ },
+ })
+
+ // Handle arrow navigation within a menu bar container, and allow to escape to the editor
+ const { getFocusableElements } = useMenubarNav({
+ editor,
+ ref: containerRef,
+ onEscape: e => {
+ e.preventDefault()
+ // On escape, focus the editor
+ editor.chain().focus().run()
+ },
+ })
+
+ return (
+ {
+ // The ref we have is to the container, not the menu itself
+ if (containerRef.current === e.target?.parentNode) {
+ // Focus the first enabled button-like when the menu bar is focused
+ getFocusableElements()?.[0]?.focus()
+ }
+ }}
+ tabIndex={0}
+ >
+ editor.chain().focus().toggleBulletList().run()}
+ className={activeNodeType === 'bulletList' ? 'is-active' : ''}
+ aria-pressed={activeNodeType === 'bulletList'}
+ aria-label="Bullet List"
+ tabIndex={-1}
+ >
+ Bullet list
+
+ editor.chain().focus().toggleOrderedList().run()}
+ className={activeNodeType === 'orderedList' ? 'is-active' : ''}
+ aria-pressed={activeNodeType === 'orderedList'}
+ aria-label="Ordered List"
+ tabIndex={-1}
+ >
+ Ordered List
+
+ editor.chain().focus().setHorizontalRule().run()}
+ aria-label="Horizontal rule"
+ tabIndex={-1}
+ >
+ Horizontal rule
+
+ editor.chain().focus().setHardBreak().run()}
+ aria-label="Hard break"
+ tabIndex={-1}
+ >
+ Hard break
+
+
+ )
+}
diff --git a/demos/src/Examples/Accessibility/React/MenuBar.tsx b/demos/src/Examples/Accessibility/React/MenuBar.tsx
new file mode 100644
index 0000000..98d318a
--- /dev/null
+++ b/demos/src/Examples/Accessibility/React/MenuBar.tsx
@@ -0,0 +1,317 @@
+import type { Editor } from '@tiptap/core'
+import { useEditorState } from '@tiptap/react'
+import React, { useEffect, useRef, useState } from 'react'
+
+import { useMenubarNav } from './useMenubarNav.js'
+
+/**
+ * Handles the heading dropdown
+ */
+function NodeTypeDropdown({ editor }: { editor: Editor }) {
+ const dropdownRef = useRef(null)
+ const menuRef = useRef(null)
+ const [isOpen, setIsOpen] = useState(false)
+
+ const editorState = useEditorState({
+ editor,
+ selector: ctx => {
+ const activeNode = ctx.editor.state.selection.$from.node(1)
+
+ return {
+ activeNodeType: activeNode?.type.name ?? 'paragraph',
+ isParagraph: ctx.editor.isActive('paragraph'),
+ isHeading1: ctx.editor.isActive('heading', { level: 1 }),
+ isHeading2: ctx.editor.isActive('heading', { level: 2 }),
+ isHeading3: ctx.editor.isActive('heading', { level: 3 }),
+ isHeading4: ctx.editor.isActive('heading', { level: 4 }),
+ isHeading5: ctx.editor.isActive('heading', { level: 5 }),
+ isHeading6: ctx.editor.isActive('heading', { level: 6 }),
+ isBulletList: ctx.editor.isActive('bulletList'),
+ isOrderedList: ctx.editor.isActive('orderedList'),
+ isCodeBlock: ctx.editor.isActive('codeBlock'),
+ isBlockquote: ctx.editor.isActive('blockquote'),
+ }
+ },
+ })
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
+ setIsOpen(false)
+ }
+ }
+
+ document.addEventListener('click', handleClickOutside)
+ return () => {
+ document.removeEventListener('click', handleClickOutside)
+ }
+ }, [])
+
+ return (
+ {
+ // Escape or tab should close the dropdown if it's open
+ if (isOpen && (e.key === 'Escape' || e.key === 'Tab')) {
+ setIsOpen(false)
+ if (e.key === 'Escape') {
+ // Prevent the editor from handling the escape key
+ e.preventDefault()
+ }
+ }
+ }}
+ >
+
{
+ setIsOpen(open => !open)
+ e.stopPropagation()
+ }}
+ onKeyDown={e => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ setIsOpen(open => !open)
+ }
+ }}
+ aria-haspopup="true"
+ aria-expanded={isOpen}
+ aria-controls="heading-dropdown"
+ className={`node-type-dropdown__trigger${isOpen ? ' is-active' : ''}`}
+ tabIndex={-1}
+ >
+ Node Type:{' '}
+ {editorState.activeNodeType.slice(0, 1).toUpperCase() + editorState.activeNodeType.slice(1)}
+
+ {isOpen && (
+
+ {
+ editor.chain().focus().setParagraph().run()
+ setIsOpen(false)
+ }}
+ className={editorState.isParagraph ? 'is-active' : ''}
+ tabIndex={-1}
+ role="menuitem"
+ aria-label="Paragraph"
+ aria-pressed={editorState.isParagraph}
+ // This is the first element in the dropdown, so if it loses focus, close the dropdown
+ onBlur={e => {
+ // Is it not within the dropdown?
+ if (!dropdownRef.current?.contains(e.relatedTarget as Node)) {
+ e.preventDefault()
+ setIsOpen(false)
+ }
+ }}
+ >
+ Paragraph
+
+ {
+ editor.chain().focus().toggleHeading({ level: 1 }).run()
+ setIsOpen(false)
+ }}
+ className={editorState.isHeading1 ? 'is-active' : ''}
+ aria-pressed={editorState.isHeading1}
+ tabIndex={-1}
+ role="menuitem"
+ aria-label="H1"
+ >
+ H1
+
+ {
+ editor.chain().focus().toggleHeading({ level: 2 }).run()
+ setIsOpen(false)
+ }}
+ className={editorState.isHeading2 ? 'is-active' : ''}
+ aria-pressed={editorState.isHeading2}
+ tabIndex={-1}
+ role="menuitem"
+ aria-label="H2"
+ >
+ H2
+
+ {
+ editor.chain().focus().toggleHeading({ level: 3 }).run()
+ setIsOpen(false)
+ }}
+ className={editorState.isHeading3 ? 'is-active' : ''}
+ aria-pressed={editorState.isHeading3}
+ tabIndex={-1}
+ role="menuitem"
+ aria-label="H3"
+ >
+ H3
+
+ {
+ editor.chain().focus().toggleHeading({ level: 4 }).run()
+ setIsOpen(false)
+ }}
+ className={editorState.isHeading4 ? 'is-active' : ''}
+ aria-pressed={editorState.isHeading4}
+ tabIndex={-1}
+ role="menuitem"
+ aria-label="H4"
+ >
+ H4
+
+ {
+ editor.chain().focus().toggleHeading({ level: 5 }).run()
+ setIsOpen(false)
+ }}
+ className={editorState.isHeading5 ? 'is-active' : ''}
+ aria-pressed={editorState.isHeading5}
+ tabIndex={-1}
+ role="menuitem"
+ aria-label="H5"
+ >
+ H5
+
+ {
+ editor.chain().focus().toggleHeading({ level: 6 }).run()
+ setIsOpen(false)
+ }}
+ className={editorState.isHeading6 ? 'is-active' : ''}
+ aria-pressed={editorState.isHeading6}
+ tabIndex={-1}
+ role="menuitem"
+ aria-label="H6"
+ >
+ H6
+
+ {
+ editor.chain().focus().toggleBulletList().run()
+ setIsOpen(false)
+ }}
+ className={editorState.isBulletList ? 'is-active' : ''}
+ aria-pressed={editorState.isBulletList}
+ tabIndex={-1}
+ role="menuitem"
+ aria-label="Bullet list"
+ >
+ Bullet list
+
+ {
+ editor.chain().focus().toggleOrderedList().run()
+ setIsOpen(false)
+ }}
+ className={editorState.isOrderedList ? 'is-active' : ''}
+ aria-pressed={editorState.isOrderedList}
+ tabIndex={-1}
+ role="menuitem"
+ aria-label="Ordered List"
+ >
+ Ordered list
+
+ {
+ editor.chain().focus().toggleCodeBlock().run()
+ setIsOpen(false)
+ }}
+ className={editorState.isCodeBlock ? 'is-active' : ''}
+ aria-pressed={editorState.isCodeBlock}
+ tabIndex={-1}
+ role="menuitem"
+ aria-label="Code block"
+ >
+ Code block
+
+ {
+ editor.chain().focus().toggleBlockquote().run()
+ setIsOpen(false)
+ }}
+ className={editorState.isBlockquote ? 'is-active' : ''}
+ aria-pressed={editorState.isBlockquote}
+ tabIndex={-1}
+ role="menuitem"
+ aria-label="Blockquote"
+ onBlur={e => {
+ if (!dropdownRef.current?.contains(e.relatedTarget as Node)) {
+ e.preventDefault()
+ setIsOpen(false)
+ }
+ }}
+ >
+ Blockquote
+
+
+ )}
+
+ )
+}
+
+/**
+ * An accessible static top menu bar for the editor
+ */
+export function MenuBar({ editor }: { editor: Editor }) {
+ const containerRef = useRef(null)
+
+ const editorState = useEditorState({
+ editor,
+ selector: ctx => {
+ return {
+ canUndo: ctx.editor.can().chain().focus().undo().run(),
+ canRedo: ctx.editor.can().chain().focus().redo().run(),
+ }
+ },
+ })
+
+ useMenubarNav({
+ ref: containerRef,
+ editor,
+ onKeydown: event => {
+ // Handle focus on alt + f10
+ if (event.altKey && event.key === 'F10') {
+ event.preventDefault()
+ containerRef.current?.querySelector('button')?.focus()
+ }
+ },
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+
+
+
+ editor.chain().focus().undo().run()}
+ disabled={!editorState.canUndo}
+ tabIndex={-1}
+ aria-label="Undo"
+ >
+ Undo
+
+ editor.chain().focus().redo().run()}
+ disabled={!editorState.canRedo}
+ tabIndex={-1}
+ aria-label="Redo"
+ >
+ Redo
+
+
+
+ )
+}
diff --git a/demos/src/Examples/Accessibility/React/TextMenu.tsx b/demos/src/Examples/Accessibility/React/TextMenu.tsx
new file mode 100644
index 0000000..1bc3b57
--- /dev/null
+++ b/demos/src/Examples/Accessibility/React/TextMenu.tsx
@@ -0,0 +1,118 @@
+import { Selection } from '@tiptap/pm/state'
+import type { Editor } from '@tiptap/react'
+import { useEditorState } from '@tiptap/react'
+import { BubbleMenu } from '@tiptap/react/menus'
+import React, { useRef } from 'react'
+
+import { useMenubarNav } from './useMenubarNav.js'
+
+/**
+ * Handles formatting text with marks like bold, italic, etc.
+ */
+export function TextMenu({ editor }: { editor: Editor }) {
+ const containerRef = useRef(null)
+
+ const editorState = useEditorState({
+ editor,
+ selector: ctx => {
+ return {
+ isBold: ctx.editor.isActive('bold'),
+ canBold: ctx.editor.can().chain().focus().toggleBold().run(),
+ isItalic: ctx.editor.isActive('italic'),
+ canItalic: ctx.editor.can().chain().focus().toggleItalic().run(),
+ isStrike: ctx.editor.isActive('strike'),
+ canStrike: ctx.editor.can().chain().focus().toggleStrike().run(),
+ isCode: ctx.editor.isActive('code'),
+ canCode: ctx.editor.can().chain().focus().toggleCode().run(),
+ canClearMarks: ctx.editor.can().chain().focus().unsetAllMarks().run(),
+ }
+ },
+ })
+
+ // Handle arrow navigation within a menu bar container, and allow to escape to the editor
+ const { getFocusableElements } = useMenubarNav({
+ editor,
+ ref: containerRef,
+ onEscape: e => {
+ e.preventDefault()
+ // On escape, focus the editor & dismiss the menu by moving the selection to the end of the selection
+ editor
+ .chain()
+ .focus()
+ .command(({ tr }) => {
+ tr.setSelection(Selection.near(tr.selection.$to))
+ return true
+ })
+ .run()
+ },
+ })
+
+ return (
+ {
+ // The ref we have is to the container, not the menu itself
+ if (containerRef.current === e.target?.parentNode) {
+ // Focus the first button when the menu bar is focused
+ getFocusableElements()?.[0]?.focus()
+ }
+ }}
+ tabIndex={0}
+ >
+ editor.chain().focus().toggleBold().run()}
+ disabled={!editorState.canBold}
+ className={editorState.isBold ? 'is-active' : ''}
+ aria-pressed={editorState.isBold}
+ aria-label="Bold"
+ tabIndex={-1}
+ >
+ Bold
+
+ editor.chain().focus().toggleItalic().run()}
+ disabled={!editorState.canItalic}
+ className={editorState.isItalic ? 'is-active' : ''}
+ aria-pressed={editorState.isItalic}
+ aria-label="Italic"
+ tabIndex={-1}
+ >
+ Italic
+
+ editor.chain().focus().toggleStrike().run()}
+ disabled={!editorState.canStrike}
+ className={editorState.isStrike ? 'is-active' : ''}
+ aria-pressed={editorState.isStrike}
+ tabIndex={-1}
+ >
+ Strike
+
+ editor.chain().focus().toggleCode().run()}
+ disabled={!editorState.canCode}
+ className={editorState.isCode ? 'is-active' : ''}
+ aria-pressed={editorState.isCode}
+ aria-label="Code"
+ tabIndex={-1}
+ >
+ Code
+
+ editor.chain().focus().unsetAllMarks().run()}
+ aria-label="Clear Marks"
+ tabIndex={-1}
+ >
+ Clear marks
+
+
+ )
+}
diff --git a/demos/src/Examples/Accessibility/React/index.html b/demos/src/Examples/Accessibility/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Accessibility/React/index.tsx b/demos/src/Examples/Accessibility/React/index.tsx
new file mode 100644
index 0000000..b168c22
--- /dev/null
+++ b/demos/src/Examples/Accessibility/React/index.tsx
@@ -0,0 +1,42 @@
+import './styles.scss'
+
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+import { InsertMenu } from './InsertMenu.jsx'
+import { MenuBar } from './MenuBar.jsx'
+import { TextMenu } from './TextMenu.jsx'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [StarterKit],
+ content: `
+ Accessibility Demo
+ Tab into the demo & navigate around with only your keyboard
+ Use Alt + F10 to focus the menu bar
+ `,
+ editorProps: {
+ attributes: {
+ // Make sure the editor is announced as a rich text editor
+ 'aria-label': 'Rich Text Editor',
+ // editor accepts multiline input
+ 'aria-multiline': 'true',
+ 'aria-readonly': 'false',
+ },
+ },
+ })
+
+ return (
+
+ {/* The static menu bar */}
+
+ {/* The editor content */}
+
+ {/* Handles marks: bold, italic, etc. using a bubble menu */}
+
+ {/* Handles nodes: headings, lists, etc. using a floating menu */}
+
+
+ )
+}
diff --git a/demos/src/Examples/Accessibility/React/styles.scss b/demos/src/Examples/Accessibility/React/styles.scss
new file mode 100644
index 0000000..7c73666
--- /dev/null
+++ b/demos/src/Examples/Accessibility/React/styles.scss
@@ -0,0 +1,219 @@
+.tiptap {
+ /**
+ * This fixes a bug with voiceover where it speaks text that runs from one element to another as one word like so:
+ *
+ * Heading
+ * Paragraph
+ *
+ * Voiceover would read this as "HeadingParagraph" instead of "Heading Paragraph"
+ * So, we add a zero-width space after each block element to fix this.
+ */
+ h1::after,
+ h2::after,
+ h3::after,
+ h4::after,
+ h5::after,
+ h6::after,
+ p::after {
+ content: '\200B';
+ }
+}
+
+.node-type-dropdown__container {
+ position: relative;
+}
+
+.node-type-dropdown__items {
+ display: flex;
+ flex-direction: column;
+ position: absolute;
+ left: 0;
+ right: 0;
+ gap: 0.25rem;
+ padding: 0.25rem 0;
+ background-color: var(--white);
+ z-index: 1000;
+
+ button {
+ background-color: unset;
+
+ &:hover {
+ background-color: var(--gray-3);
+ }
+
+ &.is-active {
+ background-color: var(--purple);
+
+ &:hover {
+ background-color: var(--purple-contrast);
+ }
+ }
+ }
+}
+
+/* Bubble menu styles */
+.bubble-menu {
+ background-color: var(--white);
+ border: 1px solid var(--gray-1);
+ border-radius: 0.7rem;
+ box-shadow: var(--shadow);
+ display: flex;
+ padding: 0.2rem;
+
+ button {
+ background-color: unset;
+
+ &:hover {
+ background-color: var(--gray-3);
+ }
+
+ &.is-active {
+ background-color: var(--purple);
+
+ &:hover {
+ background-color: var(--purple-contrast);
+ }
+ }
+ }
+}
+
+/* Floating menu styles */
+.floating-menu {
+ display: flex;
+ background-color: var(--gray-3);
+ padding: 0.1rem;
+ border-radius: 0.5rem;
+
+ button {
+ background-color: unset;
+ padding: 0.275rem 0.425rem;
+ border-radius: 0.3rem;
+
+ &:hover {
+ background-color: var(--gray-3);
+ }
+
+ &.is-active {
+ background-color: var(--white);
+ color: var(--purple);
+
+ &:hover {
+ color: var(--purple-contrast);
+ }
+ }
+ }
+}
+
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+ &:focus {
+ outline: 1px solid var(--purple);
+ outline-offset: 4px;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+
+ /* Link styles */
+ a {
+ color: var(--purple);
+ cursor: pointer;
+
+ &:hover {
+ color: var(--purple-contrast);
+ }
+ }
+}
+
+.is-empty::before {
+ color: var(--gray-4);
+ content: attr(data-placeholder);
+ float: left;
+ height: 0;
+ pointer-events: none;
+}
diff --git a/demos/src/Examples/Accessibility/React/useMenubarNav.ts b/demos/src/Examples/Accessibility/React/useMenubarNav.ts
new file mode 100644
index 0000000..b6b4406
--- /dev/null
+++ b/demos/src/Examples/Accessibility/React/useMenubarNav.ts
@@ -0,0 +1,127 @@
+import type { Editor } from '@tiptap/core'
+import type React from 'react'
+import { useCallback, useEffect, useRef } from 'react'
+
+/**
+ * Handle arrow navigation within a menu bar container, and allow to escape to the editor
+ */
+export function useMenubarNav({
+ ref: containerRef,
+ editor,
+ onEscape = (e, ctx) => {
+ e.preventDefault()
+ ctx.editor.commands.focus()
+ },
+ onKeydown = () => {
+ // Do nothing
+ },
+}: {
+ /**
+ * Ref to the menu bar container
+ */
+ ref: React.RefObject
+ /**
+ * The editor instance
+ */
+ editor: Editor
+ /**
+ * Callback when the user presses the escape key
+ */
+ onEscape?: (
+ event: KeyboardEvent,
+ ctx: { editor: Editor; ref: React.RefObject },
+ ) => void
+ /**
+ * Callback when a keyboard event occurs
+ * @note Call `event.preventDefault()` to prevent the default behavior
+ */
+ onKeydown?: (
+ event: KeyboardEvent,
+ ctx: { editor: Editor; ref: React.RefObject },
+ ) => void
+}) {
+ const callbacks = useRef({
+ onEscape,
+ onKeydown,
+ })
+
+ // Always take the latest callback
+ callbacks.current = {
+ onEscape,
+ onKeydown,
+ }
+
+ const getFocusableElements = useCallback(() => {
+ if (!containerRef.current) {
+ return []
+ }
+
+ return Array.from(
+ containerRef.current.querySelectorAll('button, [role="button"], [tabindex="0"]'),
+ ).filter(el => !el.hasAttribute('disabled'))
+ }, [containerRef])
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (!containerRef.current) {
+ return
+ }
+
+ callbacks.current.onKeydown(event, { editor, ref: containerRef })
+ if (event.defaultPrevented) {
+ return
+ }
+
+ const elements = getFocusableElements()
+ const isWithinContainer = containerRef.current.contains(event.target as HTMLElement)
+
+ if (isWithinContainer) {
+ // Allow to escape to the editor
+ if (event.key === 'Escape') {
+ callbacks.current.onEscape(event, { editor, ref: containerRef })
+ if (event.defaultPrevented) {
+ return true
+ }
+ }
+
+ // Move to the first element in the menu bar
+ if (event.key === 'Home') {
+ event.preventDefault()
+ elements[0].focus()
+ return true
+ }
+
+ // Move to the last element in the menu bar
+ if (event.key === 'End') {
+ event.preventDefault()
+ elements[elements.length - 1].focus()
+ return true
+ }
+
+ // Move forward in the menu bar
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
+ const element = elements.indexOf(event.target as HTMLElement)
+
+ elements[(element + 1) % elements.length].focus()
+ event.preventDefault()
+ }
+
+ // Move backward in the menu bar
+ if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
+ const element = elements.indexOf(event.target as HTMLElement)
+
+ elements[(element - 1 + elements.length) % elements.length].focus()
+ event.preventDefault()
+ }
+ }
+ }
+
+ window.addEventListener('keydown', handleKeyDown)
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown)
+ }
+ }, [containerRef, editor, getFocusableElements])
+
+ return { getFocusableElements }
+}
diff --git a/demos/src/Examples/AutolinkValidation/React/index.html b/demos/src/Examples/AutolinkValidation/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/AutolinkValidation/React/index.jsx b/demos/src/Examples/AutolinkValidation/React/index.jsx
new file mode 100644
index 0000000..e34370a
--- /dev/null
+++ b/demos/src/Examples/AutolinkValidation/React/index.jsx
@@ -0,0 +1,83 @@
+import './styles.scss'
+
+import Link from '@tiptap/extension-link'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ Link.configure({
+ validate: link => /^https?:\/\//.test(link),
+ }),
+ ],
+ content: `
+ Hey! Try to type in url with and without a http/s protocol. - Links without a protocol should not get auto linked
+ `,
+ editorProps: {
+ attributes: {
+ spellcheck: 'false',
+ },
+ },
+ shouldRerenderOnTransaction: true,
+ })
+
+ const editorState = useEditorState({
+ editor,
+ selector: ctx => {
+ return {
+ isLink: ctx.editor.isActive('link'),
+ }
+ },
+ })
+
+ const setLink = React.useCallback(() => {
+ const previousUrl = editor.getAttributes('link').href
+ const url = window.prompt('URL', previousUrl)
+
+ // cancelled
+ if (url === null) {
+ return
+ }
+
+ // empty
+ if (url === '') {
+ editor.chain().focus().extendMarkRange('link').unsetLink().run()
+
+ return
+ }
+
+ // update link
+ editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
+ }, [editor])
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+
+ Set link
+
+ editor.chain().focus().unsetLink().run()}
+ disabled={!editorState.isLink}
+ data-testid="unsetLink"
+ >
+ Unset link
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Examples/AutolinkValidation/React/styles.scss b/demos/src/Examples/AutolinkValidation/React/styles.scss
new file mode 100644
index 0000000..1db630c
--- /dev/null
+++ b/demos/src/Examples/AutolinkValidation/React/styles.scss
@@ -0,0 +1,101 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+
+ /* Link styles */
+ a {
+ color: var(--purple);
+ cursor: pointer;
+
+ &:hover {
+ color: var(--purple-contrast);
+ }
+ }
+}
diff --git a/demos/src/Examples/AutolinkValidation/Vue/index.html b/demos/src/Examples/AutolinkValidation/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/AutolinkValidation/Vue/index.vue b/demos/src/Examples/AutolinkValidation/Vue/index.vue
new file mode 100644
index 0000000..5aaa9de
--- /dev/null
+++ b/demos/src/Examples/AutolinkValidation/Vue/index.vue
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/AutolinkValidation/index.spec.ts b/demos/src/Examples/AutolinkValidation/index.spec.ts
new file mode 100644
index 0000000..83d0b60
--- /dev/null
+++ b/demos/src/Examples/AutolinkValidation/index.spec.ts
@@ -0,0 +1,151 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'AutolinkValidation'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Examples'
+
+const validLinks: [string, string][] = [
+ ['https://tiptap.dev ', 'https://tiptap.dev'],
+ ['http://tiptap.dev ', 'http://tiptap.dev'],
+ ['https://www.tiptap.dev/ ', 'https://www.tiptap.dev/'],
+ ['http://www.tiptap.dev/ ', 'http://www.tiptap.dev/'],
+ ['[http://www.example.com/] ', 'http://www.example.com/'],
+ ['(http://www.example.com/) ', 'http://www.example.com/'],
+]
+
+const invalidLinks = ['tiptap.dev', 'www.tiptap.dev', 'https://tiptap.dev']
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any) => {
+ el.editor.commands.clearContent()
+ })
+ })
+
+ validLinks.forEach(([rawTextInput, textThatShouldBeLinked]) => {
+ test(`should autolink ${rawTextInput}`, async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type(rawTextInput)
+
+ await expect(page.locator('.tiptap a').first()).toContainText(textThatShouldBeLinked)
+ })
+ })
+
+ invalidLinks.forEach(rawTextInput => {
+ test(`should not autolink ${rawTextInput}`, async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type(rawTextInput)
+
+ await expect(page.locator('.tiptap a')).toHaveCount(0)
+ })
+ })
+
+ if (frameworkPath === 'React') {
+ test('should not relink unset links after entering second link', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type('https://tiptap.dev ')
+ await editor.press('Home')
+
+ await expect(page.locator('.tiptap')).toHaveText('https://tiptap.dev ')
+
+ await page.locator('[data-testid=unsetLink]').click()
+ await expect(page.locator('.tiptap a')).toHaveCount(0)
+
+ await editor.press('End')
+ await editor.type('http://www.example.com/ ')
+
+ await expect(page.locator('.tiptap a')).toHaveCount(1)
+ await expect(page.locator('.tiptap a').first()).toHaveAttribute(
+ 'href',
+ 'http://www.example.com/',
+ )
+ })
+
+ test('should not relink unset links after hitting next paragraph', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type('https://tiptap.dev ')
+ await editor.press('Home')
+ await expect(page.locator('.tiptap')).toHaveText('https://tiptap.dev ')
+
+ await page.locator('[data-testid=unsetLink]').click()
+ await expect(page.locator('.tiptap a')).toHaveCount(0)
+
+ await editor.press('End')
+ await editor.type(
+ 'typing other text should prevent the link from relinking when hitting enter',
+ )
+ await editor.press('Enter')
+
+ await expect(page.locator('.tiptap a')).toHaveCount(0)
+ })
+
+ test('should not relink unset links after modifying', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type('https://tiptap.dev ')
+ await editor.press('Home')
+ await expect(page.locator('.tiptap')).toHaveText('https://tiptap.dev ')
+
+ await page.locator('[data-testid=unsetLink]').click()
+ await expect(page.locator('.tiptap a')).toHaveCount(0)
+
+ await editor.press('Home')
+ await Array.from({ length: 'https://'.length }).reduce>(
+ previousPromise => previousPromise.then(() => editor.press('ArrowRight')),
+ Promise.resolve(),
+ )
+ await editor.type('blah')
+
+ await expect(page.locator('.tiptap')).toHaveText('https://blahtiptap.dev ')
+ await expect(page.locator('.tiptap a')).toHaveCount(0)
+ })
+
+ test('should autolink after hitting enter (new paragraph)', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type('https://tiptap.dev')
+ await editor.press('Enter')
+
+ await expect(page.locator('.tiptap a')).toHaveCount(1)
+ await expect(page.locator('.tiptap a').first()).toHaveAttribute(
+ 'href',
+ 'https://tiptap.dev',
+ )
+ })
+
+ test('should autolink after hitting shift-enter (hardbreak)', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type('https://tiptap.dev')
+ await editor.press('Shift+Enter')
+
+ await expect(page.locator('.tiptap a')).toHaveCount(1)
+ await expect(page.locator('.tiptap a').first()).toHaveAttribute(
+ 'href',
+ 'https://tiptap.dev',
+ )
+ })
+ }
+ })
+ })
+})
diff --git a/demos/src/Examples/Book/React/index.html b/demos/src/Examples/Book/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Book/React/index.jsx b/demos/src/Examples/Book/React/index.jsx
new file mode 100644
index 0000000..99350d2
--- /dev/null
+++ b/demos/src/Examples/Book/React/index.jsx
@@ -0,0 +1,157 @@
+import './styles.scss'
+
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+import { content } from '../content.js'
+
+const menuBarStateSelector = ctx => ({
+ isBold: ctx.editor.isActive('bold') ?? false,
+ isItalic: ctx.editor.isActive('italic') ?? false,
+ isStrike: ctx.editor.isActive('strike') ?? false,
+ isCode: ctx.editor.isActive('code') ?? false,
+ isParagraph: ctx.editor.isActive('paragraph') ?? false,
+ isHeading1: ctx.editor.isActive('heading', { level: 1 }) ?? false,
+ isHeading2: ctx.editor.isActive('heading', { level: 2 }) ?? false,
+ isHeading3: ctx.editor.isActive('heading', { level: 3 }) ?? false,
+ isHeading4: ctx.editor.isActive('heading', { level: 4 }) ?? false,
+ isHeading5: ctx.editor.isActive('heading', { level: 5 }) ?? false,
+ isHeading6: ctx.editor.isActive('heading', { level: 6 }) ?? false,
+ isBulletList: ctx.editor.isActive('bulletList') ?? false,
+ isOrderedList: ctx.editor.isActive('orderedList') ?? false,
+ isCodeBlock: ctx.editor.isActive('codeBlock') ?? false,
+ isBlockquote: ctx.editor.isActive('blockquote') ?? false,
+})
+
+const MenuBar = ({ editor }) => {
+ const editorState = useEditorState({ editor, selector: menuBarStateSelector })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+
+
+ editor.chain().focus().toggleBold().run()}
+ className={editorState.isBold ? 'is-active' : ''}
+ >
+ Bold
+
+ editor.chain().focus().toggleItalic().run()}
+ className={editorState.isItalic ? 'is-active' : ''}
+ >
+ Italic
+
+ editor.chain().focus().toggleStrike().run()}
+ className={editorState.isStrike ? 'is-active' : ''}
+ >
+ Strike
+
+ editor.chain().focus().toggleCode().run()}
+ className={editorState.isCode ? 'is-active' : ''}
+ >
+ Code
+
+ editor.chain().focus().unsetAllMarks().run()}>Clear marks
+ editor.chain().focus().clearNodes().run()}>Clear nodes
+ editor.chain().focus().setParagraph().run()}
+ className={editorState.isParagraph ? 'is-active' : ''}
+ >
+ Paragraph
+
+ editor.chain().focus().toggleHeading({ level: 1 }).run()}
+ className={editorState.isHeading1 ? 'is-active' : ''}
+ >
+ H1
+
+ editor.chain().focus().toggleHeading({ level: 2 }).run()}
+ className={editorState.isHeading2 ? 'is-active' : ''}
+ >
+ H2
+
+ editor.chain().focus().toggleHeading({ level: 3 }).run()}
+ className={editorState.isHeading3 ? 'is-active' : ''}
+ >
+ H3
+
+ editor.chain().focus().toggleHeading({ level: 4 }).run()}
+ className={editorState.isHeading4 ? 'is-active' : ''}
+ >
+ H4
+
+ editor.chain().focus().toggleHeading({ level: 5 }).run()}
+ className={editorState.isHeading5 ? 'is-active' : ''}
+ >
+ H5
+
+ editor.chain().focus().toggleHeading({ level: 6 }).run()}
+ className={editorState.isHeading6 ? 'is-active' : ''}
+ >
+ H6
+
+ editor.chain().focus().toggleBulletList().run()}
+ className={editorState.isBulletList ? 'is-active' : ''}
+ >
+ Bullet list
+
+ editor.chain().focus().toggleOrderedList().run()}
+ className={editorState.isOrderedList ? 'is-active' : ''}
+ >
+ Ordered list
+
+ editor.chain().focus().toggleCodeBlock().run()}
+ className={editorState.isCodeBlock ? 'is-active' : ''}
+ >
+ Code block
+
+ editor.chain().focus().toggleBlockquote().run()}
+ className={editorState.isBlockquote ? 'is-active' : ''}
+ >
+ Blockquote
+
+ editor.chain().focus().setHorizontalRule().run()}>
+ Horizontal rule
+
+ editor.chain().focus().setHardBreak().run()}>Hard break
+ editor.chain().focus().undo().run()}>Undo
+ editor.chain().focus().redo().run()}>Redo
+
+
+ )
+}
+
+export default () => {
+ const editor = useEditor({
+ extensions: [StarterKit],
+ content,
+ editorProps: {
+ attributes: {
+ spellcheck: 'false',
+ },
+ },
+ })
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/demos/src/Examples/Book/React/styles.scss b/demos/src/Examples/Book/React/styles.scss
new file mode 100644
index 0000000..15283b1
--- /dev/null
+++ b/demos/src/Examples/Book/React/styles.scss
@@ -0,0 +1,91 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
diff --git a/demos/src/Examples/Book/Vue/index.html b/demos/src/Examples/Book/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Book/Vue/index.vue b/demos/src/Examples/Book/Vue/index.vue
new file mode 100644
index 0000000..14118c9
--- /dev/null
+++ b/demos/src/Examples/Book/Vue/index.vue
@@ -0,0 +1,234 @@
+
+
+
+
+
+ Bold
+
+
+ Italic
+
+
+ Strike
+
+
+ Code
+
+ Clear marks
+ Clear nodes
+
+ Paragraph
+
+
+ H1
+
+
+ H2
+
+
+ H3
+
+
+ H4
+
+
+ H5
+
+
+ H6
+
+
+ Bullet list
+
+
+ Ordered list
+
+
+ Code block
+
+
+ Blockquote
+
+ Horizontal rule
+ Hard break
+ Undo
+ Redo
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/CSSModules/React/index.html b/demos/src/Examples/CSSModules/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/CSSModules/React/index.jsx b/demos/src/Examples/CSSModules/React/index.jsx
new file mode 100644
index 0000000..20fbdb6
--- /dev/null
+++ b/demos/src/Examples/CSSModules/React/index.jsx
@@ -0,0 +1,179 @@
+import './styles.scss'
+
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+import styles from './index.module.css'
+
+const menuBarStateSelector = ctx => ({
+ isBold: ctx.editor.isActive('bold') ?? false,
+ isItalic: ctx.editor.isActive('italic') ?? false,
+ isStrike: ctx.editor.isActive('strike') ?? false,
+ isCode: ctx.editor.isActive('code') ?? false,
+ isParagraph: ctx.editor.isActive('paragraph') ?? false,
+ isHeading1: ctx.editor.isActive('heading', { level: 1 }) ?? false,
+ isHeading2: ctx.editor.isActive('heading', { level: 2 }) ?? false,
+ isHeading3: ctx.editor.isActive('heading', { level: 3 }) ?? false,
+ isHeading4: ctx.editor.isActive('heading', { level: 4 }) ?? false,
+ isHeading5: ctx.editor.isActive('heading', { level: 5 }) ?? false,
+ isHeading6: ctx.editor.isActive('heading', { level: 6 }) ?? false,
+ isBulletList: ctx.editor.isActive('bulletList') ?? false,
+ isOrderedList: ctx.editor.isActive('orderedList') ?? false,
+ isCodeBlock: ctx.editor.isActive('codeBlock') ?? false,
+ isBlockquote: ctx.editor.isActive('blockquote') ?? false,
+})
+
+const MenuBar = ({ editor }) => {
+ const editorState = useEditorState({ editor, selector: menuBarStateSelector })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+
+ editor.chain().focus().toggleBold().run()}
+ className={editorState.isBold ? 'is-active' : ''}
+ >
+ Bold
+
+ editor.chain().focus().toggleItalic().run()}
+ className={editorState.isItalic ? 'is-active' : ''}
+ >
+ Italic
+
+ editor.chain().focus().toggleStrike().run()}
+ className={editorState.isStrike ? 'is-active' : ''}
+ >
+ Strike
+
+ editor.chain().focus().toggleCode().run()}
+ className={editorState.isCode ? 'is-active' : ''}
+ >
+ Code
+
+ editor.chain().focus().unsetAllMarks().run()}>Clear marks
+ editor.chain().focus().clearNodes().run()}>Clear nodes
+ editor.chain().focus().setParagraph().run()}
+ className={editorState.isParagraph ? 'is-active' : ''}
+ >
+ Paragraph
+
+ editor.chain().focus().toggleHeading({ level: 1 }).run()}
+ className={editorState.isHeading1 ? 'is-active' : ''}
+ >
+ H1
+
+ editor.chain().focus().toggleHeading({ level: 2 }).run()}
+ className={editorState.isHeading2 ? 'is-active' : ''}
+ >
+ H2
+
+ editor.chain().focus().toggleHeading({ level: 3 }).run()}
+ className={editorState.isHeading3 ? 'is-active' : ''}
+ >
+ H3
+
+ editor.chain().focus().toggleHeading({ level: 4 }).run()}
+ className={editorState.isHeading4 ? 'is-active' : ''}
+ >
+ H4
+
+ editor.chain().focus().toggleHeading({ level: 5 }).run()}
+ className={editorState.isHeading5 ? 'is-active' : ''}
+ >
+ H5
+
+ editor.chain().focus().toggleHeading({ level: 6 }).run()}
+ className={editorState.isHeading6 ? 'is-active' : ''}
+ >
+ H6
+
+ editor.chain().focus().toggleBulletList().run()}
+ className={editorState.isBulletList ? 'is-active' : ''}
+ >
+ Bullet list
+
+ editor.chain().focus().toggleOrderedList().run()}
+ className={editorState.isOrderedList ? 'is-active' : ''}
+ >
+ Ordered list
+
+ editor.chain().focus().toggleCodeBlock().run()}
+ className={editorState.isCodeBlock ? 'is-active' : ''}
+ >
+ Code block
+
+ editor.chain().focus().toggleBlockquote().run()}
+ className={editorState.isBlockquote ? 'is-active' : ''}
+ >
+ Blockquote
+
+ editor.chain().focus().setHorizontalRule().run()}>
+ Horizontal rule
+
+ editor.chain().focus().setHardBreak().run()}>Hard break
+ editor.chain().focus().undo().run()}>Undo
+ editor.chain().focus().redo().run()}>Redo
+
+ )
+}
+
+export default () => {
+ const editor = useEditor({
+ extensions: [StarterKit],
+ content: `
+
+ Hi there,
+
+
+ this is a basic example of Tiptap . Sure, there are all kind of basic text styles you’d probably expect from a text editor. But wait until you see the lists:
+
+
+
+ That’s a bullet list with one …
+
+
+ … or two list items.
+
+
+
+ Isn’t that great? And all of that is editable. But wait, there’s more. Let’s try a code block:
+
+ body {
+ display: none;
+}
+
+ I know, I know, this is impressive. It’s only the tip of the iceberg though. Give it a try and click a little bit around. Don’t forget to check the other examples too.
+
+
+ Wow, that’s amazing. Good work, boy! 👏
+
+ — Mom
+
+ `,
+ })
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/demos/src/Examples/CSSModules/React/index.module.css b/demos/src/Examples/CSSModules/React/index.module.css
new file mode 100644
index 0000000..8936631
--- /dev/null
+++ b/demos/src/Examples/CSSModules/React/index.module.css
@@ -0,0 +1,4 @@
+.toolbar {
+ background-color: red;
+ padding: 16px;
+}
diff --git a/demos/src/Examples/CSSModules/React/styles.scss b/demos/src/Examples/CSSModules/React/styles.scss
new file mode 100644
index 0000000..15283b1
--- /dev/null
+++ b/demos/src/Examples/CSSModules/React/styles.scss
@@ -0,0 +1,91 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
diff --git a/demos/src/Examples/CSSModules/Vue/index.html b/demos/src/Examples/CSSModules/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/CSSModules/Vue/index.module.css b/demos/src/Examples/CSSModules/Vue/index.module.css
new file mode 100644
index 0000000..8936631
--- /dev/null
+++ b/demos/src/Examples/CSSModules/Vue/index.module.css
@@ -0,0 +1,4 @@
+.toolbar {
+ background-color: red;
+ padding: 16px;
+}
diff --git a/demos/src/Examples/CSSModules/Vue/index.vue b/demos/src/Examples/CSSModules/Vue/index.vue
new file mode 100644
index 0000000..2c56d02
--- /dev/null
+++ b/demos/src/Examples/CSSModules/Vue/index.vue
@@ -0,0 +1,255 @@
+
+
+
+ Bold
+
+
+ Italic
+
+
+ Strike
+
+
+ Code
+
+ Clear marks
+ Clear nodes
+
+ Paragraph
+
+
+ H1
+
+
+ H2
+
+
+ H3
+
+
+ H4
+
+
+ H5
+
+
+ H6
+
+
+ Bullet list
+
+
+ Ordered list
+
+
+ Code block
+
+
+ Blockquote
+
+ Horizontal rule
+ Hard break
+ Undo
+ Redo
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/CSSModules/index.spec.ts b/demos/src/Examples/CSSModules/index.spec.ts
new file mode 100644
index 0000000..62f076e
--- /dev/null
+++ b/demos/src/Examples/CSSModules/index.spec.ts
@@ -0,0 +1,25 @@
+import { expect, test } from '@playwright/test'
+
+const demoName = 'CSSModules'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Examples'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test('should apply the toolbar styles from the CSS module', async ({ page }) => {
+ const toolbar = page.locator('.toolbar')
+
+ await expect(toolbar).toBeVisible()
+ await expect(toolbar).toHaveCSS('background-color', 'rgb(255, 0, 0)')
+ await expect(toolbar).toHaveCSS('padding', '16px')
+ })
+ })
+ })
+})
diff --git a/demos/src/Examples/CodeBlockLanguage/React/CodeBlockComponent.jsx b/demos/src/Examples/CodeBlockLanguage/React/CodeBlockComponent.jsx
new file mode 100644
index 0000000..0b68a52
--- /dev/null
+++ b/demos/src/Examples/CodeBlockLanguage/React/CodeBlockComponent.jsx
@@ -0,0 +1,31 @@
+import './CodeBlockComponent.scss'
+
+import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
+import React from 'react'
+
+export default ({
+ node: {
+ attrs: { language: defaultLanguage },
+ },
+ updateAttributes,
+ extension,
+}) => (
+
+ updateAttributes({ language: event.target.value })}
+ >
+ auto
+ —
+ {extension.options.lowlight.listLanguages().map((lang, index) => (
+
+ {lang}
+
+ ))}
+
+
+
+
+
+)
diff --git a/demos/src/Examples/CodeBlockLanguage/React/CodeBlockComponent.scss b/demos/src/Examples/CodeBlockLanguage/React/CodeBlockComponent.scss
new file mode 100644
index 0000000..d5f5ed3
--- /dev/null
+++ b/demos/src/Examples/CodeBlockLanguage/React/CodeBlockComponent.scss
@@ -0,0 +1,13 @@
+.tiptap {
+ .code-block {
+ position: relative;
+
+ select {
+ position: absolute;
+ background-color: var(--white);
+ background-image: url('data:image/svg+xml;utf8, ');
+ right: 0.5rem;
+ top: 0.5rem;
+ }
+ }
+}
diff --git a/demos/src/Examples/CodeBlockLanguage/React/index.html b/demos/src/Examples/CodeBlockLanguage/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/CodeBlockLanguage/React/index.jsx b/demos/src/Examples/CodeBlockLanguage/React/index.jsx
new file mode 100644
index 0000000..5cb1b32
--- /dev/null
+++ b/demos/src/Examples/CodeBlockLanguage/React/index.jsx
@@ -0,0 +1,93 @@
+import './styles.scss'
+
+import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, ReactNodeViewRenderer, useEditor, useEditorState } from '@tiptap/react'
+import css from 'highlight.js/lib/languages/css'
+import js from 'highlight.js/lib/languages/javascript'
+import ts from 'highlight.js/lib/languages/typescript'
+import html from 'highlight.js/lib/languages/xml'
+// load all languages with "all" or common languages with "common"
+import { all, createLowlight } from 'lowlight'
+import React from 'react'
+
+// oxlint-disable-next-line
+import CodeBlockComponent from './CodeBlockComponent'
+
+// create a lowlight instance
+const lowlight = createLowlight(all)
+
+// you can also register individual languages
+lowlight.register('html', html)
+lowlight.register('css', css)
+lowlight.register('js', js)
+lowlight.register('ts', ts)
+
+const MenuBar = ({ editor }) => {
+ const { isCodeBlock } = useEditorState({
+ editor,
+ selector: ctx => ({
+ isCodeBlock: ctx.editor.isActive('codeBlock') ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+
+
+ editor.chain().focus().toggleCodeBlock().run()}
+ className={isCodeBlock ? 'is-active' : ''}
+ >
+ Toggle code block
+
+
+
+ )
+}
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ CodeBlockLowlight.extend({
+ addNodeView() {
+ return ReactNodeViewRenderer(CodeBlockComponent)
+ },
+ }).configure({ lowlight }),
+ ],
+ content: `
+
+ That's a boring paragraph followed by a fenced code block:
+
+ for (var i=1; i <= 20; i++)
+{
+ if (i % 15 == 0)
+ console.log("FizzBuzz");
+ else if (i % 3 == 0)
+ console.log("Fizz");
+ else if (i % 5 == 0)
+ console.log("Buzz");
+ else
+ console.log(i);
+}
+
+ Press Command/Ctrl + Enter to leave the fenced code block and continue typing in boring paragraphs.
+
+ `,
+ })
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/demos/src/Examples/CodeBlockLanguage/React/styles.scss b/demos/src/Examples/CodeBlockLanguage/React/styles.scss
new file mode 100644
index 0000000..a848474
--- /dev/null
+++ b/demos/src/Examples/CodeBlockLanguage/React/styles.scss
@@ -0,0 +1,75 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+
+ /* Code styling */
+ .hljs-comment,
+ .hljs-quote {
+ color: #616161;
+ }
+
+ .hljs-variable,
+ .hljs-template-variable,
+ .hljs-attribute,
+ .hljs-tag,
+ .hljs-name,
+ .hljs-regexp,
+ .hljs-link,
+ .hljs-name,
+ .hljs-selector-id,
+ .hljs-selector-class {
+ color: #f98181;
+ }
+
+ .hljs-number,
+ .hljs-meta,
+ .hljs-built_in,
+ .hljs-builtin-name,
+ .hljs-literal,
+ .hljs-type,
+ .hljs-params {
+ color: #fbbc88;
+ }
+
+ .hljs-string,
+ .hljs-symbol,
+ .hljs-bullet {
+ color: #b9f18d;
+ }
+
+ .hljs-title,
+ .hljs-section {
+ color: #faf594;
+ }
+
+ .hljs-keyword,
+ .hljs-selector-tag {
+ color: #70cff8;
+ }
+
+ .hljs-emphasis {
+ font-style: italic;
+ }
+
+ .hljs-strong {
+ font-weight: 700;
+ }
+ }
+}
diff --git a/demos/src/Examples/CodeBlockLanguage/Vue/CodeBlockComponent.vue b/demos/src/Examples/CodeBlockLanguage/Vue/CodeBlockComponent.vue
new file mode 100644
index 0000000..d6d8f8f
--- /dev/null
+++ b/demos/src/Examples/CodeBlockLanguage/Vue/CodeBlockComponent.vue
@@ -0,0 +1,58 @@
+
+
+
+ auto
+ —
+
+ {{ language }}
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/CodeBlockLanguage/Vue/index.html b/demos/src/Examples/CodeBlockLanguage/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/CodeBlockLanguage/Vue/index.vue b/demos/src/Examples/CodeBlockLanguage/Vue/index.vue
new file mode 100644
index 0000000..b90cf00
--- /dev/null
+++ b/demos/src/Examples/CodeBlockLanguage/Vue/index.vue
@@ -0,0 +1,168 @@
+
+
+
+
+
+ Toggle code block
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/CollaborativeEditing/React/MenuBar.jsx b/demos/src/Examples/CollaborativeEditing/React/MenuBar.jsx
new file mode 100644
index 0000000..f3d3294
--- /dev/null
+++ b/demos/src/Examples/CollaborativeEditing/React/MenuBar.jsx
@@ -0,0 +1,155 @@
+import './MenuBar.scss'
+
+import { useEditorState } from '@tiptap/react'
+import React, { Fragment } from 'react'
+
+import MenuItem from './MenuItem.jsx'
+
+export default ({ editor }) => {
+ const editorState = useEditorState({
+ editor,
+ selector: ctx => ({
+ isBold: ctx.editor.isActive('bold') ?? false,
+ isItalic: ctx.editor.isActive('italic') ?? false,
+ isStrike: ctx.editor.isActive('strike') ?? false,
+ isCode: ctx.editor.isActive('code') ?? false,
+ isHighlight: ctx.editor.isActive('highlight') ?? false,
+ isHeading1: ctx.editor.isActive('heading', { level: 1 }) ?? false,
+ isHeading2: ctx.editor.isActive('heading', { level: 2 }) ?? false,
+ isParagraph: ctx.editor.isActive('paragraph') ?? false,
+ isBulletList: ctx.editor.isActive('bulletList') ?? false,
+ isOrderedList: ctx.editor.isActive('orderedList') ?? false,
+ isTaskList: ctx.editor.isActive('taskList') ?? false,
+ isCodeBlock: ctx.editor.isActive('codeBlock') ?? false,
+ isBlockquote: ctx.editor.isActive('blockquote') ?? false,
+ }),
+ })
+
+ const items = [
+ {
+ icon: 'bold',
+ title: 'Bold',
+ action: () => editor.chain().focus().toggleBold().run(),
+ isActive: () => editorState.isBold,
+ },
+ {
+ icon: 'italic',
+ title: 'Italic',
+ action: () => editor.chain().focus().toggleItalic().run(),
+ isActive: () => editorState.isItalic,
+ },
+ {
+ icon: 'strikethrough',
+ title: 'Strike',
+ action: () => editor.chain().focus().toggleStrike().run(),
+ isActive: () => editorState.isStrike,
+ },
+ {
+ icon: 'code-view',
+ title: 'Code',
+ action: () => editor.chain().focus().toggleCode().run(),
+ isActive: () => editorState.isCode,
+ },
+ {
+ icon: 'mark-pen-line',
+ title: 'Highlight',
+ action: () => editor.chain().focus().toggleHighlight().run(),
+ isActive: () => editorState.isHighlight,
+ },
+ {
+ type: 'divider',
+ },
+ {
+ icon: 'h-1',
+ title: 'Heading 1',
+ action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
+ isActive: () => editorState.isHeading1,
+ },
+ {
+ icon: 'h-2',
+ title: 'Heading 2',
+ action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
+ isActive: () => editorState.isHeading2,
+ },
+ {
+ icon: 'paragraph',
+ title: 'Paragraph',
+ action: () => editor.chain().focus().setParagraph().run(),
+ isActive: () => editorState.isParagraph,
+ },
+ {
+ icon: 'list-unordered',
+ title: 'Bullet list',
+ action: () => editor.chain().focus().toggleBulletList().run(),
+ isActive: () => editorState.isBulletList,
+ },
+ {
+ icon: 'list-ordered',
+ title: 'Ordered list',
+ action: () => editor.chain().focus().toggleOrderedList().run(),
+ isActive: () => editorState.isOrderedList,
+ },
+ {
+ icon: 'list-check-2',
+ title: 'Task list',
+ action: () => editor.chain().focus().toggleTaskList().run(),
+ isActive: () => editorState.isTaskList,
+ },
+ {
+ icon: 'code-box-line',
+ title: 'Code block',
+ action: () => editor.chain().focus().toggleCodeBlock().run(),
+ isActive: () => editorState.isCodeBlock,
+ },
+ {
+ type: 'divider',
+ },
+ {
+ icon: 'double-quotes-l',
+ title: 'Blockquote',
+ action: () => editor.chain().focus().toggleBlockquote().run(),
+ isActive: () => editorState.isBlockquote,
+ },
+ {
+ icon: 'separator',
+ title: 'Horizontal rule',
+ action: () => editor.chain().focus().setHorizontalRule().run(),
+ },
+ {
+ type: 'divider',
+ },
+ {
+ icon: 'text-wrap',
+ title: 'Hard break',
+ action: () => editor.chain().focus().setHardBreak().run(),
+ },
+ {
+ icon: 'format-clear',
+ title: 'Clear format',
+ action: () => editor.chain().focus().clearNodes().unsetAllMarks().run(),
+ },
+ {
+ type: 'divider',
+ },
+ {
+ icon: 'arrow-go-back-line',
+ title: 'Undo',
+ action: () => editor.chain().focus().undo().run(),
+ },
+ {
+ icon: 'arrow-go-forward-line',
+ title: 'Redo',
+ action: () => editor.chain().focus().redo().run(),
+ },
+ ]
+
+ return (
+
+ {items.map((item, index) => (
+
+ {item.type === 'divider' ?
: }
+
+ ))}
+
+ )
+}
diff --git a/demos/src/Examples/CollaborativeEditing/React/MenuBar.scss b/demos/src/Examples/CollaborativeEditing/React/MenuBar.scss
new file mode 100644
index 0000000..9f0833d
--- /dev/null
+++ b/demos/src/Examples/CollaborativeEditing/React/MenuBar.scss
@@ -0,0 +1,7 @@
+.divider {
+ background-color: rgba(#fff, 0.25);
+ height: 1.25rem;
+ margin-left: 0.5rem;
+ margin-right: 0.75rem;
+ width: 1px;
+}
diff --git a/demos/src/Examples/CollaborativeEditing/React/MenuItem.jsx b/demos/src/Examples/CollaborativeEditing/React/MenuItem.jsx
new file mode 100644
index 0000000..c9df642
--- /dev/null
+++ b/demos/src/Examples/CollaborativeEditing/React/MenuItem.jsx
@@ -0,0 +1,16 @@
+import './MenuItem.scss'
+
+import React from 'react'
+import remixiconUrl from 'remixicon/fonts/remixicon.symbol.svg'
+
+export default ({ icon, title, action, isActive = null }) => (
+
+
+
+
+
+)
diff --git a/demos/src/Examples/CollaborativeEditing/React/MenuItem.scss b/demos/src/Examples/CollaborativeEditing/React/MenuItem.scss
new file mode 100644
index 0000000..c2e2e18
--- /dev/null
+++ b/demos/src/Examples/CollaborativeEditing/React/MenuItem.scss
@@ -0,0 +1,22 @@
+.menu-item {
+ background-color: transparent;
+ border: none;
+ border-radius: 0.4rem;
+ color: #fff;
+ cursor: pointer;
+ height: 1.75rem;
+ margin-right: 0.25rem;
+ padding: 0.25rem;
+ width: 1.75rem;
+
+ svg {
+ fill: currentColor;
+ height: 100%;
+ width: 100%;
+ }
+
+ &:hover,
+ &.is-active {
+ background-color: #303030;
+ }
+}
diff --git a/demos/src/Examples/CollaborativeEditing/React/index.html b/demos/src/Examples/CollaborativeEditing/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/CollaborativeEditing/React/index.jsx b/demos/src/Examples/CollaborativeEditing/React/index.jsx
new file mode 100644
index 0000000..9b69282
--- /dev/null
+++ b/demos/src/Examples/CollaborativeEditing/React/index.jsx
@@ -0,0 +1,155 @@
+import './styles.scss'
+
+import { TiptapCollabProvider } from '@hocuspocus/provider'
+import Collaboration from '@tiptap/extension-collaboration'
+import CollaborationCaret from '@tiptap/extension-collaboration-caret'
+import Highlight from '@tiptap/extension-highlight'
+import { TaskItem, TaskList } from '@tiptap/extension-list'
+import { CharacterCount } from '@tiptap/extensions'
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React, { useCallback, useEffect, useState } from 'react'
+import * as Y from 'yjs'
+
+import { variables } from '../../../variables.js'
+import MenuBar from './MenuBar.jsx'
+
+const colors = ['#958DF1', '#F98181', '#FBBC88', '#FAF594', '#70CFF8', '#94FADB', '#B9F18D']
+const names = [
+ 'Lea Thompson',
+ 'Cyndi Lauper',
+ 'Tom Cruise',
+ 'Madonna',
+ 'Jerry Hall',
+ 'Joan Collins',
+ 'Winona Ryder',
+ 'Christina Applegate',
+ 'Alyssa Milano',
+ 'Molly Ringwald',
+ 'Ally Sheedy',
+ 'Debbie Harry',
+ 'Olivia Newton-John',
+ 'Elton John',
+ 'Michael J. Fox',
+ 'Axl Rose',
+ 'Emilio Estevez',
+ 'Ralph Macchio',
+ 'Rob Lowe',
+ 'Jennifer Grey',
+ 'Mickey Rourke',
+ 'John Cusack',
+ 'Matthew Broderick',
+ 'Justine Bateman',
+ 'Lisa Bonet',
+]
+
+const getRandomElement = list => list[Math.floor(Math.random() * list.length)]
+
+const getRandomRoom = () => {
+ const roomNumbers = variables.collabRooms?.trim()?.split(',') ?? [10, 11, 12]
+
+ return getRandomElement(roomNumbers.map(number => `rooms.${number}`))
+}
+const getRandomColor = () => getRandomElement(colors)
+const getRandomName = () => getRandomElement(names)
+
+const room = getRandomRoom()
+
+const ydoc = new Y.Doc()
+const websocketProvider = new TiptapCollabProvider({
+ appId: '7j9y6m10',
+ name: room,
+ document: ydoc,
+})
+
+const getInitialUser = () => {
+ return (
+ JSON.parse(localStorage.getItem('currentUser')) || {
+ name: getRandomName(),
+ color: getRandomColor(),
+ }
+ )
+}
+
+export default () => {
+ const [status, setStatus] = useState('connecting')
+ const [currentUser, setCurrentUser] = useState(getInitialUser)
+ const [userCount, setUserCount] = useState(0)
+
+ const editor = useEditor({
+ extensions: [
+ StarterKit.configure({
+ undoRedo: false,
+ }),
+ Highlight,
+ TaskList,
+ TaskItem,
+ CharacterCount.configure({
+ limit: 10000,
+ }),
+ Collaboration.configure({
+ document: ydoc,
+ }),
+ CollaborationCaret.configure({
+ provider: websocketProvider,
+ }),
+ ],
+ })
+
+ useEffect(() => {
+ // Update status changes
+ websocketProvider.on('status', event => {
+ setStatus(event.status)
+ })
+ }, [])
+
+ useEffect(() => {
+ if (!editor) {
+ return undefined
+ }
+
+ const updateUserCount = () => {
+ setUserCount(editor.storage.collaborationCaret.users.length)
+ }
+
+ updateUserCount()
+ websocketProvider.awareness.on('update', updateUserCount)
+
+ return () => {
+ websocketProvider.awareness.off('update', updateUserCount)
+ }
+ }, [editor])
+
+ // Save current user to localStorage and emit to editor
+ useEffect(() => {
+ if (editor && currentUser) {
+ localStorage.setItem('currentUser', JSON.stringify(currentUser))
+ editor.chain().focus().updateUser(currentUser).run()
+ }
+ }, [editor, currentUser])
+
+ const setName = useCallback(() => {
+ const name = (window.prompt('Name') || '').trim().substring(0, 32)
+
+ if (name) {
+ return setCurrentUser({ ...currentUser, name })
+ }
+ }, [currentUser])
+
+ return (
+
+ {editor &&
}
+
+
+
+ {status === 'connected'
+ ? `${userCount} user${userCount === 1 ? '' : 's'} online in ${room}`
+ : 'offline'}
+
+
+ {currentUser.name}
+
+
+
+ )
+}
diff --git a/demos/src/Examples/CollaborativeEditing/React/styles.scss b/demos/src/Examples/CollaborativeEditing/React/styles.scss
new file mode 100644
index 0000000..0826ea8
--- /dev/null
+++ b/demos/src/Examples/CollaborativeEditing/React/styles.scss
@@ -0,0 +1,199 @@
+/* Basic editor styles */
+.tiptap {
+ > * + * {
+ margin-top: 0.75em;
+ }
+
+ ul,
+ ol {
+ padding: 0 1rem;
+ }
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ }
+
+ code {
+ background-color: rgba(#616161, 0.1);
+ color: #616161;
+ }
+
+ pre {
+ background: #0d0d0d;
+ border-radius: 0.5rem;
+ color: #fff;
+ font-family: 'JetBrainsMono', monospace;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ mark {
+ background-color: #faf594;
+ }
+
+ img {
+ height: auto;
+ max-width: 100%;
+ }
+
+ hr {
+ margin: 1rem 0;
+ }
+
+ blockquote {
+ border-left: 2px solid rgba(#0d0d0d, 0.1);
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 2px solid rgba(#0d0d0d, 0.1);
+ margin: 2rem 0;
+ }
+
+ ul[data-type='taskList'] {
+ list-style: none;
+ padding: 0;
+
+ li {
+ align-items: center;
+ display: flex;
+
+ > label {
+ flex: 0 0 auto;
+ margin-right: 0.5rem;
+ user-select: none;
+ }
+
+ > div {
+ flex: 1 1 auto;
+ }
+ }
+ }
+}
+
+.editor {
+ background-color: #fff;
+ border: 3px solid #0d0d0d;
+ border-radius: 0.75rem;
+ color: #0d0d0d;
+ display: flex;
+ flex-direction: column;
+ max-height: 26rem;
+
+ &__header {
+ align-items: center;
+ background: #0d0d0d;
+ border-bottom: 3px solid #0d0d0d;
+ border-top-left-radius: 0.25rem;
+ border-top-right-radius: 0.25rem;
+ display: flex;
+ flex: 0 0 auto;
+ flex-wrap: wrap;
+ padding: 0.25rem;
+ }
+
+ &__content {
+ flex: 1 1 auto;
+ overflow-x: hidden;
+ overflow-y: auto;
+ padding: 1.25rem 1rem;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ &__footer {
+ align-items: center;
+ border-top: 3px solid #0d0d0d;
+ color: #0d0d0d;
+ display: flex;
+ flex: 0 0 auto;
+ font-size: 12px;
+ flex-wrap: wrap;
+ font-weight: 600;
+ justify-content: space-between;
+ padding: 0.25rem 0.75rem;
+ white-space: nowrap;
+ }
+
+ /* Some information about the status */
+ &__status {
+ align-items: center;
+ border-radius: 5px;
+ display: flex;
+
+ &::before {
+ background: rgba(#0d0d0d, 0.5);
+ border-radius: 50%;
+ content: ' ';
+ display: inline-block;
+ flex: 0 0 auto;
+ height: 0.5rem;
+ margin-right: 0.5rem;
+ width: 0.5rem;
+ }
+
+ &--connecting::before {
+ background: #616161;
+ }
+
+ &--connected::before {
+ background: #b9f18d;
+ }
+ }
+
+ &__name {
+ button {
+ background: none;
+ border: none;
+ border-radius: 0.4rem;
+ color: #0d0d0d;
+ font: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ padding: 0.25rem 0.5rem;
+
+ &:hover {
+ background-color: #0d0d0d;
+ color: #fff;
+ }
+ }
+ }
+}
+
+/* Give a remote user a caret */
+.collaboration-carets__caret {
+ border-left: 1px solid #0d0d0d;
+ border-right: 1px solid #0d0d0d;
+ margin-left: -1px;
+ margin-right: -1px;
+ pointer-events: none;
+ position: relative;
+ word-break: normal;
+}
+
+/* Render the username above the caret */
+.collaboration-carets__label {
+ border-radius: 3px 3px 3px 0;
+ color: #0d0d0d;
+ font-size: 12px;
+ font-style: normal;
+ font-weight: 600;
+ left: -1px;
+ line-height: normal;
+ padding: 0.1rem 0.3rem;
+ position: absolute;
+ top: -1.4em;
+ user-select: none;
+ white-space: nowrap;
+}
diff --git a/demos/src/Examples/CollaborativeEditing/Vue/MenuBar.vue b/demos/src/Examples/CollaborativeEditing/Vue/MenuBar.vue
new file mode 100644
index 0000000..b3c72f9
--- /dev/null
+++ b/demos/src/Examples/CollaborativeEditing/Vue/MenuBar.vue
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/CollaborativeEditing/Vue/MenuItem.vue b/demos/src/Examples/CollaborativeEditing/Vue/MenuItem.vue
new file mode 100644
index 0000000..e4bddf6
--- /dev/null
+++ b/demos/src/Examples/CollaborativeEditing/Vue/MenuItem.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/CollaborativeEditing/Vue/index.html b/demos/src/Examples/CollaborativeEditing/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/CollaborativeEditing/Vue/index.vue b/demos/src/Examples/CollaborativeEditing/Vue/index.vue
new file mode 100644
index 0000000..948d6da
--- /dev/null
+++ b/demos/src/Examples/CollaborativeEditing/Vue/index.vue
@@ -0,0 +1,372 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/Community/React/MentionList.jsx b/demos/src/Examples/Community/React/MentionList.jsx
new file mode 100644
index 0000000..4c81c19
--- /dev/null
+++ b/demos/src/Examples/Community/React/MentionList.jsx
@@ -0,0 +1,68 @@
+import './MentionList.scss'
+
+import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
+
+export const MentionList = forwardRef((props, ref) => {
+ const [selectedIndex, setSelectedIndex] = useState(0)
+
+ const selectItem = index => {
+ const item = props.items[index]
+
+ if (item) {
+ props.command({ id: item })
+ }
+ }
+
+ const upHandler = () => {
+ setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length)
+ }
+
+ const downHandler = () => {
+ setSelectedIndex((selectedIndex + 1) % props.items.length)
+ }
+
+ const enterHandler = () => {
+ selectItem(selectedIndex)
+ }
+
+ useEffect(() => setSelectedIndex(0), [props.items])
+
+ useImperativeHandle(ref, () => ({
+ onKeyDown: ({ event }) => {
+ if (event.key === 'ArrowUp') {
+ upHandler()
+ return true
+ }
+
+ if (event.key === 'ArrowDown') {
+ downHandler()
+ return true
+ }
+
+ if (event.key === 'Enter') {
+ enterHandler()
+ return true
+ }
+
+ return false
+ },
+ }))
+
+ return (
+
+ {props.items.length ? (
+ props.items.map((item, index) => (
+
selectItem(index)}
+ >
+ {item}
+
+ ))
+ ) : (
+
No result
+ )}
+
+ )
+})
diff --git a/demos/src/Examples/Community/React/MentionList.scss b/demos/src/Examples/Community/React/MentionList.scss
new file mode 100644
index 0000000..e9ee1ab
--- /dev/null
+++ b/demos/src/Examples/Community/React/MentionList.scss
@@ -0,0 +1,31 @@
+/* Dropdown menu */
+.dropdown-menu {
+ background: var(--white);
+ border: 1px solid var(--gray-1);
+ border-radius: 0.7rem;
+ box-shadow: var(--shadow);
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ overflow: auto;
+ padding: 0.4rem;
+ position: relative;
+
+ button {
+ align-items: center;
+ background-color: transparent;
+ display: flex;
+ gap: 0.25rem;
+ text-align: left;
+ width: 100%;
+
+ &:hover,
+ &:hover.is-selected {
+ background-color: var(--gray-3);
+ }
+
+ &.is-selected {
+ background-color: var(--gray-2);
+ }
+ }
+}
diff --git a/demos/src/Examples/Community/React/index.html b/demos/src/Examples/Community/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Community/React/index.jsx b/demos/src/Examples/Community/React/index.jsx
new file mode 100644
index 0000000..d681f9b
--- /dev/null
+++ b/demos/src/Examples/Community/React/index.jsx
@@ -0,0 +1,80 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Mention from '@tiptap/extension-mention'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { CharacterCount } from '@tiptap/extensions'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+import suggestion from './suggestion.js'
+
+export default () => {
+ const limit = 280
+
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ CharacterCount.configure({
+ limit,
+ }),
+ Mention.configure({
+ HTMLAttributes: {
+ class: 'mention',
+ },
+ suggestion,
+ }),
+ ],
+ content: `
+
+ What do you all think about the new movie?
+
+ `,
+ })
+
+ const { characterCount } = useEditorState({
+ editor,
+ selector: ctx => {
+ if (!ctx.editor) {
+ return {
+ characterCount: 0,
+ }
+ }
+
+ return {
+ characterCount: ctx.editor.storage.characterCount.characters(),
+ }
+ },
+ })
+
+ const percentage = editor ? Math.round((100 / limit) * characterCount) : 0
+ const isAtLimit = characterCount === limit
+
+ return (
+ <>
+
+ {editor && (
+
+
+
+
+
+
+ {characterCount} / {limit} characters
+
+ )}
+ >
+ )
+}
diff --git a/demos/src/Examples/Community/React/styles.scss b/demos/src/Examples/Community/React/styles.scss
new file mode 100644
index 0000000..b16c1d7
--- /dev/null
+++ b/demos/src/Examples/Community/React/styles.scss
@@ -0,0 +1,33 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ .mention {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ box-decoration-break: clone;
+ color: var(--purple);
+ padding: 0.1rem 0.3rem;
+ }
+}
+
+/* Character count */
+.character-count {
+ align-items: center;
+ color: var(--gray-5);
+ display: flex;
+ font-size: 0.75rem;
+ gap: 0.5rem;
+ margin: 1.5rem;
+
+ svg {
+ color: var(--purple);
+ }
+
+ &--warning,
+ &--warning svg {
+ color: var(--red);
+ }
+}
diff --git a/demos/src/Examples/Community/React/suggestion.js b/demos/src/Examples/Community/React/suggestion.js
new file mode 100644
index 0000000..896631f
--- /dev/null
+++ b/demos/src/Examples/Community/React/suggestion.js
@@ -0,0 +1,104 @@
+import { computePosition, flip, shift } from '@floating-ui/dom'
+import { posToDOMRect, ReactRenderer } from '@tiptap/react'
+
+import { MentionList } from './MentionList.jsx'
+
+const updatePosition = (editor, element) => {
+ const virtualElement = {
+ getBoundingClientRect: () =>
+ posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to),
+ }
+
+ computePosition(virtualElement, element, {
+ placement: 'bottom-start',
+ strategy: 'absolute',
+ middleware: [shift(), flip()],
+ }).then(({ x, y, strategy }) => {
+ element.style.width = 'max-content'
+ element.style.position = strategy
+ element.style.left = `${x}px`
+ element.style.top = `${y}px`
+ })
+}
+
+export default {
+ items: ({ query }) => {
+ return [
+ 'Lea Thompson',
+ 'Cyndi Lauper',
+ 'Tom Cruise',
+ 'Madonna',
+ 'Jerry Hall',
+ 'Joan Collins',
+ 'Winona Ryder',
+ 'Christina Applegate',
+ 'Alyssa Milano',
+ 'Molly Ringwald',
+ 'Ally Sheedy',
+ 'Debbie Harry',
+ 'Olivia Newton-John',
+ 'Elton John',
+ 'Michael J. Fox',
+ 'Axl Rose',
+ 'Emilio Estevez',
+ 'Ralph Macchio',
+ 'Rob Lowe',
+ 'Jennifer Grey',
+ 'Mickey Rourke',
+ 'John Cusack',
+ 'Matthew Broderick',
+ 'Justine Bateman',
+ 'Lisa Bonet',
+ ]
+ .filter(item => item.toLowerCase().startsWith(query.toLowerCase()))
+ .slice(0, 5)
+ },
+
+ render: () => {
+ let reactRenderer
+
+ return {
+ onStart: props => {
+ if (!props.clientRect) {
+ return
+ }
+
+ reactRenderer = new ReactRenderer(MentionList, {
+ props,
+ editor: props.editor,
+ })
+
+ reactRenderer.element.style.position = 'absolute'
+
+ document.body.appendChild(reactRenderer.element)
+
+ updatePosition(props.editor, reactRenderer.element)
+ },
+
+ onUpdate(props) {
+ reactRenderer.updateProps(props)
+
+ if (!props.clientRect) {
+ return
+ }
+ updatePosition(props.editor, reactRenderer.element)
+ },
+
+ onKeyDown(props) {
+ if (props.event.key === 'Escape') {
+ reactRenderer.destroy()
+ reactRenderer.element.remove()
+
+ return true
+ }
+
+ return reactRenderer.ref?.onKeyDown(props)
+ },
+
+ onExit() {
+ reactRenderer.destroy()
+ reactRenderer.element.remove()
+ },
+ }
+ },
+}
diff --git a/demos/src/Examples/Community/Vue/MentionList.vue b/demos/src/Examples/Community/Vue/MentionList.vue
new file mode 100644
index 0000000..9b62c75
--- /dev/null
+++ b/demos/src/Examples/Community/Vue/MentionList.vue
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/Community/Vue/index.html b/demos/src/Examples/Community/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Community/Vue/index.vue b/demos/src/Examples/Community/Vue/index.vue
new file mode 100644
index 0000000..9afddb5
--- /dev/null
+++ b/demos/src/Examples/Community/Vue/index.vue
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+
+
+
+ {{ editor.storage.characterCount.characters() }} / {{ limit }} characters
+
+
+
+
+
+
diff --git a/demos/src/Examples/Community/Vue/suggestion.js b/demos/src/Examples/Community/Vue/suggestion.js
new file mode 100644
index 0000000..34748a6
--- /dev/null
+++ b/demos/src/Examples/Community/Vue/suggestion.js
@@ -0,0 +1,108 @@
+import { computePosition, flip, shift } from '@floating-ui/dom'
+import { posToDOMRect, VueRenderer } from '@tiptap/vue'
+
+import MentionList from './MentionList.vue'
+
+const updatePosition = (editor, element) => {
+ const virtualElement = {
+ getBoundingClientRect: () =>
+ posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to),
+ }
+
+ computePosition(virtualElement, element, {
+ placement: 'bottom-start',
+ strategy: 'absolute',
+ middleware: [shift(), flip()],
+ }).then(({ x, y, strategy }) => {
+ element.style.width = 'max-content'
+ element.style.position = strategy
+ element.style.left = `${x}px`
+ element.style.top = `${y}px`
+ })
+}
+
+export default {
+ items: ({ query }) => {
+ return [
+ 'Lea Thompson',
+ 'Cyndi Lauper',
+ 'Tom Cruise',
+ 'Madonna',
+ 'Jerry Hall',
+ 'Joan Collins',
+ 'Winona Ryder',
+ 'Christina Applegate',
+ 'Alyssa Milano',
+ 'Molly Ringwald',
+ 'Ally Sheedy',
+ 'Debbie Harry',
+ 'Olivia Newton-John',
+ 'Elton John',
+ 'Michael J. Fox',
+ 'Axl Rose',
+ 'Emilio Estevez',
+ 'Ralph Macchio',
+ 'Rob Lowe',
+ 'Jennifer Grey',
+ 'Mickey Rourke',
+ 'John Cusack',
+ 'Matthew Broderick',
+ 'Justine Bateman',
+ 'Lisa Bonet',
+ ]
+ .filter(item => item.toLowerCase().startsWith(query.toLowerCase()))
+ .slice(0, 5)
+ },
+
+ render: () => {
+ let component
+
+ return {
+ onStart: props => {
+ component = new VueRenderer(MentionList, {
+ // using vue 2:
+ // parent: this,
+ // propsData: props,
+ props,
+ editor: props.editor,
+ })
+
+ if (!props.clientRect) {
+ return
+ }
+
+ component.element.style.position = 'absolute'
+
+ document.body.appendChild(component.element)
+
+ updatePosition(props.editor, component.element)
+ },
+
+ onUpdate(props) {
+ component.updateProps(props)
+
+ if (!props.clientRect) {
+ return
+ }
+
+ updatePosition(props.editor, component.element)
+ },
+
+ onKeyDown(props) {
+ if (props.event.key === 'Escape') {
+ component.destroy()
+ component.element.remove()
+
+ return true
+ }
+
+ return component.ref?.onKeyDown(props)
+ },
+
+ onExit() {
+ component.destroy()
+ component.element.remove()
+ },
+ }
+ },
+}
diff --git a/demos/src/Examples/Community/index.spec.ts b/demos/src/Examples/Community/index.spec.ts
new file mode 100644
index 0000000..02b65bf
--- /dev/null
+++ b/demos/src/Examples/Community/index.spec.ts
@@ -0,0 +1,60 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'Community'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Examples'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await getEditor(page)
+ })
+
+ test('should count the characters correctly', async ({ page }) => {
+ await expect(page.locator('.character-count')).toContainText('44 / 280 characters')
+
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.evaluate((el: any) => el.editor.commands.focus('end'))
+ await page.keyboard.type(' Hello World')
+
+ await expect(page.locator('.character-count')).toContainText('56 / 280 characters')
+
+ await editor.evaluate((el: any) => {
+ el.editor.chain().focus().clearContent().run()
+ })
+ await page.keyboard.type('Hello World')
+
+ await expect(page.locator('.character-count')).toContainText('11 / 280 characters')
+ })
+
+ test('should mention a user', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any) => {
+ el.editor.chain().focus().clearContent().run()
+ })
+ await editor.click()
+ await editor.type('@')
+
+ const dropdown = page.locator('.dropdown-menu')
+ await expect(dropdown).toBeVisible()
+
+ const firstButton = dropdown.locator('button').first()
+ const name = await firstButton.innerText()
+
+ await firstButton.click()
+
+ await expect(page.locator('.tiptap')).toHaveText(`@${name} `)
+ await expect(page.locator('.character-count')).toContainText('2 / 280 characters')
+ })
+ })
+ })
+})
diff --git a/demos/src/Examples/CustomDocument/React/index.html b/demos/src/Examples/CustomDocument/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/CustomDocument/React/index.jsx b/demos/src/Examples/CustomDocument/React/index.jsx
new file mode 100644
index 0000000..831924b
--- /dev/null
+++ b/demos/src/Examples/CustomDocument/React/index.jsx
@@ -0,0 +1,44 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import { Placeholder } from '@tiptap/extensions'
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+const CustomDocument = Document.extend({
+ content: 'heading block*',
+})
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ CustomDocument,
+ StarterKit.configure({
+ document: false,
+ trailingNode: {
+ node: 'paragraph',
+ },
+ }),
+ Placeholder.configure({
+ placeholder: ({ node }) => {
+ if (node.type.name === 'heading') {
+ return 'What’s the title?'
+ }
+
+ return 'Can you add some further context?'
+ },
+ }),
+ ],
+ content: `
+
+ It’ll always have a heading …
+
+
+ … if you pass a custom document. That’s the beauty of having full control over the schema.
+
+ `,
+ })
+
+ return
+}
diff --git a/demos/src/Examples/CustomDocument/React/styles.scss b/demos/src/Examples/CustomDocument/React/styles.scss
new file mode 100644
index 0000000..f9b943b
--- /dev/null
+++ b/demos/src/Examples/CustomDocument/React/styles.scss
@@ -0,0 +1,109 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+
+ /* Placeholder (at the top) */
+ /* p.is-editor-empty:first-child::before {
+ color: var(--gray-4);
+ content: attr(data-placeholder);
+ float: left;
+ height: 0;
+ pointer-events: none;
+ }*/
+
+ /* Placeholder (on every new line) */
+ .is-empty::before {
+ color: var(--gray-4);
+ content: attr(data-placeholder);
+ float: left;
+ height: 0;
+ pointer-events: none;
+ }
+}
diff --git a/demos/src/Examples/CustomDocument/Vue/index.html b/demos/src/Examples/CustomDocument/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/CustomDocument/Vue/index.vue b/demos/src/Examples/CustomDocument/Vue/index.vue
new file mode 100644
index 0000000..f698a86
--- /dev/null
+++ b/demos/src/Examples/CustomDocument/Vue/index.vue
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/CustomDocument/index.spec.ts b/demos/src/Examples/CustomDocument/index.spec.ts
new file mode 100644
index 0000000..03ef6f8
--- /dev/null
+++ b/demos/src/Examples/CustomDocument/index.spec.ts
@@ -0,0 +1,87 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'CustomDocument'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Examples'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test('should have a working tiptap instance', async ({ page }) => {
+ const editor = await getEditor(page)
+ const hasEditor = await editor.evaluate((el: any) => !!el.editor)
+
+ expect(hasEditor).toBe(true)
+ })
+
+ test('should have a headline and a paragraph', async ({ page }) => {
+ await expect(page.locator('.tiptap h1')).toHaveText('It’ll always have a heading …')
+ await expect(page.locator('.tiptap p').first()).toHaveText(
+ '… if you pass a custom document. That’s the beauty of having full control over the schema.',
+ )
+ })
+
+ test('should have a tooltip for a paragraph on a new line', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.evaluate((el: any) => el.editor.commands.focus('end'))
+ await page.keyboard.press('Enter')
+
+ await expect(page.locator('.tiptap p[data-placeholder]').first()).toHaveAttribute(
+ 'data-placeholder',
+ 'Can you add some further context?',
+ )
+ })
+
+ test('should have a headline after clearing the document', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any) => {
+ el.editor.chain().focus().clearContent().run()
+ })
+
+ await expect(page.locator('.tiptap h1[data-placeholder]')).toHaveAttribute(
+ 'data-placeholder',
+ 'What’s the title?',
+ )
+ await expect(page.locator('.tiptap h1[data-placeholder]')).toHaveAttribute(
+ 'class',
+ 'is-empty is-editor-empty',
+ )
+ })
+
+ test('should have a headline after clearing & enter paragraph automatically after adding a headline', async ({
+ page,
+ }) => {
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any) => {
+ el.editor.chain().focus().clearContent().insertContent('Hello world').run()
+ })
+ await page.locator('.tiptap h1').click()
+ await page.keyboard.press('End')
+ await page.keyboard.press('Enter')
+
+ await expect(page.locator('.tiptap h1')).toHaveText('Hello world')
+ await expect(page.locator('.tiptap p[data-placeholder]').first()).toHaveAttribute(
+ 'data-placeholder',
+ 'Can you add some further context?',
+ )
+
+ await page.keyboard.type('This is a paragraph for this test document')
+ await expect(page.locator('.tiptap p').first()).toHaveText(
+ 'This is a paragraph for this test document',
+ )
+ })
+ })
+ })
+})
diff --git a/demos/src/Examples/CustomParagraph/React/Paragraph.jsx b/demos/src/Examples/CustomParagraph/React/Paragraph.jsx
new file mode 100644
index 0000000..9d7af9c
--- /dev/null
+++ b/demos/src/Examples/CustomParagraph/React/Paragraph.jsx
@@ -0,0 +1,28 @@
+import { Paragraph as BaseParagraph } from '@tiptap/extension-paragraph'
+import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
+
+const ParagraphComponent = ({ node }) => {
+ return (
+
+
+ {node.textContent.length}
+
+
+
+ )
+}
+
+export const Paragraph = BaseParagraph.extend({
+ addNodeView() {
+ return ReactNodeViewRenderer(ParagraphComponent)
+ },
+})
diff --git a/demos/src/Examples/CustomParagraph/React/index.html b/demos/src/Examples/CustomParagraph/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/CustomParagraph/React/index.jsx b/demos/src/Examples/CustomParagraph/React/index.jsx
new file mode 100644
index 0000000..ed47ad1
--- /dev/null
+++ b/demos/src/Examples/CustomParagraph/React/index.jsx
@@ -0,0 +1,25 @@
+import './styles.scss'
+
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+import { Paragraph } from './Paragraph.jsx'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ StarterKit.configure({
+ paragraph: false,
+ }),
+ Paragraph,
+ ],
+ content: `
+
+ Each line shows the number of characters in the paragraph.
+
+ `,
+ })
+
+ return
+}
diff --git a/demos/src/Examples/CustomParagraph/React/styles.scss b/demos/src/Examples/CustomParagraph/React/styles.scss
new file mode 100644
index 0000000..15283b1
--- /dev/null
+++ b/demos/src/Examples/CustomParagraph/React/styles.scss
@@ -0,0 +1,91 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
diff --git a/demos/src/Examples/CustomParagraph/Vue/Component.vue b/demos/src/Examples/CustomParagraph/Vue/Component.vue
new file mode 100644
index 0000000..6342113
--- /dev/null
+++ b/demos/src/Examples/CustomParagraph/Vue/Component.vue
@@ -0,0 +1,32 @@
+
+
+ {{ node.textContent.length }}
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/CustomParagraph/Vue/Extension.js b/demos/src/Examples/CustomParagraph/Vue/Extension.js
new file mode 100644
index 0000000..867bbd6
--- /dev/null
+++ b/demos/src/Examples/CustomParagraph/Vue/Extension.js
@@ -0,0 +1,10 @@
+import { Paragraph as BaseParagraph } from '@tiptap/extension-paragraph'
+import { VueNodeViewRenderer } from '@tiptap/vue'
+
+import Component from './Component.vue'
+
+export default BaseParagraph.extend({
+ addNodeView() {
+ return VueNodeViewRenderer(Component)
+ },
+})
diff --git a/demos/src/Examples/CustomParagraph/Vue/index.html b/demos/src/Examples/CustomParagraph/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/CustomParagraph/Vue/index.vue b/demos/src/Examples/CustomParagraph/Vue/index.vue
new file mode 100644
index 0000000..25366c4
--- /dev/null
+++ b/demos/src/Examples/CustomParagraph/Vue/index.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+n
diff --git a/demos/src/Examples/CustomParagraph/index.spec.ts b/demos/src/Examples/CustomParagraph/index.spec.ts
new file mode 100644
index 0000000..106f72e
--- /dev/null
+++ b/demos/src/Examples/CustomParagraph/index.spec.ts
@@ -0,0 +1,33 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'CustomParagraph'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Examples'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test('updates the character-count label after pressing Enter', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any) => {
+ el.editor.chain().focus().setContent('First line
').run()
+ })
+ await editor.click()
+ await editor.press('End')
+ await editor.press('Enter')
+
+ await expect(page.locator('.tiptap p').nth(1)).toHaveText('')
+ await expect(page.locator('.tiptap .label').nth(1)).toHaveText('0')
+ })
+ })
+ })
+})
diff --git a/demos/src/Examples/DecorationComponents/React/Counter.tsx b/demos/src/Examples/DecorationComponents/React/Counter.tsx
new file mode 100644
index 0000000..f20e72e
--- /dev/null
+++ b/demos/src/Examples/DecorationComponents/React/Counter.tsx
@@ -0,0 +1,23 @@
+import type { ReactWidgetDecorationProps } from '@tiptap/react'
+import React, { useState } from 'react'
+
+interface CounterProps extends ReactWidgetDecorationProps {
+ index: number
+}
+
+/**
+ * A small interactive widget rendered into a ProseMirror widget decoration.
+ *
+ * It keeps its own `count` state. Because the widget is keyed by a stable id,
+ * editing the document reuses this exact component instance instead of
+ * remounting it — so the count survives while you type.
+ */
+export const Counter = ({ index }: CounterProps) => {
+ const [count, setCount] = useState(0)
+
+ return (
+ setCount(value => value + 1)}>
+ ¶ {index + 1} · 👍 {count}
+
+ )
+}
diff --git a/demos/src/Examples/DecorationComponents/React/index.html b/demos/src/Examples/DecorationComponents/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/DecorationComponents/React/index.tsx b/demos/src/Examples/DecorationComponents/React/index.tsx
new file mode 100644
index 0000000..b2e25e3
--- /dev/null
+++ b/demos/src/Examples/DecorationComponents/React/index.tsx
@@ -0,0 +1,63 @@
+import '../styles.scss'
+
+import { Extension } from '@tiptap/core'
+import type { Decoration } from '@tiptap/core'
+import UniqueID from '@tiptap/extension-unique-id'
+import StarterKit from '@tiptap/starter-kit'
+import { EditorContent, ReactWidgetRenderer, useEditor } from '@tiptap/react'
+
+import { Counter } from './Counter.js'
+
+/** Renders a Counter widget at the end of every paragraph using ReactWidgetRenderer. */
+const ParagraphCounters = Extension.create({
+ name: 'paragraphCounters',
+
+ addDecorations() {
+ return {
+ create: ({ editor, state }) => {
+ const decorations: Decoration[] = []
+ let index = 0
+
+ state.doc.forEach((node, offset) => {
+ if (node.type.name !== 'paragraph') {
+ return
+ }
+
+ const currentIndex = index
+
+ decorations.push(
+ ReactWidgetRenderer(Counter, {
+ editor,
+ pos: offset + node.nodeSize - 1,
+ // uniqueID sets ids late, so id is null on the first render
+ key: `paragraph-counter-${node.attrs.id ?? `pos-${offset}`}`,
+ props: { index: currentIndex },
+ side: 1,
+ }),
+ )
+
+ index += 1
+ })
+
+ return decorations
+ },
+ }
+ },
+})
+
+export default () => {
+ const editor = useEditor({
+ extensions: [StarterKit, UniqueID.configure({ types: ['paragraph'] }), ParagraphCounters],
+ content: `
+ Decoration components
+ Each paragraph gets an interactive React widget. Click a counter, then type in this paragraph — the count survives because the widget instance is reused, not remounted.
+ This second paragraph has its own independent counter. Add or remove paragraphs to see the indexes update.
+ `,
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return
+}
diff --git a/demos/src/Examples/DecorationComponents/Vue/Counter.vue b/demos/src/Examples/DecorationComponents/Vue/Counter.vue
new file mode 100644
index 0000000..619144c
--- /dev/null
+++ b/demos/src/Examples/DecorationComponents/Vue/Counter.vue
@@ -0,0 +1,24 @@
+
+ ¶ {{ index + 1 }} · 👍 {{ count }}
+
+
+
diff --git a/demos/src/Examples/DecorationComponents/Vue/index.html b/demos/src/Examples/DecorationComponents/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/DecorationComponents/Vue/index.vue b/demos/src/Examples/DecorationComponents/Vue/index.vue
new file mode 100644
index 0000000..ed18d8c
--- /dev/null
+++ b/demos/src/Examples/DecorationComponents/Vue/index.vue
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/DecorationComponents/index.spec.ts b/demos/src/Examples/DecorationComponents/index.spec.ts
new file mode 100644
index 0000000..65489af
--- /dev/null
+++ b/demos/src/Examples/DecorationComponents/index.spec.ts
@@ -0,0 +1,130 @@
+import { expect, test } from '@playwright/test'
+
+import { focusEditorEnd, getEditor } from '../../../test/helpers.js'
+
+const demoName = 'DecorationComponents'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Examples'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(frameworkPath, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test('renders one counter per paragraph, indexed and starting at zero', async ({ page }) => {
+ const editor = await getEditor(page)
+ const counters = page.locator('.decoration-counter')
+
+ await expect(counters).toHaveCount(2)
+ await expect(counters).toHaveText(['¶ 1 · 👍 0', '¶ 2 · 👍 0'])
+
+ // The heading must stay undecorated, only paragraphs get a counter.
+ await expect(editor.locator('p .decoration-counter')).toHaveCount(2)
+ await expect(editor.locator('h2 .decoration-counter')).toHaveCount(0)
+ })
+
+ test('increments only the clicked counter', async ({ page }) => {
+ const counters = page.locator('.decoration-counter')
+
+ await expect(counters).toHaveCount(2)
+
+ await counters.nth(0).click()
+ await counters.nth(0).click()
+ await counters.nth(0).click()
+ await counters.nth(1).click()
+
+ await expect(counters).toHaveText(['¶ 1 · 👍 3', '¶ 2 · 👍 1'])
+ })
+
+ test('adds a counter with the next index for a new paragraph', async ({ page }) => {
+ const counters = page.locator('.decoration-counter')
+
+ await expect(counters).toHaveCount(2)
+
+ await focusEditorEnd(page)
+ await page.keyboard.press('Enter')
+ await page.keyboard.type('A third paragraph.')
+
+ await expect(counters).toHaveCount(3)
+ await expect(counters).toHaveText(['¶ 1 · 👍 0', '¶ 2 · 👍 0', '¶ 3 · 👍 0'])
+ })
+
+ test('keeps a widget mounted when its paragraph is edited and split', async ({ page }) => {
+ const editor = await getEditor(page)
+ const counters = page.locator('.decoration-counter')
+ const duplicateKeyWarnings: string[] = []
+
+ page.on('console', message => {
+ if (
+ message.type() === 'warning' &&
+ message.text().includes('Duplicate widget decoration key')
+ ) {
+ duplicateKeyWarnings.push(message.text())
+ }
+ })
+
+ await expect(counters).toHaveCount(2)
+
+ await counters.nth(0).click()
+ await expect(counters.nth(0)).toContainText('👍 1')
+
+ const firstParagraph = editor.locator('p').nth(0)
+ await firstParagraph.click()
+ await page.keyboard.press('End')
+ await page.keyboard.type(' edited')
+ await page.keyboard.press('Enter')
+
+ await expect(counters).toHaveCount(3)
+ await expect(counters.nth(0)).toContainText('¶ 1')
+ await expect(counters.nth(1)).toContainText('¶ 2')
+ await expect(counters.nth(2)).toContainText('¶ 3')
+ await expect(counters.filter({ hasText: '👍 1' })).toHaveCount(1)
+ expect(duplicateKeyWarnings).toEqual([])
+ })
+
+ test('keeps widget state independent between paragraphs', async ({ page }) => {
+ const counters = page.locator('.decoration-counter')
+
+ await expect(counters).toHaveCount(2)
+ await counters.nth(0).click()
+ await counters.nth(1).click()
+
+ await expect(counters.nth(0)).toContainText('👍 1')
+ await expect(counters.nth(1)).toContainText('👍 1')
+ })
+
+ test('removes widgets when their paragraphs are removed', async ({ page }) => {
+ const editor = await getEditor(page)
+ const counters = page.locator('.decoration-counter')
+
+ await expect(counters).toHaveCount(2)
+
+ await editor.evaluate((el: any) => {
+ el.editor.commands.setContent(
+ 'Decoration components Only one paragraph remains.
',
+ )
+ })
+
+ await expect(counters).toHaveCount(1)
+ await expect(counters.first()).toContainText('¶ 1')
+ })
+
+ test('recreates widgets cleanly after navigation', async ({ page }) => {
+ const counters = page.locator('.decoration-counter')
+
+ await counters.first().click()
+ await expect(counters.first()).toContainText('👍 1')
+
+ await page.goto(`/src/Examples/Decorations/${frameworkPath}/`)
+ await page.goto(fullDemoPath)
+
+ await expect(page.locator('.decoration-counter')).toHaveCount(2)
+ await expect(page.locator('.decoration-counter').first()).toContainText('👍 0')
+ })
+ })
+ })
+})
diff --git a/demos/src/Examples/DecorationComponents/styles.scss b/demos/src/Examples/DecorationComponents/styles.scss
new file mode 100644
index 0000000..6d69363
--- /dev/null
+++ b/demos/src/Examples/DecorationComponents/styles.scss
@@ -0,0 +1,26 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
+
+.decoration-counter {
+ background-color: var(--purple-light);
+ border: 1px solid var(--purple);
+ border-radius: 0.4rem;
+ color: var(--purple-contrast);
+ cursor: pointer;
+ font-size: 0.75rem;
+ line-height: 1;
+ margin-left: 0.4rem;
+ padding: 0.2rem 0.4rem;
+ user-select: none;
+ vertical-align: middle;
+ white-space: nowrap;
+
+ &:hover {
+ background-color: var(--purple);
+ color: var(--white);
+ }
+}
diff --git a/demos/src/Examples/Decorations/React/highlight-decorations.ts b/demos/src/Examples/Decorations/React/highlight-decorations.ts
new file mode 100644
index 0000000..fce0568
--- /dev/null
+++ b/demos/src/Examples/Decorations/React/highlight-decorations.ts
@@ -0,0 +1,103 @@
+import { Decoration, Extension } from '@tiptap/core'
+import type { Editor } from '@tiptap/core'
+import type { EditorState } from '@tiptap/pm/state'
+
+export interface HighlightDecorationsOptions {
+ /** The term to highlight. Change at runtime via `editor.storage.highlightDecorations.term`. */
+ term: string
+}
+
+export interface HighlightDecorationsStorage {
+ term: string
+}
+
+declare module '@tiptap/core' {
+ interface Storage {
+ highlightDecorations: HighlightDecorationsStorage
+ }
+}
+
+/** Demo extension showing inline, widget, and node decorations. */
+export const HighlightDecorations = Extension.create<
+ HighlightDecorationsOptions,
+ HighlightDecorationsStorage
+>({
+ name: 'highlightDecorations',
+
+ addOptions() {
+ return {
+ term: 'tiptap',
+ }
+ },
+
+ addStorage() {
+ return {
+ term: this.options.term,
+ }
+ },
+
+ addDecorations() {
+ // Scans [from, to] and returns decorations. `create` scans the whole doc,
+ // `createInRange` scans only the edited blocks.
+ const scan = (editor: Editor, state: EditorState, from: number, to: number) => {
+ const decorations: Decoration[] = []
+ const term = editor.storage.highlightDecorations.term.trim().toLowerCase()
+
+ state.doc.nodesBetween(from, to, (node, pos) => {
+ // node decoration: outline headings
+ if (node.type.name === 'heading') {
+ decorations.push(
+ Decoration.Node(pos, pos + node.nodeSize, { class: 'decoration-heading' }),
+ )
+ }
+
+ if (!term || !node.isText || !node.text) {
+ return
+ }
+
+ const text = node.text.toLowerCase()
+ let index = text.indexOf(term)
+
+ while (index !== -1) {
+ const matchFrom = pos + index
+ const matchTo = matchFrom + term.length
+
+ // inline decoration: highlight the match
+ decorations.push(Decoration.Inline(matchFrom, matchTo, { class: 'decoration-highlight' }))
+
+ // widget decoration: a star marker before each match.
+ // Position-based key is fine here because the widget is stateless.
+ // For stateful widgets, use a stable domain key like `comment-${id}`.
+ decorations.push(
+ Decoration.Widget(
+ matchFrom,
+ () => {
+ const marker = document.createElement('span')
+
+ marker.className = 'decoration-marker'
+ marker.ariaHidden = 'true'
+ marker.textContent = '★'
+
+ return marker
+ },
+ { key: `highlight-marker-${matchFrom}`, side: -1 },
+ ),
+ )
+
+ index = text.indexOf(term, index + term.length)
+ }
+ })
+
+ return decorations
+ }
+
+ return {
+ // Only rescan changed blocks on edits; force full rebuild on term change.
+ update: 'changedRanges',
+
+ create: ({ editor, state }) => scan(editor, state, 0, state.doc.content.size),
+
+ createInRange: ({ editor, state, from, to }) => scan(editor, state, from, to),
+ }
+ },
+})
diff --git a/demos/src/Examples/Decorations/React/index.html b/demos/src/Examples/Decorations/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Decorations/React/index.tsx b/demos/src/Examples/Decorations/React/index.tsx
new file mode 100644
index 0000000..ba861a2
--- /dev/null
+++ b/demos/src/Examples/Decorations/React/index.tsx
@@ -0,0 +1,63 @@
+import './styles.scss'
+
+import StarterKit from '@tiptap/starter-kit'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React, { useState } from 'react'
+
+import { HighlightDecorations } from './highlight-decorations.js'
+
+const initialTerm = 'tiptap'
+
+export default () => {
+ const [term, setTerm] = useState(initialTerm)
+
+ const editor = useEditor({
+ extensions: [StarterKit, HighlightDecorations.configure({ term: initialTerm })],
+ content: `
+ The Decorations API
+
+ With the new Decorations API, an extension can declare its decorations the
+ same way it declares commands or keymaps. This Tiptap demo highlights every
+ occurrence of a search term using an inline decoration, adds
+ a widget marker before each match, and outlines headings with
+ a node decoration.
+
+ Try it out
+
+ Change the search term below. Tiptap forces a recompute with
+ updateDecorations. Editing the document rescans only the
+ changed blocks because this extension uses update: 'changedRanges'.
+
+ `,
+ })
+
+ const handleTermChange = (event: React.ChangeEvent) => {
+ const value = event.target.value
+
+ setTerm(value)
+
+ if (editor) {
+ editor.storage.highlightDecorations.term = value
+ editor.commands.updateDecorations('highlightDecorations')
+ }
+ }
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+
+ Highlight term:{' '}
+
+
+ editor.commands.updateDecorations()}>Re-apply
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Examples/Decorations/React/styles.scss b/demos/src/Examples/Decorations/React/styles.scss
new file mode 100644
index 0000000..273d78c
--- /dev/null
+++ b/demos/src/Examples/Decorations/React/styles.scss
@@ -0,0 +1,30 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* inline decoration */
+ .decoration-highlight {
+ background-color: var(--yellow);
+ border-radius: 0.2rem;
+ box-decoration-break: clone;
+ padding: 0.1rem 0;
+ }
+
+ /* widget decoration */
+ .decoration-marker {
+ color: var(--yellow-contrast);
+ font-size: 0.8em;
+ margin-right: 0.1rem;
+ vertical-align: super;
+ }
+
+ /* node decoration */
+ .decoration-heading {
+ background-color: var(--purple-light);
+ border-left: 3px solid var(--purple);
+ border-radius: 0.2rem;
+ padding-left: 0.4rem;
+ }
+}
diff --git a/demos/src/Examples/Decorations/Vue/highlight-decorations.ts b/demos/src/Examples/Decorations/Vue/highlight-decorations.ts
new file mode 100644
index 0000000..fce0568
--- /dev/null
+++ b/demos/src/Examples/Decorations/Vue/highlight-decorations.ts
@@ -0,0 +1,103 @@
+import { Decoration, Extension } from '@tiptap/core'
+import type { Editor } from '@tiptap/core'
+import type { EditorState } from '@tiptap/pm/state'
+
+export interface HighlightDecorationsOptions {
+ /** The term to highlight. Change at runtime via `editor.storage.highlightDecorations.term`. */
+ term: string
+}
+
+export interface HighlightDecorationsStorage {
+ term: string
+}
+
+declare module '@tiptap/core' {
+ interface Storage {
+ highlightDecorations: HighlightDecorationsStorage
+ }
+}
+
+/** Demo extension showing inline, widget, and node decorations. */
+export const HighlightDecorations = Extension.create<
+ HighlightDecorationsOptions,
+ HighlightDecorationsStorage
+>({
+ name: 'highlightDecorations',
+
+ addOptions() {
+ return {
+ term: 'tiptap',
+ }
+ },
+
+ addStorage() {
+ return {
+ term: this.options.term,
+ }
+ },
+
+ addDecorations() {
+ // Scans [from, to] and returns decorations. `create` scans the whole doc,
+ // `createInRange` scans only the edited blocks.
+ const scan = (editor: Editor, state: EditorState, from: number, to: number) => {
+ const decorations: Decoration[] = []
+ const term = editor.storage.highlightDecorations.term.trim().toLowerCase()
+
+ state.doc.nodesBetween(from, to, (node, pos) => {
+ // node decoration: outline headings
+ if (node.type.name === 'heading') {
+ decorations.push(
+ Decoration.Node(pos, pos + node.nodeSize, { class: 'decoration-heading' }),
+ )
+ }
+
+ if (!term || !node.isText || !node.text) {
+ return
+ }
+
+ const text = node.text.toLowerCase()
+ let index = text.indexOf(term)
+
+ while (index !== -1) {
+ const matchFrom = pos + index
+ const matchTo = matchFrom + term.length
+
+ // inline decoration: highlight the match
+ decorations.push(Decoration.Inline(matchFrom, matchTo, { class: 'decoration-highlight' }))
+
+ // widget decoration: a star marker before each match.
+ // Position-based key is fine here because the widget is stateless.
+ // For stateful widgets, use a stable domain key like `comment-${id}`.
+ decorations.push(
+ Decoration.Widget(
+ matchFrom,
+ () => {
+ const marker = document.createElement('span')
+
+ marker.className = 'decoration-marker'
+ marker.ariaHidden = 'true'
+ marker.textContent = '★'
+
+ return marker
+ },
+ { key: `highlight-marker-${matchFrom}`, side: -1 },
+ ),
+ )
+
+ index = text.indexOf(term, index + term.length)
+ }
+ })
+
+ return decorations
+ }
+
+ return {
+ // Only rescan changed blocks on edits; force full rebuild on term change.
+ update: 'changedRanges',
+
+ create: ({ editor, state }) => scan(editor, state, 0, state.doc.content.size),
+
+ createInRange: ({ editor, state, from, to }) => scan(editor, state, from, to),
+ }
+ },
+})
diff --git a/demos/src/Examples/Decorations/Vue/index.html b/demos/src/Examples/Decorations/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Decorations/Vue/index.vue b/demos/src/Examples/Decorations/Vue/index.vue
new file mode 100644
index 0000000..c7abdf3
--- /dev/null
+++ b/demos/src/Examples/Decorations/Vue/index.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+ Highlight term:
+
+
+ Re-apply
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/Decorations/Vue/styles.scss b/demos/src/Examples/Decorations/Vue/styles.scss
new file mode 100644
index 0000000..273d78c
--- /dev/null
+++ b/demos/src/Examples/Decorations/Vue/styles.scss
@@ -0,0 +1,30 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* inline decoration */
+ .decoration-highlight {
+ background-color: var(--yellow);
+ border-radius: 0.2rem;
+ box-decoration-break: clone;
+ padding: 0.1rem 0;
+ }
+
+ /* widget decoration */
+ .decoration-marker {
+ color: var(--yellow-contrast);
+ font-size: 0.8em;
+ margin-right: 0.1rem;
+ vertical-align: super;
+ }
+
+ /* node decoration */
+ .decoration-heading {
+ background-color: var(--purple-light);
+ border-left: 3px solid var(--purple);
+ border-radius: 0.2rem;
+ padding-left: 0.4rem;
+ }
+}
diff --git a/demos/src/Examples/Decorations/index.spec.ts b/demos/src/Examples/Decorations/index.spec.ts
new file mode 100644
index 0000000..6ef3a1d
--- /dev/null
+++ b/demos/src/Examples/Decorations/index.spec.ts
@@ -0,0 +1,73 @@
+import { expect, test } from '@playwright/test'
+
+import { focusEditorEnd, getEditor } from '../../../test/helpers.js'
+
+const demoName = 'Decorations'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Examples'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(frameworkPath, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test('renders inline, widget and node decorations for the initial term', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await expect(editor.locator('.decoration-highlight')).toHaveCount(2)
+ await expect(editor.locator('.decoration-marker')).toHaveCount(2)
+
+ for (const highlight of await editor.locator('.decoration-highlight').all()) {
+ await expect(highlight).toHaveText(/tiptap/i)
+ }
+
+ await expect(editor.locator('h2')).toHaveCount(2)
+ await expect(editor.locator('h2.decoration-heading')).toHaveCount(2)
+ })
+
+ test('updates the highlights when the search term changes', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await page.getByPlaceholder('Search term').fill('keymaps')
+
+ await expect(editor.locator('.decoration-highlight')).toHaveCount(1)
+ await expect(editor.locator('.decoration-highlight')).toHaveText('keymaps')
+ await expect(editor.locator('.decoration-marker')).toHaveCount(1)
+
+ await page.getByPlaceholder('Search term').fill('')
+
+ await expect(editor.locator('.decoration-highlight')).toHaveCount(0)
+ await expect(editor.locator('.decoration-marker')).toHaveCount(0)
+ await expect(editor.locator('h2.decoration-heading')).toHaveCount(2)
+ })
+
+ test('maps and renders decorations for content typed by the user', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await focusEditorEnd(page)
+ await page.keyboard.type(' Tiptap rocks.')
+
+ await expect(editor.locator('.decoration-highlight')).toHaveCount(3)
+ await expect(editor.locator('.decoration-marker')).toHaveCount(3)
+ await expect(editor.locator('.decoration-highlight').last()).toHaveText('Tiptap')
+ await expect(editor.locator('p').last()).toContainText('Tiptap rocks.')
+ })
+
+ test('decorates a heading added by the user', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await focusEditorEnd(page)
+ await page.keyboard.press('Enter')
+ await page.keyboard.type('## Fresh heading')
+
+ await expect(editor.locator('h2')).toHaveCount(3)
+ await expect(editor.locator('h2.decoration-heading')).toHaveCount(3)
+ await expect(editor.locator('h2.decoration-heading').last()).toHaveText('Fresh heading')
+ })
+ })
+ })
+})
diff --git a/demos/src/Examples/Default/React/MenuBar.tsx b/demos/src/Examples/Default/React/MenuBar.tsx
new file mode 100644
index 0000000..df91ebd
--- /dev/null
+++ b/demos/src/Examples/Default/React/MenuBar.tsx
@@ -0,0 +1,129 @@
+import type { Editor } from '@tiptap/core'
+import { useEditorState } from '@tiptap/react'
+import React from 'react'
+
+import { menuBarStateSelector } from './menuBarState.js'
+
+export const MenuBar = ({ editor }: { editor: Editor | null }) => {
+ const editorState = useEditorState({
+ editor,
+ selector: menuBarStateSelector,
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+
+
+ editor.chain().focus().toggleBold().run()}
+ disabled={!editorState.canBold}
+ className={editorState.isBold ? 'is-active' : ''}
+ >
+ Bold
+
+ editor.chain().focus().toggleItalic().run()}
+ disabled={!editorState.canItalic}
+ className={editorState.isItalic ? 'is-active' : ''}
+ >
+ Italic
+
+ editor.chain().focus().toggleStrike().run()}
+ disabled={!editorState.canStrike}
+ className={editorState.isStrike ? 'is-active' : ''}
+ >
+ Strike
+
+ editor.chain().focus().toggleCode().run()}
+ disabled={!editorState.canCode}
+ className={editorState.isCode ? 'is-active' : ''}
+ >
+ Code
+
+ editor.chain().focus().unsetAllMarks().run()}>Clear marks
+ editor.chain().focus().clearNodes().run()}>Clear nodes
+ editor.chain().focus().setParagraph().run()}
+ className={editorState.isParagraph ? 'is-active' : ''}
+ >
+ Paragraph
+
+ editor.chain().focus().toggleHeading({ level: 1 }).run()}
+ className={editorState.isHeading1 ? 'is-active' : ''}
+ >
+ H1
+
+ editor.chain().focus().toggleHeading({ level: 2 }).run()}
+ className={editorState.isHeading2 ? 'is-active' : ''}
+ >
+ H2
+
+ editor.chain().focus().toggleHeading({ level: 3 }).run()}
+ className={editorState.isHeading3 ? 'is-active' : ''}
+ >
+ H3
+
+ editor.chain().focus().toggleHeading({ level: 4 }).run()}
+ className={editorState.isHeading4 ? 'is-active' : ''}
+ >
+ H4
+
+ editor.chain().focus().toggleHeading({ level: 5 }).run()}
+ className={editorState.isHeading5 ? 'is-active' : ''}
+ >
+ H5
+
+ editor.chain().focus().toggleHeading({ level: 6 }).run()}
+ className={editorState.isHeading6 ? 'is-active' : ''}
+ >
+ H6
+
+ editor.chain().focus().toggleBulletList().run()}
+ className={editorState.isBulletList ? 'is-active' : ''}
+ >
+ Bullet list
+
+ editor.chain().focus().toggleOrderedList().run()}
+ className={editorState.isOrderedList ? 'is-active' : ''}
+ >
+ Ordered list
+
+ editor.chain().focus().toggleCodeBlock().run()}
+ className={editorState.isCodeBlock ? 'is-active' : ''}
+ >
+ Code block
+
+ editor.chain().focus().toggleBlockquote().run()}
+ className={editorState.isBlockquote ? 'is-active' : ''}
+ >
+ Blockquote
+
+ editor.chain().focus().setHorizontalRule().run()}>
+ Horizontal rule
+
+ editor.chain().focus().setHardBreak().run()}>Hard break
+ editor.chain().focus().undo().run()} disabled={!editorState.canUndo}>
+ Undo
+
+ editor.chain().focus().redo().run()} disabled={!editorState.canRedo}>
+ Redo
+
+
+
+ )
+}
diff --git a/demos/src/Examples/Default/React/index.html b/demos/src/Examples/Default/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Default/React/index.tsx b/demos/src/Examples/Default/React/index.tsx
new file mode 100644
index 0000000..cd12981
--- /dev/null
+++ b/demos/src/Examples/Default/React/index.tsx
@@ -0,0 +1,53 @@
+import './styles.scss'
+
+import { TextStyleKit } from '@tiptap/extension-text-style'
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+import { MenuBar } from './MenuBar.jsx'
+
+const extensions = [TextStyleKit, StarterKit]
+
+export default () => {
+ const editor = useEditor({
+ extensions,
+ content: `
+
+ Hi there,
+
+
+ this is a basic example of Tiptap . Sure, there are all kind of basic text styles you'd probably expect from a text editor. But wait until you see the lists:
+
+
+
+ That's a bullet list with one …
+
+
+ … or two list items.
+
+
+
+ Isn't that great? And all of that is editable. But wait, there's more. Let's try a code block:
+
+body {
+ display: none;
+}
+
+ I know, I know, this is impressive. It's only the tip of the iceberg though. Give it a try and click a little bit around. Don't forget to check the other examples too.
+
+
+ Wow, that's amazing. Good work, boy! 👏
+
+ — Mom
+
+`,
+ })
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/demos/src/Examples/Default/React/menuBarState.ts b/demos/src/Examples/Default/React/menuBarState.ts
new file mode 100644
index 0000000..8a2d43f
--- /dev/null
+++ b/demos/src/Examples/Default/React/menuBarState.ts
@@ -0,0 +1,42 @@
+import type { Editor } from '@tiptap/core'
+import type { EditorStateSnapshot } from '@tiptap/react'
+
+/**
+ * State selector for the MenuBar component.
+ * Extracts the relevant editor state for rendering menu buttons.
+ */
+export function menuBarStateSelector(ctx: EditorStateSnapshot) {
+ return {
+ // Text formatting
+ isBold: ctx.editor.isActive('bold') ?? false,
+ canBold: ctx.editor.can().chain().toggleBold().run() ?? false,
+ isItalic: ctx.editor.isActive('italic') ?? false,
+ canItalic: ctx.editor.can().chain().toggleItalic().run() ?? false,
+ isStrike: ctx.editor.isActive('strike') ?? false,
+ canStrike: ctx.editor.can().chain().toggleStrike().run() ?? false,
+ isCode: ctx.editor.isActive('code') ?? false,
+ canCode: ctx.editor.can().chain().toggleCode().run() ?? false,
+ canClearMarks: ctx.editor.can().chain().unsetAllMarks().run() ?? false,
+
+ // Block types
+ isParagraph: ctx.editor.isActive('paragraph') ?? false,
+ isHeading1: ctx.editor.isActive('heading', { level: 1 }) ?? false,
+ isHeading2: ctx.editor.isActive('heading', { level: 2 }) ?? false,
+ isHeading3: ctx.editor.isActive('heading', { level: 3 }) ?? false,
+ isHeading4: ctx.editor.isActive('heading', { level: 4 }) ?? false,
+ isHeading5: ctx.editor.isActive('heading', { level: 5 }) ?? false,
+ isHeading6: ctx.editor.isActive('heading', { level: 6 }) ?? false,
+
+ // Lists and blocks
+ isBulletList: ctx.editor.isActive('bulletList') ?? false,
+ isOrderedList: ctx.editor.isActive('orderedList') ?? false,
+ isCodeBlock: ctx.editor.isActive('codeBlock') ?? false,
+ isBlockquote: ctx.editor.isActive('blockquote') ?? false,
+
+ // History
+ canUndo: ctx.editor.can().chain().undo().run() ?? false,
+ canRedo: ctx.editor.can().chain().redo().run() ?? false,
+ }
+}
+
+export type MenuBarState = ReturnType
diff --git a/demos/src/Examples/Default/React/styles.scss b/demos/src/Examples/Default/React/styles.scss
new file mode 100644
index 0000000..15283b1
--- /dev/null
+++ b/demos/src/Examples/Default/React/styles.scss
@@ -0,0 +1,91 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
diff --git a/demos/src/Examples/Default/Svelte/index.html b/demos/src/Examples/Default/Svelte/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Default/Svelte/index.svelte b/demos/src/Examples/Default/Svelte/index.svelte
new file mode 100644
index 0000000..2020194
--- /dev/null
+++ b/demos/src/Examples/Default/Svelte/index.svelte
@@ -0,0 +1,186 @@
+
+
+{#if editor}
+
+
+ console.log && editor.chain().focus().toggleBold().run()}
+ disabled={!editor.can().chain().focus().toggleBold().run()}
+ class={editor.isActive("bold") ? "is-active" : ""}
+ >
+ Bold
+
+ editor.chain().focus().toggleItalic().run()}
+ disabled={!editor.can().chain().focus().toggleItalic().run()}
+ class={editor.isActive("italic") ? "is-active" : ""}
+ >
+ Italic
+
+ editor.chain().focus().toggleStrike().run()}
+ disabled={!editor.can().chain().focus().toggleStrike().run()}
+ class={editor.isActive("strike") ? "is-active" : ""}
+ >
+ Strike
+
+ editor.chain().focus().toggleCode().run()}
+ disabled={!editor.can().chain().focus().toggleCode().run()}
+ class={editor.isActive("code") ? "is-active" : ""}
+ >
+ Code
+
+ editor.chain().focus().unsetAllMarks().run()}>Clear marks
+ editor.chain().focus().clearNodes().run()}>Clear nodes
+ editor.chain().focus().setParagraph().run()}
+ class={editor.isActive("paragraph") ? "is-active" : ""}
+ >
+ Paragraph
+
+ editor.chain().focus().toggleHeading({ level: 1 }).run()}
+ class={editor.isActive("heading", { level: 1 }) ? "is-active" : ""}
+ >
+ H1
+
+ editor.chain().focus().toggleHeading({ level: 2 }).run()}
+ class={editor.isActive("heading", { level: 2 }) ? "is-active" : ""}
+ >
+ H2
+
+ editor.chain().focus().toggleHeading({ level: 3 }).run()}
+ class={editor.isActive("heading", { level: 3 }) ? "is-active" : ""}
+ >
+ H3
+
+ editor.chain().focus().toggleHeading({ level: 4 }).run()}
+ class={editor.isActive("heading", { level: 4 }) ? "is-active" : ""}
+ >
+ H4
+
+ editor.chain().focus().toggleHeading({ level: 5 }).run()}
+ class={editor.isActive("heading", { level: 5 }) ? "is-active" : ""}
+ >
+ H5
+
+ editor.chain().focus().toggleHeading({ level: 6 }).run()}
+ class={editor.isActive("heading", { level: 6 }) ? "is-active" : ""}
+ >
+ H6
+
+ editor.chain().focus().toggleBulletList().run()}
+ class={editor.isActive("bulletList") ? "is-active" : ""}
+ >
+ Bullet list
+
+ editor.chain().focus().toggleOrderedList().run()}
+ class={editor.isActive("orderedList") ? "is-active" : ""}
+ >
+ Ordered list
+
+ editor.chain().focus().toggleCodeBlock().run()}
+ class={editor.isActive("codeBlock") ? "is-active" : ""}
+ >
+ Code block
+
+ editor.chain().focus().toggleBlockquote().run()}
+ class={editor.isActive("blockquote") ? "is-active" : ""}
+ >
+ Blockquote
+
+ editor.chain().focus().setHorizontalRule().run()}>
+ Horizontal rule
+
+ editor.chain().focus().setHardBreak().run()}>Hard break
+ editor.chain().focus().undo().run()}
+ disabled={!editor.can().chain().focus().undo().run()}
+ >
+ Undo
+
+ editor.chain().focus().redo().run()}
+ disabled={!editor.can().chain().focus().redo().run()}
+ >
+ Redo
+
+ editor.chain().focus().setColor('#958DF1').run()}
+ class={editor.isActive('textStyle', { color: '#958DF1' }) ? 'is-active' : ''}
+ >
+ Purple
+
+
+
+{/if}
+
diff --git a/demos/src/Examples/Default/Svelte/styles.scss b/demos/src/Examples/Default/Svelte/styles.scss
new file mode 100644
index 0000000..15283b1
--- /dev/null
+++ b/demos/src/Examples/Default/Svelte/styles.scss
@@ -0,0 +1,91 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
diff --git a/demos/src/Examples/Default/Vue/index.html b/demos/src/Examples/Default/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Default/Vue/index.vue b/demos/src/Examples/Default/Vue/index.vue
new file mode 100644
index 0000000..5dbf71e
--- /dev/null
+++ b/demos/src/Examples/Default/Vue/index.vue
@@ -0,0 +1,282 @@
+
+
+
+
+
+ Bold
+
+
+ Italic
+
+
+ Strike
+
+
+ Code
+
+ Clear marks
+ Clear nodes
+
+ Paragraph
+
+
+ H1
+
+
+ H2
+
+
+ H3
+
+
+ H4
+
+
+ H5
+
+
+ H6
+
+
+ Bullet list
+
+
+ Ordered list
+
+
+ Code block
+
+
+ Blockquote
+
+ Horizontal rule
+ Hard break
+
+ Undo
+
+
+ Redo
+
+
+ Purple
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/Default/index.spec.ts b/demos/src/Examples/Default/index.spec.ts
new file mode 100644
index 0000000..ef44470
--- /dev/null
+++ b/demos/src/Examples/Default/index.spec.ts
@@ -0,0 +1,180 @@
+import { expect, test } from '@playwright/test'
+
+import { clickButton, getEditor } from '../../../test/helpers.js'
+
+const demoName = 'Default'
+const frameworkPaths = ['React', 'Vue', 'Svelte']
+const demoPath = '/src/Examples'
+
+const buttonMarks = [
+ { label: 'Bold', tag: 'strong' },
+ { label: 'Italic', tag: 'em' },
+ { label: 'Strike', tag: 's' },
+]
+
+const buttonNodes = [
+ { label: 'H1', tag: 'h1' },
+ { label: 'H2', tag: 'h2' },
+ { label: 'H3', tag: 'h3' },
+ { label: 'H4', tag: 'h4' },
+ { label: 'H5', tag: 'h5' },
+ { label: 'H6', tag: 'h6' },
+ { label: 'Bullet list', tag: 'ul' },
+ { label: 'Ordered list', tag: 'ol' },
+ { label: 'Code block', tag: 'pre code' },
+ { label: 'Blockquote', tag: 'blockquote' },
+]
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any) => {
+ el.editor.chain().focus().setContent('Example Text ').selectAll().run()
+ })
+ })
+
+ test('should apply the paragraph style when the keyboard shortcut is pressed', async ({
+ page,
+ }) => {
+ await expect(page.locator('.tiptap h1')).toBeVisible()
+
+ const editor = await getEditor(page)
+ await editor.click()
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ await editor.press(process.platform === 'darwin' ? 'Meta+Alt+0' : 'Control+Alt+0')
+
+ await expect(page.locator('.tiptap p').first()).toContainText('Example Text')
+ })
+
+ buttonMarks.forEach(m => {
+ test(`should apply ${m.label} when the button is pressed`, async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any) => {
+ el.editor.chain().focus().setContent('Hello world
').selectAll().run()
+ })
+ await clickButton(page, 'Paragraph')
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ await clickButton(page, m.label)
+
+ await expect(page.locator(`.tiptap ${m.tag}`)).toHaveText('Hello world')
+ })
+ })
+
+ test('should clear marks when the button is pressed', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any) => {
+ el.editor.chain().focus().setContent('Hello world
').selectAll().run()
+ })
+ await clickButton(page, 'Paragraph')
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ await clickButton(page, 'Bold')
+
+ await expect(page.locator('.tiptap strong')).toHaveText('Hello world')
+
+ await clickButton(page, 'Clear marks')
+ await expect(page.locator('.tiptap strong')).toHaveCount(0)
+ })
+
+ test('should clear nodes when the button is pressed', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any) => {
+ el.editor
+ .chain()
+ .focus()
+ .setContent(
+ 'Hello world
A second item
A third item
',
+ )
+ .selectAll()
+ .run()
+ })
+
+ await clickButton(page, 'Clear nodes')
+
+ await expect(page.locator('.tiptap ul')).toHaveCount(0)
+ await expect(page.locator('.tiptap p').first()).toContainText('Hello world')
+ })
+
+ buttonNodes.forEach(n => {
+ test(`should set the correct type when ${n.label} is pressed`, async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any) => {
+ el.editor.chain().focus().setContent('Hello world
').selectAll().run()
+ })
+ await clickButton(page, n.label)
+
+ await expect(page.locator(`.tiptap ${n.tag}`)).toHaveText('Hello world')
+ })
+ })
+
+ test('should add a hr when on the same line as a node', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.press('ArrowRight')
+ await clickButton(page, 'Horizontal rule')
+
+ await expect(page.locator('.tiptap hr')).toHaveCount(1)
+ })
+
+ test('should add a hr when on a new line', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.press('ArrowRight')
+ await editor.press('Enter')
+ await clickButton(page, 'Horizontal rule')
+
+ await expect(page.locator('.tiptap hr')).toHaveCount(1)
+ })
+
+ test('should add a br', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.press('ArrowRight')
+ await clickButton(page, 'Hard break')
+
+ const brCount = await page.locator('.tiptap br').count()
+
+ expect(brCount).toBeGreaterThan(0)
+ })
+
+ test('should undo', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.press('End')
+ await editor.type('~')
+ await expect(page.locator('.tiptap')).toContainText('~')
+
+ await clickButton(page, 'Undo')
+ await expect(page.locator('.tiptap')).not.toContainText('~')
+ })
+
+ test('should redo', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.press('End')
+ await editor.type('~')
+ await expect(page.locator('.tiptap')).toContainText('~')
+
+ await clickButton(page, 'Undo')
+ await expect(page.locator('.tiptap')).not.toContainText('~')
+
+ await clickButton(page, 'Redo')
+ await expect(page.locator('.tiptap')).toContainText('~')
+ })
+ })
+ })
+})
diff --git a/demos/src/Examples/Drawing/Vue/Component.vue b/demos/src/Examples/Drawing/Vue/Component.vue
new file mode 100644
index 0000000..0d4ba3f
--- /dev/null
+++ b/demos/src/Examples/Drawing/Vue/Component.vue
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/Drawing/Vue/Paper.js b/demos/src/Examples/Drawing/Vue/Paper.js
new file mode 100644
index 0000000..7a90ee9
--- /dev/null
+++ b/demos/src/Examples/Drawing/Vue/Paper.js
@@ -0,0 +1,35 @@
+import { mergeAttributes, Node, VueNodeViewRenderer } from '@tiptap/vue'
+
+import Component from './Component.vue'
+
+export default Node.create({
+ name: 'paper',
+
+ group: 'block',
+
+ atom: true,
+
+ addAttributes() {
+ return {
+ lines: {
+ default: [],
+ },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'div[data-type="paper"]',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'paper' })]
+ },
+
+ addNodeView() {
+ return VueNodeViewRenderer(Component)
+ },
+})
diff --git a/demos/src/Examples/Drawing/Vue/index.html b/demos/src/Examples/Drawing/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Drawing/Vue/index.vue b/demos/src/Examples/Drawing/Vue/index.vue
new file mode 100644
index 0000000..3cb0a89
--- /dev/null
+++ b/demos/src/Examples/Drawing/Vue/index.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
diff --git a/demos/src/Examples/Drawing/index.spec.ts b/demos/src/Examples/Drawing/index.spec.ts
new file mode 100644
index 0000000..ad62b8e
--- /dev/null
+++ b/demos/src/Examples/Drawing/index.spec.ts
@@ -0,0 +1,54 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'Drawing'
+const frameworkPaths = ['Vue']
+const demoPath = '/src/Examples'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test('should have a working tiptap instance', async ({ page }) => {
+ const editor = await getEditor(page)
+ const hasEditor = await editor.evaluate((el: any) => !!el.editor)
+
+ expect(hasEditor).toBe(true)
+ })
+
+ test('should have a svg canvas', async ({ page }) => {
+ await expect(page.locator('.tiptap svg')).toBeVisible()
+ })
+
+ test('should draw on the svg canvas', async ({ page }) => {
+ await getEditor(page)
+ await expect(page.locator('.tiptap svg')).toBeVisible()
+
+ const color = await page.locator('input').nth(0).inputValue()
+ const size = await page.locator('input').nth(1).inputValue()
+
+ const svg = page.locator('.tiptap svg')
+ const box = await svg.boundingBox()
+
+ if (!box) {
+ throw new Error('SVG bounding box not found')
+ }
+
+ await page.mouse.move(box.x + 50, box.y + 50)
+ await page.mouse.down()
+ await page.mouse.move(box.x + 150, box.y + 150)
+ await page.mouse.up()
+
+ const path = page.locator('.tiptap svg path').first()
+ await expect(path).toHaveAttribute('stroke-width', size)
+ await expect(path).toHaveAttribute('stroke', color.toUpperCase())
+ })
+ })
+ })
+})
diff --git a/demos/src/Examples/EnterShortcuts/React/index.html b/demos/src/Examples/EnterShortcuts/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/EnterShortcuts/React/index.tsx b/demos/src/Examples/EnterShortcuts/React/index.tsx
new file mode 100644
index 0000000..600c114
--- /dev/null
+++ b/demos/src/Examples/EnterShortcuts/React/index.tsx
@@ -0,0 +1,96 @@
+import './styles.scss'
+
+import { EditorContent, Extension, useEditor, useEditorState } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+const CustomKeyboardShortcutExtension = Extension.create({
+ name: 'customKeyboardShortcuts',
+ // Set a higher priority to make sure this extension is executed first before the default keyboard shortcuts (paragraph has 1000)
+ priority: 3001,
+ addKeyboardShortcuts() {
+ return {
+ 'Ctrl-Enter': ctx => {
+ // Creates a transaction with this custom meta set
+ return ctx.editor.commands.setMeta('customKeyboardShortcutHandler', 'Ctrl-Enter')
+ },
+ 'Meta-Enter': ctx => {
+ // Creates a transaction with this custom meta set
+ return ctx.editor.commands.setMeta('customKeyboardShortcutHandler', 'Meta-Enter')
+ },
+ 'Shift-Enter': ctx => {
+ return ctx.editor.commands.setMeta('customKeyboardShortcutHandler', 'Shift-Enter')
+ },
+ }
+ },
+})
+
+export default () => {
+ const [lastShortcut, setLastShortcut] = React.useState(null)
+ const editor = useEditor({
+ extensions: [StarterKit, CustomKeyboardShortcutExtension],
+ // Listen for the custom meta set in the transaction
+ onTransaction: ({ transaction }) => {
+ if (transaction.getMeta('customKeyboardShortcutHandler')) {
+ switch (transaction.getMeta('customKeyboardShortcutHandler')) {
+ case 'Ctrl-Enter':
+ setLastShortcut('Ctrl-Enter')
+ break
+ case 'Meta-Enter':
+ setLastShortcut('Meta-Enter')
+ break
+ case 'Shift-Enter':
+ setLastShortcut('Shift-Enter')
+ break
+ default:
+ break
+ }
+ }
+ },
+ content: `
+
+ Hey, try to hit Shift+Enter, Ctrl+Enter or Meta+Enter. The last shortcut hit will be displayed above.
+
+ `,
+ })
+
+ const { isBold, isItalic } = useEditorState({
+ editor,
+ selector: ctx => ({
+ isBold: ctx.editor.isActive('bold') ?? false,
+ isItalic: ctx.editor.isActive('italic') ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().toggleBold().run()}
+ className={isBold ? 'is-active' : ''}
+ >
+ Bold
+
+ editor.chain().focus().toggleItalic().run()}
+ className={isItalic ? 'is-active' : ''}
+ >
+ Italic
+
+
+
+ {lastShortcut
+ ? `${lastShortcut} was the last shortcut hit, and was handled by React`
+ : 'No shortcut has been hit yet, use Shift+Enter to trigger a shortcut handler'}
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Examples/EnterShortcuts/React/styles.scss b/demos/src/Examples/EnterShortcuts/React/styles.scss
new file mode 100644
index 0000000..15283b1
--- /dev/null
+++ b/demos/src/Examples/EnterShortcuts/React/styles.scss
@@ -0,0 +1,91 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
diff --git a/demos/src/Examples/EnterShortcuts/Vue/index.html b/demos/src/Examples/EnterShortcuts/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/EnterShortcuts/Vue/index.vue b/demos/src/Examples/EnterShortcuts/Vue/index.vue
new file mode 100644
index 0000000..c0a3ede
--- /dev/null
+++ b/demos/src/Examples/EnterShortcuts/Vue/index.vue
@@ -0,0 +1,196 @@
+
+
+
+
+
+ Bold
+
+
+ Italic
+
+
+
+ {{ lastShortcut }} was the last shortcut hit, and was handled by Vue
+
+
+ No shortcut has been hit yet, use Shift+Enter to trigger a shortcut handler
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/EnterShortcuts/index.spec.ts b/demos/src/Examples/EnterShortcuts/index.spec.ts
new file mode 100644
index 0000000..bd6ffbf
--- /dev/null
+++ b/demos/src/Examples/EnterShortcuts/index.spec.ts
@@ -0,0 +1,51 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'EnterShortcuts'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Examples'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text
')
+ })
+ })
+
+ test('should update the hint html on Meta+Enter', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.press('Meta+Enter')
+
+ await expect(page.locator('.hint')).toContainText('Meta-Enter was the last shortcut')
+ })
+
+ test('should update the hint html on Shift+Enter', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.press('Shift+Enter')
+
+ await expect(page.locator('.hint')).toContainText('Shift-Enter was the last shortcut')
+ })
+
+ test('should update the hint html on Ctrl+Enter', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.press('Control+Enter')
+
+ await expect(page.locator('.hint')).toContainText('Ctrl-Enter was the last shortcut')
+ })
+ })
+ })
+})
diff --git a/demos/src/Examples/Formatting/React/index.html b/demos/src/Examples/Formatting/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Formatting/React/index.jsx b/demos/src/Examples/Formatting/React/index.jsx
new file mode 100644
index 0000000..78a0f82
--- /dev/null
+++ b/demos/src/Examples/Formatting/React/index.jsx
@@ -0,0 +1,162 @@
+import './styles.scss'
+
+import Highlight from '@tiptap/extension-highlight'
+import TextAlign from '@tiptap/extension-text-align'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+const MenuBar = ({ editor }) => {
+ const editorState = useEditorState({
+ editor,
+ selector: ctx => {
+ return {
+ // Text formatting
+ isBold: ctx.editor.isActive('bold') ?? false,
+ isItalic: ctx.editor.isActive('italic') ?? false,
+ isStrike: ctx.editor.isActive('strike') ?? false,
+ isHighlight: ctx.editor.isActive('highlight') ?? false,
+
+ // Text alignment
+ isAlignLeft: ctx.editor.isActive({ textAlign: 'left' }) ?? false,
+ isAlignCenter: ctx.editor.isActive({ textAlign: 'center' }) ?? false,
+ isAlignRight: ctx.editor.isActive({ textAlign: 'right' }) ?? false,
+ isAlignJustify: ctx.editor.isActive({ textAlign: 'justify' }) ?? false,
+
+ // Block types
+ isParagraph: ctx.editor.isActive('paragraph') ?? false,
+ isHeading1: ctx.editor.isActive('heading', { level: 1 }) ?? false,
+ isHeading2: ctx.editor.isActive('heading', { level: 2 }) ?? false,
+ isHeading3: ctx.editor.isActive('heading', { level: 3 }) ?? false,
+ }
+ },
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+
+
+ editor.chain().focus().toggleHeading({ level: 1 }).run()}
+ className={editorState.isHeading1 ? 'is-active' : ''}
+ >
+ H1
+
+ editor.chain().focus().toggleHeading({ level: 2 }).run()}
+ className={editorState.isHeading2 ? 'is-active' : ''}
+ >
+ H2
+
+ editor.chain().focus().toggleHeading({ level: 3 }).run()}
+ className={editorState.isHeading3 ? 'is-active' : ''}
+ >
+ H3
+
+ editor.chain().focus().setParagraph().run()}
+ className={editorState.isParagraph ? 'is-active' : ''}
+ >
+ Paragraph
+
+ editor.chain().focus().toggleBold().run()}
+ className={editorState.isBold ? 'is-active' : ''}
+ >
+ Bold
+
+ editor.chain().focus().toggleItalic().run()}
+ className={editorState.isItalic ? 'is-active' : ''}
+ >
+ Italic
+
+ editor.chain().focus().toggleStrike().run()}
+ className={editorState.isStrike ? 'is-active' : ''}
+ >
+ Strike
+
+ editor.chain().focus().toggleHighlight().run()}
+ className={editorState.isHighlight ? 'is-active' : ''}
+ >
+ Highlight
+
+ editor.chain().focus().setTextAlign('left').run()}
+ className={editorState.isAlignLeft ? 'is-active' : ''}
+ >
+ Left
+
+ editor.chain().focus().setTextAlign('center').run()}
+ className={editorState.isAlignCenter ? 'is-active' : ''}
+ >
+ Center
+
+ editor.chain().focus().setTextAlign('right').run()}
+ className={editorState.isAlignRight ? 'is-active' : ''}
+ >
+ Right
+
+ editor.chain().focus().setTextAlign('justify').run()}
+ className={editorState.isAlignJustify ? 'is-active' : ''}
+ >
+ Justify
+
+
+
+ )
+}
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ TextAlign.configure({
+ types: ['heading', 'paragraph'],
+ }),
+ Highlight,
+ ],
+ content: `
+
+ Devs Just Want to Have Fun by Cyndi Lauper
+
+
+ I come home in the morning light
+ My mother says, “When you gonna live your life right?”
+ Oh mother dear we’re not the fortunate ones
+ And devs, they wanna have fun
+ Oh devs just want to have fun
+
+ The phone rings in the middle of the night
+ My father yells, "What you gonna do with your life?"
+ Oh daddy dear, you know you’re still number one
+ But girls devs, they wanna have fun
+ Oh devs just want to have
+
+
+ That’s all they really want
+ Some fun
+ When the working day is done
+ Oh devs, they wanna have fun
+ Oh devs just wanna have fun
+ (devs, they wanna, wanna have fun, devs wanna have)
+
+ `,
+ })
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/demos/src/Examples/Formatting/React/styles.scss b/demos/src/Examples/Formatting/React/styles.scss
new file mode 100644
index 0000000..7cc0417
--- /dev/null
+++ b/demos/src/Examples/Formatting/React/styles.scss
@@ -0,0 +1,98 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ mark {
+ background-color: #faf594;
+ border-radius: 0.4rem;
+ box-decoration-break: clone;
+ padding: 0.1rem 0.3rem;
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
diff --git a/demos/src/Examples/Formatting/Vue/index.html b/demos/src/Examples/Formatting/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Formatting/Vue/index.vue b/demos/src/Examples/Formatting/Vue/index.vue
new file mode 100644
index 0000000..66c819a
--- /dev/null
+++ b/demos/src/Examples/Formatting/Vue/index.vue
@@ -0,0 +1,243 @@
+
+
+
+
+
+ H1
+
+
+ H2
+
+
+ H3
+
+
+ Paragraph
+
+
+ Bold
+
+
+ Italic
+
+
+ Strike
+
+
+ Highlight
+
+
+ Left
+
+
+ Center
+
+
+ Right
+
+
+ Justify
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/Formatting/index.spec.ts b/demos/src/Examples/Formatting/index.spec.ts
new file mode 100644
index 0000000..05cd2a3
--- /dev/null
+++ b/demos/src/Examples/Formatting/index.spec.ts
@@ -0,0 +1,61 @@
+import { expect, test } from '@playwright/test'
+
+import { clickButton, getEditor } from '../../../test/helpers.js'
+
+const demoName = 'Formatting'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Examples'
+
+const marks = [{ label: 'Highlight', mark: 'mark' }]
+
+const alignments = [
+ { label: 'Left', alignment: 'left' },
+ { label: 'Center', alignment: 'center' },
+ { label: 'Right', alignment: 'right' },
+ { label: 'Justify', alignment: 'justify' },
+]
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any) => {
+ el.editor.chain().focus().clearContent().run()
+ })
+ })
+
+ marks.forEach(m => {
+ test(`sets ${m.label}`, async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type('Hello world.')
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ await clickButton(page, m.label)
+
+ await expect(page.locator(`.tiptap ${m.mark}`)).toBeVisible()
+ })
+ })
+
+ alignments.forEach(a => {
+ test(`sets ${a.label}`, async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type('Hello world.')
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ await clickButton(page, a.label)
+
+ if (a.alignment !== 'left') {
+ await expect(page.locator('.tiptap p').first()).toHaveCSS('text-align', a.alignment)
+ }
+ })
+ })
+ })
+ })
+})
diff --git a/demos/src/Examples/Images/React/index.html b/demos/src/Examples/Images/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Images/React/index.jsx b/demos/src/Examples/Images/React/index.jsx
new file mode 100644
index 0000000..60e2b22
--- /dev/null
+++ b/demos/src/Examples/Images/React/index.jsx
@@ -0,0 +1,43 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Image from '@tiptap/extension-image'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { Dropcursor } from '@tiptap/extensions'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, Image, Dropcursor],
+ content: `
+ This is a basic example of implementing images. Drag to re-order.
+
+
+ `,
+ })
+
+ const addImage = () => {
+ const url = window.prompt('URL')
+
+ if (url) {
+ editor.chain().focus().setImage({ src: url }).run()
+ }
+ }
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ Add image from URL
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Examples/Images/React/styles.scss b/demos/src/Examples/Images/React/styles.scss
new file mode 100644
index 0000000..51aa105
--- /dev/null
+++ b/demos/src/Examples/Images/React/styles.scss
@@ -0,0 +1,17 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ img {
+ display: block;
+ height: auto;
+ margin: 1.5rem 0;
+ max-width: 100%;
+
+ &.ProseMirror-selectednode {
+ outline: 3px solid var(--purple);
+ }
+ }
+}
diff --git a/demos/src/Examples/Images/Vue/index.html b/demos/src/Examples/Images/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Images/Vue/index.vue b/demos/src/Examples/Images/Vue/index.vue
new file mode 100644
index 0000000..22f40ad
--- /dev/null
+++ b/demos/src/Examples/Images/Vue/index.vue
@@ -0,0 +1,76 @@
+
+
+
+
+ Add image from URL
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/Images/index.spec.ts b/demos/src/Examples/Images/index.spec.ts
new file mode 100644
index 0000000..04b0dd7
--- /dev/null
+++ b/demos/src/Examples/Images/index.spec.ts
@@ -0,0 +1,33 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'Images'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Examples'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await getEditor(page)
+ })
+
+ test('removes a selected image with Backspace', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await expect(page.locator('.tiptap img')).toHaveCount(2)
+
+ await page.locator('.tiptap img').first().click()
+ await expect(page.locator('.tiptap img.ProseMirror-selectednode')).toBeVisible()
+
+ await editor.press('Backspace')
+
+ await expect(page.locator('.tiptap img')).toHaveCount(1)
+ })
+ })
+ })
+})
diff --git a/demos/src/Examples/InteractivityComponent/React/Component.jsx b/demos/src/Examples/InteractivityComponent/React/Component.jsx
new file mode 100644
index 0000000..c9183c9
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponent/React/Component.jsx
@@ -0,0 +1,22 @@
+import { NodeViewWrapper } from '@tiptap/react'
+import React from 'react'
+
+export default props => {
+ const increase = () => {
+ props.updateAttributes({
+ count: props.node.attrs.count + 1,
+ })
+ }
+
+ return (
+
+ React Component
+
+
+
+ This button has been clicked {props.node.attrs.count} times.
+
+
+
+ )
+}
diff --git a/demos/src/Examples/InteractivityComponent/React/Extension.js b/demos/src/Examples/InteractivityComponent/React/Extension.js
new file mode 100644
index 0000000..fedc5f7
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponent/React/Extension.js
@@ -0,0 +1,36 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+import { ReactNodeViewRenderer } from '@tiptap/react'
+
+import Component from './Component.jsx'
+
+export default Node.create({
+ name: 'reactComponent',
+
+ group: 'block',
+
+ atom: true,
+
+ addAttributes() {
+ return {
+ count: {
+ default: 0,
+ },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'react-component',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['react-component', mergeAttributes(HTMLAttributes)]
+ },
+
+ addNodeView() {
+ return ReactNodeViewRenderer(Component)
+ },
+})
diff --git a/demos/src/Examples/InteractivityComponent/React/index.html b/demos/src/Examples/InteractivityComponent/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/InteractivityComponent/React/index.jsx b/demos/src/Examples/InteractivityComponent/React/index.jsx
new file mode 100644
index 0000000..c62c5a3
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponent/React/index.jsx
@@ -0,0 +1,24 @@
+import './styles.scss'
+
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+import ReactComponent from './Extension.js'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [StarterKit, ReactComponent],
+ content: `
+
+ This is still the text editor you’re used to, but enriched with node views.
+
+
+
+ Did you see that? That’s a React component. We are really living in the future.
+
+ `,
+ })
+
+ return
+}
diff --git a/demos/src/Examples/InteractivityComponent/React/styles.scss b/demos/src/Examples/InteractivityComponent/React/styles.scss
new file mode 100644
index 0000000..3d00a3e
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponent/React/styles.scss
@@ -0,0 +1,116 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+
+ /* React component */
+ .react-component {
+ background-color: var(--purple-light);
+ border: 2px solid var(--purple);
+ border-radius: 0.5rem;
+ margin: 2rem 0;
+ position: relative;
+
+ label {
+ background-color: var(--purple);
+ border-radius: 0 0 0.5rem 0;
+ color: var(--white);
+ font-size: 0.75rem;
+ font-weight: bold;
+ padding: 0.25rem 0.5rem;
+ position: absolute;
+ top: 0;
+ }
+
+ .content {
+ margin-top: 1.5rem;
+ padding: 1rem;
+ }
+ }
+}
diff --git a/demos/src/Examples/InteractivityComponent/Vue/Component.vue b/demos/src/Examples/InteractivityComponent/Vue/Component.vue
new file mode 100644
index 0000000..4cfe47a
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponent/Vue/Component.vue
@@ -0,0 +1,58 @@
+
+
+ Vue Component
+
+
+ This button has been clicked {{ node.attrs.count }} times.
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/InteractivityComponent/Vue/Extension.js b/demos/src/Examples/InteractivityComponent/Vue/Extension.js
new file mode 100644
index 0000000..de7f8e2
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponent/Vue/Extension.js
@@ -0,0 +1,36 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+import { VueNodeViewRenderer } from '@tiptap/vue'
+
+import Component from './Component.vue'
+
+export default Node.create({
+ name: 'vueComponent',
+
+ group: 'block',
+
+ atom: true,
+
+ addAttributes() {
+ return {
+ count: {
+ default: 0,
+ },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'vue-component',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['vue-component', mergeAttributes(HTMLAttributes)]
+ },
+
+ addNodeView() {
+ return VueNodeViewRenderer(Component)
+ },
+})
diff --git a/demos/src/Examples/InteractivityComponent/Vue/index.html b/demos/src/Examples/InteractivityComponent/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/InteractivityComponent/Vue/index.vue b/demos/src/Examples/InteractivityComponent/Vue/index.vue
new file mode 100644
index 0000000..590bcf1
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponent/Vue/index.vue
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/InteractivityComponent/index.spec.ts b/demos/src/Examples/InteractivityComponent/index.spec.ts
new file mode 100644
index 0000000..00c2fad
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponent/index.spec.ts
@@ -0,0 +1,38 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'InteractivityComponent'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Examples'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+ const componentClass = frameworkPath === 'React' ? '.react-component' : '.vue-component'
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test('should render a custom node', async ({ page }) => {
+ await getEditor(page)
+ await expect(page.locator(`.tiptap ${componentClass}`)).toHaveCount(1)
+ })
+
+ test('should handle count click inside custom node', async ({ page }) => {
+ await getEditor(page)
+ const button = page.locator(`.tiptap ${componentClass} button`).first()
+
+ await expect(button).toHaveText('This button has been clicked 0 times.')
+ await button.click()
+ await expect(button).toHaveText('This button has been clicked 1 times.')
+ await button.click()
+ await expect(button).toHaveText('This button has been clicked 2 times.')
+ await button.click()
+ await expect(button).toHaveText('This button has been clicked 3 times.')
+ })
+ })
+ })
+})
diff --git a/demos/src/Examples/InteractivityComponentContent/React/Component.jsx b/demos/src/Examples/InteractivityComponentContent/React/Component.jsx
new file mode 100644
index 0000000..36fc95e
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponentContent/React/Component.jsx
@@ -0,0 +1,12 @@
+import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ return (
+
+ React Component
+
+
+
+ )
+}
diff --git a/demos/src/Examples/InteractivityComponentContent/React/Extension.js b/demos/src/Examples/InteractivityComponentContent/React/Extension.js
new file mode 100644
index 0000000..b3cde83
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponentContent/React/Extension.js
@@ -0,0 +1,28 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+import { ReactNodeViewRenderer } from '@tiptap/react'
+
+import Component from './Component.jsx'
+
+export default Node.create({
+ name: 'reactComponent',
+
+ group: 'block',
+
+ content: 'inline*',
+
+ parseHTML() {
+ return [
+ {
+ tag: 'react-component',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['react-component', mergeAttributes(HTMLAttributes), 0]
+ },
+
+ addNodeView() {
+ return ReactNodeViewRenderer(Component)
+ },
+})
diff --git a/demos/src/Examples/InteractivityComponentContent/React/index.html b/demos/src/Examples/InteractivityComponentContent/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/InteractivityComponentContent/React/index.jsx b/demos/src/Examples/InteractivityComponentContent/React/index.jsx
new file mode 100644
index 0000000..a81ac35
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponentContent/React/index.jsx
@@ -0,0 +1,26 @@
+import './styles.scss'
+
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+import ReactComponent from './Extension.js'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [StarterKit, ReactComponent],
+ content: `
+
+ This is still the text editor you’re used to, but enriched with node views.
+
+
+ This is editable.
+
+
+ Did you see that? That’s a React component. We are really living in the future.
+
+ `,
+ })
+
+ return
+}
diff --git a/demos/src/Examples/InteractivityComponentContent/React/styles.scss b/demos/src/Examples/InteractivityComponentContent/React/styles.scss
new file mode 100644
index 0000000..602feeb
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponentContent/React/styles.scss
@@ -0,0 +1,123 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+
+ /* React component */
+ .react-component {
+ background-color: var(--purple-light);
+ border: 2px solid var(--purple);
+ border-radius: 0.5rem;
+ margin: 2rem 0;
+ position: relative;
+
+ label {
+ background-color: var(--purple);
+ border-radius: 0 0 0.5rem 0;
+ color: var(--white);
+ font-size: 0.75rem;
+ font-weight: bold;
+ padding: 0.25rem 0.5rem;
+ position: absolute;
+ top: 0;
+ }
+
+ .content {
+ margin-top: 1.5rem;
+ padding: 1rem;
+
+ &.is-editable {
+ border: 2px dashed var(--gray-3);
+ border-radius: 0.5rem;
+ margin: 2.5rem 1rem 1rem;
+ padding: 0.5rem;
+ }
+ }
+ }
+}
diff --git a/demos/src/Examples/InteractivityComponentContent/Vue/Component.vue b/demos/src/Examples/InteractivityComponentContent/Vue/Component.vue
new file mode 100644
index 0000000..95d66e6
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponentContent/Vue/Component.vue
@@ -0,0 +1,55 @@
+
+
+ Vue Component
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/InteractivityComponentContent/Vue/Extension.js b/demos/src/Examples/InteractivityComponentContent/Vue/Extension.js
new file mode 100644
index 0000000..6e7db93
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponentContent/Vue/Extension.js
@@ -0,0 +1,28 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+import { VueNodeViewRenderer } from '@tiptap/vue'
+
+import Component from './Component.vue'
+
+export default Node.create({
+ name: 'vueComponent',
+
+ group: 'block',
+
+ content: 'inline*',
+
+ parseHTML() {
+ return [
+ {
+ tag: 'vue-component',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['vue-component', mergeAttributes(HTMLAttributes), 0]
+ },
+
+ addNodeView() {
+ return VueNodeViewRenderer(Component)
+ },
+})
diff --git a/demos/src/Examples/InteractivityComponentContent/Vue/index.html b/demos/src/Examples/InteractivityComponentContent/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/InteractivityComponentContent/Vue/index.vue b/demos/src/Examples/InteractivityComponentContent/Vue/index.vue
new file mode 100644
index 0000000..504363c
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponentContent/Vue/index.vue
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/InteractivityComponentContent/index.spec.ts b/demos/src/Examples/InteractivityComponentContent/index.spec.ts
new file mode 100644
index 0000000..db9b871
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponentContent/index.spec.ts
@@ -0,0 +1,88 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'InteractivityComponentContent'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Examples'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+ const componentClass = frameworkPath === 'React' ? '.react-component' : '.vue-component'
+ const nodeName = frameworkPath === 'React' ? 'reactComponent' : 'vueComponent'
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test('should render a custom node', async ({ page }) => {
+ await getEditor(page)
+ await expect(page.locator(`.ProseMirror ${componentClass}`)).toHaveCount(1)
+ })
+
+ test('should allow text editing inside component', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any, name: string) => {
+ const e = el.editor
+ e.state.doc.descendants((node: any, pos: number) => {
+ if (node.type.name !== name) {
+ return true
+ }
+ const from = pos + 1
+ const to = pos + node.nodeSize - 1
+ e.chain()
+ .focus()
+ .setTextSelection({ from, to })
+ .deleteSelection()
+ .insertContent('Hello World!')
+ .run()
+ return false
+ })
+ }, nodeName)
+
+ const content = page.locator(`.ProseMirror ${componentClass} .content`).first()
+ await expect(content).toHaveText('Hello World!')
+ })
+
+ test('should allow text editing inside component with markdown text', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any, name: string) => {
+ const e = el.editor
+ e.state.doc.descendants((node: any, pos: number) => {
+ if (node.type.name !== name) {
+ return true
+ }
+ const from = pos + 1
+ const to = pos + node.nodeSize - 1
+ e.chain().focus().setTextSelection({ from, to }).deleteSelection().run()
+ return false
+ })
+ }, nodeName)
+
+ const content = page.locator(`.ProseMirror ${componentClass} .content`).first()
+
+ await content.click()
+ await page.keyboard.type('Hello World! This is **bold**.')
+
+ await expect(content).toHaveText('Hello World! This is bold.')
+ await expect(page.locator(`.ProseMirror ${componentClass} .content strong`)).toBeVisible()
+ })
+
+ test('should remove node via selectall', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await expect(page.locator(`.ProseMirror ${componentClass}`)).toHaveCount(1)
+
+ await editor.evaluate((el: any) => {
+ el.editor.chain().focus().selectAll().deleteSelection().run()
+ })
+
+ await expect(page.locator(`.ProseMirror ${componentClass}`)).toHaveCount(0)
+ })
+ })
+ })
+})
diff --git a/demos/src/Examples/InteractivityComponentProvideInject/Vue/Component.vue b/demos/src/Examples/InteractivityComponentProvideInject/Vue/Component.vue
new file mode 100644
index 0000000..dbaf623
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponentProvideInject/Vue/Component.vue
@@ -0,0 +1,50 @@
+
+
+ Vue Component
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/InteractivityComponentProvideInject/Vue/Editor.vue b/demos/src/Examples/InteractivityComponentProvideInject/Vue/Editor.vue
new file mode 100644
index 0000000..4fa6186
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponentProvideInject/Vue/Editor.vue
@@ -0,0 +1,141 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/InteractivityComponentProvideInject/Vue/Extension.js b/demos/src/Examples/InteractivityComponentProvideInject/Vue/Extension.js
new file mode 100644
index 0000000..de7f8e2
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponentProvideInject/Vue/Extension.js
@@ -0,0 +1,36 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+import { VueNodeViewRenderer } from '@tiptap/vue'
+
+import Component from './Component.vue'
+
+export default Node.create({
+ name: 'vueComponent',
+
+ group: 'block',
+
+ atom: true,
+
+ addAttributes() {
+ return {
+ count: {
+ default: 0,
+ },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'vue-component',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['vue-component', mergeAttributes(HTMLAttributes)]
+ },
+
+ addNodeView() {
+ return VueNodeViewRenderer(Component)
+ },
+})
diff --git a/demos/src/Examples/InteractivityComponentProvideInject/Vue/ValidateInject.vue b/demos/src/Examples/InteractivityComponentProvideInject/Vue/ValidateInject.vue
new file mode 100644
index 0000000..58161b3
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponentProvideInject/Vue/ValidateInject.vue
@@ -0,0 +1,20 @@
+
+
+
{{ globalValue }}
+
{{ appValue }}
+
{{ indexValue }}
+
{{ editorValue }}
+
+
+
+
+
+
diff --git a/demos/src/Examples/InteractivityComponentProvideInject/Vue/index.html b/demos/src/Examples/InteractivityComponentProvideInject/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/InteractivityComponentProvideInject/Vue/index.vue b/demos/src/Examples/InteractivityComponentProvideInject/Vue/index.vue
new file mode 100644
index 0000000..185226b
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponentProvideInject/Vue/index.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/demos/src/Examples/InteractivityComponentProvideInject/index.spec.ts b/demos/src/Examples/InteractivityComponentProvideInject/index.spec.ts
new file mode 100644
index 0000000..beaea1a
--- /dev/null
+++ b/demos/src/Examples/InteractivityComponentProvideInject/index.spec.ts
@@ -0,0 +1,45 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'InteractivityComponentProvideInject'
+const frameworkPaths = ['Vue']
+const demoPath = '/src/Examples'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test('should have a working tiptap instance', async ({ page }) => {
+ const editor = await getEditor(page)
+ const hasEditor = await editor.evaluate((el: any) => !!el.editor)
+
+ expect(hasEditor).toBe(true)
+ })
+
+ test('should render a custom node', async ({ page }) => {
+ await getEditor(page)
+ await expect(page.locator('.tiptap .vue-component')).toHaveCount(1)
+ })
+
+ test('should have global and all injected values', async ({ page }) => {
+ await getEditor(page)
+ const expectedTexts = ['globalValue', 'appValue', 'indexValue', 'editorValue']
+ const paragraphs = page.locator('.tiptap .vue-component p')
+
+ await expectedTexts.reduce>(
+ (previousPromise, text, i) =>
+ previousPromise.then(async () => {
+ await expect(paragraphs.nth(i)).toHaveText(text)
+ }),
+ Promise.resolve(),
+ )
+ })
+ })
+ })
+})
diff --git a/demos/src/Examples/JSX/React/Paragraph.tsx b/demos/src/Examples/JSX/React/Paragraph.tsx
new file mode 100644
index 0000000..8084b28
--- /dev/null
+++ b/demos/src/Examples/JSX/React/Paragraph.tsx
@@ -0,0 +1,13 @@
+/** @jsxImportSource @tiptap/core */
+import { mergeAttributes } from '@tiptap/core'
+import { Paragraph as BaseParagraph } from '@tiptap/extension-paragraph'
+
+export const Paragraph = BaseParagraph.extend({
+ renderHTML({ HTMLAttributes }) {
+ return (
+
+
+
+ )
+ },
+})
diff --git a/demos/src/Examples/JSX/React/index.html b/demos/src/Examples/JSX/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/JSX/React/index.tsx b/demos/src/Examples/JSX/React/index.tsx
new file mode 100644
index 0000000..562da83
--- /dev/null
+++ b/demos/src/Examples/JSX/React/index.tsx
@@ -0,0 +1,25 @@
+import './styles.scss'
+
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+import { Paragraph } from './Paragraph.jsx'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ StarterKit.configure({
+ paragraph: false,
+ }),
+ Paragraph,
+ ],
+ content: `
+
+ Each paragraph will be red
+
+ `,
+ })
+
+ return
+}
diff --git a/demos/src/Examples/JSX/React/styles.scss b/demos/src/Examples/JSX/React/styles.scss
new file mode 100644
index 0000000..15283b1
--- /dev/null
+++ b/demos/src/Examples/JSX/React/styles.scss
@@ -0,0 +1,91 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
diff --git a/demos/src/Examples/JSX/Vue/Paragraph.tsx b/demos/src/Examples/JSX/Vue/Paragraph.tsx
new file mode 100644
index 0000000..8084b28
--- /dev/null
+++ b/demos/src/Examples/JSX/Vue/Paragraph.tsx
@@ -0,0 +1,13 @@
+/** @jsxImportSource @tiptap/core */
+import { mergeAttributes } from '@tiptap/core'
+import { Paragraph as BaseParagraph } from '@tiptap/extension-paragraph'
+
+export const Paragraph = BaseParagraph.extend({
+ renderHTML({ HTMLAttributes }) {
+ return (
+
+
+
+ )
+ },
+})
diff --git a/demos/src/Examples/JSX/Vue/index.html b/demos/src/Examples/JSX/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/JSX/Vue/index.vue b/demos/src/Examples/JSX/Vue/index.vue
new file mode 100644
index 0000000..f0af89f
--- /dev/null
+++ b/demos/src/Examples/JSX/Vue/index.vue
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+n
diff --git a/demos/src/Examples/JSX/index.spec.ts b/demos/src/Examples/JSX/index.spec.ts
new file mode 100644
index 0000000..1d45c43
--- /dev/null
+++ b/demos/src/Examples/JSX/index.spec.ts
@@ -0,0 +1,31 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'JSX'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Examples'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test('should have a working tiptap instance', async ({ page }) => {
+ const editor = await getEditor(page)
+ const hasEditor = await editor.evaluate((el: any) => !!el.editor)
+
+ expect(hasEditor).toBe(true)
+ })
+
+ test('should have paragraphs colored as red', async ({ page }) => {
+ await getEditor(page)
+ await expect(page.locator('.tiptap p').first()).toHaveCSS('color', 'rgb(255, 0, 0)')
+ })
+ })
+ })
+})
diff --git a/demos/src/Examples/MarkdownShortcuts/React/index.html b/demos/src/Examples/MarkdownShortcuts/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/MarkdownShortcuts/React/index.jsx b/demos/src/Examples/MarkdownShortcuts/React/index.jsx
new file mode 100644
index 0000000..60bfd28
--- /dev/null
+++ b/demos/src/Examples/MarkdownShortcuts/React/index.jsx
@@ -0,0 +1,32 @@
+import './styles.scss'
+
+import Highlight from '@tiptap/extension-highlight'
+import Typography from '@tiptap/extension-typography'
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [StarterKit, Highlight, Typography],
+ content: `
+
+ Markdown shortcuts make it easy to format the text while typing.
+
+
+ To test that, start a new line and type # followed by a space to get a heading. Try #, ##, ###, ####, #####, ###### for different levels.
+
+
+ Those conventions are called input rules in Tiptap. Some of them are enabled by default. Try > for blockquotes, *, - or + for bullet lists, or \`foobar\` to highlight code, ~~tildes~~ to strike text, or ==equal signs== to highlight text.
+
+
+ You can overwrite existing input rules or add your own to nodes, marks and extensions.
+
+
+ For example, we added the Typography extension here. Try typing (c) to see how it’s converted to a proper © character. You can also try ->, >>, 1/2, !=, or --.
+
+ `,
+ })
+
+ return
+}
diff --git a/demos/src/Examples/MarkdownShortcuts/React/styles.scss b/demos/src/Examples/MarkdownShortcuts/React/styles.scss
new file mode 100644
index 0000000..7cc0417
--- /dev/null
+++ b/demos/src/Examples/MarkdownShortcuts/React/styles.scss
@@ -0,0 +1,98 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ mark {
+ background-color: #faf594;
+ border-radius: 0.4rem;
+ box-decoration-break: clone;
+ padding: 0.1rem 0.3rem;
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
diff --git a/demos/src/Examples/MarkdownShortcuts/Vue/index.html b/demos/src/Examples/MarkdownShortcuts/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/MarkdownShortcuts/Vue/index.vue b/demos/src/Examples/MarkdownShortcuts/Vue/index.vue
new file mode 100644
index 0000000..c2b1231
--- /dev/null
+++ b/demos/src/Examples/MarkdownShortcuts/Vue/index.vue
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/MarkdownShortcuts/index.spec.ts b/demos/src/Examples/MarkdownShortcuts/index.spec.ts
new file mode 100644
index 0000000..c93f2ef
--- /dev/null
+++ b/demos/src/Examples/MarkdownShortcuts/index.spec.ts
@@ -0,0 +1,84 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'MarkdownShortcuts'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Examples'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any) => {
+ el.editor.commands.clearContent()
+ })
+ })
+ ;[1, 2, 3, 4, 5, 6].forEach(level => {
+ test(`should make a h${level}`, async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type(`${'#'.repeat(level)} Headline`)
+
+ await expect(page.locator(`.tiptap h${level}`)).toContainText('Headline')
+ })
+ })
+
+ test('should create inline code', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type('`$foobar`')
+
+ await expect(page.locator('.tiptap code')).toContainText('$foobar')
+ })
+
+ test('should create a code block without language', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type('``` ')
+ await editor.press('Enter')
+ await editor.type('const foo = bar')
+ await editor.press('Enter')
+ await editor.type('```')
+
+ await expect(page.locator('.tiptap pre')).toContainText('const foo = bar')
+ })
+ ;['*', '-', '+'].forEach(symbol => {
+ test(`should create a bullet list from ${symbol}`, async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type(`${symbol} foobar`)
+
+ await expect(page.locator('.tiptap ul')).toContainText('foobar')
+ })
+ })
+
+ test('should create a ordered list', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type('1. foobar')
+
+ await expect(page.locator('.tiptap ol')).toContainText('foobar')
+ })
+
+ test('should create a blockquote', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type('> foobar')
+
+ await expect(page.locator('.tiptap blockquote')).toContainText('foobar')
+ })
+ })
+ })
+})
diff --git a/demos/src/Examples/Menus/React/index.html b/demos/src/Examples/Menus/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Menus/React/index.jsx b/demos/src/Examples/Menus/React/index.jsx
new file mode 100644
index 0000000..59c749b
--- /dev/null
+++ b/demos/src/Examples/Menus/React/index.jsx
@@ -0,0 +1,97 @@
+import './styles.scss'
+
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import { BubbleMenu, FloatingMenu } from '@tiptap/react/menus'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [StarterKit],
+ content: `
+
+ Try to select this text to see what we call the bubble menu.
+
+
+ Neat, isn’t it? Add an empty paragraph to see the floating menu.
+
+ `,
+ })
+
+ const { isBold, isItalic, isStrike, isHeading1, isHeading2, isBulletList } = useEditorState({
+ editor,
+ selector: ctx => {
+ if (!ctx.editor) {
+ return {
+ isBold: false,
+ isItalic: false,
+ isStrike: false,
+ isHeading1: false,
+ isHeading2: false,
+ isBulletList: false,
+ }
+ }
+
+ return {
+ isBold: ctx.editor.isActive('bold') ?? false,
+ isItalic: ctx.editor.isActive('italic') ?? false,
+ isStrike: ctx.editor.isActive('strike') ?? false,
+ isHeading1: ctx.editor.isActive('heading', { level: 1 }) ?? false,
+ isHeading2: ctx.editor.isActive('heading', { level: 2 }) ?? false,
+ isBulletList: ctx.editor.isActive('bulletList') ?? false,
+ }
+ },
+ })
+
+ return (
+ <>
+ {editor && (
+
+ editor.chain().focus().toggleBold().run()}
+ className={isBold ? 'is-active' : ''}
+ >
+ Bold
+
+ editor.chain().focus().toggleItalic().run()}
+ className={isItalic ? 'is-active' : ''}
+ >
+ Italic
+
+ editor.chain().focus().toggleStrike().run()}
+ className={isStrike ? 'is-active' : ''}
+ >
+ Strike
+
+
+ )}
+
+ {editor && (
+
+ editor.chain().focus().toggleHeading({ level: 1 }).run()}
+ className={isHeading1 ? 'is-active' : ''}
+ >
+ H1
+
+ editor.chain().focus().toggleHeading({ level: 2 }).run()}
+ className={isHeading2 ? 'is-active' : ''}
+ >
+ H2
+
+ editor.chain().focus().toggleBulletList().run()}
+ className={isBulletList ? 'is-active' : ''}
+ >
+ Bullet list
+
+
+ )}
+
+
+ >
+ )
+}
diff --git a/demos/src/Examples/Menus/React/styles.scss b/demos/src/Examples/Menus/React/styles.scss
new file mode 100644
index 0000000..f621f9e
--- /dev/null
+++ b/demos/src/Examples/Menus/React/styles.scss
@@ -0,0 +1,144 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
+
+/* Bubble menu */
+.bubble-menu {
+ background-color: var(--white);
+ border: 1px solid var(--gray-1);
+ border-radius: 0.7rem;
+ box-shadow: var(--shadow);
+ display: flex;
+ padding: 0.2rem;
+
+ button {
+ background-color: unset;
+
+ &:hover {
+ background-color: var(--gray-3);
+ }
+
+ &.is-active {
+ background-color: var(--purple);
+
+ &:hover {
+ background-color: var(--purple-contrast);
+ }
+ }
+ }
+}
+
+/* Floating menu */
+.floating-menu {
+ display: flex;
+ background-color: var(--gray-3);
+ padding: 0.1rem;
+ border-radius: 0.5rem;
+
+ button {
+ background-color: unset;
+ padding: 0.275rem 0.425rem;
+ border-radius: 0.3rem;
+
+ &:hover {
+ background-color: var(--gray-3);
+ }
+
+ &.is-active {
+ background-color: var(--white);
+ color: var(--purple);
+
+ &:hover {
+ color: var(--purple-contrast);
+ }
+ }
+ }
+}
diff --git a/demos/src/Examples/Menus/Vue/index.html b/demos/src/Examples/Menus/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Menus/Vue/index.vue b/demos/src/Examples/Menus/Vue/index.vue
new file mode 100644
index 0000000..ed0844e
--- /dev/null
+++ b/demos/src/Examples/Menus/Vue/index.vue
@@ -0,0 +1,236 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/Menus/index.spec.ts b/demos/src/Examples/Menus/index.spec.ts
new file mode 100644
index 0000000..49e5ef3
--- /dev/null
+++ b/demos/src/Examples/Menus/index.spec.ts
@@ -0,0 +1,61 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'Menus'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Examples'
+
+const marks = [
+ { button: 'Bold', tag: 'strong' },
+ { button: 'Italic', tag: 'em' },
+ { button: 'Strike', tag: 's' },
+]
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any) => {
+ el.editor.chain().focus().clearContent().run()
+ })
+ })
+
+ test('should show menu when the editor is empty', async ({ page }) => {
+ await expect(page.locator('.floating-menu').first()).toBeVisible()
+ })
+
+ test('should show menu when text is selected', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type('Test')
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+
+ await expect(page.locator('.bubble-menu').first()).toBeVisible()
+ })
+
+ marks.forEach(m => {
+ test(`should apply ${m.button} correctly`, async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type('Test')
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+
+ await page
+ .locator('.bubble-menu')
+ .getByRole('button', { name: m.button, exact: true })
+ .click()
+
+ await expect(page.locator(`.tiptap p ${m.tag}`)).toBeVisible()
+ })
+ })
+ })
+ })
+})
diff --git a/demos/src/Examples/Minimal/React/index.html b/demos/src/Examples/Minimal/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Minimal/React/index.jsx b/demos/src/Examples/Minimal/React/index.jsx
new file mode 100644
index 0000000..209663f
--- /dev/null
+++ b/demos/src/Examples/Minimal/React/index.jsx
@@ -0,0 +1,23 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text],
+ content: `
+
+ This is a radically reduced version of Tiptap. It has support for a document, with paragraphs and text. That’s it. It’s probably too much for real minimalists though.
+
+
+ The paragraph extension is not really required, but you need at least one node. Sure, that node can be something different.
+
+ `,
+ })
+
+ return
+}
diff --git a/demos/src/Examples/Minimal/React/styles.scss b/demos/src/Examples/Minimal/React/styles.scss
new file mode 100644
index 0000000..afd5ba2
--- /dev/null
+++ b/demos/src/Examples/Minimal/React/styles.scss
@@ -0,0 +1,6 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
diff --git a/demos/src/Examples/Minimal/Vue/index.html b/demos/src/Examples/Minimal/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Minimal/Vue/index.vue b/demos/src/Examples/Minimal/Vue/index.vue
new file mode 100644
index 0000000..fa1add4
--- /dev/null
+++ b/demos/src/Examples/Minimal/Vue/index.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/Minimal/index.spec.ts b/demos/src/Examples/Minimal/index.spec.ts
new file mode 100644
index 0000000..6ff4b83
--- /dev/null
+++ b/demos/src/Examples/Minimal/index.spec.ts
@@ -0,0 +1,68 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'Minimal'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Examples'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any) => {
+ el.editor.commands.clearContent()
+ })
+ })
+
+ test('text should be wrapped in a paragraph by default', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type('Example Text')
+
+ await expect(page.locator('.tiptap p')).toContainText('Example Text')
+ })
+
+ test('should parse paragraphs correctly', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ const result = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text
')
+ const first = el.editor.getHTML()
+ el.editor.commands.setContent('Example Text
')
+ return [first, el.editor.getHTML()]
+ })
+
+ expect(result).toEqual(['Example Text
', 'Example Text
'])
+ })
+
+ test('enter should make a new paragraph', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type('First Paragraph')
+ await editor.press('Enter')
+ await editor.type('Second Paragraph')
+
+ await expect(page.locator('.tiptap p')).toHaveCount(2)
+ })
+
+ test('backspace should remove the last paragraph', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.press('Enter')
+ await expect(page.locator('.tiptap p')).toHaveCount(2)
+
+ await editor.press('Backspace')
+ await expect(page.locator('.tiptap p')).toHaveCount(1)
+ })
+ })
+ })
+})
diff --git a/demos/src/Examples/MultiMention/React/MentionList.jsx b/demos/src/Examples/MultiMention/React/MentionList.jsx
new file mode 100644
index 0000000..9d1a396
--- /dev/null
+++ b/demos/src/Examples/MultiMention/React/MentionList.jsx
@@ -0,0 +1,68 @@
+import './MentionList.scss'
+
+import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
+
+export default forwardRef((props, ref) => {
+ const [selectedIndex, setSelectedIndex] = useState(0)
+
+ const selectItem = index => {
+ const item = props.items[index]
+
+ if (item) {
+ props.command({ id: item })
+ }
+ }
+
+ const upHandler = () => {
+ setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length)
+ }
+
+ const downHandler = () => {
+ setSelectedIndex((selectedIndex + 1) % props.items.length)
+ }
+
+ const enterHandler = () => {
+ selectItem(selectedIndex)
+ }
+
+ useEffect(() => setSelectedIndex(0), [props.items])
+
+ useImperativeHandle(ref, () => ({
+ onKeyDown: ({ event }) => {
+ if (event.key === 'ArrowUp') {
+ upHandler()
+ return true
+ }
+
+ if (event.key === 'ArrowDown') {
+ downHandler()
+ return true
+ }
+
+ if (event.key === 'Enter') {
+ enterHandler()
+ return true
+ }
+
+ return false
+ },
+ }))
+
+ return (
+
+ {props.items.length ? (
+ props.items.map((item, index) => (
+
selectItem(index)}
+ >
+ {item}
+
+ ))
+ ) : (
+
No result
+ )}
+
+ )
+})
diff --git a/demos/src/Examples/MultiMention/React/MentionList.scss b/demos/src/Examples/MultiMention/React/MentionList.scss
new file mode 100644
index 0000000..e9ee1ab
--- /dev/null
+++ b/demos/src/Examples/MultiMention/React/MentionList.scss
@@ -0,0 +1,31 @@
+/* Dropdown menu */
+.dropdown-menu {
+ background: var(--white);
+ border: 1px solid var(--gray-1);
+ border-radius: 0.7rem;
+ box-shadow: var(--shadow);
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ overflow: auto;
+ padding: 0.4rem;
+ position: relative;
+
+ button {
+ align-items: center;
+ background-color: transparent;
+ display: flex;
+ gap: 0.25rem;
+ text-align: left;
+ width: 100%;
+
+ &:hover,
+ &:hover.is-selected {
+ background-color: var(--gray-3);
+ }
+
+ &.is-selected {
+ background-color: var(--gray-2);
+ }
+ }
+}
diff --git a/demos/src/Examples/MultiMention/React/index.html b/demos/src/Examples/MultiMention/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/MultiMention/React/index.jsx b/demos/src/Examples/MultiMention/React/index.jsx
new file mode 100644
index 0000000..e46d740
--- /dev/null
+++ b/demos/src/Examples/MultiMention/React/index.jsx
@@ -0,0 +1,40 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Mention from '@tiptap/extension-mention'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+import suggestions from './suggestions.js'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Mention.configure({
+ HTMLAttributes: {
+ class: 'mention',
+ },
+ suggestions,
+ }),
+ ],
+ content: `
+ Hi everyone! Don’t forget the daily stand up at 8 AM.
+ We will talk about the movies: , and .
+ Would you mind to share what you’ve been working on lately? We fear not much happened since .
+ Let’s go through your most important points quickly.
+ I have a meeting with and don’t want to come late.
+ – Thanks, your big boss
+ `,
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return
+}
diff --git a/demos/src/Examples/MultiMention/React/styles.scss b/demos/src/Examples/MultiMention/React/styles.scss
new file mode 100644
index 0000000..ef91d9b
--- /dev/null
+++ b/demos/src/Examples/MultiMention/React/styles.scss
@@ -0,0 +1,52 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ .mention {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ box-decoration-break: clone;
+ color: var(--purple);
+ padding: 0.1rem 0.3rem;
+ &::after {
+ content: '\200B';
+ }
+ }
+
+ /* Suggestion inline decoration styles (created by @tiptap/suggestion)
+ Visible highlight to help see the active suggestion range in demos */
+ [data-decoration-id] {
+ /* Reuse theme variables from demos setup for consistent look */
+ background-color: var(--purple-light);
+ border-bottom: 2px dashed var(--purple-contrast);
+ border-radius: 0.2rem;
+ padding: 0 0.18rem;
+ box-decoration-break: clone;
+ }
+
+ /* When the decoration is empty (no query), add a subtle hint */
+ [data-decoration-id].is-empty::after {
+ content: '\00A0';
+ border-bottom: 1px dotted var(--gray-3);
+ }
+
+ /* Make suggestion decoration visually match mention nodes */
+ .suggestion {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ box-decoration-break: clone;
+ color: var(--purple);
+ padding: 0.1rem 0.3rem;
+ }
+
+ .suggestion::after {
+ content: '\200B';
+ }
+
+ .suggestion.is-empty::after {
+ content: '\00A0';
+ border-bottom: 1px dotted rgba(31, 41, 55, 0.12);
+ }
+}
diff --git a/demos/src/Examples/MultiMention/React/suggestions.js b/demos/src/Examples/MultiMention/React/suggestions.js
new file mode 100644
index 0000000..d49f31d
--- /dev/null
+++ b/demos/src/Examples/MultiMention/React/suggestions.js
@@ -0,0 +1,160 @@
+import { computePosition, flip, shift } from '@floating-ui/dom'
+import { posToDOMRect, ReactRenderer } from '@tiptap/react'
+
+import MentionList from './MentionList.jsx'
+
+const updatePosition = (editor, element) => {
+ const virtualElement = {
+ getBoundingClientRect: () =>
+ posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to),
+ }
+
+ computePosition(virtualElement, element, {
+ placement: 'bottom-start',
+ strategy: 'absolute',
+ middleware: [shift(), flip()],
+ }).then(({ x, y, strategy }) => {
+ element.style.width = 'max-content'
+ element.style.position = strategy
+ element.style.left = `${x}px`
+ element.style.top = `${y}px`
+ })
+}
+
+export default [
+ {
+ items: ({ query }) => {
+ return [
+ 'Lea Thompson',
+ 'Cyndi Lauper',
+ 'Tom Cruise',
+ 'Madonna',
+ 'Jerry Hall',
+ 'Joan Collins',
+ 'Winona Ryder',
+ 'Christina Applegate',
+ 'Alyssa Milano',
+ 'Molly Ringwald',
+ 'Ally Sheedy',
+ 'Debbie Harry',
+ 'Olivia Newton-John',
+ 'Elton John',
+ 'Michael J. Fox',
+ 'Axl Rose',
+ 'Emilio Estevez',
+ 'Ralph Macchio',
+ 'Rob Lowe',
+ 'Jennifer Grey',
+ 'Mickey Rourke',
+ 'John Cusack',
+ 'Matthew Broderick',
+ 'Justine Bateman',
+ 'Lisa Bonet',
+ ]
+ .filter(item => item.toLowerCase().startsWith(query.toLowerCase()))
+ .slice(0, 5)
+ },
+
+ render: () => {
+ let component
+
+ return {
+ onStart: props => {
+ component = new ReactRenderer(MentionList, {
+ props,
+ editor: props.editor,
+ })
+
+ if (!props.clientRect) {
+ return
+ }
+
+ component.element.style.position = 'absolute'
+
+ document.body.appendChild(component.element)
+
+ updatePosition(props.editor, component.element)
+ },
+
+ onUpdate(props) {
+ component.updateProps(props)
+
+ if (!props.clientRect) {
+ return
+ }
+
+ updatePosition(props.editor, component.element)
+ },
+
+ onKeyDown(props) {
+ if (props.event.key === 'Escape') {
+ component.destroy()
+
+ return true
+ }
+
+ return component.ref?.onKeyDown(props)
+ },
+
+ onExit() {
+ component.destroy()
+ },
+ }
+ },
+ },
+ {
+ char: '#',
+ items: ({ query }) => {
+ return ['Dirty Dancing', 'Pirates of the Caribbean', 'The Matrix']
+ .filter(item => item.toLowerCase().startsWith(query.toLowerCase()))
+ .slice(0, 5)
+ },
+
+ render: () => {
+ let component
+
+ return {
+ onStart: props => {
+ component = new ReactRenderer(MentionList, {
+ props,
+ editor: props.editor,
+ })
+
+ if (!props.clientRect) {
+ return
+ }
+
+ component.element.style.position = 'absolute'
+
+ document.body.appendChild(component.element)
+
+ updatePosition(props.editor, component.element)
+ },
+
+ onUpdate(props) {
+ component.updateProps(props)
+
+ if (!props.clientRect) {
+ return
+ }
+
+ updatePosition(props.editor, component.element)
+ },
+
+ onKeyDown(props) {
+ if (props.event.key === 'Escape') {
+ component.destroy()
+
+ return true
+ }
+
+ return component.ref?.onKeyDown(props)
+ },
+
+ onExit() {
+ component.destroy()
+ },
+ }
+ },
+ },
+]
diff --git a/demos/src/Examples/MultiMention/Vue/MentionList.vue b/demos/src/Examples/MultiMention/Vue/MentionList.vue
new file mode 100644
index 0000000..9b62c75
--- /dev/null
+++ b/demos/src/Examples/MultiMention/Vue/MentionList.vue
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/MultiMention/Vue/index.html b/demos/src/Examples/MultiMention/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/MultiMention/Vue/index.vue b/demos/src/Examples/MultiMention/Vue/index.vue
new file mode 100644
index 0000000..9ad321e
--- /dev/null
+++ b/demos/src/Examples/MultiMention/Vue/index.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/MultiMention/Vue/suggestions.js b/demos/src/Examples/MultiMention/Vue/suggestions.js
new file mode 100644
index 0000000..9e3903a
--- /dev/null
+++ b/demos/src/Examples/MultiMention/Vue/suggestions.js
@@ -0,0 +1,169 @@
+import { computePosition, flip, shift } from '@floating-ui/dom'
+import { posToDOMRect, VueRenderer } from '@tiptap/vue'
+
+import MentionList from './MentionList.vue'
+
+const updatePosition = (editor, element) => {
+ const virtualElement = {
+ getBoundingClientRect: () =>
+ posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to),
+ }
+
+ computePosition(virtualElement, element, {
+ placement: 'bottom-start',
+ strategy: 'absolute',
+ middleware: [shift(), flip()],
+ }).then(({ x, y, strategy }) => {
+ element.style.width = 'max-content'
+ element.style.position = strategy
+ element.style.left = `${x}px`
+ element.style.top = `${y}px`
+ })
+}
+
+export default [
+ {
+ char: '@',
+ items: ({ query }) => {
+ return [
+ 'Lea Thompson',
+ 'Cyndi Lauper',
+ 'Tom Cruise',
+ 'Madonna',
+ 'Jerry Hall',
+ 'Joan Collins',
+ 'Winona Ryder',
+ 'Christina Applegate',
+ 'Alyssa Milano',
+ 'Molly Ringwald',
+ 'Ally Sheedy',
+ 'Debbie Harry',
+ 'Olivia Newton-John',
+ 'Elton John',
+ 'Michael J. Fox',
+ 'Axl Rose',
+ 'Emilio Estevez',
+ 'Ralph Macchio',
+ 'Rob Lowe',
+ 'Jennifer Grey',
+ 'Mickey Rourke',
+ 'John Cusack',
+ 'Matthew Broderick',
+ 'Justine Bateman',
+ 'Lisa Bonet',
+ ]
+ .filter(item => item.toLowerCase().startsWith(query.toLowerCase()))
+ .slice(0, 5)
+ },
+
+ render: () => {
+ let component
+
+ return {
+ onStart: props => {
+ component = new VueRenderer(MentionList, {
+ // using vue 2:
+ // parent: this,
+ // propsData: props,
+ // using vue 3:
+ props,
+ editor: props.editor,
+ })
+
+ if (!props.clientRect) {
+ return
+ }
+
+ component.element.style.position = 'absolute'
+
+ document.body.appendChild(component.element)
+
+ updatePosition(props.editor, component.element)
+ },
+
+ onUpdate(props) {
+ component.updateProps(props)
+
+ if (!props.clientRect) {
+ return
+ }
+
+ updatePosition(props.editor, component.element)
+ },
+
+ onKeyDown(props) {
+ if (props.event.key === 'Escape') {
+ component.destroy()
+
+ return true
+ }
+
+ return component.ref?.onKeyDown(props)
+ },
+
+ onExit() {
+ component.destroy()
+ },
+ }
+ },
+ },
+ {
+ char: '#',
+ items: ({ query }) => {
+ return ['Dirty Dancing', 'Pirates of the Caribbean', 'The Matrix']
+ .filter(item => item.toLowerCase().startsWith(query.toLowerCase()))
+ .slice(0, 5)
+ },
+
+ render: () => {
+ let component
+
+ return {
+ onStart: props => {
+ component = new VueRenderer(MentionList, {
+ // using vue 2:
+ // parent: this,
+ // propsData: props,
+ // using vue 3:
+ props,
+ editor: props.editor,
+ })
+
+ if (!props.clientRect) {
+ return
+ }
+
+ component.element.style.position = 'absolute'
+
+ document.body.appendChild(component.element)
+
+ updatePosition(props.editor, component.element)
+ },
+
+ onUpdate(props) {
+ component.updateProps(props)
+
+ if (!props.clientRect) {
+ return
+ }
+
+ updatePosition(props.editor, component.element)
+ },
+
+ onKeyDown(props) {
+ if (props.event.key === 'Escape') {
+ component.destroy()
+
+ return true
+ }
+
+ return component.ref?.onKeyDown(props)
+ },
+
+ onExit() {
+ component.destroy()
+ },
+ }
+ },
+ },
+]
diff --git a/demos/src/Examples/MultiMention/index.spec.ts b/demos/src/Examples/MultiMention/index.spec.ts
new file mode 100644
index 0000000..3ec5137
--- /dev/null
+++ b/demos/src/Examples/MultiMention/index.spec.ts
@@ -0,0 +1,346 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'MultiMention'
+const frameworkPaths = ['React']
+const demoPath = '/src/Examples'
+
+async function clearEditor(page: any) {
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any) => {
+ el.editor.chain().focus().clearContent().run()
+ })
+ await editor.click()
+}
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test.describe('Person mentions (@)', () => {
+ test('should insert a person mention', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any) => {
+ el.editor.commands.setContent(
+ '@Lea Thompson
',
+ )
+ })
+
+ const html = await editor.evaluate((el: HTMLElement) => el.innerHTML)
+
+ expect(html).toContain(
+ '@Lea Thompson ',
+ )
+ })
+
+ test("should open a dropdown menu when I type '@'", async ({ page }) => {
+ await clearEditor(page)
+ await page.locator('.tiptap').type('@')
+
+ await expect(page.locator('.dropdown-menu')).toBeVisible()
+ })
+
+ test('should display the correct person options in the dropdown menu', async ({ page }) => {
+ await clearEditor(page)
+ await page.locator('.tiptap').type('@')
+
+ const dropdown = page.locator('.dropdown-menu')
+ await expect(dropdown).toBeVisible()
+ await expect(dropdown.locator('button')).toHaveCount(5)
+
+ const buttons = dropdown.locator('button')
+
+ await expect(buttons.nth(0)).toContainText('Lea Thompson')
+ await expect(buttons.nth(0)).toHaveClass(/is-selected/)
+ await expect(buttons.nth(1)).toContainText('Cyndi Lauper')
+ await expect(buttons.nth(2)).toContainText('Tom Cruise')
+ await expect(buttons.nth(3)).toContainText('Madonna')
+ await expect(buttons.nth(4)).toContainText('Jerry Hall')
+ })
+
+ test('should insert Cyndi Lauper mention when clicking on her option', async ({ page }) => {
+ await clearEditor(page)
+ await page.locator('.tiptap').type('@')
+
+ await page.locator('.dropdown-menu button').nth(1).click()
+
+ const html = await page
+ .locator('.tiptap')
+ .first()
+ .evaluate((el: HTMLElement) => el.innerHTML)
+
+ expect(html).toContain(
+ '@Cyndi Lauper ',
+ )
+ })
+
+ test('should close the dropdown menu when I move the cursor outside the editor', async ({
+ page,
+ }) => {
+ await clearEditor(page)
+ await page.locator('.tiptap').type('@')
+ await expect(page.locator('.dropdown-menu')).toBeVisible()
+
+ await page.locator('.tiptap').press('Home')
+ await expect(page.locator('.dropdown-menu')).toHaveCount(0)
+ })
+
+ test('should close the dropdown menu when I press the escape key', async ({ page }) => {
+ await clearEditor(page)
+ await page.locator('.tiptap').type('@')
+ await expect(page.locator('.dropdown-menu')).toBeVisible()
+
+ await page.locator('.tiptap').press('Escape')
+ await expect(page.locator('.dropdown-menu')).toHaveCount(0)
+ })
+
+ test('should insert Tom Cruise when selecting his option with the arrow keys and pressing enter', async ({
+ page,
+ }) => {
+ await clearEditor(page)
+ await page.locator('.tiptap').type('@')
+ await expect(page.locator('.dropdown-menu')).toBeVisible()
+
+ await page.locator('.tiptap').press('ArrowDown')
+ await page.locator('.tiptap').press('ArrowDown')
+
+ await expect(page.locator('.dropdown-menu button').nth(2)).toHaveClass(/is-selected/)
+ await page.locator('.tiptap').press('Enter')
+
+ const html = await page
+ .locator('.tiptap')
+ .first()
+ .evaluate((el: HTMLElement) => el.innerHTML)
+
+ expect(html).toContain(
+ '@Tom Cruise ',
+ )
+ })
+
+ test('should show "No result" when searching for a non-existent person', async ({
+ page,
+ }) => {
+ await clearEditor(page)
+ await page.locator('.tiptap').type('@nonexistent')
+
+ await expect(page.locator('.dropdown-menu')).toContainText('No result')
+ })
+
+ test('should only show the Madonna option when I type "@mado"', async ({ page }) => {
+ await clearEditor(page)
+ await page.locator('.tiptap').type('@mado')
+
+ await expect(page.locator('.dropdown-menu button')).toHaveCount(1)
+ await expect(page.locator('.dropdown-menu button').first()).toContainText('Madonna')
+ })
+
+ test('should insert Madonna when I type "@mado" and hit enter', async ({ page }) => {
+ await clearEditor(page)
+ await page.locator('.tiptap').type('@mado')
+ await page.locator('.tiptap').press('Enter')
+
+ const html = await page
+ .locator('.tiptap')
+ .first()
+ .evaluate((el: HTMLElement) => el.innerHTML)
+
+ expect(html).toContain(
+ '@Madonna ',
+ )
+ })
+ })
+
+ test.describe('Movie mentions (#)', () => {
+ test('should insert a movie mention', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any) => {
+ el.editor.commands.setContent(
+ '#The Matrix
',
+ )
+ })
+
+ const html = await editor.evaluate((el: HTMLElement) => el.innerHTML)
+
+ expect(html).toContain(
+ '#The Matrix ',
+ )
+ })
+
+ test("should open a dropdown menu when I type '#'", async ({ page }) => {
+ await clearEditor(page)
+ await page.locator('.tiptap').type('#')
+
+ await expect(page.locator('.dropdown-menu')).toBeVisible()
+ })
+
+ test('should display the correct movie options in the dropdown menu', async ({ page }) => {
+ await clearEditor(page)
+ await page.locator('.tiptap').type('#')
+
+ const dropdown = page.locator('.dropdown-menu')
+ await expect(dropdown).toBeVisible()
+ await expect(dropdown.locator('button')).toHaveCount(3)
+
+ const buttons = dropdown.locator('button')
+
+ await expect(buttons.nth(0)).toContainText('Dirty Dancing')
+ await expect(buttons.nth(0)).toHaveClass(/is-selected/)
+ await expect(buttons.nth(1)).toContainText('Pirates of the Caribbean')
+ await expect(buttons.nth(2)).toContainText('The Matrix')
+ })
+
+ test('should insert Pirates of the Caribbean when clicking on its option', async ({
+ page,
+ }) => {
+ await clearEditor(page)
+ await page.locator('.tiptap').type('#')
+
+ await page.locator('.dropdown-menu button').nth(1).click()
+
+ const html = await page
+ .locator('.tiptap')
+ .first()
+ .evaluate((el: HTMLElement) => el.innerHTML)
+
+ expect(html).toContain(
+ '#Pirates of the Caribbean ',
+ )
+ })
+
+ test('should close the dropdown menu when I move the cursor outside the editor', async ({
+ page,
+ }) => {
+ await clearEditor(page)
+ await page.locator('.tiptap').type('#')
+ await expect(page.locator('.dropdown-menu')).toBeVisible()
+
+ await page.locator('.tiptap').press('Home')
+ await expect(page.locator('.dropdown-menu')).toHaveCount(0)
+ })
+
+ test('should close the dropdown menu when I press the escape key', async ({ page }) => {
+ await clearEditor(page)
+ await page.locator('.tiptap').type('#')
+ await expect(page.locator('.dropdown-menu')).toBeVisible()
+
+ await page.locator('.tiptap').press('Escape')
+ await expect(page.locator('.dropdown-menu')).toHaveCount(0)
+ })
+
+ test('should insert The Matrix via arrow keys and enter', async ({ page }) => {
+ await clearEditor(page)
+ await page.locator('.tiptap').type('#')
+ await expect(page.locator('.dropdown-menu')).toBeVisible()
+
+ await page.locator('.tiptap').press('ArrowDown')
+ await page.locator('.tiptap').press('ArrowDown')
+
+ await expect(page.locator('.dropdown-menu button').nth(2)).toHaveClass(/is-selected/)
+ await page.locator('.tiptap').press('Enter')
+
+ const html = await page
+ .locator('.tiptap')
+ .first()
+ .evaluate((el: HTMLElement) => el.innerHTML)
+
+ expect(html).toContain(
+ '#The Matrix ',
+ )
+ })
+
+ test('should show "No result" when searching for a non-existent movie', async ({
+ page,
+ }) => {
+ await clearEditor(page)
+ await page.locator('.tiptap').type('#nonexistent')
+
+ await expect(page.locator('.dropdown-menu')).toContainText('No result')
+ })
+
+ test('should only show Dirty Dancing when typing "#dir"', async ({ page }) => {
+ await clearEditor(page)
+ await page.locator('.tiptap').type('#dir')
+
+ await expect(page.locator('.dropdown-menu button')).toHaveCount(1)
+ await expect(page.locator('.dropdown-menu button').first()).toContainText('Dirty Dancing')
+ })
+
+ test('should insert Dirty Dancing when typing "#dir" and hit enter', async ({ page }) => {
+ await clearEditor(page)
+ await page.locator('.tiptap').type('#dir')
+ await page.locator('.tiptap').press('Enter')
+
+ const html = await page
+ .locator('.tiptap')
+ .first()
+ .evaluate((el: HTMLElement) => el.innerHTML)
+
+ expect(html).toContain(
+ '#Dirty Dancing ',
+ )
+ })
+ })
+
+ test.describe('Interaction between mention types', () => {
+ test('should support both mention types in the same document', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any) => {
+ el.editor.commands.setContent(
+ '@Madonna starred in #Dirty Dancing
',
+ )
+ })
+
+ const html = await editor.evaluate((el: HTMLElement) => el.innerHTML)
+
+ expect(html).toContain(
+ '@Madonna ',
+ )
+ expect(html).toContain(
+ '#Dirty Dancing ',
+ )
+ })
+
+ test('should allow switching between mention types', async ({ page }) => {
+ await clearEditor(page)
+ await page.locator('.tiptap').type('@')
+ await expect(page.locator('.dropdown-menu')).toBeVisible()
+ await expect(page.locator('.dropdown-menu button').first()).toContainText('Lea Thompson')
+
+ await page.locator('.tiptap').press('Home')
+ await expect(page.locator('.dropdown-menu')).toHaveCount(0)
+
+ await clearEditor(page)
+ await page.locator('.tiptap').type('#')
+ await expect(page.locator('.dropdown-menu')).toBeVisible()
+ await expect(page.locator('.dropdown-menu button').first()).toContainText('Dirty Dancing')
+ })
+
+ test('should insert both types of mentions in sequence', async ({ page }) => {
+ await clearEditor(page)
+ await page.locator('.tiptap').type('@mado')
+ await page.locator('.tiptap').press('Enter')
+ await page.locator('.tiptap').type(' likes #the')
+ await page.locator('.tiptap').press('Enter')
+
+ const html = await page
+ .locator('.tiptap')
+ .first()
+ .evaluate((el: HTMLElement) => el.innerHTML)
+
+ expect(html).toContain('@Madonna')
+ expect(html).toContain('#The Matrix')
+ })
+ })
+ })
+ })
+})
diff --git a/demos/src/Examples/NodePos/React/index.html b/demos/src/Examples/NodePos/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/NodePos/React/index.jsx b/demos/src/Examples/NodePos/React/index.jsx
new file mode 100644
index 0000000..bb9bdcd
--- /dev/null
+++ b/demos/src/Examples/NodePos/React/index.jsx
@@ -0,0 +1,351 @@
+import './styles.scss'
+
+import Image from '@tiptap/extension-image'
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React, { useCallback, useState } from 'react'
+
+const mapNodePosToString = nodePos =>
+ `[${nodePos.node.type.name} ${nodePos.range.from}-${nodePos.range.to}] ${nodePos.textContent} | ${JSON.stringify(nodePos.node.attrs)}`
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ StarterKit.configure({
+ trailingNode: false,
+ }),
+ Image,
+ ],
+ content: `
+ This is an example document to play around with the NodePos implementation of Tiptap.
+
+ This is a simple paragraph.
+
+
+
+ Here is another paragraph inside this document.
+
+
+ Here we have a paragraph inside a blockquote.
+
+
+
+ Unsorted 1
+
+
+ Unsorted 2
+
+
+ Unsorted 2.1
+
+
+ Unsorted 2.2
+
+
+ Unsorted 2.3
+
+
+
+
+ Unsorted 3
+
+
+
+
+ Sorted 1
+
+
+ Sorted 2
+
+
+ Sorted 2.1
+
+
+ Sorted 2.2
+
+
+ Sorted 2.3
+
+
+
+
+ Sorted 3
+
+
+
+ Here we have another paragraph inside a blockquote.
+
+
+
+
+
+
+ `,
+ })
+
+ const [foundNodes, setFoundNodes] = useState(null)
+
+ const findParagraphs = useCallback(() => {
+ const nodePositions = editor.$doc.querySelectorAll('paragraph')
+
+ if (!nodePositions) {
+ setFoundNodes(null)
+ return
+ }
+
+ setFoundNodes(nodePositions)
+ }, [editor])
+
+ const findListItems = useCallback(() => {
+ const nodePositions = editor.$doc.querySelectorAll('listItem')
+
+ if (!nodePositions) {
+ setFoundNodes(null)
+ return
+ }
+
+ setFoundNodes(nodePositions)
+ }, [editor])
+
+ const findBulletList = useCallback(() => {
+ const nodePositions = editor.$doc.querySelectorAll('bulletList')
+
+ if (!nodePositions) {
+ setFoundNodes(null)
+ return
+ }
+
+ setFoundNodes(nodePositions)
+ }, [editor])
+
+ const findOrderedList = useCallback(() => {
+ const nodePositions = editor.$doc.querySelectorAll('orderedList')
+
+ if (!nodePositions) {
+ setFoundNodes(null)
+ return
+ }
+
+ setFoundNodes(nodePositions)
+ }, [editor])
+
+ const findBlockquote = useCallback(() => {
+ const nodePositions = editor.$doc.querySelectorAll('blockquote')
+
+ if (!nodePositions) {
+ setFoundNodes(null)
+ return
+ }
+
+ setFoundNodes(nodePositions)
+ }, [editor])
+
+ const findImages = useCallback(() => {
+ const nodePositions = editor.$doc.querySelectorAll('image')
+
+ if (!nodePositions) {
+ setFoundNodes(null)
+ return
+ }
+
+ setFoundNodes(nodePositions)
+ }, [editor])
+
+ const findFirstBlockquote = useCallback(() => {
+ const nodePosition = editor.$doc.querySelector('blockquote')
+
+ if (!nodePosition) {
+ setFoundNodes(null)
+ return
+ }
+
+ setFoundNodes([nodePosition])
+ }, [editor])
+
+ const findSquaredImage = useCallback(() => {
+ const nodePosition = editor.$doc.querySelector('image', { src: 'https://placehold.co/200x200' })
+
+ if (!nodePosition) {
+ setFoundNodes(null)
+ return
+ }
+
+ setFoundNodes([nodePosition])
+ }, [editor])
+
+ const findLandscapeImage = useCallback(() => {
+ const nodePosition = editor.$doc.querySelector('image', { src: 'https://placehold.co/260x200' })
+
+ if (!nodePosition) {
+ setFoundNodes(null)
+ return
+ }
+
+ setFoundNodes([nodePosition])
+ }, [editor])
+
+ const findAllLandscapeImages = useCallback(() => {
+ const nodePosition = editor.$doc.querySelectorAll('image', {
+ src: 'https://placehold.co/260x200',
+ })
+
+ if (!nodePosition) {
+ setFoundNodes(null)
+ return
+ }
+
+ setFoundNodes(nodePosition)
+ }, [editor])
+
+ const findFirstLandscapeImageWithAllQuery = useCallback(() => {
+ const nodePosition = editor.$doc.querySelectorAll(
+ 'image',
+ { src: 'https://placehold.co/260x200' },
+ true,
+ )
+
+ if (!nodePosition) {
+ setFoundNodes(null)
+ return
+ }
+
+ setFoundNodes(nodePosition)
+ }, [editor])
+
+ const findPortraitImageInBlockquote = useCallback(() => {
+ const nodePosition = editor.$doc.querySelector('image', { src: 'https://placehold.co/100x200' })
+
+ if (!nodePosition) {
+ setFoundNodes(null)
+ return
+ }
+
+ setFoundNodes([nodePosition])
+ }, [editor])
+
+ const findFirstNode = useCallback(() => {
+ const nodePosition = editor.$doc.firstChild
+
+ if (!nodePosition) {
+ setFoundNodes(null)
+ return
+ }
+
+ setFoundNodes([nodePosition])
+ }, [editor])
+
+ const findLastNode = useCallback(() => {
+ const nodePosition = editor.$doc.lastChild
+
+ if (!nodePosition) {
+ setFoundNodes(null)
+ return
+ }
+
+ setFoundNodes([nodePosition])
+ }, [editor])
+
+ const findLastNodeOfFirstBulletList = useCallback(() => {
+ const nodePosition = editor.$doc.querySelector('bulletList').lastChild
+
+ if (!nodePosition) {
+ setFoundNodes(null)
+ return
+ }
+
+ setFoundNodes([nodePosition])
+ }, [editor])
+
+ const findNonexistentNode = useCallback(() => {
+ const nodePosition = editor.$doc.querySelector('nonexistent')
+
+ if (!nodePosition) {
+ setFoundNodes(null)
+ return
+ }
+
+ setFoundNodes([nodePosition])
+ }, [editor])
+
+ return (
+ <>
+
+
+
+ Find paragraphs
+
+
+ Find list items
+
+
+ Find bullet lists
+
+
+ Find ordered lists
+
+
+ Find blockquotes
+
+
+ Find images
+
+
+
+
+ Find first blockquote
+
+
+ Find squared image
+
+
+ Find landscape image
+
+
+ Find all landscape images
+
+
+ Find first landscape image with all query
+
+
+ Find portrait image in blockquote
+
+
+
+
+ Find first node
+
+
+ Find last node
+
+
+ Find last node of first bullet list
+
+
+ Find nonexistent node
+
+
+
+
+ {foundNodes ? (
+
+ {foundNodes.map(n => (
+
+ {mapNodePosToString(n)}
+
+ ))}
+
+ ) : (
+ ''
+ )}
+ >
+ )
+}
diff --git a/demos/src/Examples/NodePos/React/styles.scss b/demos/src/Examples/NodePos/React/styles.scss
new file mode 100644
index 0000000..53ffff5
--- /dev/null
+++ b/demos/src/Examples/NodePos/React/styles.scss
@@ -0,0 +1,103 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+
+ /* Image styles */
+ img {
+ display: block;
+ height: auto;
+ margin: 1.5rem 0;
+ max-width: 100%;
+
+ &.ProseMirror-selectednode {
+ outline: 3px solid var(--purple);
+ }
+ }
+}
diff --git a/demos/src/Examples/Performance/React/index.html b/demos/src/Examples/Performance/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Performance/React/index.jsx b/demos/src/Examples/Performance/React/index.jsx
new file mode 100644
index 0000000..e69c0ae
--- /dev/null
+++ b/demos/src/Examples/Performance/React/index.jsx
@@ -0,0 +1,141 @@
+import './styles.scss'
+
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import { BubbleMenu } from '@tiptap/react/menus'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+function EditorInstance({ shouldOptimizeRendering }) {
+ const countRenderRef = React.useRef(0)
+
+ countRenderRef.current += 1
+
+ const editor = useEditor({
+ /**
+ * This option gives us the control to enable the default behavior of rendering the editor immediately.
+ */
+ immediatelyRender: true,
+ /**
+ * This option gives us the control to disable the default behavior of re-rendering the editor on every transaction.
+ */
+ shouldRerenderOnTransaction: !shouldOptimizeRendering,
+ extensions: [StarterKit],
+ content: `
+
+ A highly optimized editor that only re-renders when it’s necessary.
+
+ `,
+ })
+ /**
+ * This hook allows us to select the editor state we want to use in our component.
+ */
+ const currentEditorState = useEditorState({
+ /**
+ * The editor instance we want to use.
+ */
+ editor,
+ /**
+ * This selector allows us to select the data we want to use in our component.
+ * It is evaluated on every editor transaction and compared to it's previously returned value.
+ */
+ selector: ctx => ({
+ isBold: ctx.editor.isActive('bold'),
+ isItalic: ctx.editor.isActive('italic'),
+ isStrike: ctx.editor.isActive('strike'),
+ }),
+ /**
+ * This function allows us to customize the equality check for the selector.
+ * By default it is a `===` check.
+ */
+ equalityFn: (prev, next) => {
+ // A deep-equal function would probably be more maintainable here, but, we use a shallow one to show that it can be customized.
+ if (!next) {
+ return false
+ }
+ return (
+ prev.isBold === next.isBold &&
+ prev.isItalic === next.isItalic &&
+ prev.isStrike === next.isStrike
+ )
+ },
+ })
+
+ return (
+
+
+
+ Number of renders: {countRenderRef.current}
+
+
+ {currentEditorState && (
+
+ editor.chain().focus().toggleBold().run()}
+ className={currentEditorState.isBold ? 'is-active' : ''}
+ >
+ Bold
+
+ editor.chain().focus().toggleItalic().run()}
+ className={currentEditorState.isItalic ? 'is-active' : ''}
+ >
+ Italic
+
+ editor.chain().focus().toggleStrike().run()}
+ className={currentEditorState.isStrike ? 'is-active' : ''}
+ >
+ Strike
+
+
+ )}
+
+
+ )
+}
+
+const EditorControls = () => {
+ const [shouldOptimizeRendering, setShouldOptimizeRendering] = React.useState(true)
+ const [rendered, setRendered] = React.useState(true)
+
+ return (
+ <>
+
+ {rendered && }
+ >
+ )
+}
+
+export default () => {
+ return (
+
+
+
+ )
+}
diff --git a/demos/src/Examples/Performance/React/styles.scss b/demos/src/Examples/Performance/React/styles.scss
new file mode 100644
index 0000000..15283b1
--- /dev/null
+++ b/demos/src/Examples/Performance/React/styles.scss
@@ -0,0 +1,91 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
diff --git a/demos/src/Examples/ResizableImages/React/index.html b/demos/src/Examples/ResizableImages/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/ResizableImages/React/index.jsx b/demos/src/Examples/ResizableImages/React/index.jsx
new file mode 100644
index 0000000..651815f
--- /dev/null
+++ b/demos/src/Examples/ResizableImages/React/index.jsx
@@ -0,0 +1,54 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Image from '@tiptap/extension-image'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { Dropcursor } from '@tiptap/extensions'
+import { EditorContent, useEditor } from '@tiptap/react'
+import { useCallback } from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Image.configure({
+ resize: {
+ enabled: true,
+ alwaysPreserveAspectRatio: true,
+ },
+ }),
+ Dropcursor,
+ ],
+ content: `
+ This is a basic example of implementing images. Drag to re-order.
+
+
+ `,
+ })
+
+ const addImage = useCallback(() => {
+ const url = window.prompt('URL')
+
+ if (url) {
+ editor.chain().focus().setImage({ src: url }).run()
+ }
+ }, [editor])
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/demos/src/Examples/ResizableImages/React/styles.scss b/demos/src/Examples/ResizableImages/React/styles.scss
new file mode 100644
index 0000000..f6a7678
--- /dev/null
+++ b/demos/src/Examples/ResizableImages/React/styles.scss
@@ -0,0 +1,95 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ img {
+ display: block;
+ }
+
+ [data-resize-handle] {
+ position: absolute;
+ background: rgba(0, 0, 0, 0.5);
+ border: 1px solid rgba(255, 255, 255, 0.8);
+ border-radius: 2px;
+ z-index: 10;
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.8);
+ }
+
+ /* Corner handles */
+ &[data-resize-handle='top-left'],
+ &[data-resize-handle='top-right'],
+ &[data-resize-handle='bottom-left'],
+ &[data-resize-handle='bottom-right'] {
+ width: 8px;
+ height: 8px;
+ }
+
+ &[data-resize-handle='top-left'] {
+ top: -4px;
+ left: -4px;
+ cursor: nwse-resize;
+ }
+
+ &[data-resize-handle='top-right'] {
+ top: -4px;
+ right: -4px;
+ cursor: nesw-resize;
+ }
+
+ &[data-resize-handle='bottom-left'] {
+ bottom: -4px;
+ left: -4px;
+ cursor: nesw-resize;
+ }
+
+ &[data-resize-handle='bottom-right'] {
+ bottom: -4px;
+ right: -4px;
+ cursor: nwse-resize;
+ }
+
+ /* Edge handles */
+ &[data-resize-handle='top'],
+ &[data-resize-handle='bottom'] {
+ height: 6px;
+ left: 8px;
+ right: 8px;
+ }
+
+ &[data-resize-handle='top'] {
+ top: -3px;
+ cursor: ns-resize;
+ }
+
+ &[data-resize-handle='bottom'] {
+ bottom: -3px;
+ cursor: ns-resize;
+ }
+
+ &[data-resize-handle='left'],
+ &[data-resize-handle='right'] {
+ width: 6px;
+ top: 8px;
+ bottom: 8px;
+ }
+
+ &[data-resize-handle='left'] {
+ left: -3px;
+ cursor: ew-resize;
+ }
+
+ &[data-resize-handle='right'] {
+ right: -3px;
+ cursor: ew-resize;
+ }
+ }
+
+ [data-resize-state='true'] [data-resize-wrapper] {
+ outline: 1px solid rgba(0, 0, 0, 0.25);
+ border-radius: 0.125rem;
+ }
+}
diff --git a/demos/src/Examples/ResizableImages/Vue/index.html b/demos/src/Examples/ResizableImages/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/ResizableImages/Vue/index.vue b/demos/src/Examples/ResizableImages/Vue/index.vue
new file mode 100644
index 0000000..5f4a180
--- /dev/null
+++ b/demos/src/Examples/ResizableImages/Vue/index.vue
@@ -0,0 +1,165 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/ResizableNodes/React/index.html b/demos/src/Examples/ResizableNodes/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/ResizableNodes/React/index.tsx b/demos/src/Examples/ResizableNodes/React/index.tsx
new file mode 100644
index 0000000..60477c3
--- /dev/null
+++ b/demos/src/Examples/ResizableNodes/React/index.tsx
@@ -0,0 +1,111 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, Node as NodeExtension, ResizableNodeview, useEditor } from '@tiptap/react'
+
+const ResizableNode = NodeExtension.create({
+ name: 'resizableNode',
+ group: 'block',
+ content: 'block+',
+ selectable: true,
+ draggable: true,
+
+ addAttributes() {
+ return {
+ width: {
+ default: 'auto',
+ },
+ height: {
+ default: 'auto',
+ },
+ }
+ },
+
+ parseHTML() {
+ return [{ tag: 'div[data-resizer]' }]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ 'div',
+ {
+ 'data-resizer': '',
+ style: `width: ${HTMLAttributes.width}; height: ${HTMLAttributes.height}; border: 1px solid black; box-sizing: border-box;`,
+ },
+ 0,
+ ]
+ },
+
+ addNodeView() {
+ return props => {
+ const width = props.node.attrs.width
+ const height = props.node.attrs.height
+
+ const el = document.createElement('div')
+ el.dataset.resizer = ''
+ const content = document.createElement('div')
+ content.innerText = `Width: ${width}, Height: ${height}`
+
+ el.appendChild(content)
+
+ el.style.width = width
+ el.style.height = height
+
+ const resizable = new ResizableNodeview({
+ element: el,
+ getPos: props.getPos,
+ node: props.node,
+ onCommit: (newWidth, newHeight) => {
+ const pos = props.getPos()
+ if (pos === undefined) {
+ return
+ }
+
+ this.editor
+ .chain()
+ .setNodeSelection(pos)
+ .updateAttributes(this.name, {
+ width: newWidth,
+ height: newHeight,
+ })
+ .run()
+ },
+ onResize: (newWidth, newHeight) => {
+ content.innerText = `Width: ${newWidth}, Height: ${newHeight}`
+ },
+ onUpdate: () => false,
+ })
+
+ return resizable
+ }
+ },
+})
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, ResizableNode],
+ content: `
+ This is a resizable node demo.
+
+
+ `,
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+ >
+ )
+}
diff --git a/demos/src/Examples/ResizableNodes/React/styles.scss b/demos/src/Examples/ResizableNodes/React/styles.scss
new file mode 100644
index 0000000..5f01596
--- /dev/null
+++ b/demos/src/Examples/ResizableNodes/React/styles.scss
@@ -0,0 +1,95 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ [data-resizer] {
+ background-color: #f0f0f0;
+ }
+
+ [data-resize-handle] {
+ position: absolute;
+ background: rgba(0, 0, 0, 0.5);
+ border: 1px solid rgba(255, 255, 255, 0.8);
+ border-radius: 2px;
+ z-index: 10;
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.8);
+ }
+
+ /* Corner handles */
+ &[data-resize-handle='top-left'],
+ &[data-resize-handle='top-right'],
+ &[data-resize-handle='bottom-left'],
+ &[data-resize-handle='bottom-right'] {
+ width: 8px;
+ height: 8px;
+ }
+
+ &[data-resize-handle='top-left'] {
+ top: -4px;
+ left: -4px;
+ cursor: nwse-resize;
+ }
+
+ &[data-resize-handle='top-right'] {
+ top: -4px;
+ right: -4px;
+ cursor: nesw-resize;
+ }
+
+ &[data-resize-handle='bottom-left'] {
+ bottom: -4px;
+ left: -4px;
+ cursor: nesw-resize;
+ }
+
+ &[data-resize-handle='bottom-right'] {
+ bottom: -4px;
+ right: -4px;
+ cursor: nwse-resize;
+ }
+
+ /* Edge handles */
+ &[data-resize-handle='top'],
+ &[data-resize-handle='bottom'] {
+ height: 6px;
+ left: 8px;
+ right: 8px;
+ }
+
+ &[data-resize-handle='top'] {
+ top: -3px;
+ cursor: ns-resize;
+ }
+
+ &[data-resize-handle='bottom'] {
+ bottom: -3px;
+ cursor: ns-resize;
+ }
+
+ &[data-resize-handle='left'],
+ &[data-resize-handle='right'] {
+ width: 6px;
+ top: 8px;
+ bottom: 8px;
+ }
+
+ &[data-resize-handle='left'] {
+ left: -3px;
+ cursor: ew-resize;
+ }
+
+ &[data-resize-handle='right'] {
+ right: -3px;
+ cursor: ew-resize;
+ }
+ }
+
+ [data-resize-state='true'] [data-resize-wrapper] {
+ outline: 1px solid rgba(0, 0, 0, 0.25);
+ border-radius: 0.125rem;
+ }
+}
diff --git a/demos/src/Examples/ResizableNodes/Vue/index.html b/demos/src/Examples/ResizableNodes/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/ResizableNodes/Vue/index.vue b/demos/src/Examples/ResizableNodes/Vue/index.vue
new file mode 100644
index 0000000..c1817e7
--- /dev/null
+++ b/demos/src/Examples/ResizableNodes/Vue/index.vue
@@ -0,0 +1,231 @@
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/Savvy/React/ColorHighlighter.ts b/demos/src/Examples/Savvy/React/ColorHighlighter.ts
new file mode 100644
index 0000000..2704629
--- /dev/null
+++ b/demos/src/Examples/Savvy/React/ColorHighlighter.ts
@@ -0,0 +1,28 @@
+import { Extension } from '@tiptap/core'
+import { Plugin } from '@tiptap/pm/state'
+
+import findColors from './findColors.js'
+
+export const ColorHighlighter = Extension.create({
+ name: 'colorHighlighter',
+
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ state: {
+ init(_, { doc }) {
+ return findColors(doc)
+ },
+ apply(transaction, oldState) {
+ return transaction.docChanged ? findColors(transaction.doc) : oldState
+ },
+ },
+ props: {
+ decorations(state) {
+ return this.getState(state)
+ },
+ },
+ }),
+ ]
+ },
+})
diff --git a/demos/src/Examples/Savvy/React/SmilieReplacer.ts b/demos/src/Examples/Savvy/React/SmilieReplacer.ts
new file mode 100644
index 0000000..1c0a694
--- /dev/null
+++ b/demos/src/Examples/Savvy/React/SmilieReplacer.ts
@@ -0,0 +1,133 @@
+import { Extension, textInputRule } from '@tiptap/core'
+
+export const SmilieReplacer = Extension.create({
+ name: 'smilieReplacer',
+
+ addInputRules() {
+ return [
+ textInputRule({ find: /-___- $/, replace: '😑 ' }),
+ textInputRule({ find: /:'-\) $/, replace: '😂 ' }),
+ textInputRule({ find: /':-\) $/, replace: '😅 ' }),
+ textInputRule({ find: /':-D $/, replace: '😅 ' }),
+ textInputRule({ find: />:-\) $/, replace: '😆 ' }),
+ textInputRule({ find: /-__- $/, replace: '😑 ' }),
+ textInputRule({ find: /':-\( $/, replace: '😓 ' }),
+ textInputRule({ find: /:'-\( $/, replace: '😢 ' }),
+ textInputRule({ find: />:-\( $/, replace: '😠 ' }),
+ textInputRule({ find: /O:-\) $/, replace: '😇 ' }),
+ textInputRule({ find: /0:-3 $/, replace: '😇 ' }),
+ textInputRule({ find: /0:-\) $/, replace: '😇 ' }),
+ textInputRule({ find: /0;\^\) $/, replace: '😇 ' }),
+ textInputRule({ find: /O;-\) $/, replace: '😇 ' }),
+ textInputRule({ find: /0;-\) $/, replace: '😇 ' }),
+ textInputRule({ find: /O:-3 $/, replace: '😇 ' }),
+ textInputRule({ find: /:'\) $/, replace: '😂 ' }),
+ textInputRule({ find: /:-D $/, replace: '😃 ' }),
+ textInputRule({ find: /':\) $/, replace: '😅 ' }),
+ textInputRule({ find: /'=\) $/, replace: '😅 ' }),
+ textInputRule({ find: /':D $/, replace: '😅 ' }),
+ textInputRule({ find: /'=D $/, replace: '😅 ' }),
+ textInputRule({ find: />:\) $/, replace: '😆 ' }),
+ textInputRule({ find: />;\) $/, replace: '😆 ' }),
+ textInputRule({ find: />=\) $/, replace: '😆 ' }),
+ textInputRule({ find: /;-\) $/, replace: '😉 ' }),
+ textInputRule({ find: /\*-\) $/, replace: '😉 ' }),
+ textInputRule({ find: /;-\] $/, replace: '😉 ' }),
+ textInputRule({ find: /;\^\) $/, replace: '😉 ' }),
+ textInputRule({ find: /B-\) $/, replace: '😎 ' }),
+ textInputRule({ find: /8-\) $/, replace: '😎 ' }),
+ textInputRule({ find: /B-D $/, replace: '😎 ' }),
+ textInputRule({ find: /8-D $/, replace: '😎 ' }),
+ textInputRule({ find: /:-\* $/, replace: '😘 ' }),
+ textInputRule({ find: /:\^\* $/, replace: '😘 ' }),
+ textInputRule({ find: /:-\) $/, replace: '🙂 ' }),
+ textInputRule({ find: /-_- $/, replace: '😑 ' }),
+ textInputRule({ find: /:-X $/, replace: '😶 ' }),
+ textInputRule({ find: /:-# $/, replace: '😶 ' }),
+ textInputRule({ find: /:-x $/, replace: '😶 ' }),
+ textInputRule({ find: />.< $/, replace: '😣 ' }),
+ textInputRule({ find: /:-O $/, replace: '😮 ' }),
+ textInputRule({ find: /:-o $/, replace: '😮 ' }),
+ textInputRule({ find: /O_O $/, replace: '😮 ' }),
+ textInputRule({ find: />:O $/, replace: '😮 ' }),
+ textInputRule({ find: /:-P $/, replace: '😛 ' }),
+ textInputRule({ find: /:-p $/, replace: '😛 ' }),
+ textInputRule({ find: /:-Þ $/, replace: '😛 ' }),
+ textInputRule({ find: /:-þ $/, replace: '😛 ' }),
+ textInputRule({ find: /:-b $/, replace: '😛 ' }),
+ textInputRule({ find: />:P $/, replace: '😜 ' }),
+ textInputRule({ find: /X-P $/, replace: '😜 ' }),
+ textInputRule({ find: /x-p $/, replace: '😜 ' }),
+ textInputRule({ find: /':\( $/, replace: '😓 ' }),
+ textInputRule({ find: /'=\( $/, replace: '😓 ' }),
+ textInputRule({ find: />:\\ $/, replace: '😕 ' }),
+ textInputRule({ find: />:\/ $/, replace: '😕 ' }),
+ textInputRule({ find: /:-\/ $/, replace: '😕 ' }),
+ textInputRule({ find: /:-. $/, replace: '😕 ' }),
+ textInputRule({ find: />:\[ $/, replace: '😞 ' }),
+ textInputRule({ find: /:-\( $/, replace: '😞 ' }),
+ textInputRule({ find: /:-\[ $/, replace: '😞 ' }),
+ textInputRule({ find: /:'\( $/, replace: '😢 ' }),
+ textInputRule({ find: /;-\( $/, replace: '😢 ' }),
+ textInputRule({ find: /#-\) $/, replace: '😵 ' }),
+ textInputRule({ find: /%-\) $/, replace: '😵 ' }),
+ textInputRule({ find: /X-\) $/, replace: '😵 ' }),
+ textInputRule({ find: />:\( $/, replace: '😠 ' }),
+ textInputRule({ find: /0:3 $/, replace: '😇 ' }),
+ textInputRule({ find: /0:\) $/, replace: '😇 ' }),
+ textInputRule({ find: /O:\) $/, replace: '😇 ' }),
+ textInputRule({ find: /O=\) $/, replace: '😇 ' }),
+ textInputRule({ find: /O:3 $/, replace: '😇 ' }),
+ textInputRule({ find: /<\/3 $/, replace: '💔 ' }),
+ textInputRule({ find: /:D $/, replace: '😃 ' }),
+ textInputRule({ find: /=D $/, replace: '😃 ' }),
+ textInputRule({ find: /;\) $/, replace: '😉 ' }),
+ textInputRule({ find: /\*\) $/, replace: '😉 ' }),
+ textInputRule({ find: /;\] $/, replace: '😉 ' }),
+ textInputRule({ find: /;D $/, replace: '😉 ' }),
+ textInputRule({ find: /B\) $/, replace: '😎 ' }),
+ textInputRule({ find: /8\) $/, replace: '😎 ' }),
+ textInputRule({ find: /:\* $/, replace: '😘 ' }),
+ textInputRule({ find: /=\* $/, replace: '😘 ' }),
+ textInputRule({ find: /:\) $/, replace: '🙂 ' }),
+ textInputRule({ find: /=\] $/, replace: '🙂 ' }),
+ textInputRule({ find: /=\) $/, replace: '🙂 ' }),
+ textInputRule({ find: /:\] $/, replace: '🙂 ' }),
+ textInputRule({ find: /:X $/, replace: '😶 ' }),
+ textInputRule({ find: /:# $/, replace: '😶 ' }),
+ textInputRule({ find: /=X $/, replace: '😶 ' }),
+ textInputRule({ find: /=x $/, replace: '😶 ' }),
+ textInputRule({ find: /:x $/, replace: '😶 ' }),
+ textInputRule({ find: /=# $/, replace: '😶 ' }),
+ textInputRule({ find: /:O $/, replace: '😮 ' }),
+ textInputRule({ find: /:o $/, replace: '😮 ' }),
+ textInputRule({ find: /:P $/, replace: '😛 ' }),
+ textInputRule({ find: /=P $/, replace: '😛 ' }),
+ textInputRule({ find: /:p $/, replace: '😛 ' }),
+ textInputRule({ find: /=p $/, replace: '😛 ' }),
+ textInputRule({ find: /:Þ $/, replace: '😛 ' }),
+ textInputRule({ find: /:þ $/, replace: '😛 ' }),
+ textInputRule({ find: /:b $/, replace: '😛 ' }),
+ textInputRule({ find: /d: $/, replace: '😛 ' }),
+ textInputRule({ find: /:\/ $/, replace: '😕 ' }),
+ textInputRule({ find: /:\\ $/, replace: '😕 ' }),
+ textInputRule({ find: /=\/ $/, replace: '😕 ' }),
+ textInputRule({ find: /=\\ $/, replace: '😕 ' }),
+ textInputRule({ find: /:L $/, replace: '😕 ' }),
+ textInputRule({ find: /=L $/, replace: '😕 ' }),
+ textInputRule({ find: /:\( $/, replace: '😞 ' }),
+ textInputRule({ find: /:\[ $/, replace: '😞 ' }),
+ textInputRule({ find: /=\( $/, replace: '😞 ' }),
+ textInputRule({ find: /;\( $/, replace: '😢 ' }),
+ textInputRule({ find: /D: $/, replace: '😨 ' }),
+ textInputRule({ find: /:\$ $/, replace: '😳 ' }),
+ textInputRule({ find: /=\$ $/, replace: '😳 ' }),
+ textInputRule({ find: /#\) $/, replace: '😵 ' }),
+ textInputRule({ find: /%\) $/, replace: '😵 ' }),
+ textInputRule({ find: /X\) $/, replace: '😵 ' }),
+ textInputRule({ find: /:@ $/, replace: '😠 ' }),
+ textInputRule({ find: /<3 $/, replace: '❤️ ' }),
+ textInputRule({ find: /\/shrug $/, replace: '¯\\_(ツ)_/¯' }),
+ ]
+ },
+})
diff --git a/demos/src/Examples/Savvy/React/findColors.ts b/demos/src/Examples/Savvy/React/findColors.ts
new file mode 100644
index 0000000..32a5b0a
--- /dev/null
+++ b/demos/src/Examples/Savvy/React/findColors.ts
@@ -0,0 +1,28 @@
+import type { Node as PMNode } from '@tiptap/pm/model'
+import { Decoration, DecorationSet } from '@tiptap/pm/view'
+
+export default function (doc: PMNode): DecorationSet {
+ const hexColor = /(#[0-9a-f]{3,6})\b/gi
+ const decorations: Decoration[] = []
+
+ doc.descendants((node, position) => {
+ if (!node.text) {
+ return
+ }
+
+ Array.from(node.text.matchAll(hexColor)).forEach(match => {
+ const color = match[0]
+ const index = match.index || 0
+ const from = position + index
+ const to = from + color.length
+ const decoration = Decoration.inline(from, to, {
+ class: 'color',
+ style: `--color: ${color}`,
+ })
+
+ decorations.push(decoration)
+ })
+ })
+
+ return DecorationSet.create(doc, decorations)
+}
diff --git a/demos/src/Examples/Savvy/React/index.html b/demos/src/Examples/Savvy/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Savvy/React/index.jsx b/demos/src/Examples/Savvy/React/index.jsx
new file mode 100644
index 0000000..5a8d662
--- /dev/null
+++ b/demos/src/Examples/Savvy/React/index.jsx
@@ -0,0 +1,36 @@
+import './styles.scss'
+
+import Code from '@tiptap/extension-code'
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import Typography from '@tiptap/extension-typography'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+import { ColorHighlighter } from './ColorHighlighter.ts'
+import { SmilieReplacer } from './SmilieReplacer.ts'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, Code, Typography, ColorHighlighter, SmilieReplacer],
+ content: `
+
+ → With the Typography extension, Tiptap understands »what you mean« and adds correct characters to your text — it’s like a “typography nerd” on your side.
+
+
+ Try it out and type (c), ->, >>, 1/2, !=, -- or 1x1 here:
+
+
+
+ Or add completely custom input rules. We added a custom extension here that replaces smilies like :-), <3 or >:P with emojis. Try it out:
+
+
+
+ You can also teach the editor new things. For example to recognize hex colors and add a color swatch on the fly: #FFF, #0D0D0D, #616161, #A975FF, #FB5151, #FD9170, #FFCB6B, #68CEF8, #80cbc4, #9DEF8F
+
+ `,
+ })
+
+ return
+}
diff --git a/demos/src/Examples/Savvy/React/styles.scss b/demos/src/Examples/Savvy/React/styles.scss
new file mode 100644
index 0000000..fc41c9e
--- /dev/null
+++ b/demos/src/Examples/Savvy/React/styles.scss
@@ -0,0 +1,33 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ /* Color swatches */
+ .color {
+ white-space: nowrap;
+
+ &::before {
+ background-color: var(--color);
+ border: 1px solid rgba(128, 128, 128, 0.3);
+ border-radius: 2px;
+ content: ' ';
+ display: inline-block;
+ height: 1em;
+ margin-bottom: 0.15em;
+ margin-right: 0.1em;
+ vertical-align: middle;
+ width: 1em;
+ }
+ }
+}
diff --git a/demos/src/Examples/Savvy/Vue/ColorHighlighter.ts b/demos/src/Examples/Savvy/Vue/ColorHighlighter.ts
new file mode 100644
index 0000000..2704629
--- /dev/null
+++ b/demos/src/Examples/Savvy/Vue/ColorHighlighter.ts
@@ -0,0 +1,28 @@
+import { Extension } from '@tiptap/core'
+import { Plugin } from '@tiptap/pm/state'
+
+import findColors from './findColors.js'
+
+export const ColorHighlighter = Extension.create({
+ name: 'colorHighlighter',
+
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ state: {
+ init(_, { doc }) {
+ return findColors(doc)
+ },
+ apply(transaction, oldState) {
+ return transaction.docChanged ? findColors(transaction.doc) : oldState
+ },
+ },
+ props: {
+ decorations(state) {
+ return this.getState(state)
+ },
+ },
+ }),
+ ]
+ },
+})
diff --git a/demos/src/Examples/Savvy/Vue/SmilieReplacer.ts b/demos/src/Examples/Savvy/Vue/SmilieReplacer.ts
new file mode 100644
index 0000000..1c0a694
--- /dev/null
+++ b/demos/src/Examples/Savvy/Vue/SmilieReplacer.ts
@@ -0,0 +1,133 @@
+import { Extension, textInputRule } from '@tiptap/core'
+
+export const SmilieReplacer = Extension.create({
+ name: 'smilieReplacer',
+
+ addInputRules() {
+ return [
+ textInputRule({ find: /-___- $/, replace: '😑 ' }),
+ textInputRule({ find: /:'-\) $/, replace: '😂 ' }),
+ textInputRule({ find: /':-\) $/, replace: '😅 ' }),
+ textInputRule({ find: /':-D $/, replace: '😅 ' }),
+ textInputRule({ find: />:-\) $/, replace: '😆 ' }),
+ textInputRule({ find: /-__- $/, replace: '😑 ' }),
+ textInputRule({ find: /':-\( $/, replace: '😓 ' }),
+ textInputRule({ find: /:'-\( $/, replace: '😢 ' }),
+ textInputRule({ find: />:-\( $/, replace: '😠 ' }),
+ textInputRule({ find: /O:-\) $/, replace: '😇 ' }),
+ textInputRule({ find: /0:-3 $/, replace: '😇 ' }),
+ textInputRule({ find: /0:-\) $/, replace: '😇 ' }),
+ textInputRule({ find: /0;\^\) $/, replace: '😇 ' }),
+ textInputRule({ find: /O;-\) $/, replace: '😇 ' }),
+ textInputRule({ find: /0;-\) $/, replace: '😇 ' }),
+ textInputRule({ find: /O:-3 $/, replace: '😇 ' }),
+ textInputRule({ find: /:'\) $/, replace: '😂 ' }),
+ textInputRule({ find: /:-D $/, replace: '😃 ' }),
+ textInputRule({ find: /':\) $/, replace: '😅 ' }),
+ textInputRule({ find: /'=\) $/, replace: '😅 ' }),
+ textInputRule({ find: /':D $/, replace: '😅 ' }),
+ textInputRule({ find: /'=D $/, replace: '😅 ' }),
+ textInputRule({ find: />:\) $/, replace: '😆 ' }),
+ textInputRule({ find: />;\) $/, replace: '😆 ' }),
+ textInputRule({ find: />=\) $/, replace: '😆 ' }),
+ textInputRule({ find: /;-\) $/, replace: '😉 ' }),
+ textInputRule({ find: /\*-\) $/, replace: '😉 ' }),
+ textInputRule({ find: /;-\] $/, replace: '😉 ' }),
+ textInputRule({ find: /;\^\) $/, replace: '😉 ' }),
+ textInputRule({ find: /B-\) $/, replace: '😎 ' }),
+ textInputRule({ find: /8-\) $/, replace: '😎 ' }),
+ textInputRule({ find: /B-D $/, replace: '😎 ' }),
+ textInputRule({ find: /8-D $/, replace: '😎 ' }),
+ textInputRule({ find: /:-\* $/, replace: '😘 ' }),
+ textInputRule({ find: /:\^\* $/, replace: '😘 ' }),
+ textInputRule({ find: /:-\) $/, replace: '🙂 ' }),
+ textInputRule({ find: /-_- $/, replace: '😑 ' }),
+ textInputRule({ find: /:-X $/, replace: '😶 ' }),
+ textInputRule({ find: /:-# $/, replace: '😶 ' }),
+ textInputRule({ find: /:-x $/, replace: '😶 ' }),
+ textInputRule({ find: />.< $/, replace: '😣 ' }),
+ textInputRule({ find: /:-O $/, replace: '😮 ' }),
+ textInputRule({ find: /:-o $/, replace: '😮 ' }),
+ textInputRule({ find: /O_O $/, replace: '😮 ' }),
+ textInputRule({ find: />:O $/, replace: '😮 ' }),
+ textInputRule({ find: /:-P $/, replace: '😛 ' }),
+ textInputRule({ find: /:-p $/, replace: '😛 ' }),
+ textInputRule({ find: /:-Þ $/, replace: '😛 ' }),
+ textInputRule({ find: /:-þ $/, replace: '😛 ' }),
+ textInputRule({ find: /:-b $/, replace: '😛 ' }),
+ textInputRule({ find: />:P $/, replace: '😜 ' }),
+ textInputRule({ find: /X-P $/, replace: '😜 ' }),
+ textInputRule({ find: /x-p $/, replace: '😜 ' }),
+ textInputRule({ find: /':\( $/, replace: '😓 ' }),
+ textInputRule({ find: /'=\( $/, replace: '😓 ' }),
+ textInputRule({ find: />:\\ $/, replace: '😕 ' }),
+ textInputRule({ find: />:\/ $/, replace: '😕 ' }),
+ textInputRule({ find: /:-\/ $/, replace: '😕 ' }),
+ textInputRule({ find: /:-. $/, replace: '😕 ' }),
+ textInputRule({ find: />:\[ $/, replace: '😞 ' }),
+ textInputRule({ find: /:-\( $/, replace: '😞 ' }),
+ textInputRule({ find: /:-\[ $/, replace: '😞 ' }),
+ textInputRule({ find: /:'\( $/, replace: '😢 ' }),
+ textInputRule({ find: /;-\( $/, replace: '😢 ' }),
+ textInputRule({ find: /#-\) $/, replace: '😵 ' }),
+ textInputRule({ find: /%-\) $/, replace: '😵 ' }),
+ textInputRule({ find: /X-\) $/, replace: '😵 ' }),
+ textInputRule({ find: />:\( $/, replace: '😠 ' }),
+ textInputRule({ find: /0:3 $/, replace: '😇 ' }),
+ textInputRule({ find: /0:\) $/, replace: '😇 ' }),
+ textInputRule({ find: /O:\) $/, replace: '😇 ' }),
+ textInputRule({ find: /O=\) $/, replace: '😇 ' }),
+ textInputRule({ find: /O:3 $/, replace: '😇 ' }),
+ textInputRule({ find: /<\/3 $/, replace: '💔 ' }),
+ textInputRule({ find: /:D $/, replace: '😃 ' }),
+ textInputRule({ find: /=D $/, replace: '😃 ' }),
+ textInputRule({ find: /;\) $/, replace: '😉 ' }),
+ textInputRule({ find: /\*\) $/, replace: '😉 ' }),
+ textInputRule({ find: /;\] $/, replace: '😉 ' }),
+ textInputRule({ find: /;D $/, replace: '😉 ' }),
+ textInputRule({ find: /B\) $/, replace: '😎 ' }),
+ textInputRule({ find: /8\) $/, replace: '😎 ' }),
+ textInputRule({ find: /:\* $/, replace: '😘 ' }),
+ textInputRule({ find: /=\* $/, replace: '😘 ' }),
+ textInputRule({ find: /:\) $/, replace: '🙂 ' }),
+ textInputRule({ find: /=\] $/, replace: '🙂 ' }),
+ textInputRule({ find: /=\) $/, replace: '🙂 ' }),
+ textInputRule({ find: /:\] $/, replace: '🙂 ' }),
+ textInputRule({ find: /:X $/, replace: '😶 ' }),
+ textInputRule({ find: /:# $/, replace: '😶 ' }),
+ textInputRule({ find: /=X $/, replace: '😶 ' }),
+ textInputRule({ find: /=x $/, replace: '😶 ' }),
+ textInputRule({ find: /:x $/, replace: '😶 ' }),
+ textInputRule({ find: /=# $/, replace: '😶 ' }),
+ textInputRule({ find: /:O $/, replace: '😮 ' }),
+ textInputRule({ find: /:o $/, replace: '😮 ' }),
+ textInputRule({ find: /:P $/, replace: '😛 ' }),
+ textInputRule({ find: /=P $/, replace: '😛 ' }),
+ textInputRule({ find: /:p $/, replace: '😛 ' }),
+ textInputRule({ find: /=p $/, replace: '😛 ' }),
+ textInputRule({ find: /:Þ $/, replace: '😛 ' }),
+ textInputRule({ find: /:þ $/, replace: '😛 ' }),
+ textInputRule({ find: /:b $/, replace: '😛 ' }),
+ textInputRule({ find: /d: $/, replace: '😛 ' }),
+ textInputRule({ find: /:\/ $/, replace: '😕 ' }),
+ textInputRule({ find: /:\\ $/, replace: '😕 ' }),
+ textInputRule({ find: /=\/ $/, replace: '😕 ' }),
+ textInputRule({ find: /=\\ $/, replace: '😕 ' }),
+ textInputRule({ find: /:L $/, replace: '😕 ' }),
+ textInputRule({ find: /=L $/, replace: '😕 ' }),
+ textInputRule({ find: /:\( $/, replace: '😞 ' }),
+ textInputRule({ find: /:\[ $/, replace: '😞 ' }),
+ textInputRule({ find: /=\( $/, replace: '😞 ' }),
+ textInputRule({ find: /;\( $/, replace: '😢 ' }),
+ textInputRule({ find: /D: $/, replace: '😨 ' }),
+ textInputRule({ find: /:\$ $/, replace: '😳 ' }),
+ textInputRule({ find: /=\$ $/, replace: '😳 ' }),
+ textInputRule({ find: /#\) $/, replace: '😵 ' }),
+ textInputRule({ find: /%\) $/, replace: '😵 ' }),
+ textInputRule({ find: /X\) $/, replace: '😵 ' }),
+ textInputRule({ find: /:@ $/, replace: '😠 ' }),
+ textInputRule({ find: /<3 $/, replace: '❤️ ' }),
+ textInputRule({ find: /\/shrug $/, replace: '¯\\_(ツ)_/¯' }),
+ ]
+ },
+})
diff --git a/demos/src/Examples/Savvy/Vue/findColors.ts b/demos/src/Examples/Savvy/Vue/findColors.ts
new file mode 100644
index 0000000..32a5b0a
--- /dev/null
+++ b/demos/src/Examples/Savvy/Vue/findColors.ts
@@ -0,0 +1,28 @@
+import type { Node as PMNode } from '@tiptap/pm/model'
+import { Decoration, DecorationSet } from '@tiptap/pm/view'
+
+export default function (doc: PMNode): DecorationSet {
+ const hexColor = /(#[0-9a-f]{3,6})\b/gi
+ const decorations: Decoration[] = []
+
+ doc.descendants((node, position) => {
+ if (!node.text) {
+ return
+ }
+
+ Array.from(node.text.matchAll(hexColor)).forEach(match => {
+ const color = match[0]
+ const index = match.index || 0
+ const from = position + index
+ const to = from + color.length
+ const decoration = Decoration.inline(from, to, {
+ class: 'color',
+ style: `--color: ${color}`,
+ })
+
+ decorations.push(decoration)
+ })
+ })
+
+ return DecorationSet.create(doc, decorations)
+}
diff --git a/demos/src/Examples/Savvy/Vue/index.html b/demos/src/Examples/Savvy/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Savvy/Vue/index.vue b/demos/src/Examples/Savvy/Vue/index.vue
new file mode 100644
index 0000000..8c11a78
--- /dev/null
+++ b/demos/src/Examples/Savvy/Vue/index.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/Savvy/index.spec.ts b/demos/src/Examples/Savvy/index.spec.ts
new file mode 100644
index 0000000..0ceddaf
--- /dev/null
+++ b/demos/src/Examples/Savvy/index.spec.ts
@@ -0,0 +1,57 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'Savvy'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Examples'
+
+const cases: [string, string][] = [
+ ['(c)', '©'],
+ ['->', '→'],
+ ['>>', '»'],
+ ['1/2', '½'],
+ ['!=', '≠'],
+ ['--', '—'],
+ ['1x1', '1×1'],
+ [':-) ', '🙂'],
+ ['<3 ', '❤️'],
+ ['>:P ', '😜'],
+]
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any) => {
+ el.editor.chain().focus().clearContent().run()
+ })
+ })
+
+ cases.forEach(([input, expected]) => {
+ test(`should parse ${input} correctly`, async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type(`${input} `)
+
+ await expect(page.locator('.tiptap')).toContainText(expected)
+ })
+ })
+
+ test('should parse hex colors correctly', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type('#FD9170')
+
+ await expect(page.locator('.tiptap .color').first()).toBeVisible()
+ })
+ })
+ })
+})
diff --git a/demos/src/Examples/StaticRendering/React/index.html b/demos/src/Examples/StaticRendering/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/StaticRendering/React/index.tsx b/demos/src/Examples/StaticRendering/React/index.tsx
new file mode 100644
index 0000000..e0d3a8a
--- /dev/null
+++ b/demos/src/Examples/StaticRendering/React/index.tsx
@@ -0,0 +1,357 @@
+import './styles.scss'
+
+import type { JSONContent } from '@tiptap/react'
+import { EditorProvider, useCurrentEditor, useEditorState } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import { renderToHTMLString, renderToMarkdown, renderToReactElement } from '@tiptap/static-renderer'
+import React, { useState } from 'react'
+
+const extensions = [StarterKit]
+
+const content = `
+
+ Hi there,
+
+
+ this is a basic example of Tiptap . Sure, there are all kind of basic text styles you’d probably expect from a text editor. But wait until you see the lists:
+
+
+
+ That’s a bullet list with one …
+
+
+ … or two list items.
+
+
+
+ Isn’t that great? And all of that is editable. But wait, there’s more. Let’s try a code block:
+
+body {
+ display: none;
+}
+
+ I know, I know, this is impressive. It’s only the tip of the iceberg though. Give it a try and click a little bit around. Don’t forget to check the other examples too.
+
+
+ Wow, that’s amazing. Good work, boy! 👏
+
+ — Mom
+
+`
+
+/**
+ * This example demonstrates how to render a Prosemirror Node (or JSON Content) to a React Element.
+ * It will use your extensions to render the content based on each Node's/Mark's `renderHTML` method.
+ * This can be useful if you want to render content to React without having an actual editor instance.
+ *
+ * You have complete control over the rendering process. And can replace how each Node/Mark is rendered.
+ */
+export default () => {
+ const [tab, setTab] = useState<'react' | 'html' | 'html-element' | 'markdown'>('react')
+ const [currentJSON, setJSON] = useState(null)
+ return (
+
+
}
+ extensions={extensions}
+ content={content}
+ onUpdate={({ editor }) => {
+ setJSON(editor.getJSON())
+ }}
+ >
+
+
+ {tab === 'react' && (
+
+
React Element
+
+ This example renders the JSON content directly into a React element without using an
+ editor instance.
+
+
Notice that every paragraph now has a button counter
+
+ {currentJSON &&
+ renderToReactElement({
+ content: currentJSON,
+ extensions,
+ options: {
+ nodeMapping: {
+ paragraph: ({ node }) => {
+ // oxlint-disable-next-line react-hooks/rules-of-hooks
+ const [count, setCount] = useState(0)
+ return (
+ <>
+
setCount(count + 1)} className="primary">
+ CLICK ME
+
+
Count is: {count}
+
{node.textContent}
+ >
+ )
+ },
+ },
+ },
+ })}
+
+
+ )}
+ {tab === 'html' && (
+
+
HTML String
+
+ This example renders the JSON content into an HTML string without using an editor
+ instance or document parser.
+
+
+
+ {currentJSON &&
+ renderToHTMLString({
+ content: currentJSON,
+ extensions,
+ })}
+
+
+
+ )}
+ {tab === 'html-element' && (
+
+
To HTML Element (via dangerouslySetInnerHTML)
+
+ This example renders the JSON content into an HTML string without using an editor
+ instance or document parser, and places that result directly into the HTML using
+ dangerouslySetInnerHTML.
+
+
+
+ )}
+ {tab === 'markdown' && (
+
+
Markdown
+
+ This example renders the JSON content into a markdown without using an editor instance,
+ document parser or markdown library.
+
+
+
+ {currentJSON &&
+ renderToMarkdown({
+ content: currentJSON,
+ extensions,
+ })}
+
+
+
+ )}
+
+ )
+}
+
+function MenuBar() {
+ const { editor } = useCurrentEditor()
+
+ const editorState = useEditorState({
+ editor: editor!,
+ selector: ctx => {
+ return {
+ isBold: ctx.editor.isActive('bold') ?? false,
+ canBold: ctx.editor.can().chain().focus().toggleBold().run() ?? false,
+ isItalic: ctx.editor.isActive('italic') ?? false,
+ canItalic: ctx.editor.can().chain().focus().toggleItalic().run() ?? false,
+ isStrike: ctx.editor.isActive('strike') ?? false,
+ canStrike: ctx.editor.can().chain().focus().toggleStrike().run() ?? false,
+ isCode: ctx.editor.isActive('code') ?? false,
+ canCode: ctx.editor.can().chain().focus().toggleCode().run() ?? false,
+ canClearMarks: ctx.editor.can().chain().focus().unsetAllMarks().run() ?? false,
+ isParagraph: ctx.editor.isActive('paragraph') ?? false,
+ isHeading1: ctx.editor.isActive('heading', { level: 1 }) ?? false,
+ isHeading2: ctx.editor.isActive('heading', { level: 2 }) ?? false,
+ isHeading3: ctx.editor.isActive('heading', { level: 3 }) ?? false,
+ isHeading4: ctx.editor.isActive('heading', { level: 4 }) ?? false,
+ isHeading5: ctx.editor.isActive('heading', { level: 5 }) ?? false,
+ isHeading6: ctx.editor.isActive('heading', { level: 6 }) ?? false,
+ isBulletList: ctx.editor.isActive('bulletList') ?? false,
+ isOrderedList: ctx.editor.isActive('orderedList') ?? false,
+ isCodeBlock: ctx.editor.isActive('codeBlock') ?? false,
+ isBlockquote: ctx.editor.isActive('blockquote') ?? false,
+ canUndo: ctx.editor.can().chain().focus().undo().run() ?? false,
+ canRedo: ctx.editor.can().chain().focus().redo().run() ?? false,
+ }
+ },
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+
+
+ editor.chain().focus().toggleBold().run()}
+ disabled={!editorState.canBold}
+ className={editorState.isBold ? 'is-active' : ''}
+ >
+ Bold
+
+ editor.chain().focus().toggleItalic().run()}
+ disabled={!editorState.canItalic}
+ className={editorState.isItalic ? 'is-active' : ''}
+ >
+ Italic
+
+ editor.chain().focus().toggleStrike().run()}
+ disabled={!editorState.canStrike}
+ className={editorState.isStrike ? 'is-active' : ''}
+ >
+ Strike
+
+ editor.chain().focus().toggleCode().run()}
+ disabled={!editorState.canCode}
+ className={editorState.isCode ? 'is-active' : ''}
+ >
+ Code
+
+ editor.chain().focus().unsetAllMarks().run()}>Clear marks
+ editor.chain().focus().clearNodes().run()}>Clear nodes
+ editor.chain().focus().setParagraph().run()}
+ className={editorState.isParagraph ? 'is-active' : ''}
+ >
+ Paragraph
+
+ editor.chain().focus().toggleHeading({ level: 1 }).run()}
+ className={editorState.isHeading1 ? 'is-active' : ''}
+ >
+ H1
+
+ editor.chain().focus().toggleHeading({ level: 2 }).run()}
+ className={editorState.isHeading2 ? 'is-active' : ''}
+ >
+ H2
+
+ editor.chain().focus().toggleHeading({ level: 3 }).run()}
+ className={editorState.isHeading3 ? 'is-active' : ''}
+ >
+ H3
+
+ editor.chain().focus().toggleHeading({ level: 4 }).run()}
+ className={editorState.isHeading4 ? 'is-active' : ''}
+ >
+ H4
+
+ editor.chain().focus().toggleHeading({ level: 5 }).run()}
+ className={editorState.isHeading5 ? 'is-active' : ''}
+ >
+ H5
+
+ editor.chain().focus().toggleHeading({ level: 6 }).run()}
+ className={editorState.isHeading6 ? 'is-active' : ''}
+ >
+ H6
+
+ editor.chain().focus().toggleBulletList().run()}
+ className={editorState.isBulletList ? 'is-active' : ''}
+ >
+ Bullet list
+
+ editor.chain().focus().toggleOrderedList().run()}
+ className={editorState.isOrderedList ? 'is-active' : ''}
+ >
+ Ordered list
+
+ editor.chain().focus().toggleCodeBlock().run()}
+ className={editorState.isCodeBlock ? 'is-active' : ''}
+ >
+ Code block
+
+ editor.chain().focus().toggleBlockquote().run()}
+ className={editorState.isBlockquote ? 'is-active' : ''}
+ >
+ Blockquote
+
+ editor.chain().focus().setHorizontalRule().run()}>
+ Horizontal rule
+
+ editor.chain().focus().setHardBreak().run()}>Hard break
+ editor.chain().focus().undo().run()} disabled={!editorState.canUndo}>
+ Undo
+
+ editor.chain().focus().redo().run()} disabled={!editorState.canRedo}>
+ Redo
+
+
+
+ )
+}
diff --git a/demos/src/Examples/StaticRendering/React/styles.scss b/demos/src/Examples/StaticRendering/React/styles.scss
new file mode 100644
index 0000000..ae82a93
--- /dev/null
+++ b/demos/src/Examples/StaticRendering/React/styles.scss
@@ -0,0 +1,92 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+ overflow-x: auto;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
diff --git a/demos/src/Examples/StaticRenderingAdvanced/React/index.html b/demos/src/Examples/StaticRenderingAdvanced/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/StaticRenderingAdvanced/React/index.tsx b/demos/src/Examples/StaticRenderingAdvanced/React/index.tsx
new file mode 100644
index 0000000..bd5d8df
--- /dev/null
+++ b/demos/src/Examples/StaticRenderingAdvanced/React/index.tsx
@@ -0,0 +1,313 @@
+import {
+ Node,
+ NodeViewContent,
+ ReactNodeViewContentProvider,
+ ReactNodeViewRenderer,
+} from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import { renderToReactElement } from '@tiptap/static-renderer'
+import React, { useMemo } from 'react'
+
+// This component does not have a NodeViewContent, so it does not render it's children's rich text content
+function MyCustomComponentWithoutContent() {
+ const [count, setCount] = React.useState(200)
+
+ return (
+
+ setCount(a => a + 1)}>Click me
+ {count} Custom component without content in React!
+
+ )
+}
+
+// This component does have a NodeViewContent, so it will render it's children's rich text content
+function MyCustomComponentWithContent() {
+ const [count, setCount] = React.useState(200)
+ return (
+
+ setCount(a => a + 1)}>Click me
+ {count} Custom component with content in React!
+
+
+ )
+}
+
+const CustomNodeExtensionWithContent = Node.create({
+ name: 'customNodeExtensionWithContent',
+ content: 'text*',
+ group: 'block',
+ renderHTML() {
+ return ['div', { class: 'my-custom-component-with-content' }, 0] as const
+ },
+ addNodeView() {
+ return ReactNodeViewRenderer(MyCustomComponentWithContent)
+ },
+})
+
+const CustomNodeExtensionWithoutContent = Node.create({
+ name: 'customNodeExtensionWithoutContent',
+ atom: true,
+ renderHTML() {
+ return ['div', { class: 'my-custom-component-without-content' }] as const
+ },
+ addNodeView() {
+ return ReactNodeViewRenderer(MyCustomComponentWithoutContent)
+ },
+})
+
+/**
+ * This example demonstrates how to render a Prosemirror Node (or JSON Content) to a React Element.
+ * It will use your extensions to render the content based on each Node's/Mark's `renderHTML` method.
+ * This can be useful if you want to render content to React without having an actual editor instance.
+ *
+ * You have complete control over the rendering process. And can replace how each Node/Mark is rendered.
+ */
+export default () => {
+ const output = useMemo(() => {
+ return renderToReactElement({
+ extensions: [StarterKit, CustomNodeExtensionWithContent, CustomNodeExtensionWithoutContent],
+ options: {
+ nodeMapping: {
+ // You can replace the rendering of a node with a custom react component
+ heading({ node, children }) {
+ // oxlint-disable-next-line react-hooks/rules-of-hooks
+ const [show, setEnabled] = React.useState(false)
+
+ return (
+ setEnabled(true)}>
+ {show
+ ? `100% you can use React hooks!`
+ : `Can you use React hooks? Click to find out!`}{' '}
+ {children}
+
+ )
+ },
+ // Node views are not supported in the static renderer, so you need to supply the custom component yourself
+ customNodeExtensionWithContent({ children }) {
+ return (
+
+
+
+ )
+ },
+ customNodeExtensionWithoutContent() {
+ return
+ },
+ },
+ markMapping: {},
+ },
+ content: {
+ type: 'doc',
+ from: 0,
+ to: 574,
+ content: [
+ {
+ type: 'heading',
+ from: 0,
+ to: 11,
+ attrs: {
+ level: 2,
+ },
+ content: [
+ {
+ type: 'text',
+ from: 1,
+ to: 10,
+ text: 'Hi there,',
+ },
+ ],
+ },
+ // This is a custom node extension with content
+ {
+ type: 'customNodeExtensionWithContent',
+ content: [
+ {
+ type: 'text',
+ text: 'MY CUSTOM COMPONENT CONTENT!!!',
+ },
+ ],
+ },
+ // This is a custom node extension without content
+ {
+ type: 'customNodeExtensionWithoutContent',
+ },
+ {
+ type: 'paragraph',
+ from: 11,
+ to: 169,
+ content: [
+ {
+ type: 'text',
+ from: 12,
+ to: 22,
+ text: 'this is a ',
+ },
+ {
+ type: 'text',
+ from: 22,
+ to: 27,
+ marks: [
+ {
+ type: 'italic',
+ },
+ ],
+ text: 'basic',
+ },
+ {
+ type: 'text',
+ from: 27,
+ to: 39,
+ text: ' example of ',
+ },
+ {
+ type: 'text',
+ from: 39,
+ to: 45,
+ marks: [
+ {
+ type: 'bold',
+ },
+ ],
+ text: 'Tiptap',
+ },
+ {
+ type: 'text',
+ from: 45,
+ to: 168,
+ text: '. Sure, there are all kind of basic text styles you’d probably expect from a text editor. But wait until you see the lists:',
+ },
+ ],
+ },
+ {
+ type: 'bulletList',
+ from: 169,
+ to: 230,
+ content: [
+ {
+ type: 'listItem',
+ from: 170,
+ to: 205,
+ attrs: {
+ color: '',
+ },
+ content: [
+ {
+ type: 'paragraph',
+ from: 171,
+ to: 204,
+ content: [
+ {
+ type: 'text',
+ from: 172,
+ to: 203,
+ text: 'That’s a bullet list with one …',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'listItem',
+ from: 205,
+ to: 229,
+ attrs: {
+ color: '',
+ },
+ content: [
+ {
+ type: 'paragraph',
+ from: 206,
+ to: 228,
+ content: [
+ {
+ type: 'text',
+ from: 207,
+ to: 227,
+ text: '… or two list items.',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ from: 230,
+ to: 326,
+ content: [
+ {
+ type: 'text',
+ from: 231,
+ to: 325,
+ text: 'Isn’t that great? And all of that is editable. But wait, there’s more. Let’s try a code block:',
+ },
+ ],
+ },
+ {
+ type: 'codeBlock',
+ from: 326,
+ to: 353,
+ attrs: {
+ language: 'css',
+ },
+ content: [
+ {
+ type: 'text',
+ from: 327,
+ to: 352,
+ text: 'body {\n display: none;\n}',
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ from: 353,
+ to: 522,
+ content: [
+ {
+ type: 'text',
+ from: 354,
+ to: 521,
+ text: 'I know, I know, this is impressive. It’s only the tip of the iceberg though. Give it a try and click a little bit around. Don’t forget to check the other examples too.',
+ },
+ ],
+ },
+ {
+ type: 'blockquote',
+ from: 522,
+ to: 572,
+ content: [
+ {
+ type: 'paragraph',
+ from: 523,
+ to: 571,
+ content: [
+ {
+ type: 'text',
+ from: 524,
+ to: 564,
+ text: 'Wow, that’s amazing. Good work, boy! 👏 ',
+ },
+ {
+ type: 'hardBreak',
+ from: 564,
+ to: 565,
+ },
+ {
+ type: 'text',
+ from: 565,
+ to: 570,
+ text: '— Mom',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ })
+ }, [])
+
+ return {output}
+}
diff --git a/demos/src/Examples/SuggestionPositioning/React/DropdownList.jsx b/demos/src/Examples/SuggestionPositioning/React/DropdownList.jsx
new file mode 100644
index 0000000..bae32ad
--- /dev/null
+++ b/demos/src/Examples/SuggestionPositioning/React/DropdownList.jsx
@@ -0,0 +1,64 @@
+import React, { useEffect, useImperativeHandle, useState } from 'react'
+
+export default props => {
+ const [selectedIndex, setSelectedIndex] = useState(0)
+
+ const selectItem = index => {
+ const item = props.items[index]
+
+ if (item) {
+ props.command(item)
+ }
+ }
+
+ const upHandler = () => {
+ setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length)
+ }
+
+ const downHandler = () => {
+ setSelectedIndex((selectedIndex + 1) % props.items.length)
+ }
+
+ const enterHandler = () => {
+ selectItem(selectedIndex)
+ }
+
+ useEffect(() => setSelectedIndex(0), [props.items])
+
+ useImperativeHandle(props.ref, () => ({
+ onKeyDown: ({ event }) => {
+ if (event.key === 'ArrowUp') {
+ upHandler()
+ return true
+ }
+
+ if (event.key === 'ArrowDown') {
+ downHandler()
+ return true
+ }
+
+ if (event.key === 'Enter') {
+ enterHandler()
+ return true
+ }
+
+ return false
+ },
+ }))
+
+ return (
+
+ {props.items.length === 0 &&
No items found
}
+ {props.items.map((item, index) => (
+
event.preventDefault()}
+ onClick={() => selectItem(index)}
+ >
+ {item.label}
+
+ ))}
+
+ )
+}
diff --git a/demos/src/Examples/SuggestionPositioning/React/index.html b/demos/src/Examples/SuggestionPositioning/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/SuggestionPositioning/React/index.jsx b/demos/src/Examples/SuggestionPositioning/React/index.jsx
new file mode 100644
index 0000000..84ce9ca
--- /dev/null
+++ b/demos/src/Examples/SuggestionPositioning/React/index.jsx
@@ -0,0 +1,36 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Mention from '@tiptap/extension-mention'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+import suggestion from './suggestion.js'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Mention.configure({
+ HTMLAttributes: {
+ class: 'mention',
+ },
+ suggestion,
+ }),
+ ],
+ content: `
+ Try mentioning a colleague by typing @.
+ The popup uses floatingUi options to control placement, strategy, and middleware while the suggestion plugin keeps ownership of the anchor. It stays pinned to the top-start side and shifts inside the viewport.
+ `,
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return
+}
diff --git a/demos/src/Examples/SuggestionPositioning/React/styles.scss b/demos/src/Examples/SuggestionPositioning/React/styles.scss
new file mode 100644
index 0000000..12f1ef6
--- /dev/null
+++ b/demos/src/Examples/SuggestionPositioning/React/styles.scss
@@ -0,0 +1,49 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ .mention {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ box-decoration-break: clone;
+ color: var(--purple);
+ padding: 0.1rem 0.3rem;
+
+ &::after {
+ content: '\200B';
+ }
+ }
+}
+
+/* Dropdown menu */
+.dropdown-menu {
+ background: var(--white);
+ border: 1px solid var(--gray-1);
+ border-radius: 0.7rem;
+ box-shadow: var(--shadow);
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ overflow: auto;
+ padding: 0.4rem;
+
+ button {
+ align-items: center;
+ background-color: transparent;
+ display: flex;
+ gap: 0.25rem;
+ text-align: left;
+ width: 100%;
+
+ &:hover,
+ &:hover.is-selected {
+ background-color: var(--gray-3);
+ }
+
+ &.is-selected {
+ background-color: var(--gray-2);
+ }
+ }
+}
diff --git a/demos/src/Examples/SuggestionPositioning/React/suggestion.js b/demos/src/Examples/SuggestionPositioning/React/suggestion.js
new file mode 100644
index 0000000..67a968e
--- /dev/null
+++ b/demos/src/Examples/SuggestionPositioning/React/suggestion.js
@@ -0,0 +1,73 @@
+import { flip, shift } from '@floating-ui/dom'
+import { ReactRenderer } from '@tiptap/react'
+
+import DropdownList from './DropdownList.jsx'
+
+const items = [
+ { id: 'alice', label: 'Alice Johnson' },
+ { id: 'bob', label: 'Bob Smith' },
+ { id: 'carol', label: 'Carol Williams' },
+ { id: 'dave', label: 'Dave Brown' },
+ { id: 'eve', label: 'Eve Davis' },
+ { id: 'frank', label: 'Frank Miller' },
+ { id: 'grace', label: 'Grace Wilson' },
+ { id: 'hank', label: 'Hank Moore' },
+ { id: 'iris', label: 'Iris Taylor' },
+ { id: 'jack', label: 'Jack Anderson' },
+]
+
+export default {
+ items: ({ query }) =>
+ items.filter(item => item.label.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5),
+
+ placement: 'top-start',
+
+ offset: { mainAxis: 8 },
+
+ flip: false,
+
+ floatingUi: {
+ strategy: 'fixed',
+ middleware: [flip({ padding: 8 }), shift({ padding: 8 })],
+ },
+
+ // Dismiss the popup when clicking outside it (and outside the editor).
+ dismissOnOutsideClick: true,
+
+ render: () => {
+ let component
+ let unmount = null
+
+ return {
+ onStart: props => {
+ component = new ReactRenderer(DropdownList, {
+ props,
+ editor: props.editor,
+ })
+
+ // Managed positioning: the plugin mounts the element into the container,
+ // keeps it anchored to the cursor, and repositions it on scroll/resize.
+ unmount = props.mount(component.element)
+ },
+
+ onUpdate(props) {
+ component.updateProps(props)
+ },
+
+ onKeyDown(props) {
+ if (props.event.key === 'Escape') {
+ component.destroy()
+
+ return true
+ }
+
+ return component.ref?.onKeyDown(props)
+ },
+
+ onExit() {
+ unmount?.()
+ component.destroy()
+ },
+ }
+ },
+}
diff --git a/demos/src/Examples/SuggestionPositioning/Vue/DropdownList.vue b/demos/src/Examples/SuggestionPositioning/Vue/DropdownList.vue
new file mode 100644
index 0000000..a2a2038
--- /dev/null
+++ b/demos/src/Examples/SuggestionPositioning/Vue/DropdownList.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/SuggestionPositioning/Vue/index.html b/demos/src/Examples/SuggestionPositioning/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/SuggestionPositioning/Vue/index.vue b/demos/src/Examples/SuggestionPositioning/Vue/index.vue
new file mode 100644
index 0000000..4d03665
--- /dev/null
+++ b/demos/src/Examples/SuggestionPositioning/Vue/index.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/SuggestionPositioning/Vue/suggestion.js b/demos/src/Examples/SuggestionPositioning/Vue/suggestion.js
new file mode 100644
index 0000000..ec61ed4
--- /dev/null
+++ b/demos/src/Examples/SuggestionPositioning/Vue/suggestion.js
@@ -0,0 +1,77 @@
+import { flip, shift } from '@floating-ui/dom'
+import { VueRenderer } from '@tiptap/vue'
+
+import DropdownList from './DropdownList.vue'
+
+const items = [
+ { id: 'alice', label: 'Alice Johnson' },
+ { id: 'bob', label: 'Bob Smith' },
+ { id: 'carol', label: 'Carol Williams' },
+ { id: 'dave', label: 'Dave Brown' },
+ { id: 'eve', label: 'Eve Davis' },
+ { id: 'frank', label: 'Frank Miller' },
+ { id: 'grace', label: 'Grace Wilson' },
+ { id: 'hank', label: 'Hank Moore' },
+ { id: 'iris', label: 'Iris Taylor' },
+ { id: 'jack', label: 'Jack Anderson' },
+]
+
+export default {
+ items: ({ query }) =>
+ items.filter(item => item.label.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5),
+
+ placement: 'top-start',
+
+ offset: { mainAxis: 8 },
+
+ flip: false,
+
+ floatingUi: {
+ strategy: 'fixed',
+ middleware: [flip({ padding: 8 }), shift({ padding: 8 })],
+ },
+
+ // Dismiss the popup when clicking outside it (and outside the editor).
+ dismissOnOutsideClick: true,
+
+ render: () => {
+ let component
+ let unmount = null
+
+ return {
+ onStart: props => {
+ if (component) {
+ component.destroy()
+ }
+
+ component = new VueRenderer(DropdownList, {
+ props,
+ editor: props.editor,
+ })
+
+ // Managed positioning: the plugin mounts the element into the container,
+ // keeps it anchored to the cursor, and repositions it on scroll/resize.
+ unmount = props.mount(component.element)
+ },
+
+ onUpdate(props) {
+ component.updateProps(props)
+ },
+
+ onKeyDown(props) {
+ if (props.event.key === 'Escape') {
+ component.destroy()
+
+ return true
+ }
+
+ return component.ref?.onKeyDown(props)
+ },
+
+ onExit() {
+ unmount?.()
+ component.destroy()
+ },
+ }
+ },
+}
diff --git a/demos/src/Examples/SuggestionPositioning/index.spec.ts b/demos/src/Examples/SuggestionPositioning/index.spec.ts
new file mode 100644
index 0000000..9b50fb7
--- /dev/null
+++ b/demos/src/Examples/SuggestionPositioning/index.spec.ts
@@ -0,0 +1,62 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoPath = '/src/Examples/SuggestionPositioning'
+
+test.describe(demoPath, () => {
+ test.describe('React', () => {
+ const fullDemoPath = `${demoPath}/React/`
+
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test('opens a dropdown menu when @ is typed', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('@')
+ await expect(page.locator('.dropdown-menu')).toBeVisible()
+ })
+
+ test('shows matching items in the dropdown', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('@a')
+ // Only 'Alice Johnson' starts with 'a'
+ await expect(page.locator('.dropdown-menu button')).toHaveCount(1)
+ await expect(page.locator('.dropdown-menu button').first()).toContainText('Alice')
+ })
+
+ test('selects an item via click and inserts a mention', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('@')
+ await page.locator('.dropdown-menu button').first().click()
+ await expect(page.locator('.tiptap span.mention')).toBeVisible()
+ })
+
+ test('inserts Bob via arrow keys and Enter', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('@')
+ // Wait for dropdown to appear before pressing ArrowDown
+ await page.locator('.dropdown-menu').waitFor({ state: 'visible' })
+ await page.keyboard.press('ArrowDown')
+ await page.keyboard.press('Enter')
+ await expect(page.locator('.tiptap span.mention')).toHaveAttribute('data-id', 'bob')
+ })
+ })
+
+ test.describe('Vue', () => {
+ test('loads the editor', async ({ page }) => {
+ await page.goto(`${demoPath}/Vue/`)
+ const editor = await getEditor(page)
+ await expect(editor).toBeVisible()
+ })
+ })
+})
diff --git a/demos/src/Examples/Tables/React/index.html b/demos/src/Examples/Tables/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Tables/React/index.jsx b/demos/src/Examples/Tables/React/index.jsx
new file mode 100644
index 0000000..b64f80c
--- /dev/null
+++ b/demos/src/Examples/Tables/React/index.jsx
@@ -0,0 +1,275 @@
+import './styles.scss'
+
+import { TableCell, TableKit } from '@tiptap/extension-table'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+const CustomTableCell = TableCell.extend({
+ addAttributes() {
+ return {
+ // extend the existing attributes …
+ ...this.parent?.(),
+
+ // and add a new one …
+ backgroundColor: {
+ default: null,
+ parseHTML: element => element.getAttribute('data-background-color'),
+ renderHTML: attributes => {
+ return {
+ 'data-background-color': attributes.backgroundColor,
+ style: `background-color: ${attributes.backgroundColor}`,
+ }
+ },
+ },
+ }
+ },
+})
+
+export const tableHTML = `
+
+
+ Firstname
+ Lastname
+ Age
+
+
+ Jill
+ Smith
+ 50
+
+
+ Eve
+ Jackson
+ 94
+
+
+ John
+ Doe
+ 80
+
+
+`
+
+const MenuBar = ({ editor }) => {
+ const editorState = useEditorState({
+ editor,
+ selector: ctx => ({
+ canAddColumnBefore: ctx.editor.can().addColumnBefore() ?? false,
+ canAddColumnAfter: ctx.editor.can().addColumnAfter() ?? false,
+ canDeleteColumn: ctx.editor.can().deleteColumn() ?? false,
+ canAddRowBefore: ctx.editor.can().addRowBefore() ?? false,
+ canAddRowAfter: ctx.editor.can().addRowAfter() ?? false,
+ canDeleteRow: ctx.editor.can().deleteRow() ?? false,
+ canDeleteTable: ctx.editor.can().deleteTable() ?? false,
+ canMergeCells: ctx.editor.can().mergeCells() ?? false,
+ canSplitCell: ctx.editor.can().splitCell() ?? false,
+ canToggleHeaderColumn: ctx.editor.can().toggleHeaderColumn() ?? false,
+ canToggleHeaderRow: ctx.editor.can().toggleHeaderRow() ?? false,
+ canToggleHeaderCell: ctx.editor.can().toggleHeaderCell() ?? false,
+ canMergeOrSplit: ctx.editor.can().mergeOrSplit() ?? false,
+ canSetCellAttribute: ctx.editor.can().setCellAttribute('backgroundColor', '#FAF594') ?? false,
+ canFixTables: ctx.editor.can().fixTables() ?? false,
+ canGoToNextCell: ctx.editor.can().goToNextCell() ?? false,
+ canGoToPreviousCell: ctx.editor.can().goToPreviousCell() ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+
+
+
+ editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
+ }
+ >
+ Insert table
+
+
+ editor
+ .chain()
+ .focus()
+ .insertContent(tableHTML, {
+ parseOptions: {
+ preserveWhitespace: false,
+ },
+ })
+ .run()
+ }
+ >
+ Insert HTML table
+
+ editor.chain().focus().addColumnBefore().run()}
+ disabled={!editorState.canAddColumnBefore}
+ >
+ Add column before
+
+ editor.chain().focus().addColumnAfter().run()}
+ disabled={!editorState.canAddColumnAfter}
+ >
+ Add column after
+
+ editor.chain().focus().deleteColumn().run()}
+ disabled={!editorState.canDeleteColumn}
+ >
+ Delete column
+
+ editor.chain().focus().addRowBefore().run()}
+ disabled={!editorState.canAddRowBefore}
+ >
+ Add row before
+
+ editor.chain().focus().addRowAfter().run()}
+ disabled={!editorState.canAddRowAfter}
+ >
+ Add row after
+
+ editor.chain().focus().deleteRow().run()}
+ disabled={!editorState.canDeleteRow}
+ >
+ Delete row
+
+ editor.chain().focus().deleteTable().run()}
+ disabled={!editorState.canDeleteTable}
+ >
+ Delete table
+
+ editor.chain().focus().mergeCells().run()}
+ disabled={!editorState.canMergeCells}
+ >
+ Merge cells
+
+ editor.chain().focus().splitCell().run()}
+ disabled={!editorState.canSplitCell}
+ >
+ Split cell
+
+ editor.chain().focus().toggleHeaderColumn().run()}
+ disabled={!editorState.canToggleHeaderColumn}
+ >
+ Toggle header column
+
+ editor.chain().focus().toggleHeaderRow().run()}
+ disabled={!editorState.canToggleHeaderRow}
+ >
+ Toggle header row
+
+ editor.chain().focus().toggleHeaderCell().run()}
+ disabled={!editorState.canToggleHeaderCell}
+ >
+ Toggle header cell
+
+ editor.chain().focus().mergeOrSplit().run()}
+ disabled={!editorState.canMergeOrSplit}
+ >
+ Merge or split
+
+
+ editor.chain().focus().setCellAttribute('backgroundColor', '#FAF594').run()
+ }
+ disabled={!editorState.canSetCellAttribute}
+ >
+ Set cell attribute
+
+ editor.chain().focus().fixTables().run()}
+ disabled={!editorState.canFixTables}
+ >
+ Fix tables
+
+ editor.chain().focus().goToNextCell().run()}
+ disabled={!editorState.canGoToNextCell}
+ >
+ Go to next cell
+
+ editor.chain().focus().goToPreviousCell().run()}
+ disabled={!editorState.canGoToPreviousCell}
+ >
+ Go to previous cell
+
+
+
+ )
+}
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ TableKit.configure({
+ table: { resizable: true },
+ tableCell: false,
+ }),
+ // Default TableCell
+ // TableCell,
+ // Custom TableCell with backgroundColor attribute
+ CustomTableCell,
+ ],
+ content: `
+
+ Have you seen our tables? They are amazing!
+
+
+ Tables with rows, cells and headers (optional)
+ Support for colgroup and rowspan
+ And even resizable columns (optional)
+
+
+ Here is an example:
+
+
+
+
+ Name
+ Description
+
+
+ Cyndi Lauper
+ Singer
+ Songwriter
+ Actress
+
+
+ Marie Curie
+ Scientist
+ Chemist
+ Physicist
+
+
+ Indira Gandhi
+ Prime minister
+ Politician
+
+
+
+ `,
+ })
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/demos/src/Examples/Tables/React/styles.scss b/demos/src/Examples/Tables/React/styles.scss
new file mode 100644
index 0000000..8552f29
--- /dev/null
+++ b/demos/src/Examples/Tables/React/styles.scss
@@ -0,0 +1,152 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+
+ /* Table-specific styling */
+ table {
+ border-collapse: collapse;
+ margin: 0;
+ overflow: hidden;
+ table-layout: fixed;
+ width: 100%;
+
+ td,
+ th {
+ border: 1px solid var(--gray-3);
+ box-sizing: border-box;
+ min-width: 1em;
+ padding: 6px 8px;
+ position: relative;
+ vertical-align: top;
+
+ > * {
+ margin-bottom: 0;
+ }
+ }
+
+ th {
+ background-color: var(--gray-1);
+ font-weight: bold;
+ text-align: left;
+ }
+
+ .selectedCell:after {
+ background: var(--gray-2);
+ content: '';
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ pointer-events: none;
+ position: absolute;
+ z-index: 2;
+ }
+
+ .column-resize-handle {
+ background-color: var(--purple);
+ bottom: -2px;
+ pointer-events: none;
+ position: absolute;
+ right: -2px;
+ top: 0;
+ width: 4px;
+ }
+ }
+
+ .tableWrapper {
+ margin: 1.5rem 0;
+ overflow-x: auto;
+ }
+
+ &.resize-cursor {
+ cursor: ew-resize;
+ cursor: col-resize;
+ }
+}
diff --git a/demos/src/Examples/Tables/Vue/index.html b/demos/src/Examples/Tables/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Tables/Vue/index.vue b/demos/src/Examples/Tables/Vue/index.vue
new file mode 100644
index 0000000..2be42bf
--- /dev/null
+++ b/demos/src/Examples/Tables/Vue/index.vue
@@ -0,0 +1,406 @@
+
+
+
+
+
+ Insert table
+
+
+ Insert HTML table
+
+
+ Add column before
+
+
+ Add column after
+
+
+ Delete column
+
+
+ Add row before
+
+
+ Add row after
+
+
+ Delete row
+
+
+ Delete table
+
+
+ Merge cells
+
+
+ Split cell
+
+
+ Toggle header column
+
+
+ Toggle header row
+
+
+ Toggle header cell
+
+
+ Merge or split
+
+
+ Set cell attribute
+
+
+ Fix tables
+
+
+ Go to next cell
+
+
+ Go to previous cell
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/Tasks/React/index.html b/demos/src/Examples/Tasks/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Tasks/React/index.jsx b/demos/src/Examples/Tasks/React/index.jsx
new file mode 100644
index 0000000..2bd0839
--- /dev/null
+++ b/demos/src/Examples/Tasks/React/index.jsx
@@ -0,0 +1,35 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import { TaskItem, TaskList } from '@tiptap/extension-list'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+const CustomDocument = Document.extend({
+ content: 'taskList',
+})
+
+const CustomTaskItem = TaskItem.extend({
+ content: 'inline*',
+})
+
+export default () => {
+ const editor = useEditor({
+ extensions: [CustomDocument, Paragraph, Text, TaskList, CustomTaskItem],
+ content: `
+
+ flour
+ baking powder
+ salt
+ sugar
+ milk
+ eggs
+ butter
+
+ `,
+ })
+
+ return
+}
diff --git a/demos/src/Examples/Tasks/React/styles.scss b/demos/src/Examples/Tasks/React/styles.scss
new file mode 100644
index 0000000..0dcc9ac
--- /dev/null
+++ b/demos/src/Examples/Tasks/React/styles.scss
@@ -0,0 +1,44 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Task list specific styles */
+ ul[data-type='taskList'] {
+ list-style: none;
+ margin-left: 0;
+ padding: 0;
+
+ li {
+ align-items: center;
+ display: flex;
+
+ > label {
+ flex: 0 0 auto;
+ margin-right: 0.5rem;
+ user-select: none;
+ }
+
+ > div {
+ flex: 1 1 auto;
+ }
+ }
+
+ input[type='checkbox'] {
+ cursor: pointer;
+ }
+ }
+}
diff --git a/demos/src/Examples/Tasks/Vue/index.html b/demos/src/Examples/Tasks/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Tasks/Vue/index.vue b/demos/src/Examples/Tasks/Vue/index.vue
new file mode 100644
index 0000000..0e8faea
--- /dev/null
+++ b/demos/src/Examples/Tasks/Vue/index.vue
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/Tasks/index.spec.ts b/demos/src/Examples/Tasks/index.spec.ts
new file mode 100644
index 0000000..3e2c3c7
--- /dev/null
+++ b/demos/src/Examples/Tasks/index.spec.ts
@@ -0,0 +1,66 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'Tasks'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Examples'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ const editor = await getEditor(page)
+
+ await editor.evaluate((el: any) => {
+ el.editor.commands.clearContent()
+ })
+ })
+
+ test('should always use task items', async ({ page }) => {
+ await expect(page.locator('.tiptap input[type="checkbox"]')).toHaveCount(1)
+ })
+
+ test('should create new tasks', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type('Cook food')
+ await editor.press('Enter')
+ await editor.type('Eat food')
+ await editor.press('Enter')
+ await editor.type('Clean dishes')
+
+ await expect(page.locator('.tiptap input[type="checkbox"]')).toHaveCount(3)
+ })
+
+ test('should check and uncheck tasks on click', async ({ page }) => {
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await editor.type('Cook food')
+ await editor.press('Enter')
+ await editor.type('Eat food')
+ await editor.press('Enter')
+ await editor.type('Clean dishes')
+
+ const checkboxes = page.locator('.tiptap input[type="checkbox"]')
+
+ await checkboxes.nth(0).check({ force: true })
+ await expect(page.locator('.tiptap input[type="checkbox"]:checked')).toHaveCount(1)
+
+ await checkboxes.nth(1).check({ force: true })
+ await expect(page.locator('.tiptap input[type="checkbox"]:checked')).toHaveCount(2)
+
+ await checkboxes.nth(0).uncheck({ force: true })
+ await expect(page.locator('.tiptap input[type="checkbox"]:checked')).toHaveCount(1)
+
+ await checkboxes.nth(1).uncheck({ force: true })
+ await expect(page.locator('.tiptap input[type="checkbox"]:checked')).toHaveCount(0)
+ })
+ })
+ })
+})
diff --git a/demos/src/Examples/TextDirection/React/index.html b/demos/src/Examples/TextDirection/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/TextDirection/React/index.tsx b/demos/src/Examples/TextDirection/React/index.tsx
new file mode 100644
index 0000000..a701435
--- /dev/null
+++ b/demos/src/Examples/TextDirection/React/index.tsx
@@ -0,0 +1,127 @@
+import './styles.scss'
+
+import type { Editor } from '@tiptap/react'
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React, { useState } from 'react'
+
+const extensions = [StarterKit]
+
+function MenuBar({
+ editor,
+ globalDirection,
+ setGlobalDirection,
+}: {
+ editor: Editor
+ globalDirection: 'ltr' | 'rtl' | 'auto' | undefined
+ setGlobalDirection: (dir: 'ltr' | 'rtl' | 'auto' | undefined) => void
+}) {
+ return (
+
+
+ Global Direction:
+ setGlobalDirection(undefined)}
+ className={globalDirection === undefined ? 'is-active' : ''}
+ >
+ None
+
+ setGlobalDirection('ltr')}
+ className={globalDirection === 'ltr' ? 'is-active' : ''}
+ >
+ LTR
+
+ setGlobalDirection('rtl')}
+ className={globalDirection === 'rtl' ? 'is-active' : ''}
+ >
+ RTL
+
+ setGlobalDirection('auto')}
+ className={globalDirection === 'auto' ? 'is-active' : ''}
+ >
+ Auto
+
+
+
+
+ Set Direction on Selection:
+ editor.chain().focus().setTextDirection('ltr').run()}>
+ Set LTR
+
+ editor.chain().focus().setTextDirection('rtl').run()}>
+ Set RTL
+
+ editor.chain().focus().setTextDirection('auto').run()}>
+ Set Auto
+
+ editor.chain().focus().unsetTextDirection().run()}>
+ Unset Direction
+
+
+
+ )
+}
+
+export default () => {
+ const [globalDirection, setGlobalDirection] = useState<'ltr' | 'rtl' | 'auto' | undefined>('auto')
+
+ const editor = useEditor(
+ {
+ extensions,
+ textDirection: globalDirection,
+ content: `
+Text Direction Support
+This demo showcases the text direction feature in Tiptap. You can set the global text direction for all content, or apply it to specific nodes using commands.
+
+English Text (LTR)
+This is a paragraph in English, which is a left-to-right language. The text flows naturally from left to right.
+
+Arabic Text (RTL)
+هذا مثال على نص باللغة العربية. اللغة العربية تُكتب من اليمين إلى اليسار.
+
+Hebrew Text (RTL)
+זהו דוגמה לטקסט בעברית. עברית נכתבת מימין לשמאל.
+
+Bidirectional Text
+This paragraph contains both English and Arabic: مرحبا بك في Tiptap! This demonstrates how the auto direction works.
+Another example: Hello העולם שלום World!
+
+Lists
+
+ English list item
+ عنصر قائمة عربي
+ Mixed: English and عربي together
+
+
+
+ First item
+ البند الثاني
+ Third item: עברית
+
+
+
+ This is a blockquote with مختلط mixed content.
+
+`,
+ },
+ [globalDirection],
+ )
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+
+
+
+
+ )
+}
diff --git a/demos/src/Examples/TextDirection/React/styles.scss b/demos/src/Examples/TextDirection/React/styles.scss
new file mode 100644
index 0000000..93dc8e2
--- /dev/null
+++ b/demos/src/Examples/TextDirection/React/styles.scss
@@ -0,0 +1,109 @@
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+
+ [dir='rtl'] {
+ text-align: right;
+ }
+
+ [dir='ltr'] {
+ text-align: left;
+ }
+}
+
+.control-group {
+ .button-group {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+ align-items: center;
+
+ strong {
+ margin-right: 0.5rem;
+ }
+ }
+}
diff --git a/demos/src/Examples/TransformPastedHTML/React/index.html b/demos/src/Examples/TransformPastedHTML/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/TransformPastedHTML/React/index.jsx b/demos/src/Examples/TransformPastedHTML/React/index.jsx
new file mode 100644
index 0000000..f7c7467
--- /dev/null
+++ b/demos/src/Examples/TransformPastedHTML/React/index.jsx
@@ -0,0 +1,134 @@
+import './styles.scss'
+
+import { Extension } from '@tiptap/core'
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React, { useCallback } from 'react'
+
+export default () => {
+ // Create extension that replaces words
+ const WordReplacerExtension = Extension.create({
+ name: 'wordReplacer',
+ priority: 100,
+
+ transformPastedHTML(html) {
+ // Replace "foo" with "bar" in text content
+ return html.replace(/\bfoo\b/gi, 'bar')
+ },
+ })
+
+ // Create extension that converts tabs to spaces
+ const TabToSpacesExtension = Extension.create({
+ name: 'tabToSpaces',
+ priority: 110, // Higher priority = runs first
+
+ transformPastedHTML(html) {
+ // Convert tabs to 2 spaces
+ return html.replace(/\t/g, ' ')
+ },
+ })
+
+ // Create extension that normalizes line breaks
+ const LineBreakExtension = Extension.create({
+ name: 'lineBreak',
+ priority: 90,
+
+ transformPastedHTML(html) {
+ // Convert double line breaks to paragraph breaks
+ return html.replace(/\n\n+/g, '
')
+ },
+ })
+
+ // Create extension that removes inline styles
+ const CleanStylesExtension = Extension.create({
+ name: 'cleanStyles',
+ priority: 80,
+
+ transformPastedHTML(html) {
+ // Remove inline style attributes
+ return html.replace(/\s+style="[^"]*"/gi, '')
+ },
+ })
+
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ TabToSpacesExtension,
+ WordReplacerExtension,
+ LineBreakExtension,
+ CleanStylesExtension,
+ ],
+ content: `
+
Transform Pasted HTML Demo
+ This demo showcases the transformPastedHTML API that allows extensions to transform pasted HTML content before it's parsed into the editor.
+
+ How it works:
+
+ Tab to Spaces (priority: 110) - Converts tabs to spaces
+ Word Replacer (priority: 100) - Replaces "foo" with "bar"
+ Line Break (priority: 90) - Converts double line breaks to paragraphs
+ Clean Styles (priority: 80) - Removes inline style attributes
+
+
+ Extensions with higher priority run first. Each extension receives the output from the previous extension's transform, creating a transform chain.
+
+ Try it:
+ Use the buttons above to simulate the transformation, or try copying and pasting HTML content with tabs, "foo" text, or inline styles from another source to see the API work during actual paste events!
+
+ Note: The buttons above simulate the transform for demonstration purposes. Real paste events (Ctrl+V / Cmd+V) will also trigger these transforms automatically.
+ `,
+ })
+
+ const pasteHTML = useCallback(
+ html => {
+ if (!editor) {
+ return
+ }
+
+ // Get the transformed HTML by calling the view's transformPastedHTML
+ const transformedHTML = editor.view.props.transformPastedHTML?.(html) || html
+
+ // Insert the transformed HTML at the current position
+ editor.chain().focus().insertContent(transformedHTML).run()
+ },
+ [editor],
+ )
+
+ const pasteWithTabs = useCallback(() => {
+ const html = 'This\ttext\thas\ttabs\tbetween\twords
'
+ pasteHTML(html)
+ }, [pasteHTML])
+
+ const pasteWithFoo = useCallback(() => {
+ const html = 'The foo is strong in this foo. Foo fighters unite!
'
+ pasteHTML(html)
+ }, [pasteHTML])
+
+ const pasteWithStyles = useCallback(() => {
+ const html =
+ 'This has inline styles that will be removed
'
+ pasteHTML(html)
+ }, [pasteHTML])
+
+ return (
+
+ {editor && (
+
+
+
+ Paste with Tabs
+
+
+ Paste with "foo"
+
+
+ Paste with Styles
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/demos/src/Examples/TransformPastedHTML/React/styles.scss b/demos/src/Examples/TransformPastedHTML/React/styles.scss
new file mode 100644
index 0000000..9bc0e8f
--- /dev/null
+++ b/demos/src/Examples/TransformPastedHTML/React/styles.scss
@@ -0,0 +1,145 @@
+/* Control group styles */
+.control-group {
+ margin-bottom: 1rem;
+ padding: 1rem;
+ background-color: var(--gray-1);
+ border-radius: 0.5rem;
+}
+
+.button-group {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+
+ button {
+ padding: 0.5rem 1rem;
+ background-color: var(--purple);
+ color: white;
+ border: none;
+ border-radius: 0.25rem;
+ cursor: pointer;
+ font-size: 0.875rem;
+ font-weight: 500;
+ transition: background-color 0.2s;
+
+ &:hover {
+ background-color: var(--purple-contrast);
+ }
+ }
+}
+
+/* Info panel styles */
+.info-panel {
+ margin-top: 1.5rem;
+ padding: 1rem;
+ background-color: var(--gray-1);
+ border-radius: 0.5rem;
+ border-left: 3px solid var(--purple);
+
+ h3 {
+ margin-top: 0;
+ margin-bottom: 0.5rem;
+ font-size: 1rem;
+ color: var(--purple);
+ }
+}
+
+.log-entries {
+ max-height: 200px;
+ overflow-y: auto;
+}
+
+.log-entry {
+ padding: 0.25rem 0;
+ font-size: 0.875rem;
+ font-family: 'JetBrainsMono', monospace;
+ color: var(--gray-5);
+
+ strong {
+ color: var(--purple);
+ }
+}
+
+/* Basic editor styles */
+.tiptap {
+ padding: 1rem;
+ border: 2px solid var(--gray-3);
+ border-radius: 0.5rem;
+ min-height: 300px;
+
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* Code styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Link styles */
+ a {
+ color: var(--purple);
+ cursor: pointer;
+
+ &:hover {
+ color: var(--purple-contrast);
+ }
+ }
+
+ /* Strong styles */
+ strong {
+ font-weight: 600;
+ }
+}
diff --git a/demos/src/Examples/TransformPastedHTML/Vue/index.html b/demos/src/Examples/TransformPastedHTML/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/TransformPastedHTML/Vue/index.vue b/demos/src/Examples/TransformPastedHTML/Vue/index.vue
new file mode 100644
index 0000000..3a5362e
--- /dev/null
+++ b/demos/src/Examples/TransformPastedHTML/Vue/index.vue
@@ -0,0 +1,252 @@
+
+
+
+
+ Paste with Tabs
+ Paste with "foo"
+ Paste with Styles
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/Transition/Vue/Extension.js b/demos/src/Examples/Transition/Vue/Extension.js
new file mode 100644
index 0000000..9051c29
--- /dev/null
+++ b/demos/src/Examples/Transition/Vue/Extension.js
@@ -0,0 +1,36 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+import { VueNodeViewRenderer } from '@tiptap/vue'
+
+import Component from './VueComponent.vue'
+
+export default Node.create({
+ name: 'vueComponent',
+
+ group: 'block',
+
+ atom: true,
+
+ addAttributes() {
+ return {
+ count: {
+ default: 0,
+ },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'vue-component',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['vue-component', mergeAttributes(HTMLAttributes)]
+ },
+
+ addNodeView() {
+ return VueNodeViewRenderer(Component)
+ },
+})
diff --git a/demos/src/Examples/Transition/Vue/ParentComponent.vue b/demos/src/Examples/Transition/Vue/ParentComponent.vue
new file mode 100644
index 0000000..f1190de
--- /dev/null
+++ b/demos/src/Examples/Transition/Vue/ParentComponent.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/Transition/Vue/VueComponent.vue b/demos/src/Examples/Transition/Vue/VueComponent.vue
new file mode 100644
index 0000000..91be0a6
--- /dev/null
+++ b/demos/src/Examples/Transition/Vue/VueComponent.vue
@@ -0,0 +1,21 @@
+
+
+ Vue Component
+
+
+ This button has been clicked {{ node.attrs.count }} times.
+
+
+
+
+
diff --git a/demos/src/Examples/Transition/Vue/index.html b/demos/src/Examples/Transition/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/Transition/Vue/index.vue b/demos/src/Examples/Transition/Vue/index.vue
new file mode 100644
index 0000000..b349eb4
--- /dev/null
+++ b/demos/src/Examples/Transition/Vue/index.vue
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+ {{ showDirectEditor ? 'Hide direct editor' : 'Show direct editor' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ showNestedEditor ? 'Hide nested editor' : 'Show nested editor' }}
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Examples/TypographyRTL/React/index.html b/demos/src/Examples/TypographyRTL/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Examples/TypographyRTL/React/index.jsx b/demos/src/Examples/TypographyRTL/React/index.jsx
new file mode 100644
index 0000000..072c731
--- /dev/null
+++ b/demos/src/Examples/TypographyRTL/React/index.jsx
@@ -0,0 +1,55 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import Typography from '@tiptap/extension-typography'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ // Demonstrating automatic RTL detection via textDirection option
+ const editorAuto = useEditor({
+ extensions: [Document, Paragraph, Text, Typography],
+ textDirection: 'rtl',
+ content: `
+ اقتباس "مرحبا بالعالم" باللغة العربية
+ Automatic RTL detection:
+ `,
+ })
+
+ // Demonstrating explicit RTL configuration
+ const editorExplicit = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Typography.configure({
+ doubleQuotes: {
+ rtl: { open: '\u201D', close: '\u201C' },
+ },
+ singleQuotes: {
+ rtl: { open: '\u2019', close: '\u2018' },
+ },
+ }),
+ ],
+ content: `
+ اقتباس "مرحبا بالعالم" باللغة العربية
+ Explicit RTL config:
+ `,
+ })
+
+ return (
+
+
Automatic RTL Detection (textDirection: 'rtl')
+
+
+
+
+
Explicit RTL Configuration
+
+
+
+
+ )
+}
diff --git a/demos/src/Examples/TypographyRTL/React/styles.scss b/demos/src/Examples/TypographyRTL/React/styles.scss
new file mode 100644
index 0000000..b1c9d74
--- /dev/null
+++ b/demos/src/Examples/TypographyRTL/React/styles.scss
@@ -0,0 +1,12 @@
+.editor-auto .tiptap,
+.editor-explicit .tiptap {
+ direction: rtl;
+ text-align: right;
+}
+
+h3 {
+ margin-top: 20px;
+ margin-bottom: 10px;
+ font-size: 14px;
+ font-weight: 600;
+}
diff --git a/demos/src/Examples/TypographyRTL/index.spec.ts b/demos/src/Examples/TypographyRTL/index.spec.ts
new file mode 100644
index 0000000..7aa1159
--- /dev/null
+++ b/demos/src/Examples/TypographyRTL/index.spec.ts
@@ -0,0 +1,76 @@
+import { expect, test } from '@playwright/test'
+
+const demoName = 'TypographyRTL'
+const frameworkPaths = ['React']
+const demoPath = '/src/Examples'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await page.locator('.editor-auto .tiptap').first().waitFor()
+ })
+
+ test.describe('Automatic RTL detection', () => {
+ test.beforeEach(async ({ page }) => {
+ await page
+ .locator('.editor-auto .tiptap')
+ .first()
+ .evaluate((el: any) => {
+ el.editor.commands.clearContent()
+ })
+ })
+
+ test('should use RTL double quotes when textDirection is rtl', async ({ page }) => {
+ const editor = page.locator('.editor-auto .tiptap').first()
+
+ await editor.click()
+ await editor.type('"hello"')
+
+ await expect(editor).toContainText('”hello“')
+ })
+
+ test('should use RTL single quotes when textDirection is rtl', async ({ page }) => {
+ const editor = page.locator('.editor-auto .tiptap').first()
+
+ await editor.click()
+ await editor.type("'world'")
+
+ await expect(editor).toContainText('’world‘')
+ })
+ })
+
+ test.describe('Explicit RTL configuration', () => {
+ test.beforeEach(async ({ page }) => {
+ await page
+ .locator('.editor-explicit .tiptap')
+ .first()
+ .evaluate((el: any) => {
+ el.editor.commands.clearContent()
+ })
+ })
+
+ test('should use RTL double quotes when configured', async ({ page }) => {
+ const editor = page.locator('.editor-explicit .tiptap').first()
+
+ await editor.click()
+ await editor.type('"hello"')
+
+ await expect(editor).toContainText('”hello“')
+ })
+
+ test('should use RTL single quotes when configured', async ({ page }) => {
+ const editor = page.locator('.editor-explicit .tiptap').first()
+
+ await editor.click()
+ await editor.type("'world'")
+
+ await expect(editor).toContainText('’world‘')
+ })
+ })
+ })
+ })
+})
diff --git a/demos/src/Experiments/All/Vue/index.html b/demos/src/Experiments/All/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Experiments/All/Vue/index.vue b/demos/src/Experiments/All/Vue/index.vue
new file mode 100644
index 0000000..08ba4fd
--- /dev/null
+++ b/demos/src/Experiments/All/Vue/index.vue
@@ -0,0 +1,656 @@
+
+
+
+
+
+ Bold
+
+
+ Italic
+
+
+ Strike
+
+
+ Code
+
+ Clear marks
+ Clear nodes
+
+ Paragraph
+
+
+ H1
+
+
+ H2
+
+
+ H3
+
+
+ H4
+
+
+ H5
+
+
+ h6
+
+
+ Bullet list
+
+
+ Ordered list
+
+
+ Code block
+
+
+ Blockquote
+
+ Horizontal rule
+ Hard break
+ Undo
+ Redo
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Experiments/CollaborationAnnotation/Vue/extension/AnnotationItem.ts b/demos/src/Experiments/CollaborationAnnotation/Vue/extension/AnnotationItem.ts
new file mode 100644
index 0000000..dfcc92c
--- /dev/null
+++ b/demos/src/Experiments/CollaborationAnnotation/Vue/extension/AnnotationItem.ts
@@ -0,0 +1,37 @@
+export class AnnotationItem {
+ private decoration!: any
+
+ constructor(decoration: any) {
+ this.decoration = decoration
+ }
+
+ get id() {
+ return this.decoration.type.spec.id
+ }
+
+ get from() {
+ return this.decoration.from
+ }
+
+ get to() {
+ return this.decoration.to
+ }
+
+ get data() {
+ return this.decoration.type.spec.data
+ }
+
+ get HTMLAttributes() {
+ return this.decoration.type.attrs
+ }
+
+ toString() {
+ return JSON.stringify({
+ id: this.id,
+ data: this.data,
+ from: this.from,
+ to: this.to,
+ HTMLAttributes: this.HTMLAttributes,
+ })
+ }
+}
diff --git a/demos/src/Experiments/CollaborationAnnotation/Vue/extension/AnnotationPlugin.ts b/demos/src/Experiments/CollaborationAnnotation/Vue/extension/AnnotationPlugin.ts
new file mode 100644
index 0000000..0d82d16
--- /dev/null
+++ b/demos/src/Experiments/CollaborationAnnotation/Vue/extension/AnnotationPlugin.ts
@@ -0,0 +1,54 @@
+import { Plugin, PluginKey } from '@tiptap/pm/state'
+import { DecorationSet } from '@tiptap/pm/view'
+import type * as Y from 'yjs'
+
+import type { AnnotationItem } from './AnnotationItem.js'
+import { AnnotationState } from './AnnotationState.js'
+
+export const AnnotationPluginKey = new PluginKey('annotation')
+
+export interface AnnotationPluginOptions {
+ HTMLAttributes: {
+ [key: string]: any
+ }
+ onUpdate: (items: AnnotationItem[]) => object
+ map: Y.Map
+ instance: string
+}
+
+export const AnnotationPlugin = (options: AnnotationPluginOptions) =>
+ new Plugin({
+ key: AnnotationPluginKey,
+
+ state: {
+ init() {
+ return new AnnotationState({
+ HTMLAttributes: options.HTMLAttributes,
+ map: options.map,
+ instance: options.instance,
+ })
+ },
+ apply(transaction, pluginState, oldState, newState) {
+ return pluginState.apply(transaction, newState)
+ },
+ },
+
+ props: {
+ decorations(state) {
+ const decorations = this.getState(state)?.decorations || new DecorationSet()
+ const { selection } = state
+
+ if (!selection.empty) {
+ return decorations
+ }
+
+ const annotations = this.getState(state)?.annotationsAt(selection.from)
+
+ if (annotations) {
+ options.onUpdate(annotations)
+ }
+
+ return decorations
+ },
+ },
+ })
diff --git a/demos/src/Experiments/CollaborationAnnotation/Vue/extension/AnnotationState.ts b/demos/src/Experiments/CollaborationAnnotation/Vue/extension/AnnotationState.ts
new file mode 100644
index 0000000..e91bb45
--- /dev/null
+++ b/demos/src/Experiments/CollaborationAnnotation/Vue/extension/AnnotationState.ts
@@ -0,0 +1,180 @@
+import type { EditorState, Transaction } from '@tiptap/pm/state'
+import { Decoration, DecorationSet } from '@tiptap/pm/view'
+import {
+ absolutePositionToRelativePosition,
+ relativePositionToAbsolutePosition,
+ ySyncPluginKey,
+} from '@tiptap/y-tiptap'
+import type * as Y from 'yjs'
+
+import { AnnotationItem } from './AnnotationItem.js'
+import { AnnotationPluginKey } from './AnnotationPlugin.js'
+import type {
+ AddAnnotationAction,
+ DeleteAnnotationAction,
+ UpdateAnnotationAction,
+} from './collaboration-annotation.js'
+
+export interface AnnotationStateOptions {
+ HTMLAttributes: {
+ [key: string]: any
+ }
+ map: Y.Map
+ instance: string
+}
+
+export class AnnotationState {
+ options: AnnotationStateOptions
+
+ decorations = DecorationSet.empty
+
+ constructor(options: AnnotationStateOptions) {
+ this.options = options
+ }
+
+ randomId() {
+ // TODO: That seems … to simple.
+ return Math.floor(Math.random() * 0xffffffff).toString()
+ }
+
+ findAnnotation(id: string) {
+ const current = this.decorations.find()
+
+ for (let i = 0; i < current.length; i += 1) {
+ if (current[i].spec.id === id) {
+ return current[i]
+ }
+ }
+ }
+
+ addAnnotation(action: AddAnnotationAction, state: EditorState) {
+ const ystate = ySyncPluginKey.getState(state)
+ const { type, binding } = ystate
+ const { map } = this.options
+ const { from, to, data } = action
+ const absoluteFrom = absolutePositionToRelativePosition(from, type, binding.mapping)
+ const absoluteTo = absolutePositionToRelativePosition(to, type, binding.mapping)
+
+ map.set(this.randomId(), {
+ from: absoluteFrom,
+ to: absoluteTo,
+ data,
+ })
+ }
+
+ updateAnnotation(action: UpdateAnnotationAction) {
+ const { map } = this.options
+
+ const annotation = map.get(action.id)
+
+ map.set(action.id, {
+ from: annotation.from,
+ to: annotation.to,
+ data: action.data,
+ })
+ }
+
+ deleteAnnotation(id: string) {
+ const { map } = this.options
+
+ map.delete(id)
+ }
+
+ annotationsAt(position: number) {
+ return this.decorations.find(position, position).map(decoration => {
+ return new AnnotationItem(decoration)
+ })
+ }
+
+ createDecorations(state: EditorState) {
+ const { map, HTMLAttributes } = this.options
+ const ystate = ySyncPluginKey.getState(state)
+ const { doc, type, binding } = ystate
+ const decorations: Decoration[] = []
+
+ map.forEach((annotation, id) => {
+ const from = relativePositionToAbsolutePosition(doc, type, annotation.from, binding.mapping)
+ const to = relativePositionToAbsolutePosition(doc, type, annotation.to, binding.mapping)
+
+ if (!from || !to) {
+ return
+ }
+
+ // oxlint-disable-next-line
+ console.log(`[${this.options.instance}] Decoration.inline()`, from, to, HTMLAttributes, {
+ id,
+ data: annotation.data,
+ })
+
+ if (from === to) {
+ console.warn(
+ `[${this.options.instance}] corrupt decoration `,
+ annotation.from,
+ from,
+ annotation.to,
+ to,
+ )
+ }
+
+ decorations.push(
+ Decoration.inline(from, to, HTMLAttributes, {
+ id,
+ data: annotation.data,
+ inclusiveEnd: true,
+ }),
+ )
+ })
+
+ this.decorations = DecorationSet.create(state.doc, decorations)
+ }
+
+ apply(transaction: Transaction, state: EditorState) {
+ // Add/Remove annotations
+ const action = transaction.getMeta(AnnotationPluginKey) as
+ | AddAnnotationAction
+ | UpdateAnnotationAction
+ | DeleteAnnotationAction
+
+ if (action && action.type) {
+ // oxlint-disable-next-line
+ console.log(`[${this.options.instance}] action: ${action.type}`)
+
+ if (action.type === 'addAnnotation') {
+ this.addAnnotation(action, state)
+ }
+
+ if (action.type === 'updateAnnotation') {
+ this.updateAnnotation(action)
+ }
+
+ if (action.type === 'deleteAnnotation') {
+ this.deleteAnnotation(action.id)
+ }
+
+ // @ts-ignore
+ if (action.type === 'createDecorations') {
+ this.createDecorations(state)
+ }
+
+ return this
+ }
+
+ // Use Y.js to update positions
+ const ystate = ySyncPluginKey.getState(state)
+
+ if (ystate.isChangeOrigin) {
+ // oxlint-disable-next-line
+ console.log(`[${this.options.instance}] isChangeOrigin: true → createDecorations`)
+ this.createDecorations(state)
+
+ return this
+ }
+
+ // Use ProseMirror to update positions
+ // oxlint-disable-next-line
+ console.log(`[${this.options.instance}] isChangeOrigin: false → ProseMirror mapping`)
+ this.decorations = this.decorations.map(transaction.mapping, transaction.doc)
+
+ return this
+ }
+}
diff --git a/demos/src/Experiments/CollaborationAnnotation/Vue/extension/collaboration-annotation.ts b/demos/src/Experiments/CollaborationAnnotation/Vue/extension/collaboration-annotation.ts
new file mode 100644
index 0000000..fcf2462
--- /dev/null
+++ b/demos/src/Experiments/CollaborationAnnotation/Vue/extension/collaboration-annotation.ts
@@ -0,0 +1,154 @@
+import { Extension } from '@tiptap/core'
+import type * as Y from 'yjs'
+
+import { AnnotationPlugin, AnnotationPluginKey } from './AnnotationPlugin.js'
+
+export interface AddAnnotationAction {
+ type: 'addAnnotation'
+ data: any
+ from: number
+ to: number
+}
+
+export interface UpdateAnnotationAction {
+ type: 'updateAnnotation'
+ id: string
+ data: any
+}
+
+export interface DeleteAnnotationAction {
+ type: 'deleteAnnotation'
+ id: string
+}
+
+export interface AnnotationOptions {
+ HTMLAttributes: {
+ [key: string]: any
+ }
+ /**
+ * An event listener which receives annotations for the current selection.
+ */
+ onUpdate: (items: any[]) => object
+ /**
+ * An initialized Y.js document.
+ */
+ document: Y.Doc | null
+ /**
+ * Name of a Y.js map, can be changed to sync multiple fields with one Y.js document.
+ */
+ field: string
+ /**
+ * A raw Y.js map, can be used instead of `document` and `field`.
+ */
+ map: Y.Map | null
+ instance: string
+}
+
+function getMapFromOptions(options: AnnotationOptions): Y.Map {
+ return options.map ? options.map : (options.document?.getMap(options.field) as Y.Map)
+}
+
+declare module '@tiptap/core' {
+ interface Commands {
+ annotation: {
+ addAnnotation: (data: any) => ReturnType
+ updateAnnotation: (id: string, data: any) => ReturnType
+ deleteAnnotation: (id: string) => ReturnType
+ }
+ }
+}
+
+export const CollaborationAnnotation = Extension.create({
+ name: 'annotation',
+
+ priority: 1000,
+
+ addOptions() {
+ return {
+ HTMLAttributes: {
+ class: 'annotation',
+ },
+ onUpdate: decorations => decorations,
+ document: null,
+ field: 'annotations',
+ map: null,
+ instance: '',
+ }
+ },
+
+ onCreate() {
+ const map = getMapFromOptions(this.options)
+
+ map.observe(() => {
+ // oxlint-disable-next-line
+ console.log(`[${this.options.instance}] map updated → createDecorations`)
+
+ const transaction = this.editor.state.tr.setMeta(AnnotationPluginKey, {
+ type: 'createDecorations',
+ })
+
+ this.editor.view.dispatch(transaction)
+ })
+ },
+
+ addCommands() {
+ return {
+ addAnnotation:
+ (data: any) =>
+ ({ dispatch, state }) => {
+ const { selection } = state
+
+ if (selection.empty) {
+ return false
+ }
+
+ if (dispatch && data) {
+ state.tr.setMeta(AnnotationPluginKey, {
+ type: 'addAnnotation',
+ from: selection.from,
+ to: selection.to,
+ data,
+ })
+ }
+
+ return true
+ },
+ updateAnnotation:
+ (id: string, data: any) =>
+ ({ dispatch, state }) => {
+ if (dispatch) {
+ state.tr.setMeta(AnnotationPluginKey, {
+ type: 'updateAnnotation',
+ id,
+ data,
+ })
+ }
+
+ return true
+ },
+ deleteAnnotation:
+ id =>
+ ({ dispatch, state }) => {
+ if (dispatch) {
+ state.tr.setMeta(AnnotationPluginKey, {
+ type: 'deleteAnnotation',
+ id,
+ })
+ }
+
+ return true
+ },
+ }
+ },
+
+ addProseMirrorPlugins() {
+ return [
+ AnnotationPlugin({
+ HTMLAttributes: this.options.HTMLAttributes,
+ onUpdate: this.options.onUpdate,
+ map: getMapFromOptions(this.options),
+ instance: this.options.instance,
+ }),
+ ]
+ },
+})
diff --git a/demos/src/Experiments/CollaborationAnnotation/Vue/extension/index.ts b/demos/src/Experiments/CollaborationAnnotation/Vue/extension/index.ts
new file mode 100644
index 0000000..9ede9db
--- /dev/null
+++ b/demos/src/Experiments/CollaborationAnnotation/Vue/extension/index.ts
@@ -0,0 +1,5 @@
+import { CollaborationAnnotation } from './collaboration-annotation.js'
+
+export * from './collaboration-annotation.js'
+
+export default CollaborationAnnotation
diff --git a/demos/src/Experiments/CollaborationAnnotation/Vue/index.html b/demos/src/Experiments/CollaborationAnnotation/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Experiments/CollaborationAnnotation/Vue/index.vue b/demos/src/Experiments/CollaborationAnnotation/Vue/index.vue
new file mode 100644
index 0000000..03abb31
--- /dev/null
+++ b/demos/src/Experiments/CollaborationAnnotation/Vue/index.vue
@@ -0,0 +1,188 @@
+
+
+
+
+
Original Editor
+
+ Comment
+
+
+
+
+
+ {{ comment }}
+
+
+ Update
+
+ Remove
+
+
+
+
+
Another Editor
+
+
+ Comment
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Experiments/CollaborationAnnotation/index.spec.ts b/demos/src/Experiments/CollaborationAnnotation/index.spec.ts
new file mode 100644
index 0000000..515110c
--- /dev/null
+++ b/demos/src/Experiments/CollaborationAnnotation/index.spec.ts
@@ -0,0 +1,21 @@
+import { expect, test } from '@playwright/test'
+
+const demoName = 'CollaborationAnnotation'
+const frameworkPaths = ['Vue']
+const demoPath = '/src/Experiments'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test('renders two editors', async ({ page }) => {
+ await expect(page.locator('.tiptap')).toHaveCount(2)
+ })
+ })
+ })
+})
diff --git a/demos/src/Experiments/CollaborationMappingPerformance/React/index.html b/demos/src/Experiments/CollaborationMappingPerformance/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Experiments/CollaborationMappingPerformance/React/index.tsx b/demos/src/Experiments/CollaborationMappingPerformance/React/index.tsx
new file mode 100644
index 0000000..45e797f
--- /dev/null
+++ b/demos/src/Experiments/CollaborationMappingPerformance/React/index.tsx
@@ -0,0 +1,460 @@
+import './styles.scss'
+
+import Collaboration from '@tiptap/extension-collaboration'
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { Placeholder } from '@tiptap/extensions'
+import type { Node as PMNode } from '@tiptap/pm/model'
+import { Plugin, PluginKey } from '@tiptap/pm/state'
+import { Decoration, DecorationSet } from '@tiptap/pm/view'
+import type { MappablePosition } from '@tiptap/react'
+import { EditorContent, Extension, useEditor } from '@tiptap/react'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { WebrtcProvider } from 'y-webrtc'
+import * as Y from 'yjs'
+
+/**
+ * Generates HTML content with the specified number of paragraphs.
+ * @param paragraphCount - The number of paragraphs to generate.
+ * @returns HTML string with the generated content.
+ */
+function generateContent(paragraphCount: number): string {
+ const loremPhrases = [
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
+ 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
+ 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.',
+ 'Duis aute irure dolor in reprehenderit in voluptate velit esse cillum.',
+ 'Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia.',
+ 'Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit.',
+ 'Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet.',
+ 'Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse.',
+ 'At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis.',
+ 'Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit.',
+ ]
+
+ const paragraphs: string[] = []
+
+ for (let i = 0; i < paragraphCount; i += 1) {
+ const sentenceCount = 3 + Math.floor(Math.random() * 5)
+ const sentences: string[] = []
+
+ for (let j = 0; j < sentenceCount; j += 1) {
+ sentences.push(loremPhrases[Math.floor(Math.random() * loremPhrases.length)])
+ }
+
+ paragraphs.push(`${sentences.join(' ')}
`)
+ }
+
+ return paragraphs.join('\n')
+}
+
+/**
+ * Content size presets with different amounts of text.
+ */
+const CONTENT_SIZES = {
+ small: generateContent(5),
+ medium: generateContent(25),
+ large: generateContent(100),
+ veryLarge: generateContent(500),
+} as const
+
+type ContentSize = keyof typeof CONTENT_SIZES
+
+/**
+ * Decoration count options.
+ */
+const DECORATION_COUNTS = [5, 50, 500, 1000] as const
+
+type DecorationCount = (typeof DECORATION_COUNTS)[number]
+
+/**
+ * Creates a ProseMirror DecorationSet from a list of positions.
+ * @param positions - The positions where the decorations should be placed.
+ * @param doc - The ProseMirror document node.
+ * @returns A ProseMirror DecorationSet.
+ */
+function createDecorations(positions: number[], doc: PMNode): DecorationSet {
+ const validPositions = positions.filter(pos => pos >= 0 && pos <= doc.content.size)
+
+ return DecorationSet.create(
+ doc,
+ validPositions.map(position =>
+ Decoration.widget(position, () => {
+ const element = document.createElement('span')
+
+ element.classList.add('decoration')
+
+ return element
+ }),
+ ),
+ )
+}
+
+/**
+ * The state of the DecorationsExtension ProseMirror plugin.
+ */
+interface PluginState {
+ positions: MappablePosition[]
+ decorations: DecorationSet
+}
+
+const DecorationsPluginKey = new PluginKey('decorations')
+
+/**
+ * Metadata for the decorations plugin transactions.
+ */
+interface DecorationsMeta {
+ type: 'add' | 'set' | 'clear'
+ positions?: MappablePosition[]
+}
+
+/**
+ * Callback type for performance logging from the extension.
+ */
+type PerformanceCallback = (action: string, duration: number) => void
+
+/**
+ * Global ref for the performance callback, set by the component.
+ */
+let performanceCallback: PerformanceCallback | null = null
+
+/**
+ * Sets the performance callback for the decorations extension.
+ * @param callback - The callback to invoke with performance data.
+ */
+function setPerformanceCallback(callback: PerformanceCallback | null): void {
+ performanceCallback = callback
+}
+
+/**
+ * An extension that allows you to insert decorations into the editor.
+ * Tracks decoration positions and remaps them on document changes.
+ * Also measures the time taken for position mapping.
+ */
+const DecorationsExtension = Extension.create({
+ name: 'decorations',
+ addProseMirrorPlugins() {
+ const editor = this.editor
+
+ return [
+ new Plugin({
+ key: DecorationsPluginKey,
+ state: {
+ init: () => ({
+ positions: [],
+ decorations: DecorationSet.empty,
+ }),
+ apply(transaction, pluginState, _oldState, newState) {
+ let positions = pluginState.positions
+ let mappingDuration = 0
+
+ if (transaction.docChanged && positions.length > 0) {
+ const startTime = performance.now()
+
+ positions = positions.map(
+ position => editor.utils.getUpdatedPosition(position, transaction).position,
+ )
+ mappingDuration = performance.now() - startTime
+
+ if (performanceCallback) {
+ performanceCallback(
+ `Position mapping (${positions.length} positions)`,
+ mappingDuration,
+ )
+ }
+ }
+
+ const metadata = transaction.getMeta(DecorationsPluginKey) as
+ | DecorationsMeta
+ | undefined
+
+ if (metadata) {
+ if (metadata.type === 'clear') {
+ positions = []
+ } else if (metadata.type === 'set' && metadata.positions) {
+ positions = metadata.positions
+ } else if (metadata.type === 'add' && metadata.positions) {
+ positions = [...positions, ...metadata.positions]
+ }
+ }
+
+ return {
+ positions,
+ decorations: createDecorations(
+ positions.map(position => position.position),
+ newState.doc,
+ ),
+ }
+ },
+ },
+ props: {
+ decorations: state => DecorationsPluginKey.getState(state)?.decorations,
+ },
+ }),
+ ]
+ },
+})
+
+const ydoc = new Y.Doc()
+
+// oxlint-disable-next-line no-unused-vars
+const provider = new WebrtcProvider('tiptap-collab-mapping-perf-experiment', ydoc)
+
+/**
+ * Performance logging entry.
+ */
+interface PerformanceEntry {
+ timestamp: number
+ action: string
+ duration: number
+}
+
+export default () => {
+ const [contentSize, setContentSize] = useState('small')
+ const [decorationCount, setDecorationCount] = useState(5)
+ const [performanceLog, setPerformanceLog] = useState([])
+ const [currentDecorationCount, setCurrentDecorationCount] = useState(0)
+ const [docSize, setDocSize] = useState(0)
+
+ const transactionStartTime = useRef(null)
+ const pendingAction = useRef(null)
+
+ /**
+ * Logs a performance measurement.
+ */
+ const logPerformance = useCallback((action: string, duration: number) => {
+ setPerformanceLog(prev => [{ timestamp: Date.now(), action, duration }, ...prev.slice(0, 99)])
+ }, [])
+
+ useEffect(() => {
+ setPerformanceCallback(logPerformance)
+
+ return () => {
+ setPerformanceCallback(null)
+ }
+ }, [logPerformance])
+
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Collaboration.configure({
+ document: ydoc,
+ }),
+ Placeholder.configure({
+ placeholder: 'Content will be loaded here...',
+ }),
+ DecorationsExtension,
+ ],
+ onTransaction: () => {
+ if (transactionStartTime.current !== null && pendingAction.current !== null) {
+ const duration = performance.now() - transactionStartTime.current
+
+ logPerformance(pendingAction.current, duration)
+ transactionStartTime.current = null
+ pendingAction.current = null
+ }
+ },
+ onUpdate: ({ editor: updatedEditor }) => {
+ setDocSize(updatedEditor.state.doc.content.size)
+ },
+ })
+
+ /**
+ * Starts timing a measured action.
+ */
+ const startTiming = useCallback((action: string) => {
+ transactionStartTime.current = performance.now()
+ pendingAction.current = action
+ }, [])
+
+ /**
+ * Loads content of the selected size into the editor.
+ */
+ const loadContent = useCallback(() => {
+ if (!editor) {
+ return
+ }
+
+ startTiming(`Load ${contentSize} content`)
+
+ editor.commands.command(({ tr }) => {
+ tr.setMeta(DecorationsPluginKey, { type: 'clear' })
+
+ return true
+ })
+
+ editor.commands.setContent(CONTENT_SIZES[contentSize])
+ setCurrentDecorationCount(0)
+ }, [editor, contentSize, startTiming])
+
+ /**
+ * Generates random positions within the document.
+ */
+ const generateRandomPositions = useCallback(
+ (count: number): number[] => {
+ if (!editor) {
+ return []
+ }
+
+ const size = editor.state.doc.content.size
+ const positions: number[] = []
+
+ for (let i = 0; i < count; i += 1) {
+ const pos = Math.floor(Math.random() * (size - 1)) + 1
+
+ positions.push(pos)
+ }
+
+ return positions.sort((a, b) => a - b)
+ },
+ [editor],
+ )
+
+ /**
+ * Adds decorations at random positions in the document.
+ */
+ const addDecorations = useCallback(() => {
+ if (!editor) {
+ return
+ }
+
+ startTiming(`Add ${decorationCount} decorations`)
+ const positions = generateRandomPositions(decorationCount)
+
+ editor.commands.command(({ tr }) => {
+ const metadata: DecorationsMeta = {
+ type: 'set',
+ positions: positions.map(position => editor.utils.createMappablePosition(position)),
+ }
+ tr.setMeta(DecorationsPluginKey, metadata)
+
+ return true
+ })
+
+ setCurrentDecorationCount(decorationCount)
+ }, [editor, decorationCount, generateRandomPositions, startTiming])
+
+ /**
+ * Clears all decorations from the editor.
+ */
+ const clearDecorations = useCallback(() => {
+ if (!editor) {
+ return
+ }
+
+ startTiming('Clear decorations')
+
+ editor.commands.command(({ tr }) => {
+ tr.setMeta(DecorationsPluginKey, { type: 'clear' })
+
+ return true
+ })
+
+ setCurrentDecorationCount(0)
+ }, [editor, startTiming])
+
+ /**
+ * Runs a typing simulation to test position mapping performance.
+ */
+ const runTypingTest = useCallback(() => {
+ if (!editor) {
+ return
+ }
+
+ startTiming(`Insert text (${currentDecorationCount} decorations)`)
+ const testString = 'Testing position mapping performance. '
+ const insertPosition = Math.floor(editor.state.doc.content.size / 2)
+
+ editor.commands.insertContentAt(insertPosition, testString)
+ }, [editor, currentDecorationCount, startTiming])
+
+ /**
+ * Clears the performance log.
+ */
+ const clearLog = useCallback(() => {
+ setPerformanceLog([])
+ }, [])
+
+ useEffect(() => {
+ if (editor && editor.isEmpty) {
+ loadContent()
+ }
+ }, [editor, loadContent])
+
+ useEffect(() => {
+ if (editor) {
+ setDocSize(editor.state.doc.content.size)
+ }
+ }, [editor])
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ Content:
+ setContentSize(e.target.value as ContentSize)}>
+ Small (5p)
+ Medium (25p)
+ Large (100p)
+ Very Large (500p)
+
+ Load
+
+
+
+ Decorations:
+ setDecorationCount(Number(e.target.value) as DecorationCount)}
+ >
+ {DECORATION_COUNTS.map(count => (
+
+ {count}
+
+ ))}
+
+ Add
+
+ Clear
+
+
+
+
+ Test:
+ Insert Text
+
+
+
+ Doc: {docSize.toLocaleString()} chars
+ Decorations: {currentDecorationCount.toLocaleString()}
+
+
+
+
+
+ {performanceLog.length > 0 && (
+
+
+ Performance Log:
+
+ Clear
+
+
+ {performanceLog.map((entry, index) => (
+
+ {new Date(entry.timestamp).toLocaleTimeString()}: {entry.action} -{' '}
+ {entry.duration.toFixed(2)}ms
+
+ ))}
+
+ )}
+ >
+ )
+}
diff --git a/demos/src/Experiments/CollaborationMappingPerformance/React/styles.scss b/demos/src/Experiments/CollaborationMappingPerformance/React/styles.scss
new file mode 100644
index 0000000..0c19ed7
--- /dev/null
+++ b/demos/src/Experiments/CollaborationMappingPerformance/React/styles.scss
@@ -0,0 +1,45 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* Placeholder (at the top) */
+ p.is-editor-empty:first-child::before {
+ color: var(--gray-4);
+ content: attr(data-placeholder);
+ float: left;
+ height: 0;
+ pointer-events: none;
+ }
+}
+
+.decoration {
+ background-color: red;
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ margin: 0 1px;
+ vertical-align: middle;
+}
+
+.performance-log {
+ margin-top: 16px;
+ padding: 12px;
+ background: var(--gray-1);
+ border-radius: 8px;
+ font-family: monospace;
+ font-size: 12px;
+ max-height: 200px;
+ overflow-y: auto;
+}
+
+.performance-log-entry {
+ padding: 4px 0;
+ border-bottom: 1px solid var(--gray-2);
+}
+
+.performance-log-entry:last-child {
+ border-bottom: none;
+}
diff --git a/demos/src/Experiments/Commands/Vue/CommandsList.vue b/demos/src/Experiments/Commands/Vue/CommandsList.vue
new file mode 100644
index 0000000..b38ee2f
--- /dev/null
+++ b/demos/src/Experiments/Commands/Vue/CommandsList.vue
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Experiments/Commands/Vue/commands.js b/demos/src/Experiments/Commands/Vue/commands.js
new file mode 100644
index 0000000..ea3f57f
--- /dev/null
+++ b/demos/src/Experiments/Commands/Vue/commands.js
@@ -0,0 +1,26 @@
+import { Extension } from '@tiptap/core'
+import Suggestion from '@tiptap/suggestion'
+
+export default Extension.create({
+ name: 'commands',
+
+ addOptions() {
+ return {
+ suggestion: {
+ char: '/',
+ command: ({ editor, range, props }) => {
+ props.command({ editor, range })
+ },
+ },
+ }
+ },
+
+ addProseMirrorPlugins() {
+ return [
+ Suggestion({
+ editor: this.editor,
+ ...this.options.suggestion,
+ }),
+ ]
+ },
+})
diff --git a/demos/src/Experiments/Commands/Vue/index.html b/demos/src/Experiments/Commands/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Experiments/Commands/Vue/index.vue b/demos/src/Experiments/Commands/Vue/index.vue
new file mode 100644
index 0000000..1a9f918
--- /dev/null
+++ b/demos/src/Experiments/Commands/Vue/index.vue
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Experiments/Commands/Vue/suggestion.js b/demos/src/Experiments/Commands/Vue/suggestion.js
new file mode 100644
index 0000000..c5b98b9
--- /dev/null
+++ b/demos/src/Experiments/Commands/Vue/suggestion.js
@@ -0,0 +1,104 @@
+import { computePosition, flip, shift } from '@floating-ui/dom'
+import { posToDOMRect, VueRenderer } from '@tiptap/vue'
+
+import CommandsList from './CommandsList.vue'
+
+const updatePosition = (editor, element) => {
+ const virtualElement = {
+ getBoundingClientRect: () =>
+ posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to),
+ }
+
+ computePosition(virtualElement, element, {
+ placement: 'bottom-start',
+ strategy: 'absolute',
+ middleware: [shift(), flip()],
+ }).then(({ x, y, strategy }) => {
+ element.style.width = 'max-content'
+ element.style.position = strategy
+ element.style.left = `${x}px`
+ element.style.top = `${y}px`
+ })
+}
+
+export default {
+ items: ({ query }) => {
+ return [
+ {
+ title: 'Heading 1',
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run()
+ },
+ },
+ {
+ title: 'Heading 2',
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).setNode('heading', { level: 2 }).run()
+ },
+ },
+ {
+ title: 'Bold',
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).setMark('bold').run()
+ },
+ },
+ {
+ title: 'Italic',
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).setMark('italic').run()
+ },
+ },
+ ]
+ .filter(item => item.title.toLowerCase().startsWith(query.toLowerCase()))
+ .slice(0, 10)
+ },
+
+ render: () => {
+ let component
+
+ return {
+ onStart: props => {
+ component = new VueRenderer(CommandsList, {
+ props,
+ editor: props.editor,
+ })
+
+ if (!props.clientRect) {
+ return
+ }
+
+ component.element.style.position = 'absolute'
+
+ document.body.appendChild(component.element)
+
+ updatePosition(props.editor, component.element)
+ },
+
+ onUpdate(props) {
+ component.updateProps(props)
+
+ if (!props.clientRect) {
+ return
+ }
+
+ updatePosition(props.editor, component.element)
+ },
+
+ onKeyDown(props) {
+ if (props.event.key === 'Escape') {
+ component.destroy()
+ component.element.remove()
+
+ return true
+ }
+
+ return component.ref?.onKeyDown(props)
+ },
+
+ onExit() {
+ component.destroy()
+ component.element.remove()
+ },
+ }
+ },
+}
diff --git a/demos/src/Experiments/Commands/index.spec.ts b/demos/src/Experiments/Commands/index.spec.ts
new file mode 100644
index 0000000..ab052c1
--- /dev/null
+++ b/demos/src/Experiments/Commands/index.spec.ts
@@ -0,0 +1,61 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'Commands'
+const frameworkPaths = ['Vue']
+const demoPath = '/src/Experiments'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ })
+
+ test('opens a popup after typing a slash and inserts the chosen item', async ({ page }) => {
+ const items = [{ tag: 'h1' }, { tag: 'h2' }, { tag: 'strong' }, { tag: 'em' }]
+ const editor = await getEditor(page)
+ const dropdown = page.locator('.dropdown-menu')
+
+ await items.reduce(async (prev, item, i) => {
+ await prev
+ await editor.click()
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.type('/')
+ await expect(dropdown).toBeVisible()
+ await dropdown.locator('button').nth(i).click()
+ await editor.evaluate(
+ (el: any, text: string) => el.editor.commands.insertContent(text),
+ `I am a ${item.tag}`,
+ )
+ await expect(page.locator(`.tiptap ${item.tag}`)).toHaveText(`I am a ${item.tag}`)
+ }, Promise.resolve())
+ })
+
+ test('closes the popup without any command via esc', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.click()
+ await editor.type('/')
+ await expect(page.locator('.dropdown-menu')).toBeVisible()
+ await page.keyboard.press('Escape')
+ await expect(page.locator('.dropdown-menu')).toHaveCount(0)
+ })
+
+ test('opens the popup when the cursor is after a slash', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.click()
+ await editor.type('/')
+ await expect(page.locator('.dropdown-menu')).toBeVisible()
+ await page.keyboard.press('ArrowLeft')
+ await expect(page.locator('.dropdown-menu')).toHaveCount(0)
+ await page.keyboard.press('ArrowRight')
+ await expect(page.locator('.dropdown-menu')).toBeVisible()
+ })
+ })
+ })
+})
diff --git a/demos/src/Experiments/DestroyingEditor/Vue/index.html b/demos/src/Experiments/DestroyingEditor/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Experiments/DestroyingEditor/Vue/index.vue b/demos/src/Experiments/DestroyingEditor/Vue/index.vue
new file mode 100644
index 0000000..a7707f8
--- /dev/null
+++ b/demos/src/Experiments/DestroyingEditor/Vue/index.vue
@@ -0,0 +1,135 @@
+
+
+
+
+ editor.chain().toggleBold().focus().run()">Make bold
+ editor.destroy()">Destroy editor
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Experiments/Embeds/Vue/iframe.ts b/demos/src/Experiments/Embeds/Vue/iframe.ts
new file mode 100644
index 0000000..3481f0e
--- /dev/null
+++ b/demos/src/Experiments/Embeds/Vue/iframe.ts
@@ -0,0 +1,80 @@
+import { Node } from '@tiptap/core'
+
+export interface IframeOptions {
+ allowFullscreen: boolean
+ HTMLAttributes: {
+ [key: string]: any
+ }
+}
+
+declare module '@tiptap/core' {
+ interface Commands {
+ iframe: {
+ /**
+ * Add an iframe
+ */
+ setIframe: (options: { src: string }) => ReturnType
+ }
+ }
+}
+
+export default Node.create({
+ name: 'iframe',
+
+ group: 'block',
+
+ atom: true,
+
+ addOptions() {
+ return {
+ allowFullscreen: true,
+ HTMLAttributes: {
+ class: 'iframe-wrapper',
+ },
+ }
+ },
+
+ addAttributes() {
+ return {
+ src: {
+ default: null,
+ },
+ frameborder: {
+ default: 0,
+ },
+ allowfullscreen: {
+ default: this.options.allowFullscreen,
+ parseHTML: () => this.options.allowFullscreen,
+ },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'iframe',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]]
+ },
+
+ addCommands() {
+ return {
+ setIframe:
+ (options: { src: string }) =>
+ ({ tr, dispatch }) => {
+ const { selection } = tr
+ const node = this.type.create(options)
+
+ if (dispatch) {
+ tr.replaceRangeWith(selection.from, selection.to, node)
+ }
+
+ return true
+ },
+ }
+ },
+})
diff --git a/demos/src/Experiments/Embeds/Vue/index.html b/demos/src/Experiments/Embeds/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Experiments/Embeds/Vue/index.vue b/demos/src/Experiments/Embeds/Vue/index.vue
new file mode 100644
index 0000000..6522127
--- /dev/null
+++ b/demos/src/Experiments/Embeds/Vue/index.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Experiments/ExtensionStorage/React/CustomExtension.ts b/demos/src/Experiments/ExtensionStorage/React/CustomExtension.ts
new file mode 100644
index 0000000..ee7f910
--- /dev/null
+++ b/demos/src/Experiments/ExtensionStorage/React/CustomExtension.ts
@@ -0,0 +1,19 @@
+import { Extension } from '@tiptap/core'
+
+type CustomStorage = {
+ foo: number
+}
+
+export const CustomExtension = Extension.create({
+ name: 'custom',
+
+ addStorage() {
+ return {
+ foo: 123,
+ }
+ },
+
+ onUpdate() {
+ this.storage.foo += 1
+ },
+})
diff --git a/demos/src/Experiments/ExtensionStorage/React/index.html b/demos/src/Experiments/ExtensionStorage/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Experiments/ExtensionStorage/React/index.jsx b/demos/src/Experiments/ExtensionStorage/React/index.jsx
new file mode 100644
index 0000000..dbb78d7
--- /dev/null
+++ b/demos/src/Experiments/ExtensionStorage/React/index.jsx
@@ -0,0 +1,30 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+import { CustomExtension } from './CustomExtension.ts'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, CustomExtension],
+ content: `
+
+ This is a radically reduced version of Tiptap. It has support for a document, with paragraphs and text. That’s it. It’s probably too much for real minimalists though.
+
+
+ The paragraph extension is not really required, but you need at least one node. Sure, that node can be something different.
+
+ `,
+ })
+
+ return (
+ <>
+
+ Reactive storage: {editor?.storage.custom.foo}
+ >
+ )
+}
diff --git a/demos/src/Experiments/ExtensionStorage/React/styles.scss b/demos/src/Experiments/ExtensionStorage/React/styles.scss
new file mode 100644
index 0000000..afd5ba2
--- /dev/null
+++ b/demos/src/Experiments/ExtensionStorage/React/styles.scss
@@ -0,0 +1,6 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
diff --git a/demos/src/Experiments/ExtensionStorage/Vue/CustomExtension.ts b/demos/src/Experiments/ExtensionStorage/Vue/CustomExtension.ts
new file mode 100644
index 0000000..ee7f910
--- /dev/null
+++ b/demos/src/Experiments/ExtensionStorage/Vue/CustomExtension.ts
@@ -0,0 +1,19 @@
+import { Extension } from '@tiptap/core'
+
+type CustomStorage = {
+ foo: number
+}
+
+export const CustomExtension = Extension.create({
+ name: 'custom',
+
+ addStorage() {
+ return {
+ foo: 123,
+ }
+ },
+
+ onUpdate() {
+ this.storage.foo += 1
+ },
+})
diff --git a/demos/src/Experiments/ExtensionStorage/Vue/index.html b/demos/src/Experiments/ExtensionStorage/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Experiments/ExtensionStorage/Vue/index.vue b/demos/src/Experiments/ExtensionStorage/Vue/index.vue
new file mode 100644
index 0000000..53a60ab
--- /dev/null
+++ b/demos/src/Experiments/ExtensionStorage/Vue/index.vue
@@ -0,0 +1,54 @@
+
+
+
+
Reactive storage: {{ editor?.storage.custom.foo }}
+
+
+
+
+
+
diff --git a/demos/src/Experiments/Figure/Vue/figure.ts b/demos/src/Experiments/Figure/Vue/figure.ts
new file mode 100644
index 0000000..f8c7a6a
--- /dev/null
+++ b/demos/src/Experiments/Figure/Vue/figure.ts
@@ -0,0 +1,199 @@
+import { findChildrenInRange, mergeAttributes, Node, nodeInputRule, Tracker } from '@tiptap/core'
+
+export interface FigureOptions {
+ HTMLAttributes: Record
+}
+
+declare module '@tiptap/core' {
+ interface Commands {
+ figure: {
+ /**
+ * Add a figure element
+ */
+ setFigure: (options: {
+ src: string
+ alt?: string
+ title?: string
+ caption?: string
+ }) => ReturnType
+
+ /**
+ * Converts an image to a figure
+ */
+ imageToFigure: () => ReturnType
+
+ /**
+ * Converts a figure to an image
+ */
+ figureToImage: () => ReturnType
+ }
+ }
+}
+
+export const inputRegex = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/
+
+export const Figure = Node.create({
+ name: 'figure',
+
+ addOptions() {
+ return {
+ HTMLAttributes: {},
+ }
+ },
+
+ group: 'block',
+
+ content: 'inline*',
+
+ draggable: true,
+
+ isolating: true,
+
+ addAttributes() {
+ return {
+ src: {
+ default: null,
+ parseHTML: element => element.querySelector('img')?.getAttribute('src'),
+ },
+
+ alt: {
+ default: null,
+ parseHTML: element => element.querySelector('img')?.getAttribute('alt'),
+ },
+
+ title: {
+ default: null,
+ parseHTML: element => element.querySelector('img')?.getAttribute('title'),
+ },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'figure',
+ contentElement: 'figcaption',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ 'figure',
+ this.options.HTMLAttributes,
+ ['img', mergeAttributes(HTMLAttributes, { draggable: false, contenteditable: false })],
+ ['figcaption', 0],
+ ]
+ },
+
+ addCommands() {
+ return {
+ setFigure:
+ ({ caption, ...attrs }) =>
+ ({ chain }) => {
+ return (
+ chain()
+ .insertContent({
+ type: this.name,
+ attrs,
+ content: caption ? [{ type: 'text', text: caption }] : [],
+ })
+ // set cursor at end of caption field
+ .command(({ tr, commands }) => {
+ const { doc, selection } = tr
+ const position = doc.resolve(selection.to - 2).end()
+
+ return commands.setTextSelection(position)
+ })
+ .run()
+ )
+ },
+
+ imageToFigure:
+ () =>
+ ({ tr, commands }) => {
+ const { doc, selection } = tr
+ const { from, to } = selection
+ const images = findChildrenInRange(doc, { from, to }, node => node.type.name === 'image')
+
+ if (!images.length) {
+ return false
+ }
+
+ const tracker = new Tracker(tr)
+
+ return commands.forEach(images, ({ node, pos }) => {
+ const mapResult = tracker.map(pos)
+
+ if (mapResult.deleted) {
+ return false
+ }
+
+ const range = {
+ from: mapResult.position,
+ to: mapResult.position + node.nodeSize,
+ }
+
+ return commands.insertContentAt(range, {
+ type: this.name,
+ attrs: {
+ src: node.attrs.src,
+ },
+ })
+ })
+ },
+
+ figureToImage:
+ () =>
+ ({ tr, commands }) => {
+ const { doc, selection } = tr
+ const { from, to } = selection
+ const figures = findChildrenInRange(
+ doc,
+ { from, to },
+ node => node.type.name === this.name,
+ )
+
+ if (!figures.length) {
+ return false
+ }
+
+ const tracker = new Tracker(tr)
+
+ return commands.forEach(figures, ({ node, pos }) => {
+ const mapResult = tracker.map(pos)
+
+ if (mapResult.deleted) {
+ return false
+ }
+
+ const range = {
+ from: mapResult.position,
+ to: mapResult.position + node.nodeSize,
+ }
+
+ return commands.insertContentAt(range, {
+ type: 'image',
+ attrs: {
+ src: node.attrs.src,
+ },
+ })
+ })
+ },
+ }
+ },
+
+ addInputRules() {
+ return [
+ nodeInputRule({
+ find: inputRegex,
+ type: this.type,
+ getAttributes: match => {
+ const [, src, alt, title] = match
+
+ return { src, alt, title }
+ },
+ }),
+ ]
+ },
+})
diff --git a/demos/src/Experiments/Figure/Vue/index.html b/demos/src/Experiments/Figure/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Experiments/Figure/Vue/index.vue b/demos/src/Experiments/Figure/Vue/index.vue
new file mode 100644
index 0000000..cfb11a9
--- /dev/null
+++ b/demos/src/Experiments/Figure/Vue/index.vue
@@ -0,0 +1,216 @@
+
+
+
+
+ Add image with caption
+
+ Add caption to image
+
+
+ Remove caption from image
+
+
+
+
+
+
+ HTML
+ {{ editor.getHTML() }}
+
+
+
+
+
+
+
diff --git a/demos/src/Experiments/GenericFigure/Vue/figcaption.ts b/demos/src/Experiments/GenericFigure/Vue/figcaption.ts
new file mode 100644
index 0000000..2638903
--- /dev/null
+++ b/demos/src/Experiments/GenericFigure/Vue/figcaption.ts
@@ -0,0 +1,29 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+
+export const Figcaption = Node.create({
+ name: 'figcaption',
+
+ addOptions() {
+ return {
+ HTMLAttributes: {},
+ }
+ },
+
+ content: 'inline*',
+
+ selectable: false,
+
+ draggable: false,
+
+ parseHTML() {
+ return [
+ {
+ tag: 'figcaption',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['figcaption', mergeAttributes(HTMLAttributes), 0]
+ },
+})
diff --git a/demos/src/Experiments/GenericFigure/Vue/figure.ts b/demos/src/Experiments/GenericFigure/Vue/figure.ts
new file mode 100644
index 0000000..5972b83
--- /dev/null
+++ b/demos/src/Experiments/GenericFigure/Vue/figure.ts
@@ -0,0 +1,58 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+import { Plugin } from '@tiptap/pm/state'
+
+export const Figure = Node.create({
+ name: 'figure',
+
+ addOptions() {
+ return {
+ HTMLAttributes: {},
+ }
+ },
+
+ group: 'block',
+
+ content: 'block figcaption',
+
+ draggable: true,
+
+ isolating: true,
+
+ parseHTML() {
+ return [
+ {
+ tag: `figure[data-type="${this.name}"]`,
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['figure', mergeAttributes(HTMLAttributes, { 'data-type': this.name }), 0]
+ },
+
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ props: {
+ handleDOMEvents: {
+ // prevent dragging nodes out of the figure
+ dragstart: (view, event) => {
+ if (!event.target) {
+ return false
+ }
+
+ const pos = view.posAtDOM(event.target as HTMLElement, 0)
+ const $pos = view.state.doc.resolve(pos)
+
+ if ($pos.parent.type === this.type) {
+ event.preventDefault()
+ }
+
+ return false
+ },
+ },
+ },
+ }),
+ ]
+ },
+})
diff --git a/demos/src/Experiments/GenericFigure/Vue/index.html b/demos/src/Experiments/GenericFigure/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Experiments/GenericFigure/Vue/index.vue b/demos/src/Experiments/GenericFigure/Vue/index.vue
new file mode 100644
index 0000000..a8ce87d
--- /dev/null
+++ b/demos/src/Experiments/GenericFigure/Vue/index.vue
@@ -0,0 +1,425 @@
+
+
+
+
+ Add table with caption
+ Add image with caption
+ Remove table with caption
+ Remove image with caption
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Experiments/GlobalDragHandle/Vue/DragHandle.js b/demos/src/Experiments/GlobalDragHandle/Vue/DragHandle.js
new file mode 100644
index 0000000..2c80f7a
--- /dev/null
+++ b/demos/src/Experiments/GlobalDragHandle/Vue/DragHandle.js
@@ -0,0 +1,172 @@
+import { Extension } from '@tiptap/core'
+import { NodeSelection, Plugin } from '@tiptap/pm/state'
+
+function removeNode(node) {
+ node.parentNode.removeChild(node)
+}
+
+function absoluteRect(node) {
+ const data = node.getBoundingClientRect()
+
+ return {
+ top: data.top,
+ left: data.left,
+ width: data.width,
+ }
+}
+
+export default Extension.create({
+ addProseMirrorPlugins() {
+ function blockPosAtCoords(coords, view) {
+ const pos = view.posAtCoords(coords)
+ let node = view.domAtPos(pos.pos)
+
+ node = node.node
+
+ while (node && node.parentNode) {
+ if (node.parentNode?.classList?.contains('ProseMirror')) {
+ // todo
+ break
+ }
+
+ node = node.parentNode
+ }
+
+ if (node && node.nodeType === 1) {
+ const desc = view.docView.nearestDesc(node, true)
+
+ if (!(!desc || desc === view.docView)) {
+ return desc.posBefore
+ }
+ }
+ return null
+ }
+
+ function dragStart(e, view) {
+ // Must delete this line, Otherwise: Uncaught TypeError: Cannot set property composing of # which has only a getter
+ // view.composing = true
+
+ if (!e.dataTransfer) {
+ return
+ }
+
+ const coords = { left: e.clientX + 50, top: e.clientY }
+ const pos = blockPosAtCoords(coords, view)
+
+ if (pos != null) {
+ view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)))
+
+ const slice = view.state.selection.content()
+
+ // console.log({
+ // from: view.nodeDOM(view.state.selection.from),
+ // to: view.nodeDOM(view.state.selection.to),
+ // })
+ const { dom, text } = view.serializeForClipboard(slice)
+
+ e.dataTransfer.clearData()
+ e.dataTransfer.setData('text/html', dom.innerHTML)
+ e.dataTransfer.setData('text/plain', text)
+
+ const el = document.querySelector('.ProseMirror-selectednode')
+
+ e.dataTransfer?.setDragImage(el, 0, 0)
+ view.dragging = { slice, move: true }
+ }
+ }
+
+ function dragEnd(e, view) {
+ // reset the dragging, otherwise wrong content after dragging across multi editors repeatedly
+ view.dragging = null
+ }
+
+ let dropElement
+ const WIDTH = 28
+
+ return [
+ new Plugin({
+ view(editorView) {
+ const element = document.createElement('div')
+
+ element.draggable = 'true'
+ element.classList.add('global-drag-handle')
+ element.addEventListener('dragstart', e => dragStart(e, editorView))
+ element.addEventListener('dragend', e => dragEnd(e, editorView))
+ dropElement = element
+ // append to editor's parentNode (not document.body), to match the logic of dragging across multi editors in pasteRule.ts
+ editorView.dom.parentNode.appendChild(dropElement)
+
+ return {
+ // update(view, prevState) {
+ // },
+ destroy() {
+ removeNode(dropElement)
+ dropElement = null
+ },
+ }
+ },
+ props: {
+ handleDrop(view, event, slice, moved) {
+ if (moved) {
+ // setTimeout(() => {
+ // console.log('remove selection')
+ // view.dispatch(view.state.tr.deleteSelection())
+ // }, 50)
+ }
+ },
+ // handlePaste() {
+ // alert(2)
+ // },
+ handleDOMEvents: {
+ // drop(view, event) {
+ // setTimeout(() => {
+ // const node = document.querySelector('.ProseMirror-hideselection')
+ // if (node) {
+ // node.classList.remove('ProseMirror-hideselection')
+ // }
+ // }, 50)
+ // },
+ mousemove(view, event) {
+ const coords = {
+ left: event.clientX + WIDTH + 50,
+ top: event.clientY,
+ }
+ const pos = view.posAtCoords(coords)
+
+ if (pos) {
+ let node = view.domAtPos(pos?.pos)
+
+ if (node) {
+ node = node.node
+ while (node && node.parentNode) {
+ if (node.parentNode?.classList?.contains('ProseMirror')) {
+ // todo
+ break
+ }
+ node = node.parentNode
+ }
+
+ if (node instanceof Element) {
+ const cstyle = window.getComputedStyle(node)
+ const lineHeight = parseInt(cstyle.lineHeight, 10)
+ // const top = parseInt(cstyle.marginTop, 10) + parseInt(cstyle.paddingTop, 10)
+ const top = 0
+ const rect = absoluteRect(node)
+ const win = node.ownerDocument.defaultView
+
+ rect.top += win.pageYOffset + (lineHeight - 24) / 2 + top
+ rect.left += win.pageXOffset
+ rect.width = `${WIDTH}px`
+
+ dropElement.style.left = `${-WIDTH + rect.left}px`
+ dropElement.style.top = `${rect.top}px`
+ }
+ }
+ }
+ },
+ },
+ },
+ }),
+ ]
+ },
+})
diff --git a/demos/src/Experiments/GlobalDragHandle/Vue/index.html b/demos/src/Experiments/GlobalDragHandle/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Experiments/GlobalDragHandle/Vue/index.vue b/demos/src/Experiments/GlobalDragHandle/Vue/index.vue
new file mode 100644
index 0000000..8a96a74
--- /dev/null
+++ b/demos/src/Experiments/GlobalDragHandle/Vue/index.vue
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Experiments/IsolatingClear/React/index.html b/demos/src/Experiments/IsolatingClear/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Experiments/IsolatingClear/React/index.jsx b/demos/src/Experiments/IsolatingClear/React/index.jsx
new file mode 100644
index 0000000..6b386a3
--- /dev/null
+++ b/demos/src/Experiments/IsolatingClear/React/index.jsx
@@ -0,0 +1,199 @@
+import './styles.scss'
+
+import { ListItem } from '@tiptap/extension-list'
+import { Color, TextStyle } from '@tiptap/extension-text-style'
+import { EditorContent, Node, useEditor, useEditorState } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+import { content } from '../content.ts'
+
+const WrapperBlock = Node.create({
+ name: 'wrapperBlock',
+
+ group: 'block',
+
+ isolating: true,
+
+ content: 'block*',
+
+ parseHTML() {
+ return [{ tag: 'div[data-wrapper-block]' }]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['div', { ...HTMLAttributes, 'data-wrapper-block': true }, 0]
+ },
+})
+
+const MenuBar = ({ editor }) => {
+ const editorState = useEditorState({
+ editor,
+ selector: ctx => ({
+ isBold: ctx.editor.isActive('bold') ?? false,
+ canBold: ctx.editor.can().chain().focus().toggleBold().run() ?? false,
+ isItalic: ctx.editor.isActive('italic') ?? false,
+ canItalic: ctx.editor.can().chain().focus().toggleItalic().run() ?? false,
+ isStrike: ctx.editor.isActive('strike') ?? false,
+ canStrike: ctx.editor.can().chain().focus().toggleStrike().run() ?? false,
+ isCode: ctx.editor.isActive('code') ?? false,
+ canCode: ctx.editor.can().chain().focus().toggleCode().run() ?? false,
+ isParagraph: ctx.editor.isActive('paragraph') ?? false,
+ isHeading1: ctx.editor.isActive('heading', { level: 1 }) ?? false,
+ isHeading2: ctx.editor.isActive('heading', { level: 2 }) ?? false,
+ isHeading3: ctx.editor.isActive('heading', { level: 3 }) ?? false,
+ isHeading4: ctx.editor.isActive('heading', { level: 4 }) ?? false,
+ isHeading5: ctx.editor.isActive('heading', { level: 5 }) ?? false,
+ isHeading6: ctx.editor.isActive('heading', { level: 6 }) ?? false,
+ isBulletList: ctx.editor.isActive('bulletList') ?? false,
+ isOrderedList: ctx.editor.isActive('orderedList') ?? false,
+ isCodeBlock: ctx.editor.isActive('codeBlock') ?? false,
+ isBlockquote: ctx.editor.isActive('blockquote') ?? false,
+ canUndo: ctx.editor.can().chain().focus().undo().run() ?? false,
+ canRedo: ctx.editor.can().chain().focus().redo().run() ?? false,
+ isPurple: ctx.editor.isActive('textStyle', { color: '#958DF1' }) ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+
+
+ editor.chain().focus().toggleBold().run()}
+ disabled={!editorState.canBold}
+ className={editorState.isBold ? 'is-active' : ''}
+ >
+ Bold
+
+ editor.chain().focus().toggleItalic().run()}
+ disabled={!editorState.canItalic}
+ className={editorState.isItalic ? 'is-active' : ''}
+ >
+ Italic
+
+ editor.chain().focus().toggleStrike().run()}
+ disabled={!editorState.canStrike}
+ className={editorState.isStrike ? 'is-active' : ''}
+ >
+ Strike
+
+ editor.chain().focus().toggleCode().run()}
+ disabled={!editorState.canCode}
+ className={editorState.isCode ? 'is-active' : ''}
+ >
+ Code
+
+ editor.chain().focus().unsetAllMarks().run()}>Clear marks
+ editor.chain().focus().clearNodes().run()}>Clear nodes
+ editor.chain().focus().setParagraph().run()}
+ className={editorState.isParagraph ? 'is-active' : ''}
+ >
+ Paragraph
+
+ editor.chain().focus().toggleHeading({ level: 1 }).run()}
+ className={editorState.isHeading1 ? 'is-active' : ''}
+ >
+ H1
+
+ editor.chain().focus().toggleHeading({ level: 2 }).run()}
+ className={editorState.isHeading2 ? 'is-active' : ''}
+ >
+ H2
+
+ editor.chain().focus().toggleHeading({ level: 3 }).run()}
+ className={editorState.isHeading3 ? 'is-active' : ''}
+ >
+ H3
+
+ editor.chain().focus().toggleHeading({ level: 4 }).run()}
+ className={editorState.isHeading4 ? 'is-active' : ''}
+ >
+ H4
+
+ editor.chain().focus().toggleHeading({ level: 5 }).run()}
+ className={editorState.isHeading5 ? 'is-active' : ''}
+ >
+ H5
+
+ editor.chain().focus().toggleHeading({ level: 6 }).run()}
+ className={editorState.isHeading6 ? 'is-active' : ''}
+ >
+ H6
+
+ editor.chain().focus().toggleBulletList().run()}
+ className={editorState.isBulletList ? 'is-active' : ''}
+ >
+ Bullet list
+
+ editor.chain().focus().toggleOrderedList().run()}
+ className={editorState.isOrderedList ? 'is-active' : ''}
+ >
+ Ordered list
+
+ editor.chain().focus().toggleCodeBlock().run()}
+ className={editorState.isCodeBlock ? 'is-active' : ''}
+ >
+ Code block
+
+ editor.chain().focus().toggleBlockquote().run()}
+ className={editorState.isBlockquote ? 'is-active' : ''}
+ >
+ Blockquote
+
+ editor.chain().focus().setHorizontalRule().run()}>
+ Horizontal rule
+
+ editor.chain().focus().setHardBreak().run()}>Hard break
+ editor.chain().focus().undo().run()} disabled={!editorState.canUndo}>
+ Undo
+
+ editor.chain().focus().redo().run()} disabled={!editorState.canRedo}>
+ Redo
+
+ editor.chain().focus().setColor('#958DF1').run()}
+ className={editorState.isPurple ? 'is-active' : ''}
+ >
+ Purple
+
+
+
+ )
+}
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ WrapperBlock,
+ Color.configure({ types: [TextStyle.name, ListItem.name] }),
+ TextStyle.configure({ types: [ListItem.name] }),
+ ],
+ content,
+ })
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/demos/src/Experiments/IsolatingClear/React/styles.scss b/demos/src/Experiments/IsolatingClear/React/styles.scss
new file mode 100644
index 0000000..3285141
--- /dev/null
+++ b/demos/src/Experiments/IsolatingClear/React/styles.scss
@@ -0,0 +1,99 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+
+ /* Demo styles */
+ div[data-wrapper-block] {
+ border: 2px solid #000;
+ border-radius: 0.4rem;
+ margin: 1rem 0;
+ padding: 1rem;
+ }
+}
diff --git a/demos/src/Experiments/IsolatingClear/content.ts b/demos/src/Experiments/IsolatingClear/content.ts
new file mode 100644
index 0000000..53e81f8
--- /dev/null
+++ b/demos/src/Experiments/IsolatingClear/content.ts
@@ -0,0 +1,14 @@
+export const content = `
+
+
Change me into a headline.
+
+
+
Change me into a headline.
+
+
+
Change me into a headline.
+
+
+
Change me into a headline.
+
+`
diff --git a/demos/src/Experiments/IsolatingClear/index.spec.ts b/demos/src/Experiments/IsolatingClear/index.spec.ts
new file mode 100644
index 0000000..6242e5f
--- /dev/null
+++ b/demos/src/Experiments/IsolatingClear/index.spec.ts
@@ -0,0 +1,37 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'IsolatingClear'
+const frameworkPaths = ['React']
+const demoPath = '/src/Experiments'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await setEditorContent(page, 'Example Text ')
+ const editor = await getEditor(page)
+ await editor.click()
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ })
+
+ test('applies the paragraph style when the keyboard shortcut is pressed', async ({
+ page,
+ }) => {
+ const editor = await getEditor(page)
+
+ await expect(editor.locator('h1')).toHaveCount(1)
+
+ const isMac = process.platform === 'darwin'
+ await editor.press(isMac ? 'Meta+Alt+0' : 'Control+Alt+0')
+
+ await expect(editor.locator('p').first()).toContainText('Example Text')
+ await expect(editor.locator('h1')).toHaveCount(0)
+ })
+ })
+ })
+})
diff --git a/demos/src/Experiments/Linter/Vue/extension/Linter.ts b/demos/src/Experiments/Linter/Vue/extension/Linter.ts
new file mode 100644
index 0000000..eccf7e5
--- /dev/null
+++ b/demos/src/Experiments/Linter/Vue/extension/Linter.ts
@@ -0,0 +1,111 @@
+import { Extension } from '@tiptap/core'
+import type { Node as ProsemirrorNode } from '@tiptap/pm/model'
+import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
+import { Decoration, DecorationSet } from '@tiptap/pm/view'
+
+import type { Result as Issue } from './LinterPlugin.js'
+import type LinterPlugin from './LinterPlugin.js'
+
+interface IconDivElement extends HTMLDivElement {
+ issue?: Issue
+}
+
+function renderIcon(issue: Issue) {
+ const icon: IconDivElement = document.createElement('div')
+
+ icon.className = 'lint-icon'
+ icon.title = issue.message
+ icon.issue = issue
+
+ return icon
+}
+
+function runAllLinterPlugins(doc: ProsemirrorNode, plugins: Array) {
+ const decorations: [any?] = []
+
+ const results = plugins
+ .map(RegisteredLinterPlugin => {
+ return new RegisteredLinterPlugin(doc).scan().getResults()
+ })
+ .flat()
+
+ results.forEach(issue => {
+ decorations.push(
+ Decoration.inline(issue.from, issue.to, {
+ class: 'problem',
+ }),
+ Decoration.widget(issue.from, renderIcon(issue)),
+ )
+ })
+
+ return DecorationSet.create(doc, decorations)
+}
+
+export interface LinterOptions {
+ plugins: Array
+}
+
+export const Linter = Extension.create({
+ name: 'linter',
+
+ addOptions() {
+ return {
+ plugins: [],
+ }
+ },
+
+ addProseMirrorPlugins() {
+ const { plugins } = this.options
+
+ return [
+ new Plugin({
+ key: new PluginKey('linter'),
+ state: {
+ init(_, { doc }) {
+ return runAllLinterPlugins(doc, plugins)
+ },
+ apply(transaction, oldState) {
+ return transaction.docChanged ? runAllLinterPlugins(transaction.doc, plugins) : oldState
+ },
+ },
+ props: {
+ decorations(state) {
+ return this.getState(state)
+ },
+ handleClick(view, _, event) {
+ const target = event.target as IconDivElement
+
+ if (/lint-icon/.test(target.className) && target.issue) {
+ const { from, to } = target.issue
+
+ view.dispatch(
+ view.state.tr
+ .setSelection(TextSelection.create(view.state.doc, from, to))
+ .scrollIntoView(),
+ )
+
+ return true
+ }
+
+ return false
+ },
+ handleDoubleClick(view, _, event) {
+ const target = event.target as IconDivElement
+
+ if (/lint-icon/.test((event.target as HTMLElement).className) && target.issue) {
+ const prob = target.issue
+
+ if (prob.fix) {
+ prob.fix(view, prob)
+ view.focus()
+ return true
+ }
+ }
+
+ return false
+ },
+ },
+ }),
+ ]
+ },
+})
diff --git a/demos/src/Experiments/Linter/Vue/extension/LinterPlugin.ts b/demos/src/Experiments/Linter/Vue/extension/LinterPlugin.ts
new file mode 100644
index 0000000..bd472d3
--- /dev/null
+++ b/demos/src/Experiments/Linter/Vue/extension/LinterPlugin.ts
@@ -0,0 +1,37 @@
+import type { Node as ProsemirrorNode } from '@tiptap/pm/model'
+
+export interface Result {
+ message: string
+ from: number
+ to: number
+ // oxlint-disable-next-lineno-unsafe-function-type
+ fix?: Function
+}
+
+export default class LinterPlugin {
+ protected doc
+
+ private results: Array = []
+
+ constructor(doc: ProsemirrorNode) {
+ this.doc = doc
+ }
+
+ // oxlint-disable-next-lineno-unsafe-function-type
+ record(message: string, from: number, to: number, fix?: Function) {
+ this.results.push({
+ message,
+ from,
+ to,
+ fix,
+ })
+ }
+
+ scan() {
+ return this
+ }
+
+ getResults() {
+ return this.results
+ }
+}
diff --git a/demos/src/Experiments/Linter/Vue/extension/index.ts b/demos/src/Experiments/Linter/Vue/extension/index.ts
new file mode 100644
index 0000000..7501498
--- /dev/null
+++ b/demos/src/Experiments/Linter/Vue/extension/index.ts
@@ -0,0 +1,8 @@
+import { Linter } from './Linter.js'
+
+export * from './Linter.js'
+export default Linter
+
+export { BadWords } from './plugins/BadWords.js'
+export { HeadingLevel } from './plugins/HeadingLevel.js'
+export { Punctuation } from './plugins/Punctuation.js'
diff --git a/demos/src/Experiments/Linter/Vue/extension/plugins/BadWords.ts b/demos/src/Experiments/Linter/Vue/extension/plugins/BadWords.ts
new file mode 100644
index 0000000..72c196b
--- /dev/null
+++ b/demos/src/Experiments/Linter/Vue/extension/plugins/BadWords.ts
@@ -0,0 +1,25 @@
+import LinterPlugin from '../LinterPlugin.js'
+
+export class BadWords extends LinterPlugin {
+ public regex = /\b(obviously|clearly|evidently|simply)\b/gi
+
+ scan() {
+ this.doc.descendants((node: any, position: number) => {
+ if (!node.isText) {
+ return
+ }
+
+ const matches = this.regex.exec(node.text)
+
+ if (matches) {
+ this.record(
+ `Try not to say '${matches[0]}'`,
+ position + matches.index,
+ position + matches.index + matches[0].length,
+ )
+ }
+ })
+
+ return this
+ }
+}
diff --git a/demos/src/Experiments/Linter/Vue/extension/plugins/HeadingLevel.ts b/demos/src/Experiments/Linter/Vue/extension/plugins/HeadingLevel.ts
new file mode 100644
index 0000000..c03132a
--- /dev/null
+++ b/demos/src/Experiments/Linter/Vue/extension/plugins/HeadingLevel.ts
@@ -0,0 +1,35 @@
+import type { EditorView } from '@tiptap/pm/view'
+
+import type { Result as Issue } from '../LinterPlugin.js'
+import LinterPlugin from '../LinterPlugin.js'
+
+export class HeadingLevel extends LinterPlugin {
+ fixHeader(level: number) {
+ return function ({ state, dispatch }: EditorView, issue: Issue) {
+ dispatch(state.tr.setNodeMarkup(issue.from - 1, undefined, { level }))
+ }
+ }
+
+ scan() {
+ let lastHeadLevel: number | null = null
+
+ this.doc.descendants((node, position) => {
+ if (node.type.name === 'heading') {
+ // Check whether heading levels fit under the current level
+ const { level } = node.attrs
+
+ if (lastHeadLevel != null && level > lastHeadLevel + 1) {
+ this.record(
+ `Heading too small (${level} under ${lastHeadLevel})`,
+ position + 1,
+ position + 1 + node.content.size,
+ this.fixHeader(lastHeadLevel + 1),
+ )
+ }
+ lastHeadLevel = level
+ }
+ })
+
+ return this
+ }
+}
diff --git a/demos/src/Experiments/Linter/Vue/extension/plugins/Punctuation.ts b/demos/src/Experiments/Linter/Vue/extension/plugins/Punctuation.ts
new file mode 100644
index 0000000..87e1ef1
--- /dev/null
+++ b/demos/src/Experiments/Linter/Vue/extension/plugins/Punctuation.ts
@@ -0,0 +1,39 @@
+import type { EditorView } from '@tiptap/pm/view'
+
+import type { Result as Issue } from '../LinterPlugin.js'
+import LinterPlugin from '../LinterPlugin.js'
+
+export class Punctuation extends LinterPlugin {
+ public regex = / ([,.!?:]) ?/g
+
+ fix(replacement: any) {
+ return function ({ state, dispatch }: EditorView, issue: Issue) {
+ dispatch(state.tr.replaceWith(issue.from, issue.to, state.schema.text(replacement)))
+ }
+ }
+
+ scan() {
+ this.doc.descendants((node, position) => {
+ if (!node.isText) {
+ return
+ }
+
+ if (!node.text) {
+ return
+ }
+
+ const matches = this.regex.exec(node.text)
+
+ if (matches) {
+ this.record(
+ 'Suspicious spacing around punctuation',
+ position + matches.index,
+ position + matches.index + matches[0].length,
+ this.fix(`${matches[1]} `),
+ )
+ }
+ })
+
+ return this
+ }
+}
diff --git a/demos/src/Experiments/Linter/Vue/index.html b/demos/src/Experiments/Linter/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Experiments/Linter/Vue/index.vue b/demos/src/Experiments/Linter/Vue/index.vue
new file mode 100644
index 0000000..ae38aaa
--- /dev/null
+++ b/demos/src/Experiments/Linter/Vue/index.vue
@@ -0,0 +1,134 @@
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Experiments/MultipleEditorStyles/React/index.html b/demos/src/Experiments/MultipleEditorStyles/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Experiments/MultipleEditorStyles/React/index.tsx b/demos/src/Experiments/MultipleEditorStyles/React/index.tsx
new file mode 100644
index 0000000..4744ff5
--- /dev/null
+++ b/demos/src/Experiments/MultipleEditorStyles/React/index.tsx
@@ -0,0 +1,48 @@
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import { useState } from 'react'
+
+function EditorComponent({ label }: { label?: string }) {
+ const editor = useEditor({
+ extensions: [StarterKit],
+ content: 'Hello World!
',
+ editorProps: {
+ attributes: {
+ style: 'padding: 8px',
+ },
+ },
+ })
+
+ return (
+
+ )
+}
+
+export default function MultipleEditors() {
+ const [editorA, setEditorA] = useState(false)
+ const [editorB, setEditorB] = useState(false)
+
+ const toolbar = (
+
+ setEditorA(prev => !prev)}>
+ {editorA ? 'Destroy' : 'Create'} Editor A
+
+ setEditorB(prev => !prev)}>
+ {editorB ? 'Destroy' : 'Create'} Editor B
+
+
+ )
+
+ return (
+
+ {toolbar}
+
+ {editorA && }
+ {editorB && }
+
+
+ )
+}
diff --git a/demos/src/Experiments/MultipleEditors/Vue/index.html b/demos/src/Experiments/MultipleEditors/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Experiments/MultipleEditors/Vue/index.vue b/demos/src/Experiments/MultipleEditors/Vue/index.vue
new file mode 100644
index 0000000..83f7a25
--- /dev/null
+++ b/demos/src/Experiments/MultipleEditors/Vue/index.vue
@@ -0,0 +1,233 @@
+
+
+
+
+ JSON
+ {{ json }}
+
+
+
+
+
+
diff --git a/demos/src/Experiments/MultipleEditors/index.spec.ts b/demos/src/Experiments/MultipleEditors/index.spec.ts
new file mode 100644
index 0000000..98e14b9
--- /dev/null
+++ b/demos/src/Experiments/MultipleEditors/index.spec.ts
@@ -0,0 +1,21 @@
+import { expect, test } from '@playwright/test'
+
+const demoName = 'MultipleEditors'
+const frameworkPaths = ['Vue']
+const demoPath = '/src/Experiments'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test('loads multiple editors', async ({ page }) => {
+ await expect(page.locator('.tiptap').first()).toBeVisible()
+ })
+ })
+ })
+})
diff --git a/demos/src/Experiments/OnDelete/React/index.html b/demos/src/Experiments/OnDelete/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Experiments/OnDelete/React/index.tsx b/demos/src/Experiments/OnDelete/React/index.tsx
new file mode 100644
index 0000000..94ccf13
--- /dev/null
+++ b/demos/src/Experiments/OnDelete/React/index.tsx
@@ -0,0 +1,34 @@
+import './styles.scss'
+
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+export default () => {
+ const [mostRecentDeletion, setMostRecentDeletion] = React.useState(null)
+ const editor = useEditor({
+ extensions: [StarterKit],
+ content: `
+
+ Try to delete this text.
+
+ `,
+ onDelete({ node, transaction, newPos }) {
+ const newNode = transaction.doc.nodeAt(newPos)
+ setMostRecentDeletion(`${node.toString()} -> ${newNode?.toString()}`)
+ },
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
Most recent deletion: {mostRecentDeletion}
+
+
+ >
+ )
+}
diff --git a/demos/src/Experiments/OnDelete/React/styles.scss b/demos/src/Experiments/OnDelete/React/styles.scss
new file mode 100644
index 0000000..afd5ba2
--- /dev/null
+++ b/demos/src/Experiments/OnDelete/React/styles.scss
@@ -0,0 +1,6 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
diff --git a/demos/src/Experiments/OnUpdateRerender/React/index.html b/demos/src/Experiments/OnUpdateRerender/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Experiments/OnUpdateRerender/React/index.jsx b/demos/src/Experiments/OnUpdateRerender/React/index.jsx
new file mode 100644
index 0000000..069b155
--- /dev/null
+++ b/demos/src/Experiments/OnUpdateRerender/React/index.jsx
@@ -0,0 +1,43 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+const TiptapComponent = ({ onUpdate }) => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text],
+ content: `
+
+ This is a radically reduced version of Tiptap. It has support for a document, with paragraphs and text. That’s it. It’s probably too much for real minimalists though.
+
+
+ The paragraph extension is not really required, but you need at least one node. Sure, that node can be something different.
+
+ `,
+ onUpdate,
+ })
+
+ return
+}
+
+export default () => {
+ const [index, setIndex] = React.useState(0)
+
+ const handleUpdate = ({ editor: currentEditor }) => {
+ console.log(index, 'onUpdate', currentEditor.getHTML()) // oxlint-disable-line no-console
+ }
+
+ return (
+ <>
+
+
+ setIndex(index + 1)}>INC = {index}
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Experiments/OnUpdateRerender/React/styles.scss b/demos/src/Experiments/OnUpdateRerender/React/styles.scss
new file mode 100644
index 0000000..afd5ba2
--- /dev/null
+++ b/demos/src/Experiments/OnUpdateRerender/React/styles.scss
@@ -0,0 +1,6 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
diff --git a/demos/src/Experiments/OnUpdateRerender/Vue/TiptapComponent.vue b/demos/src/Experiments/OnUpdateRerender/Vue/TiptapComponent.vue
new file mode 100644
index 0000000..256ea7a
--- /dev/null
+++ b/demos/src/Experiments/OnUpdateRerender/Vue/TiptapComponent.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Experiments/OnUpdateRerender/Vue/index.html b/demos/src/Experiments/OnUpdateRerender/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Experiments/OnUpdateRerender/Vue/index.vue b/demos/src/Experiments/OnUpdateRerender/Vue/index.vue
new file mode 100644
index 0000000..bc33fc8
--- /dev/null
+++ b/demos/src/Experiments/OnUpdateRerender/Vue/index.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Experiments/Tailwind/JS/index.html b/demos/src/Experiments/Tailwind/JS/index.html
new file mode 100644
index 0000000..e6f1f23
--- /dev/null
+++ b/demos/src/Experiments/Tailwind/JS/index.html
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/BackgroundColor/React/index.html b/demos/src/Extensions/BackgroundColor/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/BackgroundColor/React/index.tsx b/demos/src/Extensions/BackgroundColor/React/index.tsx
new file mode 100644
index 0000000..1ae40f1
--- /dev/null
+++ b/demos/src/Extensions/BackgroundColor/React/index.tsx
@@ -0,0 +1,112 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { BackgroundColor, TextStyle } from '@tiptap/extension-text-style'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, TextStyle, BackgroundColor],
+ content: `
+ Oh, for some reason that’s purple.
+ Oh, for some reason that’s purple but with rgba.
+ `,
+ })
+
+ const editorState = useEditorState({
+ editor,
+ selector: ctx => {
+ return {
+ color: ctx.editor.getAttributes('textStyle').backgroundColor,
+ isPurple: ctx.editor.isActive('textStyle', { backgroundColor: '#958DF1' }),
+ isRed: ctx.editor.isActive('textStyle', { backgroundColor: '#F98181' }),
+ isOrange: ctx.editor.isActive('textStyle', { backgroundColor: '#FBBC88' }),
+ isYellow: ctx.editor.isActive('textStyle', { backgroundColor: '#FAF594' }),
+ isBlue: ctx.editor.isActive('textStyle', { backgroundColor: '#70CFF8' }),
+ isTeal: ctx.editor.isActive('textStyle', { backgroundColor: '#94FADB' }),
+ isGreen: ctx.editor.isActive('textStyle', { backgroundColor: '#B9F18D' }),
+ }
+ },
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+
+ editor.chain().focus().setBackgroundColor(event.currentTarget.value).run()
+ }
+ value={editorState.color}
+ data-testid="setBackgroundColor"
+ />
+ editor.chain().focus().setBackgroundColor('#958DF1').run()}
+ className={editorState.isPurple ? 'is-active' : ''}
+ data-testid="setPurple"
+ >
+ Purple
+
+ editor.chain().focus().setBackgroundColor('#F98181').run()}
+ className={editorState.isRed ? 'is-active' : ''}
+ data-testid="setRed"
+ >
+ Red
+
+ editor.chain().focus().setBackgroundColor('#FBBC88').run()}
+ className={editorState.isOrange ? 'is-active' : ''}
+ data-testid="setOrange"
+ >
+ Orange
+
+ editor.chain().focus().setBackgroundColor('#FAF594').run()}
+ className={editorState.isYellow ? 'is-active' : ''}
+ data-testid="setYellow"
+ >
+ Yellow
+
+ editor.chain().focus().setBackgroundColor('#70CFF8').run()}
+ className={editorState.isBlue ? 'is-active' : ''}
+ data-testid="setBlue"
+ >
+ Blue
+
+ editor.chain().focus().setBackgroundColor('#94FADB').run()}
+ className={editorState.isTeal ? 'is-active' : ''}
+ data-testid="setTeal"
+ >
+ Teal
+
+ editor.chain().focus().setBackgroundColor('#B9F18D').run()}
+ className={editorState.isGreen ? 'is-active' : ''}
+ data-testid="setGreen"
+ >
+ Green
+
+ editor.chain().focus().unsetBackgroundColor().run()}
+ data-testid="unsetBackgroundColor"
+ >
+ Unset color
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Extensions/BackgroundColor/React/styles.scss b/demos/src/Extensions/BackgroundColor/React/styles.scss
new file mode 100644
index 0000000..afd5ba2
--- /dev/null
+++ b/demos/src/Extensions/BackgroundColor/React/styles.scss
@@ -0,0 +1,6 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
diff --git a/demos/src/Extensions/BackgroundColor/Vue/index.html b/demos/src/Extensions/BackgroundColor/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/BackgroundColor/Vue/index.vue b/demos/src/Extensions/BackgroundColor/Vue/index.vue
new file mode 100644
index 0000000..0532980
--- /dev/null
+++ b/demos/src/Extensions/BackgroundColor/Vue/index.vue
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+ Purple
+
+
+ Red
+
+
+ Orange
+
+
+ Yellow
+
+
+ Blue
+
+
+ Teal
+
+
+ Green
+
+
+ Unset color
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/BubbleMenu/React/index.html b/demos/src/Extensions/BubbleMenu/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/BubbleMenu/React/index.jsx b/demos/src/Extensions/BubbleMenu/React/index.jsx
new file mode 100644
index 0000000..d1447e8
--- /dev/null
+++ b/demos/src/Extensions/BubbleMenu/React/index.jsx
@@ -0,0 +1,139 @@
+import './styles.scss'
+
+import {
+ EditorContent,
+ findParentNode,
+ posToDOMRect,
+ useEditor,
+ useEditorState,
+} from '@tiptap/react'
+import { BubbleMenu } from '@tiptap/react/menus'
+import StarterKit from '@tiptap/starter-kit'
+import React, { useEffect } from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [StarterKit],
+ content: `
+
+ Hey, try to select some text here. There will popup a menu for selecting some inline styles. Remember: you have full control about content and styling of this menu.
+
+
+ Select any item to display a global menu
+ Item 1
+ Item 2
+ Item 3
+ Item 4
+
+ `,
+ })
+
+ const [showMenu, setShowMenu] = React.useState(true)
+ const [isEditable, setIsEditable] = React.useState(true)
+
+ useEffect(() => {
+ if (editor) {
+ editor.setEditable(isEditable)
+ }
+ }, [isEditable, editor])
+
+ const { isBold, isItalic, isStrikethrough } = useEditorState({
+ editor,
+ selector: ctx => ({
+ isBold: ctx.editor.isActive('bold') ?? false,
+ isItalic: ctx.editor.isActive('italic') ?? false,
+ isStrikethrough: ctx.editor.isActive('strike') ?? false,
+ }),
+ })
+
+ return (
+ <>
+ {
+ setShowMenu(old => !old)
+ editor.commands.focus()
+ }}
+ >
+ Toggle menu
+
+
+
+ setIsEditable(!isEditable)} />
+ Editable
+
+
+
+ {editor && showMenu && (
+ <>
+
+
+ editor.chain().focus().toggleBold().run()}
+ className={isBold ? 'is-active' : ''}
+ type="button"
+ >
+ Bold
+
+ editor.chain().focus().toggleItalic().run()}
+ className={isItalic ? 'is-active' : ''}
+ type="button"
+ >
+ Italic
+
+ editor.chain().focus().toggleStrike().run()}
+ className={isStrikethrough ? 'is-active' : ''}
+ type="button"
+ >
+ Strike
+
+
+
+
+ editor.isActive('bulletList') || editor.isActive('orderedList')}
+ getReferencedVirtualElement={() => {
+ const parentNode = findParentNode(
+ node => node.type.name === 'bulletList' || node.type.name === 'orderedList',
+ )(editor.state.selection)
+ if (parentNode) {
+ const domRect = posToDOMRect(
+ editor.view,
+ parentNode.start,
+ parentNode.start + parentNode.node.nodeSize,
+ )
+ return {
+ getBoundingClientRect: () => domRect,
+ getClientRects: () => [domRect],
+ }
+ }
+ return null
+ }}
+ options={{ placement: 'top-start', offset: 8 }}
+ >
+
+ {
+ const chain = editor.chain().focus()
+ if (editor.isActive('bulletList')) {
+ chain.toggleOrderedList()
+ } else {
+ chain.toggleBulletList()
+ }
+ chain.run()
+ }}
+ type="button"
+ >
+ Toggle list type
+
+
+
+ >
+ )}
+
+ >
+ )
+}
diff --git a/demos/src/Extensions/BubbleMenu/React/styles.scss b/demos/src/Extensions/BubbleMenu/React/styles.scss
new file mode 100644
index 0000000..823554d
--- /dev/null
+++ b/demos/src/Extensions/BubbleMenu/React/styles.scss
@@ -0,0 +1,117 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
+
+/* Bubble menu */
+.bubble-menu {
+ background-color: var(--white);
+ border: 1px solid var(--gray-1);
+ border-radius: 0.7rem;
+ box-shadow: var(--shadow);
+ display: flex;
+ padding: 0.2rem;
+
+ button {
+ background-color: unset;
+
+ &:hover {
+ background-color: var(--gray-3);
+ }
+
+ &.is-active {
+ background-color: var(--purple);
+
+ &:hover {
+ background-color: var(--purple-contrast);
+ }
+ }
+ }
+}
diff --git a/demos/src/Extensions/BubbleMenu/Vue/index.html b/demos/src/Extensions/BubbleMenu/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/BubbleMenu/Vue/index.vue b/demos/src/Extensions/BubbleMenu/Vue/index.vue
new file mode 100644
index 0000000..0f618e6
--- /dev/null
+++ b/demos/src/Extensions/BubbleMenu/Vue/index.vue
@@ -0,0 +1,263 @@
+
+
+
{
+ showMenu = !showMenu
+ editor && editor.commands.focus()
+ }
+ "
+ >
+ Toggle menu
+
+
+
+ (isEditable = !isEditable)" />
+ Editable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/CharacterCount/React/index.html b/demos/src/Extensions/CharacterCount/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/CharacterCount/React/index.jsx b/demos/src/Extensions/CharacterCount/React/index.jsx
new file mode 100644
index 0000000..4741c22
--- /dev/null
+++ b/demos/src/Extensions/CharacterCount/React/index.jsx
@@ -0,0 +1,69 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { CharacterCount } from '@tiptap/extensions'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+
+const limit = 280
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ CharacterCount.configure({
+ limit,
+ }),
+ ],
+ content: `
+
+ Let‘s make sure people can’t write more than 280 characters. I bet you could build one of the biggest social networks on that idea.
+
+ `,
+ })
+
+ const { charactersCount, wordsCount } = useEditorState({
+ editor,
+ selector: context => ({
+ charactersCount: context.editor.storage.characterCount.characters(),
+ wordsCount: context.editor.storage.characterCount.words(),
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ const percentage = editor ? Math.round((100 / limit) * charactersCount) : 0
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {charactersCount} / {limit} characters
+
+ {wordsCount} words
+
+ >
+ )
+}
diff --git a/demos/src/Extensions/CharacterCount/React/styles.scss b/demos/src/Extensions/CharacterCount/React/styles.scss
new file mode 100644
index 0000000..dd40f64
--- /dev/null
+++ b/demos/src/Extensions/CharacterCount/React/styles.scss
@@ -0,0 +1,25 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
+
+/* Character count */
+.character-count {
+ align-items: center;
+ color: var(--gray-5);
+ display: flex;
+ font-size: 0.75rem;
+ gap: 0.5rem;
+ margin: 1.5rem;
+
+ svg {
+ color: var(--purple);
+ }
+
+ &--warning,
+ &--warning svg {
+ color: var(--red);
+ }
+}
diff --git a/demos/src/Extensions/CharacterCount/Vue/index.html b/demos/src/Extensions/CharacterCount/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/CharacterCount/Vue/index.vue b/demos/src/Extensions/CharacterCount/Vue/index.vue
new file mode 100644
index 0000000..53ca868
--- /dev/null
+++ b/demos/src/Extensions/CharacterCount/Vue/index.vue
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ editor.storage.characterCount.characters() }} / {{ limit }} characters
+
+ {{ editor.storage.characterCount.words() }} words
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/Collaboration/React/index.html b/demos/src/Extensions/Collaboration/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/Collaboration/React/index.jsx b/demos/src/Extensions/Collaboration/React/index.jsx
new file mode 100644
index 0000000..f1ecf92
--- /dev/null
+++ b/demos/src/Extensions/Collaboration/React/index.jsx
@@ -0,0 +1,35 @@
+import './styles.scss'
+
+import Collaboration from '@tiptap/extension-collaboration'
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { Placeholder } from '@tiptap/extensions'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+import { WebrtcProvider } from 'y-webrtc'
+import * as Y from 'yjs'
+
+const ydoc = new Y.Doc()
+
+// oxlint-disable-next-line no-unused-vars
+const provider = new WebrtcProvider('tiptap-collaboration-extension', ydoc)
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Collaboration.configure({
+ document: ydoc,
+ }),
+ Placeholder.configure({
+ placeholder:
+ 'Write something … It’ll be shared with everyone else looking at this example.',
+ }),
+ ],
+ })
+
+ return
+}
diff --git a/demos/src/Extensions/Collaboration/React/styles.scss b/demos/src/Extensions/Collaboration/React/styles.scss
new file mode 100644
index 0000000..5507b20
--- /dev/null
+++ b/demos/src/Extensions/Collaboration/React/styles.scss
@@ -0,0 +1,15 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* Placeholder (at the top) */
+ p.is-editor-empty:first-child::before {
+ color: var(--gray-4);
+ content: attr(data-placeholder);
+ float: left;
+ height: 0;
+ pointer-events: none;
+ }
+}
diff --git a/demos/src/Extensions/Collaboration/Vue/index.html b/demos/src/Extensions/Collaboration/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/Collaboration/Vue/index.vue b/demos/src/Extensions/Collaboration/Vue/index.vue
new file mode 100644
index 0000000..ceea9e5
--- /dev/null
+++ b/demos/src/Extensions/Collaboration/Vue/index.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/CollaborationCaret/React/index.html b/demos/src/Extensions/CollaborationCaret/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/CollaborationCaret/React/index.jsx b/demos/src/Extensions/CollaborationCaret/React/index.jsx
new file mode 100644
index 0000000..d270ceb
--- /dev/null
+++ b/demos/src/Extensions/CollaborationCaret/React/index.jsx
@@ -0,0 +1,55 @@
+import './styles.scss'
+
+import Collaboration from '@tiptap/extension-collaboration'
+import CollaborationCaret from '@tiptap/extension-collaboration-caret'
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { Placeholder } from '@tiptap/extensions'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+import { WebrtcProvider } from 'y-webrtc'
+import * as Y from 'yjs'
+
+const ydoc = new Y.Doc()
+const provider = new WebrtcProvider('tiptap-collaboration-caret-extension', ydoc)
+
+function Component() {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Collaboration.configure({
+ document: ydoc,
+ }),
+ CollaborationCaret.configure({
+ provider,
+ user: {
+ name: 'Cyndi Lauper',
+ color: '#f783ac',
+ },
+ }),
+ Placeholder.configure({
+ placeholder:
+ 'Write something … It’ll be shared with everyone else looking at this example.',
+ }),
+ ],
+ })
+
+ return
+}
+
+function App() {
+ const useStrictMode = true
+
+ return useStrictMode ? (
+
+
+
+ ) : (
+
+ )
+}
+
+export default App
diff --git a/demos/src/Extensions/CollaborationCaret/React/styles.scss b/demos/src/Extensions/CollaborationCaret/React/styles.scss
new file mode 100644
index 0000000..c290f98
--- /dev/null
+++ b/demos/src/Extensions/CollaborationCaret/React/styles.scss
@@ -0,0 +1,46 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* Placeholder (at the top) */
+ p.is-editor-empty:first-child::before {
+ color: var(--gray-4);
+ content: attr(data-placeholder);
+ float: left;
+ height: 0;
+ pointer-events: none;
+ }
+
+ p {
+ word-break: break-all;
+ }
+
+ /* Give a remote user a caret */
+ .collaboration-carets__caret {
+ border-left: 1px solid #0d0d0d;
+ border-right: 1px solid #0d0d0d;
+ margin-left: -1px;
+ margin-right: -1px;
+ pointer-events: none;
+ position: relative;
+ word-break: normal;
+ }
+
+ /* Render the username above the caret */
+ .collaboration-carets__label {
+ border-radius: 3px 3px 3px 0;
+ color: #0d0d0d;
+ font-size: 12px;
+ font-style: normal;
+ font-weight: 600;
+ left: -1px;
+ line-height: normal;
+ padding: 0.1rem 0.3rem;
+ position: absolute;
+ top: -1.4em;
+ user-select: none;
+ white-space: nowrap;
+ }
+}
diff --git a/demos/src/Extensions/CollaborationCaret/Vue/index.html b/demos/src/Extensions/CollaborationCaret/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/CollaborationCaret/Vue/index.vue b/demos/src/Extensions/CollaborationCaret/Vue/index.vue
new file mode 100644
index 0000000..9b3dfe7
--- /dev/null
+++ b/demos/src/Extensions/CollaborationCaret/Vue/index.vue
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/CollaborationMapPositions/React/index.html b/demos/src/Extensions/CollaborationMapPositions/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/CollaborationMapPositions/React/index.tsx b/demos/src/Extensions/CollaborationMapPositions/React/index.tsx
new file mode 100644
index 0000000..593f1d8
--- /dev/null
+++ b/demos/src/Extensions/CollaborationMapPositions/React/index.tsx
@@ -0,0 +1,140 @@
+import './styles.scss'
+
+import Collaboration from '@tiptap/extension-collaboration'
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { Placeholder } from '@tiptap/extensions'
+import type { Node as PMNode } from '@tiptap/pm/model'
+import { Plugin, PluginKey } from '@tiptap/pm/state'
+import { Decoration, DecorationSet } from '@tiptap/pm/view'
+import { type MappablePosition, EditorContent, Extension, useEditor } from '@tiptap/react'
+import { WebrtcProvider } from 'y-webrtc'
+import * as Y from 'yjs'
+
+/**
+ * Creates a ProseMirror DecorationSet from a list of decoration data.
+ * @returns A ProseMirror DecorationSet
+ */
+function createDecorations(data: MappablePosition[], doc: PMNode): DecorationSet {
+ return DecorationSet.create(
+ doc,
+ data.map(({ position }) =>
+ Decoration.widget(position, () => {
+ const element = document.createElement('span')
+ element.classList.add('decoration')
+ return element
+ }),
+ ),
+ )
+}
+
+/**
+ * The state of the DecorationsExtension ProseMirror plugin.
+ */
+interface PluginState {
+ decorationData: MappablePosition[]
+ decorations: DecorationSet
+}
+
+const DecorationsPluginKey = new PluginKey('decorations')
+
+/**
+ * An extension that allows you to insert decorations into the editor. Inside
+ * its ProseMirror plugin, it keeps track of the decoration data and
+ * decorations.
+ */
+const DecorationsExtension = Extension.create({
+ name: 'decorations',
+ addProseMirrorPlugins() {
+ const editor = this.editor
+ return [
+ new Plugin({
+ key: DecorationsPluginKey,
+ state: {
+ init: () => ({
+ decorationData: [],
+ decorations: DecorationSet.empty,
+ }),
+ apply(transaction, pluginState, _oldState, newState) {
+ let decorationData = pluginState.decorationData
+
+ // If the transaction changes the document, update the decoration
+ // positions
+ if (transaction.docChanged) {
+ decorationData = decorationData.map(position => {
+ const result = editor.utils.getUpdatedPosition(position, transaction)
+ return result.position
+ })
+ }
+
+ // If the transaction adds a decoration, add it to the decoration data.
+ const metadata = transaction.getMeta(DecorationsPluginKey) as
+ | MappablePosition
+ | undefined
+ if (metadata) {
+ decorationData.push(metadata)
+ }
+
+ return {
+ decorationData,
+ // Create new ProseMirror decorations in the positions determined
+ // by the decoration data.
+ decorations: createDecorations(decorationData, newState.doc),
+ }
+ },
+ },
+ props: {
+ decorations: state => DecorationsPluginKey.getState(state)?.decorations,
+ },
+ }),
+ ]
+ },
+})
+
+const ydoc = new Y.Doc()
+
+// oxlint-disable-next-line no-new
+new WebrtcProvider('tiptap-collaboration-extension', ydoc)
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Collaboration.configure({
+ document: ydoc,
+ }),
+ Placeholder.configure({
+ placeholder:
+ 'Write something … It’ll be shared with everyone else looking at this example.',
+ }),
+ DecorationsExtension,
+ ],
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.commands.command(props => {
+ const position = props.state.selection.from
+ const decorationData = editor.utils.createMappablePosition(position)
+ props.tr.setMeta(DecorationsPluginKey, decorationData)
+ return true
+ })
+ }
+ >
+ Insert decoration
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Extensions/CollaborationMapPositions/React/styles.scss b/demos/src/Extensions/CollaborationMapPositions/React/styles.scss
new file mode 100644
index 0000000..1ee0a5a
--- /dev/null
+++ b/demos/src/Extensions/CollaborationMapPositions/React/styles.scss
@@ -0,0 +1,22 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* Placeholder (at the top) */
+ p.is-editor-empty:first-child::before {
+ color: var(--gray-4);
+ content: attr(data-placeholder);
+ float: left;
+ height: 0;
+ pointer-events: none;
+ }
+}
+
+.decoration {
+ background-color: red;
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+}
diff --git a/demos/src/Extensions/CollaborationWithMenus/React/index.html b/demos/src/Extensions/CollaborationWithMenus/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/CollaborationWithMenus/React/index.jsx b/demos/src/Extensions/CollaborationWithMenus/React/index.jsx
new file mode 100644
index 0000000..ee4f4f5
--- /dev/null
+++ b/demos/src/Extensions/CollaborationWithMenus/React/index.jsx
@@ -0,0 +1,76 @@
+import './styles.scss'
+
+import Bold from '@tiptap/extension-bold'
+import Collaboration from '@tiptap/extension-collaboration'
+import Document from '@tiptap/extension-document'
+import Heading from '@tiptap/extension-heading'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { Placeholder } from '@tiptap/extensions'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import { BubbleMenu, FloatingMenu } from '@tiptap/react/menus'
+import React from 'react'
+import { WebrtcProvider } from 'y-webrtc'
+import * as Y from 'yjs'
+
+const ydoc = new Y.Doc()
+
+// oxlint-disable-next-line no-unused-vars
+const provider = new WebrtcProvider('tiptap-collaboration-extension', ydoc)
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Heading,
+ Bold,
+ Collaboration.configure({
+ document: ydoc,
+ }),
+ Placeholder.configure({
+ placeholder:
+ 'Write something … It’ll be shared with everyone else looking at this example.',
+ }),
+ ],
+ })
+
+ const { isBold, isHeading1 } = useEditorState({
+ editor,
+ selector: ctx => ({
+ isBold: ctx.editor.isActive('bold') ?? false,
+ isHeading1: ctx.editor.isActive('heading', { level: 1 }) ?? false,
+ }),
+ })
+
+ return (
+ <>
+ {editor && (
+ <>
+
+
+ editor.chain().focus().toggleBold().run()}
+ className={isBold ? 'is-active' : ''}
+ >
+ Bold
+
+
+
+
+
+ editor.chain().focus().toggleHeading({ level: 1 }).run()}
+ className={isHeading1 ? 'is-active' : ''}
+ >
+ H1
+
+
+
+ >
+ )}
+
+ >
+ )
+}
diff --git a/demos/src/Extensions/CollaborationWithMenus/React/styles.scss b/demos/src/Extensions/CollaborationWithMenus/React/styles.scss
new file mode 100644
index 0000000..0bffeb7
--- /dev/null
+++ b/demos/src/Extensions/CollaborationWithMenus/React/styles.scss
@@ -0,0 +1,72 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* Placeholder (at the top) */
+ p.is-editor-empty:first-child::before {
+ color: var(--gray-4);
+ content: attr(data-placeholder);
+ float: left;
+ height: 0;
+ pointer-events: none;
+ }
+
+ &.ProseMirror-focused p.is-editor-empty:first-child::before {
+ content: '';
+ }
+}
+
+/* Bubble menu */
+.bubble-menu {
+ background-color: var(--white);
+ border: 1px solid var(--gray-1);
+ border-radius: 0.7rem;
+ box-shadow: var(--shadow);
+ display: flex;
+ padding: 0.2rem;
+
+ button {
+ background-color: unset;
+
+ &:hover {
+ background-color: var(--gray-3);
+ }
+
+ &.is-active {
+ background-color: var(--purple);
+
+ &:hover {
+ background-color: var(--purple-contrast);
+ }
+ }
+ }
+}
+
+/* Floating menu */
+.floating-menu {
+ display: flex;
+ background-color: var(--gray-3);
+ padding: 0.1rem;
+ border-radius: 0.5rem;
+
+ button {
+ background-color: unset;
+ padding: 0.275rem 0.425rem;
+ border-radius: 0.3rem;
+
+ &:hover {
+ background-color: var(--gray-3);
+ }
+
+ &.is-active {
+ background-color: var(--white);
+ color: var(--purple);
+
+ &:hover {
+ color: var(--purple-contrast);
+ }
+ }
+ }
+}
diff --git a/demos/src/Extensions/CollaborationWithMenus/Vue/index.html b/demos/src/Extensions/CollaborationWithMenus/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/CollaborationWithMenus/Vue/index.vue b/demos/src/Extensions/CollaborationWithMenus/Vue/index.vue
new file mode 100644
index 0000000..8ae2127
--- /dev/null
+++ b/demos/src/Extensions/CollaborationWithMenus/Vue/index.vue
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/Color/React/index.html b/demos/src/Extensions/Color/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/Color/React/index.tsx b/demos/src/Extensions/Color/React/index.tsx
new file mode 100644
index 0000000..c6e50b1
--- /dev/null
+++ b/demos/src/Extensions/Color/React/index.tsx
@@ -0,0 +1,109 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { Color, TextStyle } from '@tiptap/extension-text-style'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, TextStyle, Color],
+ content: `
+ Oh, for some reason that’s purple. This text is using transparent, red rgba colors.
+ `,
+ })
+
+ const editorState = useEditorState({
+ editor,
+ selector: ctx => {
+ return {
+ color: ctx.editor.getAttributes('textStyle').color,
+ isPurple: ctx.editor.isActive('textStyle', { color: '#958DF1' }),
+ isRed: ctx.editor.isActive('textStyle', { color: '#F98181' }),
+ isOrange: ctx.editor.isActive('textStyle', { color: '#FBBC88' }),
+ isYellow: ctx.editor.isActive('textStyle', { color: '#FAF594' }),
+ isBlue: ctx.editor.isActive('textStyle', { color: '#70CFF8' }),
+ isTeal: ctx.editor.isActive('textStyle', { color: '#94FADB' }),
+ isGreen: ctx.editor.isActive('textStyle', { color: '#B9F18D' }),
+ }
+ },
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().setColor(event.currentTarget.value).run()}
+ value={editorState.color}
+ data-testid="setColor"
+ />
+ editor.chain().focus().setColor('#958DF1').run()}
+ className={editorState.isPurple ? 'is-active' : ''}
+ data-testid="setPurple"
+ >
+ Purple
+
+ editor.chain().focus().setColor('#F98181').run()}
+ className={editorState.isRed ? 'is-active' : ''}
+ data-testid="setRed"
+ >
+ Red
+
+ editor.chain().focus().setColor('#FBBC88').run()}
+ className={editorState.isOrange ? 'is-active' : ''}
+ data-testid="setOrange"
+ >
+ Orange
+
+ editor.chain().focus().setColor('#FAF594').run()}
+ className={editorState.isYellow ? 'is-active' : ''}
+ data-testid="setYellow"
+ >
+ Yellow
+
+ editor.chain().focus().setColor('#70CFF8').run()}
+ className={editorState.isBlue ? 'is-active' : ''}
+ data-testid="setBlue"
+ >
+ Blue
+
+ editor.chain().focus().setColor('#94FADB').run()}
+ className={editorState.isTeal ? 'is-active' : ''}
+ data-testid="setTeal"
+ >
+ Teal
+
+ editor.chain().focus().setColor('#B9F18D').run()}
+ className={editorState.isGreen ? 'is-active' : ''}
+ data-testid="setGreen"
+ >
+ Green
+
+ editor.chain().focus().unsetColor().run()}
+ data-testid="unsetColor"
+ >
+ Unset color
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Extensions/Color/React/styles.scss b/demos/src/Extensions/Color/React/styles.scss
new file mode 100644
index 0000000..afd5ba2
--- /dev/null
+++ b/demos/src/Extensions/Color/React/styles.scss
@@ -0,0 +1,6 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
diff --git a/demos/src/Extensions/Color/Vue/index.html b/demos/src/Extensions/Color/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/Color/Vue/index.vue b/demos/src/Extensions/Color/Vue/index.vue
new file mode 100644
index 0000000..281c0bd
--- /dev/null
+++ b/demos/src/Extensions/Color/Vue/index.vue
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+ Purple
+
+
+ Red
+
+
+ Orange
+
+
+ Yellow
+
+
+ Blue
+
+
+ Teal
+
+
+ Green
+
+ Unset color
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/DragHandle/React/index.html b/demos/src/Extensions/DragHandle/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/DragHandle/React/index.jsx b/demos/src/Extensions/DragHandle/React/index.jsx
new file mode 100644
index 0000000..638fa1e
--- /dev/null
+++ b/demos/src/Extensions/DragHandle/React/index.jsx
@@ -0,0 +1,173 @@
+import './styles.scss'
+
+import DragHandle from '@tiptap/extension-drag-handle-react'
+import Image from '@tiptap/extension-image'
+import { TableKit } from '@tiptap/extension-table'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import { useEffect, useState } from 'react'
+
+const NESTED_CONFIG_LTR = { edgeDetection: { threshold: -16, edges: ['left'] } }
+const NESTED_CONFIG_RTL = { edgeDetection: { threshold: -16, edges: ['right'] } }
+
+export default () => {
+ const [nested, setNested] = useState(true)
+ const [rtl, setRtl] = useState(false)
+
+ const editor = useEditor({
+ extensions: [StarterKit, Image.configure({ inline: false }), TableKit],
+ content: `
+ The Complete Guide to Modern Web Development
+ Web development has evolved significantly over the past decade. What once required multiple tools and complex setups can now be accomplished with modern frameworks and libraries that prioritize developer experience.
+
+
+
+ تجربة سحب هذا النص توضح كيف يجب أن يلتصق شبح السحب بالمؤشر حتى داخل المحتوى من اليمين إلى اليسار.
+
+ Getting Started
+ Before diving into the technical details, it's important to understand the foundational concepts that make modern web development possible.
+
+
+ "The best code is no code at all. Every new line of code you willingly bring into the world is code that has to be debugged, code that has to be read and understood." - Jeff Atwood
+
+
+ This philosophy guides much of modern development practices, emphasizing simplicity and maintainability over complexity.
+
+
+
+
+ Feature
+ Description
+ Example
+
+
+
+
+ Component-Based Architecture
+ Breaks down the UI into reusable components.
+ <MyComponent />
+
+
+ Virtual DOM
+ Improves performance by minimizing direct DOM manipulation.
+ <VirtualDOMComponent />
+
+
+
+
+
+
+ Key Technologies
+ Here are the essential technologies every web developer should be familiar with:
+
+
+ HTML5 and semantic markup
+ CSS3 with modern layout techniques
+
+ Flexbox for one-dimensional layouts
+ Grid for two-dimensional layouts
+ Custom properties (CSS variables)
+
+
+ JavaScript (ES6+)
+ TypeScript for type safety
+
+
+ Framework Comparison
+ Choosing the right framework depends on your project requirements:
+
+
+ React - Component-based UI library
+ Vue - Progressive framework
+ Angular - Full-featured platform
+ Svelte - Compile-time framework
+
+
+
+
+ Best Practices
+ Following established best practices ensures your code remains maintainable and scalable.
+
+
+ Always write code as if the person who ends up maintaining it is a violent psychopath who knows where you live.
+
+
+ Code Organization
+ A well-organized codebase is crucial for long-term project success. Consider these principles:
+
+
+ Separation of concerns
+ DRY (Don't Repeat Yourself)
+ KISS (Keep It Simple, Stupid)
+
+
+ By following these guidelines, you'll create applications that are easier to maintain, test, and extend over time.
+ `,
+ })
+
+ const isEditable = useEditorState({
+ editor,
+ selector: context => {
+ return context.editor.isEditable
+ },
+ })
+
+ const toggleEditable = () => {
+ editor.setEditable(!editor.isEditable)
+ editor.view.dispatch(editor.view.state.tr)
+ }
+
+ const toggleNested = () => {
+ setNested(!nested)
+ }
+
+ const toggleRtl = () => {
+ setRtl(!rtl)
+ }
+
+ let nestedConfig = false
+
+ if (nested) {
+ nestedConfig = rtl ? NESTED_CONFIG_RTL : NESTED_CONFIG_LTR
+ }
+
+ const computePositionConfig = { placement: rtl ? 'right-start' : 'left-start' }
+
+ useEffect(() => {
+ if (!editor) {
+ return
+ }
+
+ if (rtl) {
+ editor.view.dom.setAttribute('dir', 'rtl')
+ } else {
+ editor.view.dom.removeAttribute('dir')
+ }
+ }, [editor, rtl])
+
+ return (
+ <>
+
+
+
+ Toggle editable
+
+
+ Toggle nested drag handle
+
+
+ Toggle RTL editor
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Extensions/DragHandle/React/styles.scss b/demos/src/Extensions/DragHandle/React/styles.scss
new file mode 100644
index 0000000..635a846
--- /dev/null
+++ b/demos/src/Extensions/DragHandle/React/styles.scss
@@ -0,0 +1,68 @@
+::selection {
+ background-color: #70cff850;
+}
+
+.ProseMirror {
+ padding: 1rem 1rem 1rem 0;
+
+ * {
+ margin-top: 0.75em;
+ }
+
+ > * {
+ margin-inline-start: 3rem;
+ }
+
+ .ProseMirror-widget * {
+ margin-top: auto;
+ }
+
+ ul,
+ ol {
+ padding-inline: 1rem;
+ }
+}
+
+.ProseMirror-noderangeselection {
+ *::selection {
+ background: transparent;
+ }
+
+ * {
+ caret-color: transparent;
+ }
+}
+
+.ProseMirror-selectednode,
+.ProseMirror-selectednoderange {
+ position: relative;
+
+ &::before {
+ position: absolute;
+ pointer-events: none;
+ z-index: -1;
+ content: '';
+ top: -0.25rem;
+ left: -0.25rem;
+ right: -0.25rem;
+ bottom: -0.25rem;
+ background-color: #70cff850;
+ border-radius: 0.2rem;
+ }
+}
+
+.custom-drag-handle {
+ &::after {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 1rem;
+ height: 1.25rem;
+ content: '⠿';
+ font-weight: 700;
+ cursor: grab;
+ background: #0d0d0d10;
+ color: #0d0d0d50;
+ border-radius: 0.25rem;
+ }
+}
diff --git a/demos/src/Extensions/DragHandle/Vue/index.html b/demos/src/Extensions/DragHandle/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/DragHandle/Vue/index.vue b/demos/src/Extensions/DragHandle/Vue/index.vue
new file mode 100644
index 0000000..c201516
--- /dev/null
+++ b/demos/src/Extensions/DragHandle/Vue/index.vue
@@ -0,0 +1,264 @@
+
+
+
+
+ Toggle editable
+
+
+ Toggle nested drag handle
+
+ Toggle RTL editor
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/DragHandle/Vue/style.css b/demos/src/Extensions/DragHandle/Vue/style.css
new file mode 100644
index 0000000..60f1eab
--- /dev/null
+++ b/demos/src/Extensions/DragHandle/Vue/style.css
@@ -0,0 +1,3 @@
+body {
+ color: red;
+}
diff --git a/demos/src/Extensions/DragHandleWithNodeViews/React/extensions/recommendation/Recommendation.jsx b/demos/src/Extensions/DragHandleWithNodeViews/React/extensions/recommendation/Recommendation.jsx
new file mode 100644
index 0000000..2b4dd24
--- /dev/null
+++ b/demos/src/Extensions/DragHandleWithNodeViews/React/extensions/recommendation/Recommendation.jsx
@@ -0,0 +1,67 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+import { ReactNodeViewRenderer } from '@tiptap/react'
+
+import { RecommendationView } from './views/index.jsx'
+
+export const Recommendation = Node.create({
+ name: 'recommendation',
+
+ group: 'block',
+
+ draggable: true,
+
+ addOptions() {
+ return {
+ publicationId: '',
+ HTMLAttributes: {
+ class: `node-${this.name}`,
+ },
+ }
+ },
+
+ addAttributes() {
+ return {
+ id: {
+ default: undefined,
+ parseHTML: element => element.getAttribute('data-id'),
+ renderHTML: attributes => ({
+ 'data-id': attributes.id,
+ }),
+ },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: `div.node-${this.name}`,
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]
+ },
+
+ addCommands() {
+ return {
+ setRecommendation:
+ () =>
+ ({ chain }) =>
+ chain()
+ .focus()
+ .insertContent({
+ type: this.name,
+ })
+ .run(),
+ }
+ },
+
+ addNodeView() {
+ return ReactNodeViewRenderer(RecommendationView, {
+ trackNodeViewPosition: true,
+ })
+ },
+})
+
+export default Recommendation
diff --git a/demos/src/Extensions/DragHandleWithNodeViews/React/extensions/recommendation/index.jsx b/demos/src/Extensions/DragHandleWithNodeViews/React/extensions/recommendation/index.jsx
new file mode 100644
index 0000000..5c3d4ba
--- /dev/null
+++ b/demos/src/Extensions/DragHandleWithNodeViews/React/extensions/recommendation/index.jsx
@@ -0,0 +1 @@
+export * from './Recommendation.jsx'
diff --git a/demos/src/Extensions/DragHandleWithNodeViews/React/extensions/recommendation/views/RecommendationView.jsx b/demos/src/Extensions/DragHandleWithNodeViews/React/extensions/recommendation/views/RecommendationView.jsx
new file mode 100644
index 0000000..5fe7758
--- /dev/null
+++ b/demos/src/Extensions/DragHandleWithNodeViews/React/extensions/recommendation/views/RecommendationView.jsx
@@ -0,0 +1,11 @@
+import { NodeViewWrapper } from '@tiptap/react'
+
+export const RecommendationView = ({ node, getPos }) => {
+ return (
+
+ Recommendation {node.attrs.id}
+ Test
+ pos: {getPos()}
+
+ )
+}
diff --git a/demos/src/Extensions/DragHandleWithNodeViews/React/extensions/recommendation/views/index.jsx b/demos/src/Extensions/DragHandleWithNodeViews/React/extensions/recommendation/views/index.jsx
new file mode 100644
index 0000000..42bf3b5
--- /dev/null
+++ b/demos/src/Extensions/DragHandleWithNodeViews/React/extensions/recommendation/views/index.jsx
@@ -0,0 +1 @@
+export * from './RecommendationView.jsx'
diff --git a/demos/src/Extensions/DragHandleWithNodeViews/React/index.html b/demos/src/Extensions/DragHandleWithNodeViews/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/DragHandleWithNodeViews/React/index.jsx b/demos/src/Extensions/DragHandleWithNodeViews/React/index.jsx
new file mode 100644
index 0000000..0ac23c9
--- /dev/null
+++ b/demos/src/Extensions/DragHandleWithNodeViews/React/index.jsx
@@ -0,0 +1,44 @@
+import './styles.scss'
+
+import DragHandle from '@tiptap/extension-drag-handle-react'
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+import { Recommendation } from './extensions/recommendation/index.jsx'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [StarterKit, Recommendation],
+ content: `
+
+ Drag to reorder — watch the position update instantly
+
+
+ Drag any recommendation block below to a new position. The pos: value should update immediately.
+
+
+
+
+
+
+ `,
+ })
+
+ return (
+ <>
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Extensions/DragHandleWithNodeViews/React/styles.scss b/demos/src/Extensions/DragHandleWithNodeViews/React/styles.scss
new file mode 100644
index 0000000..a512c9f
--- /dev/null
+++ b/demos/src/Extensions/DragHandleWithNodeViews/React/styles.scss
@@ -0,0 +1,62 @@
+.ProseMirror {
+ padding-inline: 4rem;
+
+ > * + * {
+ margin-top: 0.75em;
+ }
+
+ [data-id] {
+ border: 3px solid #0d0d0d;
+ border-radius: 0.5rem;
+ margin: 1rem 0;
+ position: relative;
+ margin-top: 1.5rem;
+ padding: 2rem 1rem 1rem;
+
+ &::before {
+ content: attr(data-id);
+ background-color: #0d0d0d;
+ font-size: 0.6rem;
+ letter-spacing: 1px;
+ font-weight: bold;
+ text-transform: uppercase;
+ color: #fff;
+ position: absolute;
+ top: 0;
+ padding: 0.25rem 0.75rem;
+ border-radius: 0 0 0.5rem 0.5rem;
+ }
+ }
+}
+
+.drag-handle {
+ align-items: center;
+ background: #f0f0f0;
+ border-radius: 0.25rem;
+ border: 1px solid rgba(0, 0, 0, 0.1);
+ cursor: grab;
+ display: flex;
+ height: 1.5rem;
+ justify-content: center;
+ width: 1.5rem;
+
+ svg {
+ width: 1.25rem;
+ height: 1.25rem;
+ }
+}
+
+.node-recommendation {
+ padding: 0.5rem;
+ border-radius: 0.5rem;
+ border: 0.15rem solid #000;
+
+ .title {
+ font-size: 0.875rem;
+ color: #777;
+ }
+
+ p {
+ margin: 0;
+ }
+}
diff --git a/demos/src/Extensions/DragHandleWithNodeViews/Vue/extensions/recommendation/Recommendation.js b/demos/src/Extensions/DragHandleWithNodeViews/Vue/extensions/recommendation/Recommendation.js
new file mode 100644
index 0000000..502fe70
--- /dev/null
+++ b/demos/src/Extensions/DragHandleWithNodeViews/Vue/extensions/recommendation/Recommendation.js
@@ -0,0 +1,52 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+import { VueNodeViewRenderer } from '@tiptap/vue'
+
+import RecommendationView from './views/RecommendationView.vue'
+
+export const Recommendation = Node.create({
+ name: 'recommendation',
+
+ group: 'block',
+
+ draggable: true,
+
+ addOptions() {
+ return {
+ HTMLAttributes: {
+ class: `node-${this.name}`,
+ },
+ }
+ },
+
+ addAttributes() {
+ return {
+ id: {
+ default: undefined,
+ parseHTML: element => element.getAttribute('data-id'),
+ renderHTML: attributes => ({
+ 'data-id': attributes.id,
+ }),
+ },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: `div.node-${this.name}`,
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]
+ },
+
+ addNodeView() {
+ return VueNodeViewRenderer(RecommendationView, {
+ trackNodeViewPosition: true,
+ })
+ },
+})
+
+export default Recommendation
diff --git a/demos/src/Extensions/DragHandleWithNodeViews/Vue/extensions/recommendation/views/RecommendationView.vue b/demos/src/Extensions/DragHandleWithNodeViews/Vue/extensions/recommendation/views/RecommendationView.vue
new file mode 100644
index 0000000..90a7f52
--- /dev/null
+++ b/demos/src/Extensions/DragHandleWithNodeViews/Vue/extensions/recommendation/views/RecommendationView.vue
@@ -0,0 +1,19 @@
+
+
+ Recommendation {{ node.attrs.id }}
+ Test
+ pos: {{ getPos() }}
+
+
+
+
diff --git a/demos/src/Extensions/DragHandleWithNodeViews/Vue/index.html b/demos/src/Extensions/DragHandleWithNodeViews/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/DragHandleWithNodeViews/Vue/index.vue b/demos/src/Extensions/DragHandleWithNodeViews/Vue/index.vue
new file mode 100644
index 0000000..bd97b27
--- /dev/null
+++ b/demos/src/Extensions/DragHandleWithNodeViews/Vue/index.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/Dropcursor/React/index.html b/demos/src/Extensions/Dropcursor/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/Dropcursor/React/index.jsx b/demos/src/Extensions/Dropcursor/React/index.jsx
new file mode 100644
index 0000000..a6a0d3b
--- /dev/null
+++ b/demos/src/Extensions/Dropcursor/React/index.jsx
@@ -0,0 +1,21 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Image from '@tiptap/extension-image'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { Dropcursor } from '@tiptap/extensions'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, Image, Dropcursor],
+ content: `
+ Try to drag around the image. While you drag, the editor should show a decoration under your cursor. The so called dropcursor.
+
+ `,
+ })
+
+ return
+}
diff --git a/demos/src/Extensions/Dropcursor/React/styles.scss b/demos/src/Extensions/Dropcursor/React/styles.scss
new file mode 100644
index 0000000..51aa105
--- /dev/null
+++ b/demos/src/Extensions/Dropcursor/React/styles.scss
@@ -0,0 +1,17 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ img {
+ display: block;
+ height: auto;
+ margin: 1.5rem 0;
+ max-width: 100%;
+
+ &.ProseMirror-selectednode {
+ outline: 3px solid var(--purple);
+ }
+ }
+}
diff --git a/demos/src/Extensions/Dropcursor/Vue/index.html b/demos/src/Extensions/Dropcursor/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/Dropcursor/Vue/index.vue b/demos/src/Extensions/Dropcursor/Vue/index.vue
new file mode 100644
index 0000000..4c01fd9
--- /dev/null
+++ b/demos/src/Extensions/Dropcursor/Vue/index.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/FileHandler/React/index.html b/demos/src/Extensions/FileHandler/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/FileHandler/React/index.jsx b/demos/src/Extensions/FileHandler/React/index.jsx
new file mode 100644
index 0000000..56f0536
--- /dev/null
+++ b/demos/src/Extensions/FileHandler/React/index.jsx
@@ -0,0 +1,82 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import FileHandler from '@tiptap/extension-file-handler'
+import Heading from '@tiptap/extension-heading'
+import Image from '@tiptap/extension-image'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Heading,
+ Paragraph,
+ Text,
+ Image,
+ FileHandler.configure({
+ allowedMimeTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
+ onDrop: (currentEditor, files, pos) => {
+ files.forEach(file => {
+ const fileReader = new FileReader()
+
+ fileReader.readAsDataURL(file)
+ fileReader.onload = () => {
+ currentEditor
+ .chain()
+ .insertContentAt(pos, {
+ type: 'image',
+ attrs: {
+ src: fileReader.result,
+ },
+ })
+ .focus()
+ .run()
+ }
+ })
+ },
+ onPaste: (currentEditor, files, htmlContent) => {
+ files.forEach(file => {
+ if (htmlContent) {
+ // if there is htmlContent, stop manual insertion & let other extensions handle insertion via inputRule
+ // you could extract the pasted file from this url string and upload it to a server for example
+ console.log(htmlContent) // oxlint-disable-line no-console
+ return false
+ }
+
+ const fileReader = new FileReader()
+
+ fileReader.readAsDataURL(file)
+ fileReader.onload = () => {
+ currentEditor
+ .chain()
+ .insertContentAt(currentEditor.state.selection.anchor, {
+ type: 'image',
+ attrs: {
+ src: fileReader.result,
+ },
+ })
+ .focus()
+ .run()
+ }
+ })
+ },
+ }),
+ ],
+ content: `
+
+ Try to paste or drop files into this editor
+
+
+
+
+
+
+ `,
+ })
+
+ return
+}
diff --git a/demos/src/Extensions/FileHandler/React/styles.scss b/demos/src/Extensions/FileHandler/React/styles.scss
new file mode 100644
index 0000000..51aa105
--- /dev/null
+++ b/demos/src/Extensions/FileHandler/React/styles.scss
@@ -0,0 +1,17 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ img {
+ display: block;
+ height: auto;
+ margin: 1.5rem 0;
+ max-width: 100%;
+
+ &.ProseMirror-selectednode {
+ outline: 3px solid var(--purple);
+ }
+ }
+}
diff --git a/demos/src/Extensions/FileHandler/Vue/index.html b/demos/src/Extensions/FileHandler/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/FileHandler/Vue/index.vue b/demos/src/Extensions/FileHandler/Vue/index.vue
new file mode 100644
index 0000000..f83736d
--- /dev/null
+++ b/demos/src/Extensions/FileHandler/Vue/index.vue
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/FindAndReplace/React/hooks/useFindAndReplace.js b/demos/src/Extensions/FindAndReplace/React/hooks/useFindAndReplace.js
new file mode 100644
index 0000000..7ae4032
--- /dev/null
+++ b/demos/src/Extensions/FindAndReplace/React/hooks/useFindAndReplace.js
@@ -0,0 +1,82 @@
+import { useEditorState } from '@tiptap/react'
+import { useEffect, useState } from 'react'
+
+export function useFindAndReplace(editor) {
+ const { searchTerm, replaceTerm, caseSensitive, useRegex, wholeWord, resultCount, currentIndex } =
+ useEditorState({
+ editor,
+ selector: ctx => ({
+ searchTerm: ctx.editor.storage.findAndReplace.searchTerm,
+ replaceTerm: ctx.editor.storage.findAndReplace.replaceTerm,
+ caseSensitive: ctx.editor.storage.findAndReplace.caseSensitive,
+ useRegex: ctx.editor.storage.findAndReplace.useRegex,
+ wholeWord: ctx.editor.storage.findAndReplace.wholeWord,
+ resultCount: ctx.editor.storage.findAndReplace.results.length,
+ currentIndex: ctx.editor.storage.findAndReplace.currentIndex,
+ }),
+ })
+ const [searchInput, setSearchInput] = useState(searchTerm)
+
+ useEffect(() => {
+ setSearchInput(searchTerm)
+ }, [searchTerm])
+
+ const setSearchTerm = term => {
+ setSearchInput(term)
+ editor.commands.setSearchTerm(term)
+ }
+
+ const goToNextResult = () => {
+ editor.commands.goToNextResult()
+ }
+
+ const goToPreviousResult = () => {
+ editor.commands.goToPreviousResult()
+ }
+
+ const replace = () => {
+ editor.commands.replace()
+ }
+
+ const replaceAll = () => {
+ editor.commands.replaceAll()
+ }
+
+ const clearSearch = () => {
+ setSearchInput('')
+ editor.commands.clearSearch()
+ }
+
+ const onSearchKeyDown = event => {
+ if (event.key !== 'Enter') {
+ return
+ }
+
+ if (event.shiftKey) {
+ editor.commands.goToPreviousResult()
+ } else {
+ editor.commands.goToNextResult()
+ }
+ }
+
+ return {
+ searchInput,
+ replaceTerm,
+ caseSensitive,
+ useRegex,
+ wholeWord,
+ resultCount,
+ currentIndex,
+ setSearchTerm,
+ setReplaceTerm: term => editor.commands.setReplaceTerm(term),
+ setCaseSensitive: value => editor.commands.setCaseSensitive(value),
+ setUseRegex: value => editor.commands.setUseRegex(value),
+ setWholeWord: value => editor.commands.setWholeWord(value),
+ goToNextResult,
+ goToPreviousResult,
+ replace,
+ replaceAll,
+ clearSearch,
+ onSearchKeyDown,
+ }
+}
diff --git a/demos/src/Extensions/FindAndReplace/React/index.html b/demos/src/Extensions/FindAndReplace/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/FindAndReplace/React/index.jsx b/demos/src/Extensions/FindAndReplace/React/index.jsx
new file mode 100644
index 0000000..7b8437f
--- /dev/null
+++ b/demos/src/Extensions/FindAndReplace/React/index.jsx
@@ -0,0 +1,136 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import FindAndReplace from '@tiptap/extension-find-and-replace'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor } from '@tiptap/react'
+
+import { useFindAndReplace } from './hooks/useFindAndReplace'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, FindAndReplace],
+ content: `
+
+ Find and replace is one of those features you only miss once it is gone.
+ Try searching for "tiptap" in this text. Tiptap highlights every match
+ right inside the editor, no matter if it is written as tiptap, Tiptap
+ or even TIPTAP.
+
+
+ Toggle "Match case" to only find exact matches. Enable "Regex" and search
+ for colou?r to find both color and colour at once. Hit "Replace" to swap
+ the current match and jump straight to the next one.
+
+ `,
+ })
+
+ const {
+ searchInput,
+ replaceTerm,
+ caseSensitive,
+ useRegex,
+ wholeWord,
+ resultCount,
+ currentIndex,
+ setSearchTerm,
+ setReplaceTerm,
+ setCaseSensitive,
+ setUseRegex,
+ setWholeWord,
+ goToNextResult,
+ goToPreviousResult,
+ replace,
+ replaceAll,
+ clearSearch,
+ onSearchKeyDown,
+ } = useFindAndReplace(editor)
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/demos/src/Extensions/FindAndReplace/React/styles.scss b/demos/src/Extensions/FindAndReplace/React/styles.scss
new file mode 100644
index 0000000..9ed2f12
--- /dev/null
+++ b/demos/src/Extensions/FindAndReplace/React/styles.scss
@@ -0,0 +1,21 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
+
+.control-group {
+ label {
+ align-items: center;
+ display: flex;
+ gap: 0.25rem;
+ font-size: 0.875rem;
+ }
+
+ .result-count {
+ align-self: center;
+ color: var(--gray-5);
+ font-size: 0.875rem;
+ }
+}
diff --git a/demos/src/Extensions/FindAndReplace/Vue/index.html b/demos/src/Extensions/FindAndReplace/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/FindAndReplace/Vue/index.vue b/demos/src/Extensions/FindAndReplace/Vue/index.vue
new file mode 100644
index 0000000..3c0bad8
--- /dev/null
+++ b/demos/src/Extensions/FindAndReplace/Vue/index.vue
@@ -0,0 +1,171 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/FindAndReplace/Vue/utils.ts b/demos/src/Extensions/FindAndReplace/Vue/utils.ts
new file mode 100644
index 0000000..b3c90ae
--- /dev/null
+++ b/demos/src/Extensions/FindAndReplace/Vue/utils.ts
@@ -0,0 +1,76 @@
+import type { Editor } from '@tiptap/vue'
+import type { FindAndReplaceStorage } from '@tiptap/extension-find-and-replace'
+import { computed, ref } from 'vue'
+
+function createFindAndReplaceState(): FindAndReplaceStorage {
+ return {
+ searchTerm: '',
+ replaceTerm: '',
+ caseSensitive: false,
+ useRegex: false,
+ wholeWord: false,
+ results: [],
+ currentIndex: null,
+ }
+}
+
+export function useFindAndReplace() {
+ const findAndReplace = ref(createFindAndReplaceState())
+ const resultCount = computed(() => {
+ const { results, currentIndex } = findAndReplace.value
+
+ return results.length === 0 ? 'No results' : `${(currentIndex ?? 0) + 1} of ${results.length}`
+ })
+ let editor: Editor | null = null
+
+ const updateFindAndReplace = () => {
+ if (editor) {
+ findAndReplace.value = { ...editor.storage.findAndReplace }
+ }
+ }
+
+ return {
+ findAndReplace,
+ resultCount,
+ connect(nextEditor: Editor) {
+ editor = nextEditor
+ updateFindAndReplace()
+ editor.on('transaction', updateFindAndReplace)
+ },
+ disconnect() {
+ editor?.off('transaction', updateFindAndReplace)
+ editor = null
+ },
+ setSearchTerm(term: string) {
+ findAndReplace.value.searchTerm = term
+ editor?.commands.setSearchTerm(term)
+ },
+ setReplaceTerm(term: string) {
+ editor?.commands.setReplaceTerm(term)
+ },
+ setCaseSensitive(value: boolean) {
+ editor?.commands.setCaseSensitive(value)
+ },
+ setUseRegex(value: boolean) {
+ editor?.commands.setUseRegex(value)
+ },
+ setWholeWord(value: boolean) {
+ editor?.commands.setWholeWord(value)
+ },
+ goToNextResult() {
+ editor?.commands.goToNextResult()
+ },
+ goToPreviousResult() {
+ editor?.commands.goToPreviousResult()
+ },
+ replace() {
+ editor?.commands.replace()
+ },
+ replaceAll() {
+ editor?.commands.replaceAll()
+ },
+ clearSearch() {
+ editor?.commands.clearSearch()
+ },
+ }
+}
diff --git a/demos/src/Extensions/FloatingMenu/React/index.html b/demos/src/Extensions/FloatingMenu/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/FloatingMenu/React/index.jsx b/demos/src/Extensions/FloatingMenu/React/index.jsx
new file mode 100644
index 0000000..1f78a1c
--- /dev/null
+++ b/demos/src/Extensions/FloatingMenu/React/index.jsx
@@ -0,0 +1,71 @@
+import './styles.scss'
+
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import { FloatingMenu } from '@tiptap/react/menus'
+import StarterKit from '@tiptap/starter-kit'
+import React, { useEffect } from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [StarterKit],
+ content: `
+
+ This is an example of a Medium-like editor. Enter a new line and some buttons will appear.
+
+
+ `,
+ })
+
+ const [isEditable, setIsEditable] = React.useState(true)
+
+ useEffect(() => {
+ if (editor) {
+ editor.setEditable(isEditable)
+ }
+ }, [isEditable, editor])
+
+ const { isHeading1, isHeading2, isBulletList } = useEditorState({
+ editor,
+ selector: ctx => ({
+ isHeading1: ctx.editor.isActive('heading', { level: 1 }) ?? false,
+ isHeading2: ctx.editor.isActive('heading', { level: 2 }) ?? false,
+ isBulletList: ctx.editor.isActive('bulletList') ?? false,
+ }),
+ })
+
+ return (
+ <>
+
+
+ setIsEditable(!isEditable)} />
+ Editable
+
+
+ {editor && (
+
+
+ editor.chain().focus().toggleHeading({ level: 1 }).run()}
+ className={isHeading1 ? 'is-active' : ''}
+ >
+ H1
+
+ editor.chain().focus().toggleHeading({ level: 2 }).run()}
+ className={isHeading2 ? 'is-active' : ''}
+ >
+ H2
+
+ editor.chain().focus().toggleBulletList().run()}
+ className={isBulletList ? 'is-active' : ''}
+ >
+ Bullet list
+
+
+
+ )}
+
+ >
+ )
+}
diff --git a/demos/src/Extensions/FloatingMenu/React/styles.scss b/demos/src/Extensions/FloatingMenu/React/styles.scss
new file mode 100644
index 0000000..d2765cd
--- /dev/null
+++ b/demos/src/Extensions/FloatingMenu/React/styles.scss
@@ -0,0 +1,118 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
+
+/* Floating menu */
+.floating-menu {
+ display: flex;
+ background-color: var(--gray-3);
+ padding: 0.1rem;
+ border-radius: 0.5rem;
+
+ button {
+ background-color: unset;
+ padding: 0.275rem 0.425rem;
+ border-radius: 0.3rem;
+
+ &:hover {
+ background-color: var(--gray-3);
+ }
+
+ &.is-active {
+ background-color: var(--white);
+ color: var(--purple);
+
+ &:hover {
+ color: var(--purple-contrast);
+ }
+ }
+ }
+}
diff --git a/demos/src/Extensions/FloatingMenu/Vue/index.html b/demos/src/Extensions/FloatingMenu/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/FloatingMenu/Vue/index.vue b/demos/src/Extensions/FloatingMenu/Vue/index.vue
new file mode 100644
index 0000000..c98482a
--- /dev/null
+++ b/demos/src/Extensions/FloatingMenu/Vue/index.vue
@@ -0,0 +1,196 @@
+
+
+
+
+ (isEditable = !isEditable)" />
+ Editable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/FloatingMenu/index.spec.ts b/demos/src/Extensions/FloatingMenu/index.spec.ts
new file mode 100644
index 0000000..7a1e901
--- /dev/null
+++ b/demos/src/Extensions/FloatingMenu/index.spec.ts
@@ -0,0 +1,33 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'FloatingMenu'
+const frameworkPaths = ['React']
+const demoPath = '/src/Extensions'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test('does not render a floating menu on non-empty nodes', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) =>
+ el.editor.chain().setContent('Example Text
').focus().run(),
+ )
+ await expect(page.locator('[data-testid="floating-menu"]')).toHaveCount(0)
+ })
+
+ test('renders a floating menu on empty nodes', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.chain().setContent('
').focus().run())
+ await expect(page.locator('[data-testid="floating-menu"]')).toBeVisible()
+ })
+ })
+ })
+})
diff --git a/demos/src/Extensions/Focus/React/index.html b/demos/src/Extensions/Focus/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/Focus/React/index.jsx b/demos/src/Extensions/Focus/React/index.jsx
new file mode 100644
index 0000000..ff7d340
--- /dev/null
+++ b/demos/src/Extensions/Focus/React/index.jsx
@@ -0,0 +1,39 @@
+import './styles.scss'
+
+import Code from '@tiptap/extension-code'
+import Document from '@tiptap/extension-document'
+import { BulletList, ListItem } from '@tiptap/extension-list'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { Focus } from '@tiptap/extensions'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Focus.configure({
+ className: 'has-focus',
+ mode: 'all',
+ }),
+ Code,
+ BulletList,
+ ListItem,
+ ],
+ autofocus: true,
+ content: `
+
+ The focus extension adds a class to the focused node only. That enables you to add a custom styling to just that node. By default, it’ll add .has-focus, even to nested nodes.
+
+
+ Nested elements (like this list item) will be focused with the default setting of mode: all.
+ Otherwise the whole list will get the focus class, even when just a single list item is selected.
+
+ `,
+ })
+
+ return
+}
diff --git a/demos/src/Extensions/Focus/React/styles.scss b/demos/src/Extensions/Focus/React/styles.scss
new file mode 100644
index 0000000..15839cf
--- /dev/null
+++ b/demos/src/Extensions/Focus/React/styles.scss
@@ -0,0 +1,33 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ // Focus styles
+ .has-focus {
+ border-radius: 3px;
+ box-shadow: 0 0 0 2px var(--purple);
+ }
+}
diff --git a/demos/src/Extensions/Focus/Vue/index.html b/demos/src/Extensions/Focus/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/Focus/Vue/index.vue b/demos/src/Extensions/Focus/Vue/index.vue
new file mode 100644
index 0000000..6b07211
--- /dev/null
+++ b/demos/src/Extensions/Focus/Vue/index.vue
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/Focus/index.spec.ts b/demos/src/Extensions/Focus/index.spec.ts
new file mode 100644
index 0000000..b2865c0
--- /dev/null
+++ b/demos/src/Extensions/Focus/index.spec.ts
@@ -0,0 +1,21 @@
+import { expect, test } from '@playwright/test'
+
+const demoName = 'Focus'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Extensions'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test('first paragraph has has-focus class', async ({ page }) => {
+ await expect(page.locator('.tiptap p').first()).toHaveClass(/has-focus/)
+ })
+ })
+ })
+})
diff --git a/demos/src/Extensions/FontFamily/React/index.html b/demos/src/Extensions/FontFamily/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/FontFamily/React/index.jsx b/demos/src/Extensions/FontFamily/React/index.jsx
new file mode 100644
index 0000000..f4d6a18
--- /dev/null
+++ b/demos/src/Extensions/FontFamily/React/index.jsx
@@ -0,0 +1,118 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { FontFamily, TextStyle } from '@tiptap/extension-text-style'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, TextStyle, FontFamily],
+ content: `
+ Did you know that Inter is a really nice font for interfaces?
+ It doesn’t look as professional as Comic Sans.
+ Serious people use serif fonts anyway.
+ The cool kids can apply monospace fonts aswell.
+ But hopefully we all can agree, that cursive fonts are the best.
+ Then there are CSS variables, the new hotness.
+ TipTap even can handle exotic fonts as Exo 2.
+ `,
+ })
+
+ const { isInter, isComicSans, isSerif, isMonospace, isCursive, isExo2, isCssVariable } =
+ useEditorState({
+ editor,
+ selector: ctx => {
+ return {
+ isInter: ctx.editor.isActive('textStyle', { fontFamily: 'Inter' }),
+ isComicSans: ctx.editor.isActive('textStyle', {
+ fontFamily: '"Comic Sans MS", "Comic Sans"',
+ }),
+ isSerif: ctx.editor.isActive('textStyle', { fontFamily: 'serif' }),
+ isMonospace: ctx.editor.isActive('textStyle', { fontFamily: 'monospace' }),
+ isCursive: ctx.editor.isActive('textStyle', { fontFamily: 'cursive' }),
+ isExo2: ctx.editor.isActive('textStyle', { fontFamily: '"Exo 2"' }),
+ isCssVariable: ctx.editor.isActive('textStyle', {
+ fontFamily: 'var(--title-font-family)',
+ }),
+ }
+ },
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+
+ editor.chain().focus().setFontFamily('Inter').run()}
+ className={isInter ? 'is-active' : ''}
+ data-test-id="inter"
+ >
+ Inter
+
+
+ editor.chain().focus().setFontFamily('"Comic Sans MS", "Comic Sans"').run()
+ }
+ className={isComicSans ? 'is-active' : ''}
+ data-test-id="comic-sans"
+ >
+ Comic Sans
+
+ editor.chain().focus().setFontFamily('serif').run()}
+ className={isSerif ? 'is-active' : ''}
+ data-test-id="serif"
+ >
+ Serif
+
+ editor.chain().focus().setFontFamily('monospace').run()}
+ className={isMonospace ? 'is-active' : ''}
+ data-test-id="monospace"
+ >
+ Monospace
+
+ editor.chain().focus().setFontFamily('cursive').run()}
+ className={isCursive ? 'is-active' : ''}
+ data-test-id="cursive"
+ >
+ Cursive
+
+ editor.chain().focus().setFontFamily('var(--title-font-family)').run()}
+ className={isCssVariable ? 'is-active' : ''}
+ data-test-id="css-variable"
+ >
+ CSS variable
+
+ editor.chain().focus().setFontFamily('"Exo 2"').run()}
+ className={isExo2 ? 'is-active' : ''}
+ data-test-id="exo2"
+ >
+ Exo 2
+
+ editor.chain().focus().unsetFontFamily().run()}
+ data-test-id="unsetFontFamily"
+ >
+ Unset font family
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Extensions/FontFamily/React/styles.scss b/demos/src/Extensions/FontFamily/React/styles.scss
new file mode 100644
index 0000000..bea34fb
--- /dev/null
+++ b/demos/src/Extensions/FontFamily/React/styles.scss
@@ -0,0 +1,10 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
+
+html {
+ --title-font-family: 'Helvetica', sans-serif;
+}
diff --git a/demos/src/Extensions/FontFamily/Vue/index.html b/demos/src/Extensions/FontFamily/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/FontFamily/Vue/index.vue b/demos/src/Extensions/FontFamily/Vue/index.vue
new file mode 100644
index 0000000..d9af4ca
--- /dev/null
+++ b/demos/src/Extensions/FontFamily/Vue/index.vue
@@ -0,0 +1,88 @@
+
+
+
+
+
+ Inter
+
+
+ Comic Sans
+
+
+ Serif
+
+
+ Monospace
+
+
+ Cursive
+
+ Unset font family
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/FontSize/React/index.html b/demos/src/Extensions/FontSize/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/FontSize/React/index.jsx b/demos/src/Extensions/FontSize/React/index.jsx
new file mode 100644
index 0000000..1c5ead6
--- /dev/null
+++ b/demos/src/Extensions/FontSize/React/index.jsx
@@ -0,0 +1,68 @@
+import { FontSize, TextStyle } from '@tiptap/extension-text-style'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [StarterKit, TextStyle, FontSize],
+ content: `
+ Adjusting font sizes can greatly affect the readability of your text, making it easier for users to engage with your content.
+ When designing a website, it's crucial to balance large headings and smaller body text for a clean, organized layout.
+ When setting font sizes, it's important to consider accessibility, ensuring that text is readable for users with different visual impairments.
+ Too small a font size can strain the eyes, while too large can disrupt the flow of the design.
+ When designing for mobile, font sizes should be adjusted to maintain readability on smaller screens.
+ `,
+ })
+
+ const { is28px, is32px } = useEditorState({
+ editor,
+ selector: ctx => {
+ if (!ctx.editor) {
+ return {
+ is28px: false,
+ is32px: false,
+ }
+ }
+
+ return {
+ is28px: ctx.editor.isActive('textStyle', { fontSize: '28px' }) ?? false,
+ is32px: ctx.editor.isActive('textStyle', { fontSize: '32px' }) ?? false,
+ }
+ },
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().setFontSize('28px').run()}
+ className={is28px ? 'is-active' : ''}
+ data-test-id="28px"
+ >
+ Font size 28px
+
+ editor.chain().focus().setFontSize('32px').run()}
+ className={is32px ? 'is-active' : ''}
+ data-test-id="32px"
+ >
+ Font size 32px
+
+ editor.chain().focus().unsetFontSize().run()}
+ data-test-id="unsetFontSize"
+ >
+ Unset font size
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Extensions/FontSize/Vue/index.html b/demos/src/Extensions/FontSize/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/FontSize/Vue/index.vue b/demos/src/Extensions/FontSize/Vue/index.vue
new file mode 100644
index 0000000..ca531e7
--- /dev/null
+++ b/demos/src/Extensions/FontSize/Vue/index.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+ Font size 28px
+
+
+ Font size 32px
+
+
+ Unset font size
+
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/Gapcursor/React/index.html b/demos/src/Extensions/Gapcursor/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/Gapcursor/React/index.jsx b/demos/src/Extensions/Gapcursor/React/index.jsx
new file mode 100644
index 0000000..4065f33
--- /dev/null
+++ b/demos/src/Extensions/Gapcursor/React/index.jsx
@@ -0,0 +1,21 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Image from '@tiptap/extension-image'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { Gapcursor } from '@tiptap/extensions'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, Image, Gapcursor],
+ content: `
+ Try to move the cursor after the image with your arrow keys! You should see a horizontal blinking cursor below the image. This is the gapcursor.
+
+ `,
+ })
+
+ return
+}
diff --git a/demos/src/Extensions/Gapcursor/React/styles.scss b/demos/src/Extensions/Gapcursor/React/styles.scss
new file mode 100644
index 0000000..51aa105
--- /dev/null
+++ b/demos/src/Extensions/Gapcursor/React/styles.scss
@@ -0,0 +1,17 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ img {
+ display: block;
+ height: auto;
+ margin: 1.5rem 0;
+ max-width: 100%;
+
+ &.ProseMirror-selectednode {
+ outline: 3px solid var(--purple);
+ }
+ }
+}
diff --git a/demos/src/Extensions/Gapcursor/Vue/index.html b/demos/src/Extensions/Gapcursor/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/Gapcursor/Vue/index.vue b/demos/src/Extensions/Gapcursor/Vue/index.vue
new file mode 100644
index 0000000..5df2240
--- /dev/null
+++ b/demos/src/Extensions/Gapcursor/Vue/index.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/InvisibleCharacters/React/index.html b/demos/src/Extensions/InvisibleCharacters/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/InvisibleCharacters/React/index.jsx b/demos/src/Extensions/InvisibleCharacters/React/index.jsx
new file mode 100644
index 0000000..80425f4
--- /dev/null
+++ b/demos/src/Extensions/InvisibleCharacters/React/index.jsx
@@ -0,0 +1,80 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import HardBreak from '@tiptap/extension-hard-break'
+import Heading from '@tiptap/extension-heading'
+import InvisibleCharacters from '@tiptap/extension-invisible-characters'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Heading, Text, InvisibleCharacters, HardBreak],
+ content: `
+
+ This is a heading.
+
+
+ This is a paragraph.
+
+
+ This is a paragraph, but without breaks.
+
+ `,
+ })
+
+ const { isVisible } = useEditorState({
+ editor,
+ selector: ctx => {
+ if (!ctx.editor) {
+ return {
+ isVisible: false,
+ }
+ }
+
+ return {
+ isVisible: ctx.editor.storage.invisibleCharacters.visibility() ?? false,
+ }
+ },
+ })
+
+ if (!editor) {
+ return false
+ }
+
+ return (
+
+
+
+ editor.commands.showInvisibleCharacters()}>
+ Show invisible characters
+
+ {/* Works as well */}
+ {/* editor.commands.showInvisibleCharacters(false)}>showInvisibleCharacters(false) */}
+ editor.commands.hideInvisibleCharacters()}>
+ Hide invisible characters
+
+ editor.commands.toggleInvisibleCharacters()}>
+ Toggle invisible characters
+
+
+
+ {
+ const value = event.currentTarget.checked
+
+ editor.commands.showInvisibleCharacters(value)
+ }}
+ />
+ Show invisibles
+
+
+
+
+ )
+}
diff --git a/demos/src/Extensions/InvisibleCharacters/React/styles.scss b/demos/src/Extensions/InvisibleCharacters/React/styles.scss
new file mode 100644
index 0000000..afd5ba2
--- /dev/null
+++ b/demos/src/Extensions/InvisibleCharacters/React/styles.scss
@@ -0,0 +1,6 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
diff --git a/demos/src/Extensions/InvisibleCharacters/Vue/index.html b/demos/src/Extensions/InvisibleCharacters/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/InvisibleCharacters/Vue/index.vue b/demos/src/Extensions/InvisibleCharacters/Vue/index.vue
new file mode 100644
index 0000000..9086fd9
--- /dev/null
+++ b/demos/src/Extensions/InvisibleCharacters/Vue/index.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+ Show invisible characters
+
+
+
+
+ Hide invisible characters
+
+
+ Toggle invisible characters
+
+
+
+ editor.commands.showInvisibleCharacters(event.currentTarget.checked)"
+ />
+ Show invisibles
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/LineHeight/React/index.html b/demos/src/Extensions/LineHeight/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/LineHeight/React/index.tsx b/demos/src/Extensions/LineHeight/React/index.tsx
new file mode 100644
index 0000000..6f35bd3
--- /dev/null
+++ b/demos/src/Extensions/LineHeight/React/index.tsx
@@ -0,0 +1,69 @@
+import './styles.scss'
+
+import { LineHeight, TextStyle } from '@tiptap/extension-text-style'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [StarterKit, TextStyle, LineHeight],
+ content: `
+ Adjusting line heights can greatly affect the readability of your text, making it easier for users to engage with your content.
+ Line height is the vertical distance between lines of text in a paragraph. It's also known as leading, which comes from the days of metal type, when strips of lead were placed between lines of type to add space.
+ Line height is expressed as a ratio, meaning the default line height is 1.0. A line height of 1.5 would be 1.5 times the height of the font, while a line height of 2.0 would be twice the height of the font.
+ It's important to choose a line height that's appropriate for your font size and line length. A line height that's too small can make text feel cramped, while a line height that's too large can make text feel disconnected.
+ This paragraph has a line height of 1.5.
+ This paragraph has the default line height of 1.0.
+ This paragraph has a line height of 4.0.
+ `,
+ })
+ const { isLarge, isSmall, isExtraLarge } = useEditorState({
+ editor,
+ selector: ctx => {
+ return {
+ isSmall: ctx.editor.isActive('textStyle', { lineHeight: '1.5' }),
+ isLarge: ctx.editor.isActive('textStyle', { lineHeight: '2.0' }),
+ isExtraLarge: ctx.editor.isActive('textStyle', { lineHeight: '4.0' }),
+ }
+ },
+ })
+
+ return (
+ <>
+
+
+ editor.chain().focus().toggleTextStyle({ lineHeight: '1.5' }).run()}
+ className={isSmall ? 'is-active' : ''}
+ data-test-id="1.5"
+ >
+ Line height 1.5
+
+ editor.chain().focus().toggleTextStyle({ lineHeight: '2.0' }).run()}
+ className={isLarge ? 'is-active' : ''}
+ data-test-id="2.0"
+ >
+ Line height 2.0
+
+ editor.chain().focus().toggleTextStyle({ lineHeight: '4.0' }).run()}
+ className={isExtraLarge ? 'is-active' : ''}
+ data-test-id="4.0"
+ >
+ Line height 4.0
+
+ editor.chain().focus().unsetLineHeight().run()}
+ data-test-id="unsetLineHeight"
+ >
+ Unset line height
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Extensions/LineHeight/React/styles.scss b/demos/src/Extensions/LineHeight/React/styles.scss
new file mode 100644
index 0000000..a3cb76a
--- /dev/null
+++ b/demos/src/Extensions/LineHeight/React/styles.scss
@@ -0,0 +1,18 @@
+/* Basic editor styles */
+.tiptap {
+ line-height: 1;
+ :first-child {
+ margin-top: 0;
+ }
+
+ img {
+ display: block;
+ height: auto;
+ margin: 1.5rem 0;
+ max-width: 100%;
+
+ &.ProseMirror-selectednode {
+ outline: 3px solid var(--purple);
+ }
+ }
+}
diff --git a/demos/src/Extensions/LineHeight/Vue/index.html b/demos/src/Extensions/LineHeight/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/LineHeight/Vue/index.vue b/demos/src/Extensions/LineHeight/Vue/index.vue
new file mode 100644
index 0000000..f625650
--- /dev/null
+++ b/demos/src/Extensions/LineHeight/Vue/index.vue
@@ -0,0 +1,75 @@
+
+
+
+
+
+ Line height 1.5
+
+
+ Line height 2.0
+
+
+ Line height 4.0
+
+
+ Unset line height
+
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/LineHeight/Vue/styles.scss b/demos/src/Extensions/LineHeight/Vue/styles.scss
new file mode 100644
index 0000000..a3cb76a
--- /dev/null
+++ b/demos/src/Extensions/LineHeight/Vue/styles.scss
@@ -0,0 +1,18 @@
+/* Basic editor styles */
+.tiptap {
+ line-height: 1;
+ :first-child {
+ margin-top: 0;
+ }
+
+ img {
+ display: block;
+ height: auto;
+ margin: 1.5rem 0;
+ max-width: 100%;
+
+ &.ProseMirror-selectednode {
+ outline: 3px solid var(--purple);
+ }
+ }
+}
diff --git a/demos/src/Extensions/ListKeymap/React/index.html b/demos/src/Extensions/ListKeymap/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/ListKeymap/React/index.jsx b/demos/src/Extensions/ListKeymap/React/index.jsx
new file mode 100644
index 0000000..1086b02
--- /dev/null
+++ b/demos/src/Extensions/ListKeymap/React/index.jsx
@@ -0,0 +1,80 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import { BulletList, ListItem, ListKeymap } from '@tiptap/extension-list'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, BulletList, ListItem, ListKeymap],
+ content: `
+
+ A list item
+ And another one
+
+ `,
+ })
+
+ const { isBulletList, canSplitListItem, canSinkListItem, canLiftListItem } = useEditorState({
+ editor,
+ selector: ctx => {
+ if (!ctx.editor) {
+ return {
+ isBulletList: false,
+ canSplitListItem: false,
+ canSinkListItem: false,
+ canLiftListItem: false,
+ }
+ }
+
+ return {
+ isBulletList: ctx.editor.isActive('bulletList') ?? false,
+ canSplitListItem: ctx.editor.can().splitListItem('listItem') ?? false,
+ canSinkListItem: ctx.editor.can().sinkListItem('listItem') ?? false,
+ canLiftListItem: ctx.editor.can().liftListItem('listItem') ?? false,
+ }
+ },
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().toggleBulletList().run()}
+ className={isBulletList ? 'is-active' : ''}
+ >
+ Toggle bullet list
+
+ editor.chain().focus().splitListItem('listItem').run()}
+ disabled={!canSplitListItem}
+ >
+ Split list item
+
+ editor.chain().focus().sinkListItem('listItem').run()}
+ disabled={!canSinkListItem}
+ >
+ Sink list item
+
+ editor.chain().focus().liftListItem('listItem').run()}
+ disabled={!canLiftListItem}
+ >
+ Lift list item
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Extensions/ListKeymap/React/styles.scss b/demos/src/Extensions/ListKeymap/React/styles.scss
new file mode 100644
index 0000000..95fe10c
--- /dev/null
+++ b/demos/src/Extensions/ListKeymap/React/styles.scss
@@ -0,0 +1,18 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+}
diff --git a/demos/src/Extensions/ListKeymap/index.spec.ts b/demos/src/Extensions/ListKeymap/index.spec.ts
new file mode 100644
index 0000000..13ba5ef
--- /dev/null
+++ b/demos/src/Extensions/ListKeymap/index.spec.ts
@@ -0,0 +1,173 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'ListKeymap'
+const frameworkPaths = ['React']
+const demoPath = '/src/Extensions'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ const placeCaretBefore = async (
+ editor: Awaited>,
+ content: string,
+ target: string,
+ ) => {
+ await editor.evaluate(
+ (el: any, { content: innerContent, target: innerTarget }: any) => {
+ el.editor.commands.setContent(innerContent)
+ let caret = -1
+ el.editor.state.doc.descendants((node: any, pos: number) => {
+ if (caret !== -1 || !node.isText) return
+ const offset = node.text.indexOf(innerTarget)
+ if (offset !== -1) caret = pos + offset
+ })
+ if (caret === -1) throw new Error(`Could not find "${innerTarget}" in the document`)
+ el.editor.chain().focus().setTextSelection({ from: caret, to: caret }).run()
+ },
+ { content, target },
+ )
+ }
+
+ const placeCaretInEmptyParagraph = async (
+ editor: Awaited>,
+ content: string,
+ ) => {
+ await editor.evaluate(
+ (el: any, { content: innerContent }: any) => {
+ el.editor.commands.setContent(innerContent)
+ let caret = -1
+ el.editor.state.doc.descendants((node: any, pos: number) => {
+ if (caret !== -1) return
+ if (node.type.name === 'paragraph' && node.content.size === 0) {
+ caret = pos + 1
+ }
+ })
+ if (caret === -1) throw new Error('Could not find an empty paragraph in the document')
+ el.editor.chain().focus().setTextSelection({ from: caret, to: caret }).run()
+ },
+ { content },
+ )
+ }
+
+ test('backspace at the start of a non-first item lifts it out of the list', async ({
+ page,
+ }) => {
+ const editor = await getEditor(page)
+ await placeCaretBefore(editor, '', 'B')
+ await editor.press('Backspace')
+ const html = await editor.evaluate((el: any) => el.editor.getHTML())
+ expect(html).toBe('B
')
+ })
+
+ test('backspace at the start of a middle item splits the list around it', async ({
+ page,
+ }) => {
+ const editor = await getEditor(page)
+ await placeCaretBefore(
+ editor,
+ '',
+ 'B',
+ )
+ await editor.press('Backspace')
+ const html = await editor.evaluate((el: any) => el.editor.getHTML())
+ expect(html).toBe('B
')
+ })
+
+ test('lift then merge: two backspaces collapse a child back into the previous item', async ({
+ page,
+ }) => {
+ const editor = await getEditor(page)
+ await placeCaretBefore(editor, '', 'B')
+ await editor.press('Backspace')
+ await editor.press('Backspace')
+ const html = await editor.evaluate((el: any) => el.editor.getHTML())
+ expect(html).toBe('')
+ })
+
+ test('tab in a paragraph after a list sinks it into the last item', async ({ page }) => {
+ const editor = await getEditor(page)
+ await placeCaretBefore(editor, 'B
', 'B')
+ await editor.press('Tab')
+ const html = await editor.evaluate((el: any) => el.editor.getHTML())
+ expect(html).toBe('')
+ })
+
+ test('tab in an empty paragraph after a list sinks the empty paragraph in', async ({
+ page,
+ }) => {
+ const editor = await getEditor(page)
+ await placeCaretInEmptyParagraph(editor, '
')
+ await editor.press('Tab')
+ const html = await editor.evaluate((el: any) => el.editor.getHTML())
+ expect(html).toBe('')
+ })
+
+ test('tab targets the last item of a multi-item list', async ({ page }) => {
+ const editor = await getEditor(page)
+ await placeCaretBefore(editor, 'B
', 'B')
+ await editor.press('Tab')
+ const html = await editor.evaluate((el: any) => el.editor.getHTML())
+ expect(html).toBe('')
+ })
+
+ test('tab in a paragraph not preceded by a list does nothing', async ({ page }) => {
+ const editor = await getEditor(page)
+ await placeCaretBefore(editor, 'A
B
', 'B')
+ await editor.press('Tab')
+ const html = await editor.evaluate((el: any) => el.editor.getHTML())
+ expect(html).toBe('A
B
')
+ })
+
+ test('tab in the first paragraph of the document does nothing', async ({ page }) => {
+ const editor = await getEditor(page)
+ await placeCaretBefore(editor, 'A
', 'A')
+ await editor.press('Tab')
+ const html = await editor.evaluate((el: any) => el.editor.getHTML())
+ expect(html).toBe('A
')
+ })
+
+ test('tab from mid-paragraph does nothing', async ({ page }) => {
+ const editor = await getEditor(page)
+ await placeCaretBefore(editor, 'Hello
', 'ello')
+ await editor.press('Tab')
+ const html = await editor.evaluate((el: any) => el.editor.getHTML())
+ expect(html).toBe('Hello
')
+ })
+
+ test('tab inside a non-first list item still nests via sinkListItem', async ({ page }) => {
+ const editor = await getEditor(page)
+ await placeCaretBefore(editor, '', 'B')
+ await editor.press('Tab')
+ const html = await editor.evaluate((el: any) => el.editor.getHTML())
+ expect(html).toBe('')
+ })
+
+ test('backspace at the start of a sunken paragraph merges into the previous textblock', async ({
+ page,
+ }) => {
+ const editor = await getEditor(page)
+ await placeCaretBefore(editor, '', 'B')
+ await editor.press('Backspace')
+ const html = await editor.evaluate((el: any) => el.editor.getHTML())
+ expect(html).toBe('')
+ })
+
+ test('tab then backspace round-trips an empty paragraph', async ({ page }) => {
+ const editor = await getEditor(page)
+ await placeCaretInEmptyParagraph(editor, '
')
+ await editor.press('Tab')
+ await editor.press('Backspace')
+ const html = await editor.evaluate((el: any) => el.editor.getHTML())
+ expect(html).toBe('')
+ })
+ })
+ })
+})
diff --git a/demos/src/Extensions/Mathematics/React/index.html b/demos/src/Extensions/Mathematics/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/Mathematics/React/index.jsx b/demos/src/Extensions/Mathematics/React/index.jsx
new file mode 100644
index 0000000..462f4da
--- /dev/null
+++ b/demos/src/Extensions/Mathematics/React/index.jsx
@@ -0,0 +1,162 @@
+import 'katex/dist/katex.min.css'
+import './styles.scss'
+
+import Math, { migrateMathStrings } from '@tiptap/extension-mathematics'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React, { useCallback } from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ Math.configure({
+ blockOptions: {
+ onClick: (node, pos) => {
+ const newCalculation = prompt('Enter new calculation:', node.attrs.latex)
+ if (newCalculation) {
+ editor
+ .chain()
+ .setNodeSelection(pos)
+ .updateBlockMath({ latex: newCalculation })
+ .focus()
+ .run()
+ }
+ },
+ },
+ inlineOptions: {
+ onClick: node => {
+ const newCalculation = prompt('Enter new calculation:', node.attrs.latex)
+ if (newCalculation) {
+ editor
+ .chain()
+ .setNodeSelection(node.pos)
+ .updateInlineMath({ latex: newCalculation })
+ .focus()
+ .run()
+ }
+ },
+ },
+ }),
+ ],
+ onCreate: ({ editor: currentEditor }) => {
+ migrateMathStrings(currentEditor)
+ },
+ content: `
+
+ This editor supports math expressions. And it even supports converting old $\\sub(3*5=15)$ calculations.
+
+ This is a old $\\LaTeX$ calculation string with $3*5=15$ calculations.
+
+ Did you know that ? Isn't that crazy? Also Pythagoras' theorem is .
+ Also the square root of 2 is . If you want to know more about visit katex.org .
+
+
+ $\\LaTeX$
+
+
+ Do you want go deeper? Here is a list of all supported functions:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The math extension also supports block level math nodes:
+
+ `,
+ })
+
+ const { isEditable } = useEditorState({
+ editor,
+ selector: ctx => {
+ if (!ctx.editor) {
+ return {
+ isEditable: false,
+ }
+ }
+
+ return {
+ isEditable: ctx.editor.isEditable ?? false,
+ }
+ },
+ })
+
+ const toggleEditing = useCallback(
+ e => {
+ if (!editor) {
+ return
+ }
+
+ const { checked } = e.target
+
+ editor.setEditable(!checked, true)
+ editor.view.dispatch(editor.view.state.tr.scrollIntoView())
+ },
+ [editor],
+ )
+
+ const onInsertInlineMath = useCallback(() => {
+ const hasSelection = !editor.state.selection.empty
+
+ if (hasSelection) {
+ return editor.chain().setInlineMath().focus().run()
+ }
+
+ const latex = prompt('Enter inline math expression:', '')
+ return editor.chain().insertInlineMath({ latex }).focus().run()
+ }, [editor])
+
+ const onRemoveInlineMath = useCallback(() => {
+ editor.chain().deleteInlineMath().focus().run()
+ }, [editor])
+
+ const onInsertBlockMath = useCallback(() => {
+ const hasSelection = !editor.state.selection.empty
+
+ if (hasSelection) {
+ return editor.chain().setBlockMath().focus().run()
+ }
+
+ const latex = prompt('Enter block math expression:', '')
+ return editor.chain().insertBlockMath({ latex }).focus().run()
+ }, [editor])
+
+ const onRemoveBlockMath = useCallback(() => {
+ editor.chain().deleteBlockMath().focus().run()
+ }, [editor])
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+
+ Readonly
+
+
+
+
+ Insert inline math
+ Remove inline math
+ Insert block math
+ Remove block math
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Extensions/Mathematics/React/styles.scss b/demos/src/Extensions/Mathematics/React/styles.scss
new file mode 100644
index 0000000..90fd4e7
--- /dev/null
+++ b/demos/src/Extensions/Mathematics/React/styles.scss
@@ -0,0 +1,44 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ // Mathematics extension styles
+ .tiptap-mathematics-render {
+ padding: 0 0.25rem;
+
+ &--editable {
+ cursor: pointer;
+ transition: background 0.2s;
+
+ &:hover {
+ background: #eee;
+ }
+ }
+ }
+
+ .tiptap-mathematics-render {
+ border-radius: 0.25rem;
+
+ &[data-type='inline-math'] {
+ display: inline-block;
+ }
+
+ &[data-type='block-math'] {
+ display: block;
+ margin: 1rem 0;
+ padding: 1rem;
+ text-align: center;
+ }
+
+ &.inline-math-error,
+ &.block-math-error {
+ background: var(--red-light);
+ color: var(--red);
+ border: 1px solid var(--red-dark);
+ padding: 0.5rem;
+ border-radius: 0.25rem;
+ }
+ }
+}
diff --git a/demos/src/Extensions/Mathematics/Vue/index.html b/demos/src/Extensions/Mathematics/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/Mathematics/Vue/index.vue b/demos/src/Extensions/Mathematics/Vue/index.vue
new file mode 100644
index 0000000..b36a393
--- /dev/null
+++ b/demos/src/Extensions/Mathematics/Vue/index.vue
@@ -0,0 +1,198 @@
+
+
+
+
+
+ Readonly
+
+
+
+
+ Insert inline math
+ Remove inline math
+ Insert block math
+ Remove block math
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/Placeholder/React/index.html b/demos/src/Extensions/Placeholder/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/Placeholder/React/index.jsx b/demos/src/Extensions/Placeholder/React/index.jsx
new file mode 100644
index 0000000..45a3055
--- /dev/null
+++ b/demos/src/Extensions/Placeholder/React/index.jsx
@@ -0,0 +1,28 @@
+import './styles.scss'
+
+import { Placeholder } from '@tiptap/extensions'
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ Placeholder.configure({
+ // Use a placeholder:
+ placeholder: 'Write something …',
+ // Use different placeholders depending on the node type:
+ // placeholder: ({ node }) => {
+ // if (node.type.name === 'heading') {
+ // return 'What’s the title?'
+ // }
+
+ // return 'Can you add some further context?'
+ // },
+ }),
+ ],
+ })
+
+ return
+}
diff --git a/demos/src/Extensions/Placeholder/React/styles.scss b/demos/src/Extensions/Placeholder/React/styles.scss
new file mode 100644
index 0000000..6f8335b
--- /dev/null
+++ b/demos/src/Extensions/Placeholder/React/styles.scss
@@ -0,0 +1,109 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+
+ /* Placeholder (at the top) */
+ p.is-editor-empty:first-child::before {
+ color: var(--gray-4);
+ content: attr(data-placeholder);
+ float: left;
+ height: 0;
+ pointer-events: none;
+ }
+
+ /* Placeholder (on every new line) */
+ /* .is-empty::before {
+ color: var(--gray-4);
+ content: attr(data-placeholder);
+ float: left;
+ height: 0;
+ pointer-events: none;
+ } */
+}
diff --git a/demos/src/Extensions/Placeholder/Vue/index.html b/demos/src/Extensions/Placeholder/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/Placeholder/Vue/index.vue b/demos/src/Extensions/Placeholder/Vue/index.vue
new file mode 100644
index 0000000..7ad899b
--- /dev/null
+++ b/demos/src/Extensions/Placeholder/Vue/index.vue
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/Selection/React/index.html b/demos/src/Extensions/Selection/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/Selection/React/index.tsx b/demos/src/Extensions/Selection/React/index.tsx
new file mode 100644
index 0000000..206805b
--- /dev/null
+++ b/demos/src/Extensions/Selection/React/index.tsx
@@ -0,0 +1,37 @@
+import './styles.scss'
+
+import Code from '@tiptap/extension-code'
+import Document from '@tiptap/extension-document'
+import { BulletList, ListItem } from '@tiptap/extension-list'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { Selection } from '@tiptap/extensions'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Selection.configure({
+ className: 'selection',
+ }),
+ Code,
+ BulletList,
+ ListItem,
+ ],
+ content: `
+
+ The selection extension adds a class to the selected text when the editor loses focus. This lets you keep the selection visible even while the editor is blurred. By default, it adds the .selection class name.
+
+ `,
+
+ onCreate: ctx => {
+ ctx.editor.commands.setTextSelection({ from: 5, to: 30 })
+ },
+ })
+
+ return
+}
diff --git a/demos/src/Extensions/Selection/React/styles.scss b/demos/src/Extensions/Selection/React/styles.scss
new file mode 100644
index 0000000..29aabfa
--- /dev/null
+++ b/demos/src/Extensions/Selection/React/styles.scss
@@ -0,0 +1,32 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ // Selection styles
+ .selection {
+ box-shadow: 0 0 0 2px var(--purple);
+ }
+}
diff --git a/demos/src/Extensions/Selection/Vue/index.html b/demos/src/Extensions/Selection/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/Selection/Vue/index.vue b/demos/src/Extensions/Selection/Vue/index.vue
new file mode 100644
index 0000000..73c45f9
--- /dev/null
+++ b/demos/src/Extensions/Selection/Vue/index.vue
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/Selection/index.spec.ts b/demos/src/Extensions/Selection/index.spec.ts
new file mode 100644
index 0000000..9c0d9cb
--- /dev/null
+++ b/demos/src/Extensions/Selection/index.spec.ts
@@ -0,0 +1,21 @@
+import { expect, test } from '@playwright/test'
+
+const demoName = 'Selection'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Extensions'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test('first span has selection class', async ({ page }) => {
+ await expect(page.locator('.tiptap span').first()).toHaveClass(/selection/)
+ })
+ })
+ })
+})
diff --git a/demos/src/Extensions/TableOfContents/React/ToC.jsx b/demos/src/Extensions/TableOfContents/React/ToC.jsx
new file mode 100644
index 0000000..54f6c79
--- /dev/null
+++ b/demos/src/Extensions/TableOfContents/React/ToC.jsx
@@ -0,0 +1,71 @@
+import { TextSelection } from '@tiptap/pm/state'
+
+export const ToCItem = ({ item, onItemClick }) => {
+ return (
+
+ )
+}
+
+export const ToCEmptyState = () => {
+ return (
+
+
Start editing your document to see the outline.
+
+ )
+}
+
+export const ToC = ({ items = [], editor }) => {
+ if (items.length === 0) {
+ return
+ }
+
+ const onItemClick = (e, id) => {
+ e.preventDefault()
+
+ if (editor) {
+ const element = editor.view.dom.querySelector(`[data-toc-id="${id}"`)
+ const pos = editor.view.posAtDOM(element, 0)
+
+ // set focus
+ const tr = editor.view.state.tr
+
+ tr.setSelection(new TextSelection(tr.doc.resolve(pos)))
+
+ editor.view.dispatch(tr)
+
+ editor.view.focus()
+
+ // oxlint-disable-next-line
+ if (history.pushState) {
+ // oxlint-disable-next-line
+ history.pushState(null, null, `#${id}`)
+ }
+
+ window.scrollTo({
+ top: element.getBoundingClientRect().top + window.scrollY,
+ behavior: 'smooth',
+ })
+ }
+ }
+
+ return (
+ <>
+ {items.map((item, i) => (
+
+ ))}
+ >
+ )
+}
diff --git a/demos/src/Extensions/TableOfContents/React/index.html b/demos/src/Extensions/TableOfContents/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/TableOfContents/React/index.jsx b/demos/src/Extensions/TableOfContents/React/index.jsx
new file mode 100644
index 0000000..3927551
--- /dev/null
+++ b/demos/src/Extensions/TableOfContents/React/index.jsx
@@ -0,0 +1,48 @@
+import './styles.scss'
+
+import { getHierarchicalIndexes, TableOfContents } from '@tiptap/extension-table-of-contents'
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React, { useState } from 'react'
+
+import { content as bookContent } from '../content.js'
+import { ToC } from './ToC.jsx'
+
+const MemorizedToC = React.memo(ToC)
+
+export default () => {
+ const [items, setItems] = useState([])
+
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ TableOfContents.configure({
+ getIndex: getHierarchicalIndexes,
+ onUpdate(content) {
+ setItems(content)
+ },
+ }),
+ ],
+ content: bookContent,
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+
Table of contents
+
+
+
+
+
+
+ )
+}
diff --git a/demos/src/Extensions/TableOfContents/React/styles.scss b/demos/src/Extensions/TableOfContents/React/styles.scss
new file mode 100644
index 0000000..6c034bc
--- /dev/null
+++ b/demos/src/Extensions/TableOfContents/React/styles.scss
@@ -0,0 +1,100 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
+
+.col-group {
+ display: flex;
+ flex-direction: row;
+
+ @media (max-width: 540px) {
+ flex-direction: column-reverse;
+ }
+}
+
+.main {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ overflow: auto;
+}
+
+.sidebar {
+ border-left: 1px solid var(--gray-3);
+ flex-grow: 0;
+ flex-shrink: 0;
+ padding: 1rem;
+ width: 15rem;
+ position: sticky;
+ height: 100vh;
+ top: 0;
+
+ @media (min-width: 800px) {
+ width: 20rem;
+ }
+
+ @media (max-width: 540px) {
+ border-bottom: 1px solid var(--gray-3);
+ border-left: unset;
+ width: 100%;
+ height: auto;
+ position: unset;
+ padding: 1.5rem;
+ }
+}
+
+.sidebar-options {
+ align-items: flex-start;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ gap: 1rem;
+ position: sticky;
+ top: 1rem;
+}
+
+.table-of-contents {
+ display: flex;
+ flex-direction: column;
+ font-size: 0.875rem;
+ gap: 0.25rem;
+ overflow: auto;
+ text-decoration: none;
+
+ > div {
+ border-radius: 0.25rem;
+ padding-left: calc(0.875rem * (var(--level) - 1));
+ transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1);
+
+ &:hover {
+ background-color: var(--gray-2);
+ }
+ }
+
+ .empty-state {
+ color: var(--gray-5);
+ user-select: none;
+ }
+
+ .is-active a {
+ color: var(--purple);
+ }
+
+ .is-scrolled-over a {
+ color: var(--gray-5);
+ }
+
+ a {
+ color: var(--black);
+ display: flex;
+ gap: 0.25rem;
+ text-decoration: none;
+
+ &::before {
+ content: attr(data-item-index) '.';
+ }
+ }
+}
diff --git a/demos/src/Extensions/TableOfContents/Vue/ToC.vue b/demos/src/Extensions/TableOfContents/Vue/ToC.vue
new file mode 100644
index 0000000..a796633
--- /dev/null
+++ b/demos/src/Extensions/TableOfContents/Vue/ToC.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/TableOfContents/Vue/ToCEmptyState.vue b/demos/src/Extensions/TableOfContents/Vue/ToCEmptyState.vue
new file mode 100644
index 0000000..2236ab8
--- /dev/null
+++ b/demos/src/Extensions/TableOfContents/Vue/ToCEmptyState.vue
@@ -0,0 +1,5 @@
+
+
+
Start editing your document to see the outline.
+
+
diff --git a/demos/src/Extensions/TableOfContents/Vue/ToCItem.vue b/demos/src/Extensions/TableOfContents/Vue/ToCItem.vue
new file mode 100644
index 0000000..e5a7227
--- /dev/null
+++ b/demos/src/Extensions/TableOfContents/Vue/ToCItem.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
diff --git a/demos/src/Extensions/TableOfContents/Vue/index.html b/demos/src/Extensions/TableOfContents/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/TableOfContents/Vue/index.vue b/demos/src/Extensions/TableOfContents/Vue/index.vue
new file mode 100644
index 0000000..7e8fd57
--- /dev/null
+++ b/demos/src/Extensions/TableOfContents/Vue/index.vue
@@ -0,0 +1,163 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/TableOfContents/content.js b/demos/src/Extensions/TableOfContents/content.js
new file mode 100644
index 0000000..a22dbca
--- /dev/null
+++ b/demos/src/Extensions/TableOfContents/content.js
@@ -0,0 +1,27 @@
+export const content = `
+ Text editor
+ A text editor is a type of computer program that edits plain text. Such programs are sometimes known as "notepad" software (e.g. Windows Notepad). Text editors are provided with operating systems and software development packages, and can be used to change files such as configuration files, documentation files and programming language source code.
+
+ Plain text and rich text
+ There are important differences between plain text (created and edited by text editors) and rich text (such as that created by word processors or desktop publishing software).
+ Plain text exclusively consists of character representation. Each character is represented by a fixed-length sequence of one, two, or four bytes, or as a variable-length sequence of one to four bytes, in accordance to specific character encoding conventions, such as ASCII, ISO/IEC 2022, Shift JIS, UTF-8, or UTF-16. These conventions define many printable characters, but also non-printing characters that control the flow of the text, such as space, line break, and page break. Plain text contains no other information about the text itself, not even the character encoding convention employed. Plain text is stored in text files, although text files do not exclusively store plain text. Since the early days of computers, plain text was (once by necessity and now by convention) generally displayed using a monospace font, such that horizontal alignment and columnar formatting were sometimes done using whitespace characters.
+ Rich text, on the other hand, may contain metadata, character formatting data (e.g. typeface, size, weight and style), paragraph formatting data (e.g. indentation, alignment, letter and word distribution, and space between lines or other paragraphs), and page specification data (e.g. size, margin and reading direction). Rich text can be very complex. Rich text can be saved in binary format (e.g. DOC), text files adhering to a markup language (e.g. RTF or HTML), or in a hybrid form of both (e.g. Office Open XML).
+ Text editors are intended to open and save text files containing either plain text or anything that can be interpreted as plain text, including the markup for rich text or the markup for something else (e.g. SVG).
+
+ History
+
+ Before text editors existed, computer text was punched into cards with keypunch machines. Physical boxes of these thin cardboard cards were then inserted into a card reader. Magnetic tape, drum and disk card image files created from such card decks often had no line-separation characters at all, and assumed fixed-length 80- or 90-character records. An alternative to cards was Punched tape. It could be created by some teleprinters (such as the Teletype), which used special characters to indicate ends of records. Some early operating systems included batch text editors, either integrated with language processors or as separate utility programs; one early example was the ability to edit SQUOZE source files for SCAT in SHARE Operating System.
+ The first interactive text editors were "line editors" oriented to teleprinter- or typewriter-style terminals without displays. Commands (often a single keystroke) effected edits to a file at an imaginary insertion point called the "cursor". Edits were verified by typing a command to print a small section of the file, and periodically by printing the entire file. In some line editors, the cursor could be moved by commands that specified the line number in the file, text strings (context) for which to search, and eventually regular expressions. Line editors were major improvements over keypunching. Some line editors could be used by keypunch; editing commands could be taken from a deck of cards and applied to a specified file. Some common line editors supported a "verify" mode in which change commands displayed the altered lines.
+
+ Weird h5 headline
+ When computer terminals with video screens became available, screen-based text editors (sometimes called just "screen editors") became common. One of the earliest full-screen editors was O26, which was written for the operator console of the CDC 6000 series computers in 1967. Another early full-screen editor was vi. Written in the 1970s, it is still a standard editor on Unix and Linux operating systems. Also written in the 1970s was the UCSD Pascal Screen Oriented Editor, which was optimized both for indented source code and general text. Emacs, one of the first free and open-source software projects, is another early full-screen or real-time editor, one that was ported to many systems. A full-screen editor's ease-of-use and speed (compared to the line-based editors) motivated many early purchases of video terminals.
+
+ Types of text editors
+
+ Simple text editors
+ Some text editors are small and simple, while others offer broad and complex functions. For example, Unix and Unix-like operating systems have the pico editor (or a variant), but many also include the vi and Emacs editors. Microsoft Windows systems come with the simple Notepad, though many people—especially programmers—prefer other editors with more features. Under Apple Macintosh's classic Mac OS there was the native TeachText later replaced by SimpleText in 1994, which was replaced in Mac OS X by TextEdit, which combines features of a text editor with those typical of a word processor such as rulers, margins and multiple font selection. These features are not available simultaneously, but must be switched by user command, or through the program automatically determining the file type.
+
+ Word editors
+
+ Most word processors can read and write files in plain text format, allowing them to open files saved from text editors. Saving these files from a word processor, however, requires ensuring the file is written in plain text format, and that any text encoding or BOM settings won't obscure the file for its intended use. Non-WYSIWYG word processors, such as WordStar, are more easily pressed into service as text editors, and in fact were commonly used as such during the 1980s. The default file format of these word processors often resembles a markup language, with the basic format being plain text and visual formatting achieved using non-printing control characters or escape sequences. Later word processors like Microsoft Word store their files in a binary format and are almost never used to edit plain text files.
+`
diff --git a/demos/src/Extensions/TextAlign/React/index.html b/demos/src/Extensions/TextAlign/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/TextAlign/React/index.jsx b/demos/src/Extensions/TextAlign/React/index.jsx
new file mode 100644
index 0000000..b6e8c33
--- /dev/null
+++ b/demos/src/Extensions/TextAlign/React/index.jsx
@@ -0,0 +1,118 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Heading from '@tiptap/extension-heading'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import TextAlign from '@tiptap/extension-text-align'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Heading,
+ TextAlign.configure({
+ types: ['heading', 'paragraph'],
+ }),
+ ],
+ content: `
+ Heading
+ first paragraph
+ second paragraph
+ `,
+ })
+
+ const { isLeft, isCenter, isRight, isJustify, isHeading1, isHeading2 } = useEditorState({
+ editor,
+ selector: ctx => {
+ if (!ctx.editor) {
+ return {
+ isLeft: false,
+ isCenter: false,
+ isRight: false,
+ isJustify: false,
+ isHeading1: false,
+ isHeading2: false,
+ }
+ }
+
+ return {
+ isLeft: ctx.editor.isActive({ textAlign: 'left' }) ?? false,
+ isCenter: ctx.editor.isActive({ textAlign: 'center' }) ?? false,
+ isRight: ctx.editor.isActive({ textAlign: 'right' }) ?? false,
+ isJustify: ctx.editor.isActive({ textAlign: 'justify' }) ?? false,
+ isHeading1: ctx.editor.isActive({ level: 1 }) ?? false,
+ isHeading2: ctx.editor.isActive({ level: 2 }) ?? false,
+ }
+ },
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().setTextAlign('left').run()}
+ className={isLeft ? 'is-active' : ''}
+ >
+ Left
+
+ editor.chain().focus().setTextAlign('center').run()}
+ className={isCenter ? 'is-active' : ''}
+ >
+ Center
+
+ editor.chain().focus().setTextAlign('right').run()}
+ className={isRight ? 'is-active' : ''}
+ >
+ Right
+
+ editor.chain().focus().setTextAlign('justify').run()}
+ className={isJustify ? 'is-active' : ''}
+ >
+ Justify
+
+ editor.chain().focus().unsetTextAlign().run()}>
+ Unset text align
+
+ editor.chain().focus().toggleTextAlign('right').run()}
+ className={isRight ? 'is-active' : ''}
+ >
+ Toggle Right
+
+ editor.chain().focus().toggleTextAlign('right').run()}
+ className={isRight ? 'is-active' : ''}
+ >
+ Toggle Right
+
+ editor.chain().focus().toggleHeading({ level: 1 }).run()}
+ className={isHeading1 ? 'is-active' : ''}
+ >
+ Toggle H1
+
+ editor.chain().focus().toggleHeading({ level: 2 }).run()}
+ className={isHeading2 ? 'is-active' : ''}
+ >
+ Toggle H2
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Extensions/TextAlign/React/styles.scss b/demos/src/Extensions/TextAlign/React/styles.scss
new file mode 100644
index 0000000..4655151
--- /dev/null
+++ b/demos/src/Extensions/TextAlign/React/styles.scss
@@ -0,0 +1,42 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+}
diff --git a/demos/src/Extensions/TextAlign/Vue/index.html b/demos/src/Extensions/TextAlign/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/TextAlign/Vue/index.vue b/demos/src/Extensions/TextAlign/Vue/index.vue
new file mode 100644
index 0000000..a960f47
--- /dev/null
+++ b/demos/src/Extensions/TextAlign/Vue/index.vue
@@ -0,0 +1,135 @@
+
+
+
+
+
+ Left
+
+
+ Center
+
+
+ Right
+
+
+ Justify
+
+ Unset text align
+
+ Toggle Right
+
+
+ Toggle Right
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/TextAlign/index.spec.ts b/demos/src/Extensions/TextAlign/index.spec.ts
new file mode 100644
index 0000000..0a51069
--- /dev/null
+++ b/demos/src/Extensions/TextAlign/index.spec.ts
@@ -0,0 +1,96 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'TextAlign'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Extensions'
+
+const isMac = process.platform === 'darwin'
+const mod = isMac ? 'Meta' : 'Control'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await setEditorContent(page, 'Example Text
')
+ })
+
+ test('parses a null alignment correctly', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text
')
+ return el.editor.getHTML()
+ })
+ expect(html).toBe('Example Text
')
+ })
+ ;['left', 'center', 'right', 'justify'].forEach(alignment => {
+ test(`parses ${alignment} align text correctly`, async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any, a: string) => {
+ el.editor.commands.setContent(`Example Text
`)
+ }, alignment)
+ await expect(page.locator('.tiptap p').filter({ hasText: 'Example Text' })).toHaveCSS(
+ 'text-align',
+ alignment,
+ )
+ })
+ })
+
+ test('keeps the text aligned when toggling headings', async ({ page }) => {
+ const editor = await getEditor(page)
+ const alignments = ['center', 'right', 'justify']
+ const headings = [1, 2]
+
+ for (const alignment of alignments) {
+ for (const level of headings) {
+ await editor.evaluate(
+ (
+ el: any,
+ {
+ currentAlignment,
+ currentLevel,
+ }: { currentAlignment: string; currentLevel: number },
+ ) => {
+ el.editor.commands.setContent(
+ `Example Text
`,
+ )
+ el.editor.commands.toggleHeading({ level: currentLevel })
+ },
+ { currentAlignment: alignment, currentLevel: level },
+ )
+
+ await expect(
+ page.locator(`.tiptap h${level}`).filter({ hasText: 'Example Text' }),
+ ).toHaveCSS('text-align', alignment)
+ }
+ }
+ })
+
+ test('aligns the text left on the 1st button', async ({ page }) => {
+ await page.locator('button').nth(0).click()
+ await expect(page.locator('.tiptap p')).toHaveCSS('text-align', 'left')
+ })
+
+ test('aligns the text center on the 2nd button', async ({ page }) => {
+ await page.locator('button').nth(1).click()
+ await expect(page.locator('.tiptap p')).toHaveCSS('text-align', 'center')
+ })
+
+ test('aligns the text right on the 3rd button', async ({ page }) => {
+ await page.locator('button').nth(2).click()
+ await expect(page.locator('.tiptap p')).toHaveCSS('text-align', 'right')
+ })
+
+ test('aligns the text justified when pressing the keyboard shortcut', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.click()
+ await page.keyboard.press(`${mod}+Shift+J`)
+ await expect(page.locator('.tiptap p')).toHaveCSS('text-align', 'justify')
+ })
+ })
+ })
+})
diff --git a/demos/src/Extensions/TrailingNode/React/index.html b/demos/src/Extensions/TrailingNode/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/TrailingNode/React/index.tsx b/demos/src/Extensions/TrailingNode/React/index.tsx
new file mode 100644
index 0000000..bc11c2c
--- /dev/null
+++ b/demos/src/Extensions/TrailingNode/React/index.tsx
@@ -0,0 +1,23 @@
+import './styles.scss'
+
+import Code from '@tiptap/extension-code'
+import CodeBlock from '@tiptap/extension-code-block'
+import Document from '@tiptap/extension-document'
+import { BulletList, ListItem } from '@tiptap/extension-list'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { TrailingNode } from '@tiptap/extensions'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, TrailingNode, Code, BulletList, ListItem, CodeBlock],
+ content: `
+ A paragraph
+ There should be a paragraph right after this one, because it is a code-block
+ `,
+ })
+
+ return
+}
diff --git a/demos/src/Extensions/TrailingNode/React/styles.scss b/demos/src/Extensions/TrailingNode/React/styles.scss
new file mode 100644
index 0000000..15283b1
--- /dev/null
+++ b/demos/src/Extensions/TrailingNode/React/styles.scss
@@ -0,0 +1,91 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
diff --git a/demos/src/Extensions/TrailingNode/Vue/index.html b/demos/src/Extensions/TrailingNode/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/TrailingNode/Vue/index.vue b/demos/src/Extensions/TrailingNode/Vue/index.vue
new file mode 100644
index 0000000..04ccd54
--- /dev/null
+++ b/demos/src/Extensions/TrailingNode/Vue/index.vue
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/Typography/React/index.html b/demos/src/Extensions/Typography/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/Typography/React/index.jsx b/demos/src/Extensions/Typography/React/index.jsx
new file mode 100644
index 0000000..3a9b04e
--- /dev/null
+++ b/demos/src/Extensions/Typography/React/index.jsx
@@ -0,0 +1,20 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import Typography from '@tiptap/extension-typography'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, Typography],
+ content: `
+ “I have been suffering from Typomania all my life, a sickness that is incurable but not lethal.”
+ — Erik Spiekermann, December 2008
+ `,
+ })
+
+ return
+}
diff --git a/demos/src/Extensions/Typography/React/styles.scss b/demos/src/Extensions/Typography/React/styles.scss
new file mode 100644
index 0000000..afd5ba2
--- /dev/null
+++ b/demos/src/Extensions/Typography/React/styles.scss
@@ -0,0 +1,6 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
diff --git a/demos/src/Extensions/Typography/Vue/index.html b/demos/src/Extensions/Typography/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/Typography/Vue/index.vue b/demos/src/Extensions/Typography/Vue/index.vue
new file mode 100644
index 0000000..3c8d505
--- /dev/null
+++ b/demos/src/Extensions/Typography/Vue/index.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/Typography/index.spec.ts b/demos/src/Extensions/Typography/index.spec.ts
new file mode 100644
index 0000000..85b2fc6
--- /dev/null
+++ b/demos/src/Extensions/Typography/index.spec.ts
@@ -0,0 +1,71 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'Typography'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Extensions'
+
+const cases: [string, string][] = [
+ ['-- emDash', '— emDash'],
+ ['... ellipsis', '… ellipsis'],
+ ['"openDoubleQuote"', '“openDoubleQuote'],
+ ['"closeDoubleQuote"', 'closeDoubleQuote”'],
+ ["'openSingleQuote'", '‘openSingleQuote’'],
+ ["'closeSingleQuote'", 'closeSingleQuote’'],
+ ['<- leftArrow', '← leftArrow'],
+ ['-> rightArrow', '→ rightArrow'],
+ ['(c) copyright', '© copyright'],
+ ['(r) registeredTrademark', '® registeredTrademark'],
+ ['(tm) trademark', '™ trademark'],
+ ['1/2 oneHalf', '½ oneHalf'],
+ ['+/- plusMinus', '± plusMinus'],
+ ['!= notEqual', '≠ notEqual'],
+ ['<< laquorow', '« laquo'],
+ ['>> raquorow', '» raquo'],
+ ['1*1 multiplication', '1×1 multiplication'],
+ ['1x1 multiplication', '1×1 multiplication'],
+ ['1 * 1 multiplication', '1 × 1 multiplication'],
+ ['1 x 1 multiplication', '1 × 1 multiplication'],
+]
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ })
+
+ if (frameworkPath === 'React') {
+ test('keeps dates as they are', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.type('1/4/2024')
+ await expect(editor).toContainText('1/4/2024')
+ })
+
+ test('makes a fraction only with spaces afterwards', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.type('1/4')
+ await expect(editor).toContainText('1/4')
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await editor.type('1/4 ')
+ await expect(editor).toContainText('¼')
+ })
+ }
+
+ cases.forEach(([input, expected]) => {
+ test(`transforms "${input}" to contain "${expected}"`, async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.type(input)
+ await expect(editor).toContainText(expected)
+ })
+ })
+ })
+ })
+})
diff --git a/demos/src/Extensions/TypographyWithOverrides/React/index.html b/demos/src/Extensions/TypographyWithOverrides/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/TypographyWithOverrides/React/index.jsx b/demos/src/Extensions/TypographyWithOverrides/React/index.jsx
new file mode 100644
index 0000000..af305cb
--- /dev/null
+++ b/demos/src/Extensions/TypographyWithOverrides/React/index.jsx
@@ -0,0 +1,25 @@
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import Typography from '@tiptap/extension-typography'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Typography.configure({
+ rightArrow: '=====>',
+ }),
+ ],
+ content: `
+ “I have been suffering from Typomania all my life, a sickness that is incurable but not lethal.”
+ — Erik Spiekermann, December 2008
+ `,
+ })
+
+ return
+}
diff --git a/demos/src/Extensions/TypographyWithOverrides/Vue/index.html b/demos/src/Extensions/TypographyWithOverrides/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/TypographyWithOverrides/Vue/index.vue b/demos/src/Extensions/TypographyWithOverrides/Vue/index.vue
new file mode 100644
index 0000000..f7cd7fb
--- /dev/null
+++ b/demos/src/Extensions/TypographyWithOverrides/Vue/index.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
diff --git a/demos/src/Extensions/TypographyWithOverrides/index.spec.ts b/demos/src/Extensions/TypographyWithOverrides/index.spec.ts
new file mode 100644
index 0000000..e258296
--- /dev/null
+++ b/demos/src/Extensions/TypographyWithOverrides/index.spec.ts
@@ -0,0 +1,28 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'TypographyWithOverrides'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Extensions'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ })
+
+ test('uses correct override for rightArrow', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.type('-> Hello!')
+ await expect(editor).toContainText('=====> Hello!')
+ })
+ })
+ })
+})
diff --git a/demos/src/Extensions/UndoRedo/React/index.html b/demos/src/Extensions/UndoRedo/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/UndoRedo/React/index.jsx b/demos/src/Extensions/UndoRedo/React/index.jsx
new file mode 100644
index 0000000..cfa99c2
--- /dev/null
+++ b/demos/src/Extensions/UndoRedo/React/index.jsx
@@ -0,0 +1,61 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { UndoRedo } from '@tiptap/extensions'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, UndoRedo],
+ content: `
+
+ With the UndoRedo extension the Editor will keep track of your changes. And if you think you made a mistake, you can redo your changes. Try it out, change the content and hit the undo button!
+
+
+ And yes, you can also use a keyboard shortcut to undo changes (Control/Cmd Z) or redo changes (Control/Cmd Shift Z).
+
+ `,
+ })
+
+ const { canUndo, canRedo } = useEditorState({
+ editor,
+ selector: ctx => {
+ return {
+ canUndo: ctx.editor.can().chain().focus().undo().run(),
+ canRedo: ctx.editor.can().chain().focus().redo().run(),
+ }
+ },
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().undo().run()}
+ disabled={!canUndo}
+ type="button"
+ >
+ Undo
+
+ editor.chain().focus().redo().run()}
+ disabled={!canRedo}
+ type="button"
+ >
+ Redo
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Extensions/UndoRedo/React/styles.scss b/demos/src/Extensions/UndoRedo/React/styles.scss
new file mode 100644
index 0000000..afd5ba2
--- /dev/null
+++ b/demos/src/Extensions/UndoRedo/React/styles.scss
@@ -0,0 +1,6 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
diff --git a/demos/src/Extensions/UndoRedo/Vue/index.html b/demos/src/Extensions/UndoRedo/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/UndoRedo/Vue/index.vue b/demos/src/Extensions/UndoRedo/Vue/index.vue
new file mode 100644
index 0000000..b91b2e5
--- /dev/null
+++ b/demos/src/Extensions/UndoRedo/Vue/index.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+ Undo
+
+
+ Redo
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/UndoRedo/index.spec.ts b/demos/src/Extensions/UndoRedo/index.spec.ts
new file mode 100644
index 0000000..98ca0e0
--- /dev/null
+++ b/demos/src/Extensions/UndoRedo/index.spec.ts
@@ -0,0 +1,82 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'UndoRedo'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Extensions'
+
+const isMac = process.platform === 'darwin'
+const mod = isMac ? 'Meta' : 'Control'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await setEditorContent(page, 'Mistake
')
+ })
+
+ test('undoes the last change via button', async ({ page }) => {
+ const editor = await getEditor(page)
+ await expect(editor).toContainText('Mistake')
+
+ const undoBtn = page.locator('button').first()
+ await expect(undoBtn).not.toHaveAttribute('disabled', '')
+ await undoBtn.click()
+
+ await expect(editor).not.toContainText('Mistake')
+ })
+
+ test('undoes the last change with the keyboard shortcut', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.click()
+ await page.keyboard.press(`${mod}+z`)
+ await expect(editor).not.toContainText('Mistake')
+ })
+
+ test('redoes the last undone change with the keyboard shortcut', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.click()
+ await page.keyboard.press(`${mod}+z`)
+ await expect(editor).not.toContainText('Mistake')
+
+ await page.keyboard.press(`${mod}+Shift+z`)
+ await expect(editor).toContainText('Mistake')
+ })
+
+ test('redoes the last undone change via buttons', async ({ page }) => {
+ const editor = await getEditor(page)
+ const undoBtn = page.locator('button').nth(0)
+ const redoBtn = page.locator('button').nth(1)
+
+ await expect(editor).toContainText('Mistake')
+ await undoBtn.click()
+ await expect(editor).not.toContainText('Mistake')
+ await expect(undoBtn).toHaveAttribute('disabled', '')
+
+ await redoBtn.click()
+ await expect(editor).toContainText('Mistake')
+ })
+
+ test('disables undo button when there are no more changes to undo', async ({ page }) => {
+ const undoBtn = page.locator('button').first()
+ await expect(undoBtn).not.toHaveAttribute('disabled', '')
+ await undoBtn.click()
+ await expect(undoBtn).toHaveAttribute('disabled', '')
+ })
+
+ test('disables redo button when there are no more changes to redo', async ({ page }) => {
+ const undoBtn = page.locator('button').nth(0)
+ const redoBtn = page.locator('button').nth(1)
+
+ await expect(redoBtn).toHaveAttribute('disabled', '')
+ await expect(undoBtn).not.toHaveAttribute('disabled', '')
+ await undoBtn.click()
+ await expect(redoBtn).not.toHaveAttribute('disabled', '')
+ })
+ })
+ })
+})
diff --git a/demos/src/Extensions/UniqueID/React/index.html b/demos/src/Extensions/UniqueID/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/UniqueID/React/index.jsx b/demos/src/Extensions/UniqueID/React/index.jsx
new file mode 100644
index 0000000..2548860
--- /dev/null
+++ b/demos/src/Extensions/UniqueID/React/index.jsx
@@ -0,0 +1,30 @@
+import './styles.scss'
+
+import UniqueID from '@tiptap/extension-unique-id'
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ UniqueID.configure({
+ types: ['heading', 'paragraph'],
+ }),
+ ],
+ content: `
+
+ This is a very unique heading.
+
+
+ This is a unique paragraph. It’s so unique, it even has an ID attached to it.
+
+
+ And this one, too.
+
+ `,
+ })
+
+ return
+}
diff --git a/demos/src/Extensions/UniqueID/React/styles.scss b/demos/src/Extensions/UniqueID/React/styles.scss
new file mode 100644
index 0000000..d879ff1
--- /dev/null
+++ b/demos/src/Extensions/UniqueID/React/styles.scss
@@ -0,0 +1,28 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* Unique data id */
+ [data-id] {
+ border: 2px solid var(--black);
+ border-radius: 0.5rem;
+ padding: 2.5rem 1rem 1rem;
+ position: relative;
+
+ &::before {
+ background-color: var(--black);
+ border-radius: 0 0 0.5rem 0;
+ color: var(--white);
+ content: attr(data-id);
+ font-size: 0.75rem;
+ font-weight: bold;
+ left: 0;
+ line-height: 1.5;
+ padding: 0.25rem 0.5rem;
+ position: absolute;
+ top: 0;
+ }
+ }
+}
diff --git a/demos/src/Extensions/UniqueID/Vue/index.html b/demos/src/Extensions/UniqueID/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/UniqueID/Vue/index.vue b/demos/src/Extensions/UniqueID/Vue/index.vue
new file mode 100644
index 0000000..7334820
--- /dev/null
+++ b/demos/src/Extensions/UniqueID/Vue/index.vue
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/UniqueIDWithYdoc/React/index.html b/demos/src/Extensions/UniqueIDWithYdoc/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Extensions/UniqueIDWithYdoc/React/index.jsx b/demos/src/Extensions/UniqueIDWithYdoc/React/index.jsx
new file mode 100644
index 0000000..1a700b6
--- /dev/null
+++ b/demos/src/Extensions/UniqueIDWithYdoc/React/index.jsx
@@ -0,0 +1,50 @@
+import './styles.scss'
+
+import { TiptapTransformer } from '@hocuspocus/transformer'
+import Collaboration from '@tiptap/extension-collaboration'
+import Document from '@tiptap/extension-document'
+import Heading from '@tiptap/extension-heading'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import UniqueID from '@tiptap/extension-unique-id'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+const doc = TiptapTransformer.toYdoc({
+ type: 'doc',
+ content: [
+ {
+ type: 'heading',
+ attrs: { level: 1 },
+ content: [{ type: 'text', text: 'This is a predefined, collaborative ydoc' }],
+ },
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: "Let's see how this works out." }],
+ },
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'This should now generate unique IDs correctly' }],
+ },
+ ],
+})
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Heading,
+ Paragraph,
+ Text,
+ Collaboration.configure({
+ document: doc,
+ }),
+ UniqueID.configure({
+ types: ['heading', 'paragraph'],
+ filterTransaction: () => true,
+ }),
+ ],
+ })
+
+ return
+}
diff --git a/demos/src/Extensions/UniqueIDWithYdoc/React/styles.scss b/demos/src/Extensions/UniqueIDWithYdoc/React/styles.scss
new file mode 100644
index 0000000..8081117
--- /dev/null
+++ b/demos/src/Extensions/UniqueIDWithYdoc/React/styles.scss
@@ -0,0 +1,55 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* Unique data id */
+ [data-id] {
+ border: 2px solid var(--black);
+ border-radius: 0.5rem;
+ padding: 2.5rem 1rem 1rem;
+ position: relative;
+
+ &::before {
+ background-color: var(--black);
+ border-radius: 0 0 0.5rem 0;
+ color: var(--white);
+ content: attr(data-id);
+ font-size: 0.75rem;
+ font-weight: bold;
+ left: 0;
+ line-height: 1.5;
+ padding: 0.25rem 0.5rem;
+ position: absolute;
+ top: 0;
+ }
+ }
+
+ /* Give a remote user a caret */
+ .collaboration-carets__caret {
+ border-left: 1px solid #0d0d0d;
+ border-right: 1px solid #0d0d0d;
+ margin-left: -1px;
+ margin-right: -1px;
+ pointer-events: none;
+ position: relative;
+ word-break: normal;
+ }
+
+ /* Render the username above the caret */
+ .collaboration-carets__label {
+ border-radius: 3px 3px 3px 0;
+ color: #0d0d0d;
+ font-size: 12px;
+ font-style: normal;
+ font-weight: 600;
+ left: -1px;
+ line-height: normal;
+ padding: 0.1rem 0.3rem;
+ position: absolute;
+ top: -1.4em;
+ user-select: none;
+ white-space: nowrap;
+ }
+}
diff --git a/demos/src/GuideContent/ExportHTML/React/index.html b/demos/src/GuideContent/ExportHTML/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideContent/ExportHTML/React/index.jsx b/demos/src/GuideContent/ExportHTML/React/index.jsx
new file mode 100644
index 0000000..c3252df
--- /dev/null
+++ b/demos/src/GuideContent/ExportHTML/React/index.jsx
@@ -0,0 +1,97 @@
+import './styles.scss'
+
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React, { useCallback, useEffect, useState } from 'react'
+
+export default () => {
+ const [html, setHtml] = useState(null)
+ const editor = useEditor({
+ content: `
+
+ Wow, this editor instance exports its content as HTML.
+
+ `,
+ extensions: [StarterKit],
+ })
+
+ useEffect(() => {
+ if (!editor) {
+ return undefined
+ }
+
+ // Get the initial content …
+ setHtml(editor.getHTML())
+
+ // … and get the content after every change.
+ editor.on('update', () => {
+ setHtml(editor.getHTML())
+ })
+ }, [editor])
+
+ const setContent = useCallback(() => {
+ // You can pass a HTML document to the editor.
+ editor.commands.setContent(
+ `
+
+ It’s 19871. You can’t turn on a radio, or go to a mall without hearing Olivia Newton-John’s hit song, Physical.
+
+ `,
+ )
+
+ // It’s likely that you’d like to focus the Editor after most commands.
+ editor.commands.focus()
+ }, [editor])
+
+ const clearContent = useCallback(() => {
+ editor.chain().clearContent(true).focus().run()
+ }, [editor])
+
+ const { isBold, isItalic } = useEditorState({
+ editor,
+ selector: ctx => ({
+ isBold: ctx.editor.isActive('bold') ?? false,
+ isItalic: ctx.editor.isActive('italic') ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+
+ Set content
+
+
+ Clear content
+
+ editor.chain().focus().toggleBold().run()}
+ className={isBold ? 'is-active' : ''}
+ >
+ Bold
+
+ editor.chain().focus().toggleItalic().run()}
+ className={isItalic ? 'is-active' : ''}
+ >
+ Italic
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/GuideContent/ExportHTML/React/styles.scss b/demos/src/GuideContent/ExportHTML/React/styles.scss
new file mode 100644
index 0000000..15283b1
--- /dev/null
+++ b/demos/src/GuideContent/ExportHTML/React/styles.scss
@@ -0,0 +1,91 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
diff --git a/demos/src/GuideContent/ExportHTML/Vue/index.html b/demos/src/GuideContent/ExportHTML/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideContent/ExportHTML/Vue/index.vue b/demos/src/GuideContent/ExportHTML/Vue/index.vue
new file mode 100644
index 0000000..c991b4c
--- /dev/null
+++ b/demos/src/GuideContent/ExportHTML/Vue/index.vue
@@ -0,0 +1,185 @@
+
+
+
+
+ Set content
+ Clear content
+
+ Bold
+
+
+ Italic
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/GuideContent/ExportJSON/React/index.html b/demos/src/GuideContent/ExportJSON/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideContent/ExportJSON/React/index.jsx b/demos/src/GuideContent/ExportJSON/React/index.jsx
new file mode 100644
index 0000000..9fb7581
--- /dev/null
+++ b/demos/src/GuideContent/ExportJSON/React/index.jsx
@@ -0,0 +1,104 @@
+import './styles.scss'
+
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React, { useCallback, useEffect, useState } from 'react'
+
+export default () => {
+ const [json, setJson] = useState(null)
+ const editor = useEditor({
+ content: `
+
+ Wow, this editor instance exports its content as JSON.
+
+ `,
+ extensions: [StarterKit],
+ })
+
+ useEffect(() => {
+ if (!editor) {
+ return undefined
+ }
+
+ // Get the initial content …
+ setJson(editor.getJSON())
+
+ // … and get the content after every change.
+ editor.on('update', () => {
+ setJson(editor.getJSON())
+ })
+ }, [editor])
+
+ const setContent = useCallback(() => {
+ // You can pass a JSON document to the editor.
+ editor.commands.setContent({
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'It’s 19871. You can’t turn on a radio, or go to a mall without hearing Olivia Newton-John’s hit song, Physical.',
+ },
+ ],
+ },
+ ],
+ })
+
+ // It’s likely that you’d like to focus the Editor after most commands.
+ editor.commands.focus()
+ }, [editor])
+
+ const clearContent = useCallback(() => {
+ editor.chain().clearContent(true).focus().run()
+ }, [editor])
+
+ const { isBold, isItalic } = useEditorState({
+ editor,
+ selector: ctx => ({
+ isBold: ctx.editor.isActive('bold') ?? false,
+ isItalic: ctx.editor.isActive('italic') ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+
+ Set content
+
+
+ Clear content
+
+ editor.chain().focus().toggleBold().run()}
+ className={isBold ? 'is-active' : ''}
+ >
+ Bold
+
+ editor.chain().focus().toggleItalic().run()}
+ className={isItalic ? 'is-active' : ''}
+ >
+ Italic
+
+
+
+
+
+
+
+
JSON
+
+ {JSON.stringify(json, null, 2)}
+
+
+ >
+ )
+}
diff --git a/demos/src/GuideContent/ExportJSON/React/styles.scss b/demos/src/GuideContent/ExportJSON/React/styles.scss
new file mode 100644
index 0000000..15283b1
--- /dev/null
+++ b/demos/src/GuideContent/ExportJSON/React/styles.scss
@@ -0,0 +1,91 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
diff --git a/demos/src/GuideContent/ExportJSON/Vue/index.html b/demos/src/GuideContent/ExportJSON/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideContent/ExportJSON/Vue/index.vue b/demos/src/GuideContent/ExportJSON/Vue/index.vue
new file mode 100644
index 0000000..6edfc85
--- /dev/null
+++ b/demos/src/GuideContent/ExportJSON/Vue/index.vue
@@ -0,0 +1,191 @@
+
+
+
+
+ Set content
+ Clear content
+
+ Bold
+
+
+ Italic
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/GuideContent/GenerateHTML/React/index.html b/demos/src/GuideContent/GenerateHTML/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideContent/GenerateHTML/React/index.tsx b/demos/src/GuideContent/GenerateHTML/React/index.tsx
new file mode 100644
index 0000000..f3122aa
--- /dev/null
+++ b/demos/src/GuideContent/GenerateHTML/React/index.tsx
@@ -0,0 +1,51 @@
+import Bold from '@tiptap/extension-bold'
+// Option 2: Browser-only (lightweight)
+// import { generateHTML } from '@tiptap/core'
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+// Option 1: Browser + server-side
+import { generateHTML } from '@tiptap/html'
+import React, { useMemo } from 'react'
+
+const json = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Example ',
+ },
+ {
+ type: 'text',
+ marks: [
+ {
+ type: 'bold',
+ },
+ ],
+ text: 'Text',
+ },
+ ],
+ },
+ ],
+}
+
+export default () => {
+ const output = useMemo(() => {
+ return generateHTML(json, [
+ Document,
+ Paragraph,
+ Text,
+ Bold,
+ // other extensions …
+ ])
+ }, [])
+
+ return (
+
+ {output}
+
+ )
+}
diff --git a/demos/src/GuideContent/GenerateHTML/Vue/index.html b/demos/src/GuideContent/GenerateHTML/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideContent/GenerateHTML/Vue/index.vue b/demos/src/GuideContent/GenerateHTML/Vue/index.vue
new file mode 100644
index 0000000..6e0f1b5
--- /dev/null
+++ b/demos/src/GuideContent/GenerateHTML/Vue/index.vue
@@ -0,0 +1,52 @@
+
+ {{ output }}
+
+
+
diff --git a/demos/src/GuideContent/GenerateJSON/React/index.html b/demos/src/GuideContent/GenerateJSON/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideContent/GenerateJSON/React/index.jsx b/demos/src/GuideContent/GenerateJSON/React/index.jsx
new file mode 100644
index 0000000..fd6c7c3
--- /dev/null
+++ b/demos/src/GuideContent/GenerateJSON/React/index.jsx
@@ -0,0 +1,29 @@
+import Bold from '@tiptap/extension-bold'
+// Option 2: Browser-only (lightweight)
+// import { generateJSON } from '@tiptap/core'
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+// Option 1: Browser + server-side
+import { generateJSON } from '@tiptap/html'
+import React, { useMemo } from 'react'
+
+const html = 'Example Text
'
+
+export default () => {
+ const output = useMemo(() => {
+ return generateJSON(html, [
+ Document,
+ Paragraph,
+ Text,
+ Bold,
+ // other extensions …
+ ])
+ }, [])
+
+ return (
+
+ {JSON.stringify(output, null, 2)}
+
+ )
+}
diff --git a/demos/src/GuideContent/GenerateJSON/Vue/index.html b/demos/src/GuideContent/GenerateJSON/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideContent/GenerateJSON/Vue/index.vue b/demos/src/GuideContent/GenerateJSON/Vue/index.vue
new file mode 100644
index 0000000..9d2ed54
--- /dev/null
+++ b/demos/src/GuideContent/GenerateJSON/Vue/index.vue
@@ -0,0 +1,30 @@
+
+ {{ output }}
+
+
+
diff --git a/demos/src/GuideContent/GenerateText/React/index.html b/demos/src/GuideContent/GenerateText/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideContent/GenerateText/React/index.jsx b/demos/src/GuideContent/GenerateText/React/index.jsx
new file mode 100644
index 0000000..ba95ed6
--- /dev/null
+++ b/demos/src/GuideContent/GenerateText/React/index.jsx
@@ -0,0 +1,62 @@
+import { generateText } from '@tiptap/core'
+import Document from '@tiptap/extension-document'
+import HardBreak from '@tiptap/extension-hard-break'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import React, { useMemo } from 'react'
+
+const json = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'This is a paragraph.',
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Here is another paragraph …',
+ },
+ {
+ type: 'hardBreak',
+ },
+ {
+ type: 'text',
+ text: '… with an hard break.',
+ },
+ ],
+ },
+ ],
+}
+
+export default () => {
+ const output = useMemo(() => {
+ return generateText(
+ json,
+ [
+ Document,
+ Paragraph,
+ Text,
+ HardBreak,
+ // other extensions …
+ ],
+ {
+ // define a custom block separator if you want to
+ blockSeparator: '\n\n',
+ },
+ )
+ }, [])
+
+ return (
+
+ {output}
+
+ )
+}
diff --git a/demos/src/GuideContent/GenerateText/Vue/index.html b/demos/src/GuideContent/GenerateText/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideContent/GenerateText/Vue/index.vue b/demos/src/GuideContent/GenerateText/Vue/index.vue
new file mode 100644
index 0000000..6afc1c1
--- /dev/null
+++ b/demos/src/GuideContent/GenerateText/Vue/index.vue
@@ -0,0 +1,63 @@
+
+ {{ output }}
+
+
+
diff --git a/demos/src/GuideContent/ReadOnly/React/index.html b/demos/src/GuideContent/ReadOnly/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideContent/ReadOnly/React/index.jsx b/demos/src/GuideContent/ReadOnly/React/index.jsx
new file mode 100644
index 0000000..095d354
--- /dev/null
+++ b/demos/src/GuideContent/ReadOnly/React/index.jsx
@@ -0,0 +1,51 @@
+import './styles.scss'
+
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React, { useEffect, useState } from 'react'
+
+export default () => {
+ const [editable, setEditable] = useState(false)
+ const editor = useEditor({
+ shouldRerenderOnTransaction: false,
+ editable,
+ content: `
+
+ This text is read-only . No matter what you try, you are not able to edit something. Okay, if you toggle the checkbox above you’ll be able to edit the text.
+
+
+ If you want to check the state, you can call editor.isEditable().
+
+ `,
+ extensions: [StarterKit],
+ })
+
+ useEffect(() => {
+ if (!editor) {
+ return undefined
+ }
+
+ editor.setEditable(editable)
+ }, [editor, editable])
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ setEditable(event.target.checked)}
+ />
+ Editable
+
+
+
+ >
+ )
+}
diff --git a/demos/src/GuideContent/ReadOnly/React/styles.scss b/demos/src/GuideContent/ReadOnly/React/styles.scss
new file mode 100644
index 0000000..74c06e4
--- /dev/null
+++ b/demos/src/GuideContent/ReadOnly/React/styles.scss
@@ -0,0 +1,96 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+
+ &[contenteditable='false'] {
+ cursor: not-allowed;
+ opacity: 0.6;
+ }
+}
diff --git a/demos/src/GuideContent/ReadOnly/Vue/index.html b/demos/src/GuideContent/ReadOnly/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideContent/ReadOnly/Vue/index.vue b/demos/src/GuideContent/ReadOnly/Vue/index.vue
new file mode 100644
index 0000000..35d8935
--- /dev/null
+++ b/demos/src/GuideContent/ReadOnly/Vue/index.vue
@@ -0,0 +1,153 @@
+
+
+
+
+
+
+
diff --git a/demos/src/GuideContent/ReadOnly/index.spec.ts b/demos/src/GuideContent/ReadOnly/index.spec.ts
new file mode 100644
index 0000000..80dae32
--- /dev/null
+++ b/demos/src/GuideContent/ReadOnly/index.spec.ts
@@ -0,0 +1,39 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'ReadOnly'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/GuideContent'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ })
+
+ test('is read-only', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.setEditable(false))
+ await editor.click()
+ await page.keyboard.type('Edited: ')
+
+ await expect(page.locator('.tiptap p').first()).not.toContainText('Edited:')
+ })
+
+ test('is editable', async ({ page }) => {
+ await page.locator('#editable').click()
+ const editor = await getEditor(page)
+ await editor.click()
+ await page.keyboard.type('Edited: ')
+
+ await expect(page.locator('.tiptap p').first()).toContainText('Edited:')
+ })
+ })
+ })
+})
diff --git a/demos/src/GuideContent/StaticRenderHTML/React/index.html b/demos/src/GuideContent/StaticRenderHTML/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideContent/StaticRenderHTML/React/index.tsx b/demos/src/GuideContent/StaticRenderHTML/React/index.tsx
new file mode 100644
index 0000000..3cc024e
--- /dev/null
+++ b/demos/src/GuideContent/StaticRenderHTML/React/index.tsx
@@ -0,0 +1,58 @@
+import Bold from '@tiptap/extension-bold'
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { renderToHTMLString } from '@tiptap/static-renderer'
+import React, { useMemo } from 'react'
+
+const json = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Example ',
+ },
+ {
+ type: 'text',
+ marks: [
+ {
+ type: 'bold',
+ },
+ ],
+ text: 'Text',
+ },
+ ],
+ },
+ ],
+}
+
+/**
+ * This example demonstrates how to render a Prosemirror Node (or JSON Content) to an HTML string.
+ * It will use your extensions to render the content based on each Node's/Mark's `renderHTML` method.
+ * This can be useful if you want to render content to HTML without having an actual editor instance.
+ *
+ * You have complete control over the rendering process. And can replace how each Node/Mark is rendered.
+ */
+export default () => {
+ const output = useMemo(() => {
+ return renderToHTMLString({
+ content: json,
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Bold,
+ // other extensions …
+ ],
+ })
+ }, [])
+
+ return (
+
+ {output}
+
+ )
+}
diff --git a/demos/src/GuideContent/StaticRenderHTML/Vue/index.html b/demos/src/GuideContent/StaticRenderHTML/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideContent/StaticRenderHTML/Vue/index.vue b/demos/src/GuideContent/StaticRenderHTML/Vue/index.vue
new file mode 100644
index 0000000..0142eb4
--- /dev/null
+++ b/demos/src/GuideContent/StaticRenderHTML/Vue/index.vue
@@ -0,0 +1,59 @@
+
+ {{ output }}
+
+
+
diff --git a/demos/src/GuideContent/StaticRenderReact/React/index.html b/demos/src/GuideContent/StaticRenderReact/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideContent/StaticRenderReact/React/index.tsx b/demos/src/GuideContent/StaticRenderReact/React/index.tsx
new file mode 100644
index 0000000..7c2ea82
--- /dev/null
+++ b/demos/src/GuideContent/StaticRenderReact/React/index.tsx
@@ -0,0 +1,54 @@
+import Bold from '@tiptap/extension-bold'
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { renderToReactElement } from '@tiptap/static-renderer'
+import React, { useMemo } from 'react'
+
+const json = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Example ',
+ },
+ {
+ type: 'text',
+ marks: [
+ {
+ type: 'bold',
+ },
+ ],
+ text: 'Text',
+ },
+ ],
+ },
+ ],
+}
+
+/**
+ * This example demonstrates how to render a Prosemirror Node (or JSON Content) to a React Element.
+ * It will use your extensions to render the content based on each Node's/Mark's `renderHTML` method.
+ * This can be useful if you want to render content to React without having an actual editor instance.
+ *
+ * You have complete control over the rendering process. And can replace how each Node/Mark is rendered.
+ */
+export default () => {
+ const output = useMemo(() => {
+ return renderToReactElement({
+ content: json,
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Bold,
+ // other extensions …
+ ],
+ })
+ }, [])
+
+ return {output}
+}
diff --git a/demos/src/GuideGettingStarted/VModel/Vue/Editor.vue b/demos/src/GuideGettingStarted/VModel/Vue/Editor.vue
new file mode 100644
index 0000000..bf069f4
--- /dev/null
+++ b/demos/src/GuideGettingStarted/VModel/Vue/Editor.vue
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+
diff --git a/demos/src/GuideGettingStarted/VModel/Vue/index.html b/demos/src/GuideGettingStarted/VModel/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideGettingStarted/VModel/Vue/index.vue b/demos/src/GuideGettingStarted/VModel/Vue/index.vue
new file mode 100644
index 0000000..1596a2f
--- /dev/null
+++ b/demos/src/GuideGettingStarted/VModel/Vue/index.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+ Content
+ {{ content }}
+
+
+
+
+
diff --git a/demos/src/GuideGettingStarted/VModel/index.spec.ts b/demos/src/GuideGettingStarted/VModel/index.spec.ts
new file mode 100644
index 0000000..646b5f1
--- /dev/null
+++ b/demos/src/GuideGettingStarted/VModel/index.spec.ts
@@ -0,0 +1,24 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'VModel'
+const frameworkPaths = ['Vue']
+const demoPath = '/src/GuideGettingStarted'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test('loads the editor', async ({ page }) => {
+ const editor = await getEditor(page)
+ await expect(editor).toBeVisible()
+ })
+ })
+ })
+})
diff --git a/demos/src/GuideMarkViews/ReactComponent/React/Component.tsx b/demos/src/GuideMarkViews/ReactComponent/React/Component.tsx
new file mode 100644
index 0000000..93b411f
--- /dev/null
+++ b/demos/src/GuideMarkViews/ReactComponent/React/Component.tsx
@@ -0,0 +1,37 @@
+import type { MarkViewRendererProps } from '@tiptap/react'
+import { MarkViewContent } from '@tiptap/react'
+import React from 'react'
+
+// oxlint-disable-next-line no-unused-vars
+export default (props: MarkViewRendererProps) => {
+ const [count, setCount] = React.useState(props.HTMLAttributes['data-count'] ?? 0)
+
+ return (
+
+
+
+
+
+ React component:
+ {
+ setCount(count + 1)
+ }}
+ >
+ This button has been clicked {count} times.
+
+ props.updateAttributes({ 'data-count': count })}
+ >
+ Update attributes
+
+
+
+ )
+}
diff --git a/demos/src/GuideMarkViews/ReactComponent/React/Extension.ts b/demos/src/GuideMarkViews/ReactComponent/React/Extension.ts
new file mode 100644
index 0000000..f8bfb0c
--- /dev/null
+++ b/demos/src/GuideMarkViews/ReactComponent/React/Extension.ts
@@ -0,0 +1,30 @@
+import { Mark } from '@tiptap/core'
+import { ReactMarkViewRenderer } from '@tiptap/react'
+
+import Component from './Component.js'
+
+export default Mark.create({
+ name: 'reactComponent',
+
+ addAttributes() {
+ return {
+ 'data-count': { default: 0 },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'react-component',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['react-component', HTMLAttributes]
+ },
+
+ addMarkView() {
+ return ReactMarkViewRenderer(Component)
+ },
+})
diff --git a/demos/src/GuideMarkViews/ReactComponent/React/index.html b/demos/src/GuideMarkViews/ReactComponent/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideMarkViews/ReactComponent/React/index.tsx b/demos/src/GuideMarkViews/ReactComponent/React/index.tsx
new file mode 100644
index 0000000..9ae9ee9
--- /dev/null
+++ b/demos/src/GuideMarkViews/ReactComponent/React/index.tsx
@@ -0,0 +1,24 @@
+import './styles.scss'
+
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+import ReactComponent from './Extension.js'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [StarterKit, ReactComponent],
+ content: `
+
+ This is still the text editor you’re used to, but enriched with node views.
+
+ Sub-text
+
+ Did you see that? That’s a React component. We are really living in the future.
+
+ `,
+ })
+
+ return
+}
diff --git a/demos/src/GuideMarkViews/ReactComponent/React/styles.scss b/demos/src/GuideMarkViews/ReactComponent/React/styles.scss
new file mode 100644
index 0000000..bb941cc
--- /dev/null
+++ b/demos/src/GuideMarkViews/ReactComponent/React/styles.scss
@@ -0,0 +1,121 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+
+ /* React component */
+ .react-component {
+ background-color: var(--purple-light);
+ border: 2px solid var(--purple);
+ border-radius: 0.5rem;
+ margin: 2rem 0;
+ position: relative;
+
+ label {
+ background-color: var(--purple);
+ border-radius: 0 0 0.5rem 0;
+ color: var(--white);
+ font-size: 0.75rem;
+ font-weight: bold;
+ padding: 0.25rem 0.5rem;
+ position: absolute;
+ top: 0;
+ }
+
+ .content {
+ margin-top: 1.5rem;
+ padding: 1rem;
+ }
+ }
+
+ .mark-view-content-wrapper {
+ background-color: var(--gray-3);
+ padding: 0.25rem 0.5rem;
+ }
+}
diff --git a/demos/src/GuideMarkViews/ReactComponent/index.spec.ts b/demos/src/GuideMarkViews/ReactComponent/index.spec.ts
new file mode 100644
index 0000000..68ef729
--- /dev/null
+++ b/demos/src/GuideMarkViews/ReactComponent/index.spec.ts
@@ -0,0 +1,52 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'ReactComponent'
+const frameworkPaths = ['React']
+const demoPath = '/src/GuideMarkViews'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await setEditorContent(
+ page,
+ 'Example Text
Mark View Text ',
+ )
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ })
+
+ test('shows the markview', async ({ page }) => {
+ await expect(page.locator('.tiptap [data-test-id="mark-view"]')).toHaveCount(1)
+ })
+
+ test('shows the markview content in the markview', async ({ page }) => {
+ await expect(
+ page.locator('.tiptap [data-test-id="mark-view-content-wrapper"]'),
+ ).toContainText('Mark View Text')
+ })
+
+ test('allows clicking the button', async ({ page }) => {
+ const button = page.locator('.tiptap [data-test-id="count-button"]')
+ await expect(button).toContainText('This button has been clicked 0 times.')
+ await button.click()
+ await expect(button).toContainText('This button has been clicked 1 times.')
+ })
+
+ test('renders update-attributes button', async ({ page }) => {
+ await expect(page.locator('.tiptap [data-test-id="mark-view"]')).toHaveAttribute(
+ 'data-count',
+ '0',
+ )
+ await expect(
+ page.locator('.tiptap [data-test-id="update-attributes-button"]'),
+ ).toBeVisible()
+ })
+ })
+ })
+})
diff --git a/demos/src/GuideMarkViews/VueComponent/Vue/Component.vue b/demos/src/GuideMarkViews/VueComponent/Vue/Component.vue
new file mode 100644
index 0000000..ca8528a
--- /dev/null
+++ b/demos/src/GuideMarkViews/VueComponent/Vue/Component.vue
@@ -0,0 +1,68 @@
+
+
+
+ Vue Component::
+
+ This button has been clicked {{ count }} times.
+
+
+ Update attributes
+
+
+
+
+
+
+
+
diff --git a/demos/src/GuideMarkViews/VueComponent/Vue/Extension.ts b/demos/src/GuideMarkViews/VueComponent/Vue/Extension.ts
new file mode 100644
index 0000000..13b3c46
--- /dev/null
+++ b/demos/src/GuideMarkViews/VueComponent/Vue/Extension.ts
@@ -0,0 +1,30 @@
+import { Mark } from '@tiptap/core'
+import { VueMarkViewRenderer } from '@tiptap/vue'
+
+import Component from './Component.vue'
+
+export default Mark.create({
+ name: 'vueComponent',
+
+ addAttributes() {
+ return {
+ 'data-count': { default: 0 },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'vue-component',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['vue-component', HTMLAttributes]
+ },
+
+ addMarkView() {
+ return VueMarkViewRenderer(Component)
+ },
+})
diff --git a/demos/src/GuideMarkViews/VueComponent/Vue/index.html b/demos/src/GuideMarkViews/VueComponent/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideMarkViews/VueComponent/Vue/index.vue b/demos/src/GuideMarkViews/VueComponent/Vue/index.vue
new file mode 100644
index 0000000..f38800b
--- /dev/null
+++ b/demos/src/GuideMarkViews/VueComponent/Vue/index.vue
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
diff --git a/demos/src/GuideMarkViews/VueComponent/index.spec.ts b/demos/src/GuideMarkViews/VueComponent/index.spec.ts
new file mode 100644
index 0000000..b17011f
--- /dev/null
+++ b/demos/src/GuideMarkViews/VueComponent/index.spec.ts
@@ -0,0 +1,46 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'VueComponent'
+const frameworkPaths = ['Vue']
+const demoPath = '/src/GuideMarkViews'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await setEditorContent(
+ page,
+ 'Example Text
Mark View Text ',
+ )
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ })
+
+ test('shows the markview', async ({ page }) => {
+ await expect(page.locator('.tiptap [data-test-id="mark-view"]')).toHaveCount(1)
+ })
+
+ test('allows clicking the button', async ({ page }) => {
+ const button = page.locator('.tiptap [data-test-id="count-button"]')
+ await expect(button).toContainText('This button has been clicked 0 times.')
+ await button.click()
+ await expect(button).toContainText('This button has been clicked 1 times.')
+ })
+
+ test('renders update-attributes button', async ({ page }) => {
+ await expect(page.locator('.tiptap [data-test-id="mark-view"]')).toHaveAttribute(
+ 'data-count',
+ '0',
+ )
+ await expect(
+ page.locator('.tiptap [data-test-id="update-attributes-button"]'),
+ ).toBeVisible()
+ })
+ })
+ })
+})
diff --git a/demos/src/GuideNodeViews/Decorations/React/Component.tsx b/demos/src/GuideNodeViews/Decorations/React/Component.tsx
new file mode 100644
index 0000000..6413fe8
--- /dev/null
+++ b/demos/src/GuideNodeViews/Decorations/React/Component.tsx
@@ -0,0 +1,33 @@
+import type { ReactNodeViewProps } from '@tiptap/react'
+import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
+import React, { useRef } from 'react'
+
+export default (props: ReactNodeViewProps) => {
+ const renderCount = useRef(0)
+ renderCount.current += 1
+
+ const toggleHighlight = () => {
+ props.updateAttributes({
+ highlight: !props.node.attrs.highlight,
+ })
+ }
+
+ const showPosition = () => {
+ alert(`Current position: ${props.getPos()}`)
+ }
+
+ return (
+
+ React Decorations (rendered: {renderCount.current}x)
+
+
+
+
+
+ {props.node.attrs.highlight ? 'Remove Decorations' : 'Add Decorations'}
+
+ Show Position
+
+
+ )
+}
diff --git a/demos/src/GuideNodeViews/Decorations/React/Extension.ts b/demos/src/GuideNodeViews/Decorations/React/Extension.ts
new file mode 100644
index 0000000..fce9887
--- /dev/null
+++ b/demos/src/GuideNodeViews/Decorations/React/Extension.ts
@@ -0,0 +1,74 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+import { Plugin } from '@tiptap/pm/state'
+import { Decoration, DecorationSet } from '@tiptap/pm/view'
+import { ReactNodeViewRenderer } from '@tiptap/react'
+
+import Component from './Component.jsx'
+
+export default Node.create({
+ name: 'decorations',
+
+ group: 'block',
+
+ content: 'inline*',
+
+ addAttributes() {
+ return {
+ highlight: {
+ default: false,
+ parseHTML: element => element.getAttribute('data-highlight') === 'true',
+ renderHTML: attributes => ({
+ 'data-highlight': attributes.highlight,
+ }),
+ },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'decorations',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['decorations', mergeAttributes(HTMLAttributes), 0]
+ },
+
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ props: {
+ decorations(state) {
+ const { doc } = state
+ const decorations: Decoration[] = []
+ const nodeType = state.schema.nodes.decorations
+
+ doc.descendants((node, pos) => {
+ if (node.type === nodeType && node.attrs.highlight) {
+ node.forEach((child, offset) => {
+ if (child.isText && child.text) {
+ const from = pos + 1 + offset
+ const to = from + child.text.length
+ decorations.push(
+ Decoration.inline(from, to, {
+ class: 'highlight-decoration',
+ }),
+ )
+ }
+ })
+ }
+ })
+
+ return DecorationSet.create(doc, decorations)
+ },
+ },
+ }),
+ ]
+ },
+
+ addNodeView() {
+ return ReactNodeViewRenderer(Component)
+ },
+})
diff --git a/demos/src/GuideNodeViews/Decorations/React/index.html b/demos/src/GuideNodeViews/Decorations/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideNodeViews/Decorations/React/index.tsx b/demos/src/GuideNodeViews/Decorations/React/index.tsx
new file mode 100644
index 0000000..aa80755
--- /dev/null
+++ b/demos/src/GuideNodeViews/Decorations/React/index.tsx
@@ -0,0 +1,28 @@
+import './styles.scss'
+
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+import DecorationsExtension from './Extension.js'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [StarterKit, DecorationsExtension],
+ content: `
+
+ This demo shows that ProseMirror decorations update visually without React re-renders.
+
+
+ Click the button below to toggle decorations. The render counter in the label
+ stays the same — but the decorations (highlights) update immediately.
+
+
+ ProseMirror manages the contentDOM natively. The React component only
+ re-renders when the node reference or position actually changes.
+
+ `,
+ })
+
+ return
+}
diff --git a/demos/src/GuideNodeViews/Decorations/React/styles.scss b/demos/src/GuideNodeViews/Decorations/React/styles.scss
new file mode 100644
index 0000000..a04b152
--- /dev/null
+++ b/demos/src/GuideNodeViews/Decorations/React/styles.scss
@@ -0,0 +1,147 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+
+ /* React component */
+ .react-component {
+ background-color: var(--purple-light);
+ border: 2px solid var(--purple);
+ border-radius: 0.5rem;
+ margin: 2rem 0;
+ position: relative;
+
+ label {
+ background-color: var(--purple);
+ border-radius: 0 0 0.5rem 0;
+ color: var(--white);
+ font-size: 0.75rem;
+ font-weight: bold;
+ padding: 0.25rem 0.5rem;
+ position: absolute;
+ top: 0;
+ }
+
+ .content {
+ margin-top: 1.5rem;
+ padding: 1rem;
+
+ &.is-editable {
+ border: 2px dashed var(--gray-3);
+ border-radius: 0.5rem;
+ margin: 2.5rem 1rem 1rem;
+ padding: 0.5rem;
+ }
+ }
+
+ .toolbar {
+ padding: 0 1rem 1rem;
+ display: flex;
+ gap: 0.5rem;
+
+ button {
+ background-color: var(--purple);
+ border: none;
+ border-radius: 0.25rem;
+ color: var(--white);
+ cursor: pointer;
+ font-size: 0.85rem;
+ padding: 0.5rem 1rem;
+
+ &:hover {
+ opacity: 0.8;
+ }
+ }
+ }
+ }
+
+ /* Decoration styles — rendered by ProseMirror, not React */
+ .highlight-decoration {
+ background-color: rgba(255, 230, 0, 0.4);
+ border-radius: 0.15rem;
+ padding: 0.1rem 0;
+ }
+}
diff --git a/demos/src/GuideNodeViews/Decorations/Vue/Component.vue b/demos/src/GuideNodeViews/Decorations/Vue/Component.vue
new file mode 100644
index 0000000..10d237d
--- /dev/null
+++ b/demos/src/GuideNodeViews/Decorations/Vue/Component.vue
@@ -0,0 +1,107 @@
+
+
+ Vue Decorations (rendered: {{ renderCount }}x)
+
+
+
+
+
+ {{ node.attrs.highlight ? 'Remove Decorations' : 'Add Decorations' }}
+
+ Show Position
+
+
+
+
+
+
+
diff --git a/demos/src/GuideNodeViews/Decorations/Vue/Extension.js b/demos/src/GuideNodeViews/Decorations/Vue/Extension.js
new file mode 100644
index 0000000..9fc9391
--- /dev/null
+++ b/demos/src/GuideNodeViews/Decorations/Vue/Extension.js
@@ -0,0 +1,74 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+import { Plugin } from '@tiptap/pm/state'
+import { Decoration, DecorationSet } from '@tiptap/pm/view'
+import { VueNodeViewRenderer } from '@tiptap/vue'
+
+import Component from './Component.vue'
+
+export default Node.create({
+ name: 'decorations',
+
+ group: 'block',
+
+ content: 'inline*',
+
+ addAttributes() {
+ return {
+ highlight: {
+ default: false,
+ parseHTML: element => element.getAttribute('data-highlight') === 'true',
+ renderHTML: attributes => ({
+ 'data-highlight': attributes.highlight,
+ }),
+ },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'decorations',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['decorations', mergeAttributes(HTMLAttributes), 0]
+ },
+
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ props: {
+ decorations(state) {
+ const { doc } = state
+ const decorations = []
+ const nodeType = state.schema.nodes.decorations
+
+ doc.descendants((node, pos) => {
+ if (node.type === nodeType && node.attrs.highlight) {
+ node.forEach((child, offset) => {
+ if (child.isText && child.text) {
+ const from = pos + 1 + offset
+ const to = from + child.text.length
+ decorations.push(
+ Decoration.inline(from, to, {
+ class: 'highlight-decoration',
+ }),
+ )
+ }
+ })
+ }
+ })
+
+ return DecorationSet.create(doc, decorations)
+ },
+ },
+ }),
+ ]
+ },
+
+ addNodeView() {
+ return VueNodeViewRenderer(Component)
+ },
+})
diff --git a/demos/src/GuideNodeViews/Decorations/Vue/index.html b/demos/src/GuideNodeViews/Decorations/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideNodeViews/Decorations/Vue/index.vue b/demos/src/GuideNodeViews/Decorations/Vue/index.vue
new file mode 100644
index 0000000..a1e74d0
--- /dev/null
+++ b/demos/src/GuideNodeViews/Decorations/Vue/index.vue
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/GuideNodeViews/DragHandle/React/Component.jsx b/demos/src/GuideNodeViews/DragHandle/React/Component.jsx
new file mode 100644
index 0000000..0789010
--- /dev/null
+++ b/demos/src/GuideNodeViews/DragHandle/React/Component.jsx
@@ -0,0 +1,11 @@
+import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ return (
+
+
+
+
+ )
+}
diff --git a/demos/src/GuideNodeViews/DragHandle/React/DraggableItem.js b/demos/src/GuideNodeViews/DragHandle/React/DraggableItem.js
new file mode 100644
index 0000000..f964519
--- /dev/null
+++ b/demos/src/GuideNodeViews/DragHandle/React/DraggableItem.js
@@ -0,0 +1,30 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+import { ReactNodeViewRenderer } from '@tiptap/react'
+
+import Component from './Component.jsx'
+
+export default Node.create({
+ name: 'draggableItem',
+
+ group: 'block',
+
+ content: 'block+',
+
+ draggable: true,
+
+ parseHTML() {
+ return [
+ {
+ tag: 'div[data-type="draggable-item"]',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'draggable-item' }), 0]
+ },
+
+ addNodeView() {
+ return ReactNodeViewRenderer(Component)
+ },
+})
diff --git a/demos/src/GuideNodeViews/DragHandle/React/index.html b/demos/src/GuideNodeViews/DragHandle/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideNodeViews/DragHandle/React/index.jsx b/demos/src/GuideNodeViews/DragHandle/React/index.jsx
new file mode 100644
index 0000000..5944170
--- /dev/null
+++ b/demos/src/GuideNodeViews/DragHandle/React/index.jsx
@@ -0,0 +1,30 @@
+import './styles.scss'
+
+import { EditorProvider } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+import DraggableItem from './DraggableItem.js'
+
+const extensions = [StarterKit, DraggableItem]
+
+const content = `
+ This is a boring paragraph.
+
+
Followed by a fancy draggable item.
+
+
+
And another draggable item.
+
+
And a nested one.
+
+
But can we go deeper?
+
+
+
+ Let’s finish with a boring paragraph.
+ `
+
+export default () => {
+ return
+}
diff --git a/demos/src/GuideNodeViews/DragHandle/React/styles.scss b/demos/src/GuideNodeViews/DragHandle/React/styles.scss
new file mode 100644
index 0000000..04f58aa
--- /dev/null
+++ b/demos/src/GuideNodeViews/DragHandle/React/styles.scss
@@ -0,0 +1,126 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+
+ // Focus styles
+ .has-focus {
+ border-radius: 3px;
+ box-shadow: 0 0 0 2px var(--purple);
+ }
+}
+
+.draggable-item {
+ display: flex;
+ padding: 0.5rem;
+ margin: 0.5rem 0;
+ border-radius: 0.5rem;
+ background: white;
+ box-shadow:
+ 0 0 0 1px rgba(0, 0, 0, 0.05),
+ 0px 10px 20px rgba(0, 0, 0, 0.1);
+
+ .drag-handle {
+ flex: 0 0 auto;
+ position: relative;
+ width: 1rem;
+ height: 1rem;
+ top: 0.3rem;
+ margin-right: 0.5rem;
+ cursor: grab;
+ background-image: url('data:image/svg+xml;charset=UTF-8, ');
+ background-repeat: no-repeat;
+ background-size: contain;
+ background-position: center;
+ }
+
+ .content {
+ flex: 1 1 auto;
+ }
+}
diff --git a/demos/src/GuideNodeViews/DragHandle/Vue/Component.vue b/demos/src/GuideNodeViews/DragHandle/Vue/Component.vue
new file mode 100644
index 0000000..2aa2ed3
--- /dev/null
+++ b/demos/src/GuideNodeViews/DragHandle/Vue/Component.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/GuideNodeViews/DragHandle/Vue/DraggableItem.js b/demos/src/GuideNodeViews/DragHandle/Vue/DraggableItem.js
new file mode 100644
index 0000000..f6e7a52
--- /dev/null
+++ b/demos/src/GuideNodeViews/DragHandle/Vue/DraggableItem.js
@@ -0,0 +1,30 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+import { VueNodeViewRenderer } from '@tiptap/vue'
+
+import Component from './Component.vue'
+
+export default Node.create({
+ name: 'draggableItem',
+
+ group: 'block',
+
+ content: 'block+',
+
+ draggable: true,
+
+ parseHTML() {
+ return [
+ {
+ tag: 'div[data-type="draggable-item"]',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'draggable-item' }), 0]
+ },
+
+ addNodeView() {
+ return VueNodeViewRenderer(Component)
+ },
+})
diff --git a/demos/src/GuideNodeViews/DragHandle/Vue/index.html b/demos/src/GuideNodeViews/DragHandle/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideNodeViews/DragHandle/Vue/index.vue b/demos/src/GuideNodeViews/DragHandle/Vue/index.vue
new file mode 100644
index 0000000..19c56af
--- /dev/null
+++ b/demos/src/GuideNodeViews/DragHandle/Vue/index.vue
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/GuideNodeViews/JavaScript/Vue/Extension.js b/demos/src/GuideNodeViews/JavaScript/Vue/Extension.js
new file mode 100644
index 0000000..842bcf6
--- /dev/null
+++ b/demos/src/GuideNodeViews/JavaScript/Vue/Extension.js
@@ -0,0 +1,82 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+
+export default Node.create({
+ name: 'nodeView',
+
+ group: 'block',
+
+ atom: true,
+
+ addAttributes() {
+ return {
+ count: {
+ default: 0,
+ },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'node-view',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['node-view', mergeAttributes(HTMLAttributes)]
+ },
+
+ addNodeView() {
+ return ({ editor, node, getPos }) => {
+ const { view } = editor
+
+ // Markup
+ /*
+
+
Node view
+
+
+
+ This button has been clicked ${node.attrs.count} times.
+
+
+
+ */
+
+ const dom = document.createElement('div')
+
+ dom.classList.add('node-view')
+
+ const label = document.createElement('label')
+
+ label.innerHTML = 'Node view'
+
+ const content = document.createElement('div')
+
+ content.classList.add('content')
+
+ const button = document.createElement('button')
+
+ button.innerHTML = `This button has been clicked ${node.attrs.count} times.`
+ button.addEventListener('click', () => {
+ if (typeof getPos === 'function') {
+ view.dispatch(
+ view.state.tr.setNodeMarkup(getPos(), undefined, {
+ count: node.attrs.count + 1,
+ }),
+ )
+
+ editor.commands.focus()
+ }
+ })
+ content.append(button)
+
+ dom.append(label, content)
+
+ return {
+ dom,
+ }
+ }
+ },
+})
diff --git a/demos/src/GuideNodeViews/JavaScript/Vue/index.html b/demos/src/GuideNodeViews/JavaScript/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideNodeViews/JavaScript/Vue/index.vue b/demos/src/GuideNodeViews/JavaScript/Vue/index.vue
new file mode 100644
index 0000000..1e52b0a
--- /dev/null
+++ b/demos/src/GuideNodeViews/JavaScript/Vue/index.vue
@@ -0,0 +1,160 @@
+
+
+
+
+
+
+
diff --git a/demos/src/GuideNodeViews/JavaScriptContent/Vue/Extension.js b/demos/src/GuideNodeViews/JavaScriptContent/Vue/Extension.js
new file mode 100644
index 0000000..b3a6cc6
--- /dev/null
+++ b/demos/src/GuideNodeViews/JavaScriptContent/Vue/Extension.js
@@ -0,0 +1,55 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+
+export default Node.create({
+ name: 'nodeView',
+
+ group: 'block',
+
+ content: 'inline*',
+
+ parseHTML() {
+ return [
+ {
+ tag: 'node-view',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['node-view', mergeAttributes(HTMLAttributes), 0]
+ },
+
+ addNodeView() {
+ return () => {
+ // Markup
+ /*
+
+ */
+
+ const dom = document.createElement('div')
+
+ dom.classList.add('node-view')
+
+ const label = document.createElement('label')
+
+ label.innerHTML = 'Node view'
+ label.contentEditable = false
+
+ const content = document.createElement('div')
+
+ content.classList.add('content')
+ content.classList.add('is-editable')
+
+ dom.append(label, content)
+
+ return {
+ dom,
+ contentDOM: content,
+ }
+ }
+ },
+})
diff --git a/demos/src/GuideNodeViews/JavaScriptContent/Vue/index.html b/demos/src/GuideNodeViews/JavaScriptContent/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideNodeViews/JavaScriptContent/Vue/index.vue b/demos/src/GuideNodeViews/JavaScriptContent/Vue/index.vue
new file mode 100644
index 0000000..72f84f3
--- /dev/null
+++ b/demos/src/GuideNodeViews/JavaScriptContent/Vue/index.vue
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
diff --git a/demos/src/GuideNodeViews/ReactComponent/React/Component.tsx b/demos/src/GuideNodeViews/ReactComponent/React/Component.tsx
new file mode 100644
index 0000000..c496aec
--- /dev/null
+++ b/demos/src/GuideNodeViews/ReactComponent/React/Component.tsx
@@ -0,0 +1,23 @@
+import type { ReactNodeViewProps } from '@tiptap/react'
+import { NodeViewWrapper } from '@tiptap/react'
+import React from 'react'
+
+export default (props: ReactNodeViewProps) => {
+ const increase = () => {
+ props.updateAttributes({
+ count: props.node.attrs.count + 1,
+ })
+ }
+
+ return (
+
+ React Component
+
+
+
+ This button has been clicked {props.node.attrs.count} times.
+
+
+
+ )
+}
diff --git a/demos/src/GuideNodeViews/ReactComponent/React/Extension.ts b/demos/src/GuideNodeViews/ReactComponent/React/Extension.ts
new file mode 100644
index 0000000..fedc5f7
--- /dev/null
+++ b/demos/src/GuideNodeViews/ReactComponent/React/Extension.ts
@@ -0,0 +1,36 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+import { ReactNodeViewRenderer } from '@tiptap/react'
+
+import Component from './Component.jsx'
+
+export default Node.create({
+ name: 'reactComponent',
+
+ group: 'block',
+
+ atom: true,
+
+ addAttributes() {
+ return {
+ count: {
+ default: 0,
+ },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'react-component',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['react-component', mergeAttributes(HTMLAttributes)]
+ },
+
+ addNodeView() {
+ return ReactNodeViewRenderer(Component)
+ },
+})
diff --git a/demos/src/GuideNodeViews/ReactComponent/React/index.html b/demos/src/GuideNodeViews/ReactComponent/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideNodeViews/ReactComponent/React/index.tsx b/demos/src/GuideNodeViews/ReactComponent/React/index.tsx
new file mode 100644
index 0000000..c62c5a3
--- /dev/null
+++ b/demos/src/GuideNodeViews/ReactComponent/React/index.tsx
@@ -0,0 +1,24 @@
+import './styles.scss'
+
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+import ReactComponent from './Extension.js'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [StarterKit, ReactComponent],
+ content: `
+
+ This is still the text editor you’re used to, but enriched with node views.
+
+
+
+ Did you see that? That’s a React component. We are really living in the future.
+
+ `,
+ })
+
+ return
+}
diff --git a/demos/src/GuideNodeViews/ReactComponent/React/styles.scss b/demos/src/GuideNodeViews/ReactComponent/React/styles.scss
new file mode 100644
index 0000000..3d00a3e
--- /dev/null
+++ b/demos/src/GuideNodeViews/ReactComponent/React/styles.scss
@@ -0,0 +1,116 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+
+ /* React component */
+ .react-component {
+ background-color: var(--purple-light);
+ border: 2px solid var(--purple);
+ border-radius: 0.5rem;
+ margin: 2rem 0;
+ position: relative;
+
+ label {
+ background-color: var(--purple);
+ border-radius: 0 0 0.5rem 0;
+ color: var(--white);
+ font-size: 0.75rem;
+ font-weight: bold;
+ padding: 0.25rem 0.5rem;
+ position: absolute;
+ top: 0;
+ }
+
+ .content {
+ margin-top: 1.5rem;
+ padding: 1rem;
+ }
+ }
+}
diff --git a/demos/src/GuideNodeViews/ReactComponentContent/React/Component.jsx b/demos/src/GuideNodeViews/ReactComponentContent/React/Component.jsx
new file mode 100644
index 0000000..36fc95e
--- /dev/null
+++ b/demos/src/GuideNodeViews/ReactComponentContent/React/Component.jsx
@@ -0,0 +1,12 @@
+import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ return (
+
+ React Component
+
+
+
+ )
+}
diff --git a/demos/src/GuideNodeViews/ReactComponentContent/React/Extension.js b/demos/src/GuideNodeViews/ReactComponentContent/React/Extension.js
new file mode 100644
index 0000000..d2fe11d
--- /dev/null
+++ b/demos/src/GuideNodeViews/ReactComponentContent/React/Extension.js
@@ -0,0 +1,40 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+import { ReactNodeViewRenderer } from '@tiptap/react'
+
+import Component from './Component.jsx'
+
+export default Node.create({
+ name: 'reactComponent',
+
+ group: 'block',
+
+ content: 'inline*',
+
+ parseHTML() {
+ return [
+ {
+ tag: 'react-component',
+ },
+ ]
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ 'Mod-Enter': () => {
+ return this.editor
+ .chain()
+ .insertContentAt(this.editor.state.selection.head, { type: this.type.name })
+ .focus()
+ .run()
+ },
+ }
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['react-component', mergeAttributes(HTMLAttributes), 0]
+ },
+
+ addNodeView() {
+ return ReactNodeViewRenderer(Component)
+ },
+})
diff --git a/demos/src/GuideNodeViews/ReactComponentContent/React/index.html b/demos/src/GuideNodeViews/ReactComponentContent/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideNodeViews/ReactComponentContent/React/index.jsx b/demos/src/GuideNodeViews/ReactComponentContent/React/index.jsx
new file mode 100644
index 0000000..6ed1706
--- /dev/null
+++ b/demos/src/GuideNodeViews/ReactComponentContent/React/index.jsx
@@ -0,0 +1,24 @@
+import './styles.scss'
+
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+import ReactComponent from './Extension.js'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [StarterKit, ReactComponent],
+ content: `
+
+ This is still the text editor you’re used to, but enriched with node views.
+
+ This is editable. You can create a new component by pressing Mod+Enter.
+
+ Did you see that? That’s a React component. We are really living in the future.
+
+ `,
+ })
+
+ return
+}
diff --git a/demos/src/GuideNodeViews/ReactComponentContent/React/styles.scss b/demos/src/GuideNodeViews/ReactComponentContent/React/styles.scss
new file mode 100644
index 0000000..602feeb
--- /dev/null
+++ b/demos/src/GuideNodeViews/ReactComponentContent/React/styles.scss
@@ -0,0 +1,123 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+
+ /* React component */
+ .react-component {
+ background-color: var(--purple-light);
+ border: 2px solid var(--purple);
+ border-radius: 0.5rem;
+ margin: 2rem 0;
+ position: relative;
+
+ label {
+ background-color: var(--purple);
+ border-radius: 0 0 0.5rem 0;
+ color: var(--white);
+ font-size: 0.75rem;
+ font-weight: bold;
+ padding: 0.25rem 0.5rem;
+ position: absolute;
+ top: 0;
+ }
+
+ .content {
+ margin-top: 1.5rem;
+ padding: 1rem;
+
+ &.is-editable {
+ border: 2px dashed var(--gray-3);
+ border-radius: 0.5rem;
+ margin: 2.5rem 1rem 1rem;
+ padding: 0.5rem;
+ }
+ }
+ }
+}
diff --git a/demos/src/GuideNodeViews/ReactComponentContent/index.spec.ts b/demos/src/GuideNodeViews/ReactComponentContent/index.spec.ts
new file mode 100644
index 0000000..01abd38
--- /dev/null
+++ b/demos/src/GuideNodeViews/ReactComponentContent/index.spec.ts
@@ -0,0 +1,15 @@
+import { expect, test } from '@playwright/test'
+
+const demoPath = '/src/GuideNodeViews/ReactComponentContent/React/'
+
+test.describe('GuideNodeViews/ReactComponentContent/React', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(demoPath)
+ })
+
+ test('renders the initial NodeView content', async ({ page }) => {
+ await expect(page.locator('.tiptap .react-component .content')).toHaveText(
+ 'This is editable. You can create a new component by pressing Mod+Enter.',
+ )
+ })
+})
diff --git a/demos/src/GuideNodeViews/ReactComponentContext/React/Component.tsx b/demos/src/GuideNodeViews/ReactComponentContext/React/Component.tsx
new file mode 100644
index 0000000..3db34ed
--- /dev/null
+++ b/demos/src/GuideNodeViews/ReactComponentContext/React/Component.tsx
@@ -0,0 +1,27 @@
+import type { ReactNodeViewProps } from '@tiptap/react'
+import { NodeViewWrapper } from '@tiptap/react'
+import React, { useContext } from 'react'
+
+import { Context } from './Context.js'
+
+export default (props: ReactNodeViewProps) => {
+ const { value } = useContext(Context)
+
+ const increase = () => {
+ props.updateAttributes({
+ count: props.node.attrs.count + 1,
+ })
+ }
+
+ return (
+
+ React Component: {value}
+
+
+
+ This button has been clicked {props.node.attrs.count} times.
+
+
+
+ )
+}
diff --git a/demos/src/GuideNodeViews/ReactComponentContext/React/Context.ts b/demos/src/GuideNodeViews/ReactComponentContext/React/Context.ts
new file mode 100644
index 0000000..18e9fd7
--- /dev/null
+++ b/demos/src/GuideNodeViews/ReactComponentContext/React/Context.ts
@@ -0,0 +1,5 @@
+import React from 'react'
+
+export const Context = React.createContext({
+ value: 'this is the default value which should not show up',
+})
diff --git a/demos/src/GuideNodeViews/ReactComponentContext/React/Extension.ts b/demos/src/GuideNodeViews/ReactComponentContext/React/Extension.ts
new file mode 100644
index 0000000..f51a5af
--- /dev/null
+++ b/demos/src/GuideNodeViews/ReactComponentContext/React/Extension.ts
@@ -0,0 +1,36 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+import { ReactNodeViewRenderer } from '@tiptap/react'
+
+import Component from './Component.js'
+
+export default Node.create({
+ name: 'reactComponent',
+
+ group: 'block',
+
+ atom: true,
+
+ addAttributes() {
+ return {
+ count: {
+ default: 0,
+ },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'react-component',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['react-component', mergeAttributes(HTMLAttributes)]
+ },
+
+ addNodeView() {
+ return ReactNodeViewRenderer(Component)
+ },
+})
diff --git a/demos/src/GuideNodeViews/ReactComponentContext/React/index.html b/demos/src/GuideNodeViews/ReactComponentContext/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideNodeViews/ReactComponentContext/React/index.tsx b/demos/src/GuideNodeViews/ReactComponentContext/React/index.tsx
new file mode 100644
index 0000000..599065e
--- /dev/null
+++ b/demos/src/GuideNodeViews/ReactComponentContext/React/index.tsx
@@ -0,0 +1,33 @@
+import './styles.scss'
+
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+import { Context } from './Context.js'
+import ReactComponent from './Extension.js'
+
+const contextValue = {
+ value: 'Hi from react context!',
+}
+
+export default () => {
+ const editor = useEditor({
+ extensions: [StarterKit, ReactComponent],
+ content: `
+
+ This is still the text editor you’re used to, but enriched with node views.
+
+
+
+ Did you see that? That’s a React component. We are really living in the future.
+
+ `,
+ })
+
+ return (
+
+
+
+ )
+}
diff --git a/demos/src/GuideNodeViews/ReactComponentContext/React/styles.scss b/demos/src/GuideNodeViews/ReactComponentContext/React/styles.scss
new file mode 100644
index 0000000..3d00a3e
--- /dev/null
+++ b/demos/src/GuideNodeViews/ReactComponentContext/React/styles.scss
@@ -0,0 +1,116 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+
+ /* React component */
+ .react-component {
+ background-color: var(--purple-light);
+ border: 2px solid var(--purple);
+ border-radius: 0.5rem;
+ margin: 2rem 0;
+ position: relative;
+
+ label {
+ background-color: var(--purple);
+ border-radius: 0 0 0.5rem 0;
+ color: var(--white);
+ font-size: 0.75rem;
+ font-weight: bold;
+ padding: 0.25rem 0.5rem;
+ position: absolute;
+ top: 0;
+ }
+
+ .content {
+ margin-top: 1.5rem;
+ padding: 1rem;
+ }
+ }
+}
diff --git a/demos/src/GuideNodeViews/VueComponent/Vue/Component.vue b/demos/src/GuideNodeViews/VueComponent/Vue/Component.vue
new file mode 100644
index 0000000..4cfe47a
--- /dev/null
+++ b/demos/src/GuideNodeViews/VueComponent/Vue/Component.vue
@@ -0,0 +1,58 @@
+
+
+ Vue Component
+
+
+ This button has been clicked {{ node.attrs.count }} times.
+
+
+
+
+
+
+
diff --git a/demos/src/GuideNodeViews/VueComponent/Vue/Extension.js b/demos/src/GuideNodeViews/VueComponent/Vue/Extension.js
new file mode 100644
index 0000000..de7f8e2
--- /dev/null
+++ b/demos/src/GuideNodeViews/VueComponent/Vue/Extension.js
@@ -0,0 +1,36 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+import { VueNodeViewRenderer } from '@tiptap/vue'
+
+import Component from './Component.vue'
+
+export default Node.create({
+ name: 'vueComponent',
+
+ group: 'block',
+
+ atom: true,
+
+ addAttributes() {
+ return {
+ count: {
+ default: 0,
+ },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'vue-component',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['vue-component', mergeAttributes(HTMLAttributes)]
+ },
+
+ addNodeView() {
+ return VueNodeViewRenderer(Component)
+ },
+})
diff --git a/demos/src/GuideNodeViews/VueComponent/Vue/index.html b/demos/src/GuideNodeViews/VueComponent/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideNodeViews/VueComponent/Vue/index.vue b/demos/src/GuideNodeViews/VueComponent/Vue/index.vue
new file mode 100644
index 0000000..590bcf1
--- /dev/null
+++ b/demos/src/GuideNodeViews/VueComponent/Vue/index.vue
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
diff --git a/demos/src/GuideNodeViews/VueComponentContent/Vue/Component.vue b/demos/src/GuideNodeViews/VueComponentContent/Vue/Component.vue
new file mode 100644
index 0000000..95d66e6
--- /dev/null
+++ b/demos/src/GuideNodeViews/VueComponentContent/Vue/Component.vue
@@ -0,0 +1,55 @@
+
+
+ Vue Component
+
+
+
+
+
+
+
diff --git a/demos/src/GuideNodeViews/VueComponentContent/Vue/Extension.js b/demos/src/GuideNodeViews/VueComponentContent/Vue/Extension.js
new file mode 100644
index 0000000..6e7db93
--- /dev/null
+++ b/demos/src/GuideNodeViews/VueComponentContent/Vue/Extension.js
@@ -0,0 +1,28 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+import { VueNodeViewRenderer } from '@tiptap/vue'
+
+import Component from './Component.vue'
+
+export default Node.create({
+ name: 'vueComponent',
+
+ group: 'block',
+
+ content: 'inline*',
+
+ parseHTML() {
+ return [
+ {
+ tag: 'vue-component',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['vue-component', mergeAttributes(HTMLAttributes), 0]
+ },
+
+ addNodeView() {
+ return VueNodeViewRenderer(Component)
+ },
+})
diff --git a/demos/src/GuideNodeViews/VueComponentContent/Vue/index.html b/demos/src/GuideNodeViews/VueComponentContent/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/GuideNodeViews/VueComponentContent/Vue/index.vue b/demos/src/GuideNodeViews/VueComponentContent/Vue/index.vue
new file mode 100644
index 0000000..052d08c
--- /dev/null
+++ b/demos/src/GuideNodeViews/VueComponentContent/Vue/index.vue
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
diff --git a/demos/src/GuideNodeViews/VueComponentContent/index.spec.ts b/demos/src/GuideNodeViews/VueComponentContent/index.spec.ts
new file mode 100644
index 0000000..68e86d0
--- /dev/null
+++ b/demos/src/GuideNodeViews/VueComponentContent/index.spec.ts
@@ -0,0 +1,13 @@
+import { expect, test } from '@playwright/test'
+
+const demoPath = '/src/GuideNodeViews/VueComponentContent/Vue/'
+
+test.describe('GuideNodeViews/VueComponentContent/Vue', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(demoPath)
+ })
+
+ test('renders the initial NodeView content', async ({ page }) => {
+ await expect(page.locator('.tiptap .vue-component .content')).toHaveText('This is editable.')
+ })
+})
diff --git a/demos/src/Markdown/CustomSyntax/React/index.html b/demos/src/Markdown/CustomSyntax/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Markdown/CustomSyntax/React/index.jsx b/demos/src/Markdown/CustomSyntax/React/index.jsx
new file mode 100644
index 0000000..18c30df
--- /dev/null
+++ b/demos/src/Markdown/CustomSyntax/React/index.jsx
@@ -0,0 +1,200 @@
+import './styles.scss'
+
+import Image from '@tiptap/extension-image'
+import { TableKit } from '@tiptap/extension-table'
+import { Markdown } from '@tiptap/markdown'
+import {
+ createAtomBlockMarkdownSpec,
+ createBlockMarkdownSpec,
+ createInlineMarkdownSpec,
+ EditorContent,
+ Mark,
+ Node,
+ useEditor,
+} from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import { useState } from 'react'
+
+const CustomNode = Node.create({
+ name: 'custom',
+ group: 'block',
+ content: 'block*',
+
+ renderHTML() {
+ return ['div', { 'data-type': 'custom' }, 0]
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'div[data-type="custom"]',
+ },
+ ]
+ },
+
+ ...createBlockMarkdownSpec({
+ nodeName: 'custom',
+ content: 'block',
+ }),
+})
+
+const CustomAtom = Node.create({
+ name: 'customAtom',
+ group: 'block',
+ atom: true,
+
+ renderHTML() {
+ return ['div', { 'data-type': 'atom' }]
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'div[data-type="atom"]',
+ },
+ ]
+ },
+
+ addNodeView() {
+ return () => {
+ const el = document.createElement('div')
+ el.setAttribute('data-type', 'atom')
+ el.textContent = 'This is an atom node.'
+
+ return {
+ dom: el,
+ }
+ }
+ },
+
+ ...createAtomBlockMarkdownSpec({
+ nodeName: 'atom',
+ }),
+})
+
+const CustomInline = Node.create({
+ name: 'customInline',
+ group: 'inline',
+ inline: true,
+ content: 'inline*',
+
+ renderHTML() {
+ return ['span', { 'data-type': 'inline' }, 0]
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'span[data-type="inline"]',
+ },
+ ]
+ },
+
+ ...createInlineMarkdownSpec({
+ nodeName: 'customInline',
+ content: 'inline',
+ }),
+})
+
+const CustomMark = Mark.create({
+ name: 'customMark',
+
+ renderHTML() {
+ return ['span', { 'data-type': 'mark' }, 0]
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'span[data-type="mark"]',
+ },
+ ]
+ },
+
+ renderMarkdown(node, helpers) {
+ return `=>${helpers.renderChildren(node)}<=`
+ },
+
+ parseMarkdown(token, helpers) {
+ return {
+ type: this.name,
+ content: helpers.applyMark('customMark', helpers.parseInline(token.tokens || [])),
+ }
+ },
+
+ markdownTokenizer: {
+ name: 'customMark',
+ level: 'inline',
+ start(src) {
+ return src.indexOf('=>')
+ },
+ tokenize(src, _tokens, lexer) {
+ const rule = /^(=>)([\s\S]+?)(<=)/
+ const match = rule.exec(src)
+
+ if (!match) {
+ return undefined
+ }
+
+ const innerContent = match[2].trim()
+
+ return {
+ type: 'customMark',
+ raw: match[0],
+ text: innerContent,
+ tokens: lexer.inlineTokens(innerContent),
+ }
+ },
+ },
+})
+
+export default () => {
+ const [serializedContent, setSerializedContent] = useState('')
+ const editor = useEditor({
+ extensions: [
+ Markdown,
+ StarterKit,
+ Image,
+ TableKit,
+ CustomNode,
+ CustomAtom,
+ CustomInline,
+ CustomMark,
+ ],
+ content: `
+ In this demo, you can see how to define custom syntax for Markdown.
+
+
This is a custom node.
+
+
+
We also support nested nodes.
+
+
+
This is a custom node.
+
+
+
+
+ This is a paragraph with a custom inline node .
+
+ Feel free to edit this document to see the live-changes.
+ `,
+ onUpdate: ({ editor: currentEditor }) => {
+ setSerializedContent(currentEditor.getMarkdown())
+ },
+ onCreate: ({ editor: currentEditor }) => {
+ setSerializedContent(currentEditor.getMarkdown())
+ },
+ })
+
+ return (
+ <>
+
+ >
+ )
+}
diff --git a/demos/src/Markdown/CustomSyntax/React/styles.scss b/demos/src/Markdown/CustomSyntax/React/styles.scss
new file mode 100644
index 0000000..78a00a7
--- /dev/null
+++ b/demos/src/Markdown/CustomSyntax/React/styles.scss
@@ -0,0 +1,67 @@
+.grid {
+ display: flex;
+ flex-direction: column;
+ height: 100dvh;
+
+ @media screen and (min-width: 400px) {
+ flex-direction: row;
+ }
+}
+
+.editor-wrapper {
+ flex: 1 1 auto;
+ order: 1;
+ overflow: auto;
+
+ @media screen and (min-width: 400px) {
+ flex: 1 1 50%;
+ order: 0;
+ width: 50%;
+ }
+}
+
+.preview {
+ flex: 0 0 auto;
+ order: 0;
+
+ @media screen and (min-width: 400px) {
+ flex: 1 1 50%;
+ order: 1;
+ width: 50%;
+ }
+}
+
+.preview pre {
+ background-color: #f1f1f1;
+ height: 35dvh;
+ margin: 0;
+ overflow: auto;
+ padding: 1rem;
+
+ @media screen and (min-width: 400px) {
+ min-height: 100%;
+ }
+}
+
+[data-type='custom'] {
+ background: rgba(0, 0, 0, 0.05);
+ border-left: 2px solid #000;
+ margin: 1.5rem;
+ padding: 1rem;
+}
+
+[data-type='atom'] {
+ background: rgba(255, 0, 0, 0.05);
+ border-radius: 0.25rem;
+ padding: 0.25rem 0.5rem;
+}
+
+[data-type='inline'] {
+ background: rgba(0, 0, 255, 0.05);
+ border-radius: 0.25rem;
+ padding: 0.25rem 0.5rem;
+}
+
+[data-type='mark'] {
+ background: rgba(255, 255, 0, 0.3);
+}
diff --git a/demos/src/Markdown/Full/React/content.ts b/demos/src/Markdown/Full/React/content.ts
new file mode 100644
index 0000000..705eea4
--- /dev/null
+++ b/demos/src/Markdown/Full/React/content.ts
@@ -0,0 +1,174 @@
+export const mdContent = `# Welcome to the Markdown Demo
+
+This demo showcases **bidirectional** markdown support in Tiptap with extended features.
+
+## Features
+
+- **Bold text** and *italic text*
+- \`inline code\` and code blocks
+- [Links](https://tiptap.dev)
+- Lists and more!
+
+## Extended Features
+
+## Task Lists
+
+- [ ] Incomplete task
+ - [ ] Nested incomplete task
+ - [x] Completed task
+- [x] Completed task
+ - [ ] Incomplete task
+ - [x] Completed task
+
+HTML Support
+
+Markdown support comes with additional HTML support so your content can be easily parsed as well, even if not in Markdown format.
+
+
+
+
+ Lists
+
+
+
+ and
+
+
+ Sublists
+
+
+
+
+### Code
+
+Tiptap supports \`inline code\` and full code blocks:
+
+\`\`\`javascript
+import { Editor } from '@tiptap/core'
+import { StarterKit } from '@tiptap/starter-kit'
+
+const editor = new Editor({
+ extensions: [StarterKit],
+ content: 'Hello World!
',
+ element: document.querySelector('#editor'),
+})
+\`\`\`
+
+### Details
+
+:::details
+
+:::detailsSummary
+What features does Tiptap offer?
+:::
+
+:::detailsContent
+
+- Rich Text Editing
+- Collaborative Editing
+- Markdown Support
+- Content AI
+- Custom Extensions
+- More...
+
+:::
+
+:::
+
+:::details
+
+:::detailsSummary
+Where can I learn how to use Tiptap?
+:::
+
+:::detailsContent
+
+You can learn how to use Tiptap by visiting the [official documentation](https://tiptap.dev/docs).
+
+:::
+
+:::
+
+### YouTube Videos
+
+:::youtube {src="https://www.youtube.com/watch?v=dQw4w9WgXcQ" start="0" width="400" height="300"} :::
+
+### Twitch Videos
+
+:::twitch {src="https://www.twitch.tv/videos/2409205759" muted="true"} :::
+
+:::twitch {src="https://www.twitch.tv/nasa/clip/CooperativeBrainyJellyfishPJSugar-0TPiyjN_MNkrcErt" muted="true"} :::
+
+:::twitch {src="https://www.twitch.tv/LofiGirl" muted="true"} :::
+
+### Images
+
+
+
+### Mentions
+
+Hey, [@ id="Madonna"], have you seen [@ id="Tom Cruise"]?
+
+This demo supports **multi-mention** with different trigger characters:
+
+- User mentions with \`@\`: [@ id="Lea Thompson"] and [@ id="Cyndi Lauper"]
+- Tag mentions with \`#\`: [@ id="bug" char="#"] and [@ id="feature" char="#"]
+
+Try typing \`@\` or \`#\` in the editor to see suggestions!
+
+### Mathematics
+
+Inline math: $E = mc^2$ and $\\pi r^2$
+
+Block math:
+
+$$
+40*5/38
+$$
+
+### Custom React Component
+
+:::react {content="This is a custom React node view with fenced syntax!"}
+
+Isn't this great?
+
+:::
+
+:::react {content="Here is another custom React node view with more content!"}
+
+Another one with even more inline content to **edit**!
+
+:::react {content="Nested node"}
+
+Nested content is also supported!
+
+:::
+
+:::
+
+### Inline Code in Tables
+
+Pipe characters inside backtick code spans in tables should be preserved:
+
+| Expression | Meaning | Example |
+| ---------- | ------- | ------- |
+| \`||\` | or | \`a || b\` |
+| \`&&\` | and | \`a && b\` |
+
+### Try editing the markdown on the left:
+
+1. Edit the markdown text
+2. Click "Parse Markdown"
+3. See it render in the editor!
+ 1. Be very happy
+ 2. Enjoy the parsed content
+4. Try adding YouTube videos, mentions, math expressions, and custom components directly in the editor
+5. Click "Extract Markdown" to see the serialized output
+ 1. Be amazed by the fidelity of the conversion
+ 2. Share your feedback!
+
+You can also edit in the editor and see the markdown update.`
diff --git a/demos/src/Markdown/Full/React/index.html b/demos/src/Markdown/Full/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Markdown/Full/React/index.tsx b/demos/src/Markdown/Full/React/index.tsx
new file mode 100644
index 0000000..c81da33
--- /dev/null
+++ b/demos/src/Markdown/Full/React/index.tsx
@@ -0,0 +1,247 @@
+import './styles.scss'
+import 'katex/dist/katex.min.css'
+
+import { createBlockMarkdownSpec, Node } from '@tiptap/core'
+import { Details, DetailsContent, DetailsSummary } from '@tiptap/extension-details'
+import { Highlight } from '@tiptap/extension-highlight'
+import { Image } from '@tiptap/extension-image'
+import { TaskItem, TaskList } from '@tiptap/extension-list'
+import { Mathematics } from '@tiptap/extension-mathematics'
+import { Mention } from '@tiptap/extension-mention'
+import { TableKit } from '@tiptap/extension-table'
+import { Twitch } from '@tiptap/extension-twitch'
+import { Youtube } from '@tiptap/extension-youtube'
+import { Markdown } from '@tiptap/markdown'
+import {
+ EditorContent,
+ NodeViewContent,
+ NodeViewWrapper,
+ ReactNodeViewRenderer,
+ useEditor,
+} from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React, { useState } from 'react'
+
+import { mdContent } from './content.js'
+
+// Custom React component for demonstration
+const CustomReactComponent = ({ node }: any) => {
+ return (
+
+
+
Custom React Component
+
+ {node.attrs.content || 'This is a custom React node view!'}
+
+
+
+
+
+
+ )
+}
+
+// Custom node extension with React node view
+const CustomReactNode = Node.create({
+ name: 'customReactNode',
+
+ group: 'block',
+
+ content: 'block+',
+
+ addAttributes() {
+ return {
+ content: {
+ default: 'This is a custom React node view!',
+ },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'div[data-type="custom-react-node"]',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['div', { 'data-type': 'custom-react-node', ...HTMLAttributes }, 0]
+ },
+
+ addNodeView() {
+ return ReactNodeViewRenderer(CustomReactComponent)
+ },
+
+ markdownTokenName: 'customReactNode',
+
+ ...createBlockMarkdownSpec({
+ nodeName: 'customReactNode',
+ name: 'react',
+ }),
+})
+
+export default () => {
+ const [markdownInput, setMarkdownInput] = useState(mdContent)
+ const [error, setError] = useState(null)
+
+ const editor = useEditor({
+ extensions: [
+ Markdown,
+ StarterKit,
+ Details,
+ DetailsSummary,
+ DetailsContent,
+ TaskList,
+ TaskItem.configure({
+ nested: true,
+ }),
+ Youtube.configure({
+ inline: false,
+ width: 480,
+ height: 320,
+ }),
+ Twitch.configure({
+ inline: false,
+ width: 480,
+ height: 320,
+ parent: window.location.hostname,
+ }),
+ Image,
+ TableKit,
+ Highlight,
+ Mention.configure({
+ HTMLAttributes: {
+ class: 'mention',
+ },
+ suggestions: [
+ {
+ char: '@',
+ items: ({ query }) => {
+ return [
+ 'Lea Thompson',
+ 'Cyndi Lauper',
+ 'Tom Cruise',
+ 'Madonna',
+ 'Jerry Hall',
+ 'Joan Collins',
+ 'Winona Ryder',
+ 'Christina Applegate',
+ ]
+ .filter(item => item.toLowerCase().startsWith(query.toLowerCase()))
+ .slice(0, 5)
+ },
+ },
+ {
+ char: '#',
+ items: ({ query }) => {
+ return [
+ 'bug',
+ 'feature',
+ 'enhancement',
+ 'documentation',
+ 'help-wanted',
+ 'priority-high',
+ 'priority-low',
+ ]
+ .filter(item => item.toLowerCase().startsWith(query.toLowerCase()))
+ .slice(0, 5)
+ },
+ },
+ ],
+ }),
+ Mathematics,
+ CustomReactNode,
+ ],
+ content: '# Markdown Test\n\nClick **"Parse Markdown"** to load content from the left panel.',
+ contentType: 'markdown',
+ })
+
+ const parseMarkdown = () => {
+ if (!editor || !editor.markdown) {
+ setError('Editor or MarkdownManager not available')
+ return
+ }
+
+ try {
+ setError(null)
+ editor.commands.setContent(markdownInput, { contentType: 'markdown' })
+ } catch (err) {
+ console.error(err)
+ setError(`Error parsing markdown: ${err instanceof Error ? err.message : String(err)}`)
+ }
+ }
+
+ const getEditorAsMarkdown = () => {
+ if (!editor) {
+ return ''
+ }
+
+ try {
+ return editor.getMarkdown()
+ } catch {
+ return editor.getText()
+ }
+ }
+
+ return (
+
+
+
+
+ Parse Markdown →
+
+
+ {
+ if (editor) {
+ const markdown = getEditorAsMarkdown()
+ setMarkdownInput(markdown)
+ }
+ }}
+ >
+ ← Extract Markdown
+
+
+
+ editor?.commands.toggleBold()}>
+ Bold
+
+ editor?.commands.toggleItalic()}>
+ Italic
+
+
+
+
+ {error &&
{error}
}
+
+
+
+
+
+
Tiptap Editor
+
+ {editor ?
:
Loading editor…
}
+
+
+
+
+ )
+}
diff --git a/demos/src/Markdown/Full/React/styles.scss b/demos/src/Markdown/Full/React/styles.scss
new file mode 100644
index 0000000..1d5f521
--- /dev/null
+++ b/demos/src/Markdown/Full/React/styles.scss
@@ -0,0 +1,185 @@
+.markdown-parser-demo {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+
+.markdown-parser-demo .split {
+ display: flex;
+ flex: 1 1 auto;
+ gap: 1rem;
+ overflow: auto;
+ padding: 0 1.5rem 1rem;
+}
+
+.markdown-parser-demo .input-panel,
+.markdown-parser-demo .editor-panel {
+ flex: 1 1 50%;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.markdown-parser-demo .panel-label {
+ font-weight: 600;
+ color: #374151;
+ font-size: 0.875rem;
+}
+
+.markdown-parser-demo .markdown-input {
+ flex: 1 1 auto;
+ border: 1px solid #e5e7eb;
+ border-radius: 6px;
+ padding: 1rem;
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+ font-size: 14px;
+ line-height: 1.5;
+ resize: none;
+ outline: none;
+}
+
+.markdown-parser-demo .markdown-input:focus {
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+.markdown-parser-demo .editor-container {
+ flex: 1 1 auto;
+ border: 1px solid #e5e7eb;
+ border-radius: 6px;
+ padding: 1rem;
+ overflow: auto;
+}
+
+.markdown-parser-demo .editor-toolbar {
+ display: flex;
+ gap: 0.5rem;
+ padding: 0.5rem;
+ background: #f8fafc;
+ border: 1px solid #e5e7eb;
+ border-radius: 6px;
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ border-bottom: none;
+}
+
+.markdown-parser-demo .editor-toolbar + .editor-container {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ border-top: none;
+}
+
+.markdown-parser-demo .error {
+ background: #fee2e2;
+ border: 1px solid #fecaca;
+ color: #dc2626;
+ padding: 0.75rem;
+ border-radius: 6px;
+ font-size: 0.875rem;
+}
+
+.markdown-parser-demo .json-preview {
+ background: #f8fafc;
+ border: 1px solid #e5e7eb;
+ border-radius: 6px;
+ padding: 1rem;
+ margin-top: 1rem;
+ max-height: 200px;
+ overflow: auto;
+}
+
+.markdown-parser-demo .json-preview pre {
+ margin: 0;
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+ font-size: 12px;
+ color: #374151;
+}
+
+/* Basic table styles for the editor */
+.tiptap table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 0.5rem 0;
+}
+
+.tiptap table td,
+.tiptap table th {
+ border: 1px solid #d1d5db;
+ padding: 0.5rem;
+ text-align: left;
+}
+
+.tiptap table th {
+ background: #f9fafb;
+ font-weight: 600;
+}
+
+/* Custom React Component styling */
+.custom-react-node {
+ margin: 1rem 0;
+
+ .custom-react-component {
+ border: 2px solid #3b82f6;
+ border-radius: 8px;
+ padding: 16px;
+ background-color: #eff6ff;
+
+ h4 {
+ margin: 0 0 8px 0;
+ color: #1e40af;
+ font-size: 1rem;
+ font-weight: 600;
+ }
+
+ p {
+ margin: 0;
+ color: #374151;
+ }
+ }
+}
+
+/* YouTube video styling */
+.tiptap iframe {
+ max-width: 100%;
+ height: auto;
+ border-radius: 8px;
+ margin: 1rem 0;
+}
+
+/* Image styling */
+.tiptap img {
+ max-width: 100%;
+ height: auto;
+ border-radius: 4px;
+ margin: 0.5rem 0;
+}
+
+/* Mention styling */
+.tiptap .mention {
+ background: #e0e7ff;
+ color: #3730a3;
+ padding: 0.125rem 0.375rem;
+ border-radius: 0.375rem;
+ font-weight: 500;
+ text-decoration: none;
+
+ &:hover {
+ background: #c7d2fe;
+ }
+}
+
+/* Mathematics styling */
+.tiptap [data-type='block-math'] {
+ margin: 1rem 0;
+ padding: 0.5rem;
+ background: #f8fafc;
+ border-radius: 4px;
+ text-align: center;
+}
+
+.tiptap [data-type='inline-math'] {
+ background: #f1f5f9;
+ padding: 0.125rem 0.25rem;
+ border-radius: 3px;
+ font-family: 'Times New Roman', serif;
+}
diff --git a/demos/src/Markdown/Parse/React/index.html b/demos/src/Markdown/Parse/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Markdown/Parse/React/index.jsx b/demos/src/Markdown/Parse/React/index.jsx
new file mode 100644
index 0000000..6f09e21
--- /dev/null
+++ b/demos/src/Markdown/Parse/React/index.jsx
@@ -0,0 +1,106 @@
+import Image from '@tiptap/extension-image'
+import { TableKit } from '@tiptap/extension-table'
+import { Markdown } from '@tiptap/markdown'
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+
+const markdown = `# Markdown parsing demo
+
+This document demonstrates which Markdown constructs are parsed and preserved when using the @tiptap/markdown extension together with the editor's registered extensions.
+
+## Headings
+
+### H3 example
+
+Headings (H1, H2, H3...) are preserved when ${'`'}StarterKit${'`'} is enabled.
+
+## Lists
+
+- Unordered list item A
+- Unordered list item B
+ - Nested item B.1
+
+1. Ordered item 1
+2. Ordered item 2
+
+## Code
+
+Inline code works: ${'`'}const x = 1${'`'}
+
+Code block example:
+
+${'```'}js
+function greet(name) {
+ return ${'`'}Hello, ${'${'}name${'}'}!${'`'}
+}
+${'```'}
+
+## Table
+
+| Name | Feature | Left Column | Center Column | Right Column |
+| ---- | ------- | :---------- | :-----------: | -----------: |
+| A | Headings |left|centered|$1,600|
+| B | Lists |left|centered|$12|
+| C | Markdown |left|centered|$1|
+
+## Image (example)
+
+
+
+## Notes
+
+- The demo calls ${'`'}editor.commands.setContent(markdownText, { contentType: 'markdown' })${'`'} to parse Markdown.
+- Supported constructs are preserved only if a matching extension is registered (e.g., tables require ${'`'}TableKit${'`'}, images require ${'`'}Image${'`'}).
+- Unsupported constructs will be dropped/stripped from the resulting editor document.
+
+Feel free to click "Parse Markdown" or upload a \`.md\` file to test parsing with your editor configuration.
+`
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Markdown, StarterKit, Image, TableKit.configure({ table: { cellMinWidth: 150 } })],
+ content: `
+ In this demo you can parse Markdown content into Tiptap on the client-side via @tiptap/markdown.
+ Click the button above or use your own markdown file to test it out.
+ `,
+ })
+
+ return (
+ <>
+
+
+ editor.commands.setContent(markdown, { contentType: 'markdown' })}>
+ Parse Markdown
+
+ {
+ const file = event.target.files?.[0]
+
+ if (file) {
+ const reader = new FileReader()
+
+ reader.onload = e => {
+ const text = e.target?.result
+
+ if (typeof text === 'string') {
+ editor.commands.setContent(text, { contentType: 'markdown' })
+ }
+ }
+
+ reader.readAsText(file)
+ }
+ }}
+ />
+ document.getElementById('upload')?.click()}>
+ Select Markdown File
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Markdown/Serialize/React/index.html b/demos/src/Markdown/Serialize/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Markdown/Serialize/React/index.jsx b/demos/src/Markdown/Serialize/React/index.jsx
new file mode 100644
index 0000000..2f41428
--- /dev/null
+++ b/demos/src/Markdown/Serialize/React/index.jsx
@@ -0,0 +1,36 @@
+import './styles.scss'
+
+import Image from '@tiptap/extension-image'
+import { TableKit } from '@tiptap/extension-table'
+import { Markdown } from '@tiptap/markdown'
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import { useState } from 'react'
+
+export default () => {
+ const [serializedContent, setSerializedContent] = useState('')
+ const editor = useEditor({
+ extensions: [Markdown, StarterKit, Image, TableKit],
+ content: `
+ In this demo, you can serialize Tiptap content into Markdown on the client-side via @tiptap/markdown.
+ Feel free to edit this document to see the live-changes.
+ `,
+ onUpdate: ({ editor: currentEditor }) => {
+ setSerializedContent(currentEditor.getMarkdown())
+ },
+ onCreate: ({ editor: currentEditor }) => {
+ setSerializedContent(currentEditor.getMarkdown())
+ },
+ })
+
+ return (
+ <>
+
+ >
+ )
+}
diff --git a/demos/src/Markdown/Serialize/React/styles.scss b/demos/src/Markdown/Serialize/React/styles.scss
new file mode 100644
index 0000000..c684951
--- /dev/null
+++ b/demos/src/Markdown/Serialize/React/styles.scss
@@ -0,0 +1,44 @@
+.grid {
+ display: flex;
+ flex-direction: column;
+ height: 100dvh;
+
+ @media screen and (min-width: 400px) {
+ flex-direction: row;
+ }
+}
+
+.editor-wrapper {
+ flex: 1 1 auto;
+ order: 1;
+ overflow: auto;
+
+ @media screen and (min-width: 400px) {
+ flex: 1 1 50%;
+ order: 0;
+ width: 50%;
+ }
+}
+
+.preview {
+ flex: 0 0 auto;
+ order: 0;
+
+ @media screen and (min-width: 400px) {
+ flex: 1 1 50%;
+ order: 1;
+ width: 50%;
+ }
+}
+
+.preview pre {
+ background-color: #f1f1f1;
+ height: 35dvh;
+ margin: 0;
+ overflow: auto;
+ padding: 1rem;
+
+ @media screen and (min-width: 400px) {
+ min-height: 100%;
+ }
+}
diff --git a/demos/src/Marks/Bold/React/index.html b/demos/src/Marks/Bold/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/Bold/React/index.tsx b/demos/src/Marks/Bold/React/index.tsx
new file mode 100644
index 0000000..17f7da9
--- /dev/null
+++ b/demos/src/Marks/Bold/React/index.tsx
@@ -0,0 +1,57 @@
+import './styles.scss'
+
+import Bold from '@tiptap/extension-bold'
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, Bold],
+ content: `
+ This isn’t bold.
+ This is bold.
+ And this.
+ This as well.
+ Oh, and this!
+ Cool, isn’t it!?
+ Up to font weight 999!!!
+ `,
+ immediatelyRender: true,
+ })
+
+ const { isBold } = useEditorState({
+ editor,
+ selector: ctx => ({
+ isBold: ctx.editor.isActive('bold') ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().toggleBold().run()}
+ className={isBold ? 'is-active' : ''}
+ >
+ Toggle bold
+
+ editor.chain().focus().setBold().run()} disabled={isBold}>
+ Set bold
+
+ editor.chain().focus().unsetBold().run()} disabled={!isBold}>
+ Unset bold
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Marks/Bold/React/styles.scss b/demos/src/Marks/Bold/React/styles.scss
new file mode 100644
index 0000000..afd5ba2
--- /dev/null
+++ b/demos/src/Marks/Bold/React/styles.scss
@@ -0,0 +1,6 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
diff --git a/demos/src/Marks/Bold/Vue/index.html b/demos/src/Marks/Bold/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/Bold/Vue/index.vue b/demos/src/Marks/Bold/Vue/index.vue
new file mode 100644
index 0000000..d4c43da
--- /dev/null
+++ b/demos/src/Marks/Bold/Vue/index.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+ Toggle bold
+
+
+ Set bold
+
+
+ Unset bold
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Marks/Bold/index.spec.ts b/demos/src/Marks/Bold/index.spec.ts
new file mode 100644
index 0000000..6870f9c
--- /dev/null
+++ b/demos/src/Marks/Bold/index.spec.ts
@@ -0,0 +1,96 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'Bold'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Marks'
+
+const isMac = process.platform === 'darwin'
+const mod = isMac ? 'Meta' : 'Control'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await setEditorContent(page, 'Example Text
')
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ })
+
+ test('transforms b tags to strong tags', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text
')
+ return el.editor.getHTML()
+ })
+ expect(html).toBe('Example Text
')
+ })
+
+ test('omits b tags with normal font weight inline style', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text
')
+ return el.editor.getHTML()
+ })
+ expect(html).toBe('Example Text
')
+ })
+
+ test('transforms any tag with bold inline style to strong tags', async ({ page }) => {
+ const editor = await getEditor(page)
+ const results = await editor.evaluate((el: any) => {
+ const styles = ['bold', 'bolder', '500', '900']
+ return styles.map(s => {
+ el.editor.commands.setContent(
+ `Example Text
`,
+ )
+ return el.editor.getHTML()
+ })
+ })
+ results.forEach(html => expect(html).toBe('Example Text
'))
+ })
+
+ test('button makes the selected text bold', async ({ page }) => {
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap strong')).toContainText('Example Text')
+ })
+
+ test('button toggles the selected text bold', async ({ page }) => {
+ const editor = await getEditor(page)
+ await page.locator('button').first().click()
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap strong')).toHaveCount(0)
+ })
+
+ test('keyboard shortcut makes selected text bold', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => {
+ el.editor.commands.focus()
+ el.editor.commands.selectAll()
+ })
+ await editor.press(`${mod}+b`)
+ await expect(page.locator('.tiptap strong')).toContainText('Example Text')
+ })
+
+ test('markdown shortcut creates bold text', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('**Bold**')
+ await expect(page.locator('.tiptap strong')).toContainText('Bold')
+ })
+
+ test('alternative markdown shortcut creates bold text', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('__Bold__')
+ await expect(page.locator('.tiptap strong')).toContainText('Bold')
+ })
+ })
+ })
+})
diff --git a/demos/src/Marks/Code/React/index.html b/demos/src/Marks/Code/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/Code/React/index.jsx b/demos/src/Marks/Code/React/index.jsx
new file mode 100644
index 0000000..88db0a6
--- /dev/null
+++ b/demos/src/Marks/Code/React/index.jsx
@@ -0,0 +1,50 @@
+import './styles.scss'
+
+import Code from '@tiptap/extension-code'
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, Code],
+ content: `
+ This isn’t code.
+ This is code.
+ `,
+ })
+ const { isCode } = useEditorState({
+ editor,
+ selector: ctx => ({
+ isCode: ctx.editor.isActive('code') ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().toggleCode().run()}
+ className={isCode ? 'is-active' : ''}
+ >
+ Toggle code
+
+ editor.chain().focus().setCode().run()} disabled={isCode}>
+ Set code
+
+ editor.chain().focus().unsetCode().run()} disabled={!isCode}>
+ Unset code
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Marks/Code/React/styles.scss b/demos/src/Marks/Code/React/styles.scss
new file mode 100644
index 0000000..723d82a
--- /dev/null
+++ b/demos/src/Marks/Code/React/styles.scss
@@ -0,0 +1,15 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+}
diff --git a/demos/src/Marks/Code/Vue/index.html b/demos/src/Marks/Code/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/Code/Vue/index.vue b/demos/src/Marks/Code/Vue/index.vue
new file mode 100644
index 0000000..093e6e2
--- /dev/null
+++ b/demos/src/Marks/Code/Vue/index.vue
@@ -0,0 +1,76 @@
+
+
+
+
+
+ Toggle code
+
+
+ Set code
+
+
+ Unset code
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Marks/Code/index.spec.ts b/demos/src/Marks/Code/index.spec.ts
new file mode 100644
index 0000000..cc108fc
--- /dev/null
+++ b/demos/src/Marks/Code/index.spec.ts
@@ -0,0 +1,83 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'Code'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Marks'
+
+const isMac = process.platform === 'darwin'
+const mod = isMac ? 'Meta' : 'Control'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await setEditorContent(page, 'Example Text
')
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ })
+
+ test('parses code tags correctly', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html1 = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text
')
+ return el.editor.getHTML()
+ })
+ expect(html1).toBe('Example Text
')
+ })
+
+ test('button marks selected text as inline code', async ({ page }) => {
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap code')).toContainText('Example Text')
+ })
+
+ test('button toggles inline code', async ({ page }) => {
+ const editor = await getEditor(page)
+ await page.locator('button').first().click()
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap code')).toHaveCount(0)
+ })
+
+ test('keyboard shortcut creates inline code', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => {
+ el.editor.commands.focus()
+ el.editor.commands.selectAll()
+ })
+ await editor.press(`${mod}+e`)
+ await expect(page.locator('.tiptap code')).toContainText('Example Text')
+ })
+
+ test('markdown shortcut creates inline code', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await editor.pressSequentially('`Example`')
+ await expect(page.locator('.tiptap code')).toContainText('Example')
+ })
+
+ test('markdown shortcut preserves preceding non-whitespace character', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await editor.pressSequentially('a`Example`')
+ await expect(page.locator('.tiptap')).toContainText('aExample')
+ await expect(page.locator('.tiptap code')).toContainText('Example')
+ })
+
+ test('markdown shortcut preserves preceding whitespace', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await editor.pressSequentially(' `Example`')
+ await expect(page.locator('.tiptap')).toContainText(' Example')
+ await expect(page.locator('.tiptap code')).toContainText('Example')
+ })
+ })
+ })
+})
diff --git a/demos/src/Marks/Highlight/React/index.html b/demos/src/Marks/Highlight/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/Highlight/React/index.jsx b/demos/src/Marks/Highlight/React/index.jsx
new file mode 100644
index 0000000..543d10f
--- /dev/null
+++ b/demos/src/Marks/Highlight/React/index.jsx
@@ -0,0 +1,95 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Highlight from '@tiptap/extension-highlight'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, Highlight.configure({ multicolor: true })],
+ content: `
+ This isn’t highlighted.
+ But that one is.
+ And this is highlighted too, but in a different color.
+ And this one has a data attribute.
+ `,
+ })
+ const editorState = useEditorState({
+ editor,
+ selector: ctx => ({
+ isHighlight: ctx.editor.isActive('highlight') ?? false,
+ isOrange: ctx.editor.isActive('highlight', { color: '#ffc078' }) ?? false,
+ isGreen: ctx.editor.isActive('highlight', { color: '#8ce99a' }) ?? false,
+ isBlue: ctx.editor.isActive('highlight', { color: '#74c0fc' }) ?? false,
+ isPurple: ctx.editor.isActive('highlight', { color: '#b197fc' }) ?? false,
+ isRed: ctx.editor.isActive('highlight', { color: 'red' }) ?? false,
+ isLightRed: ctx.editor.isActive('highlight', { color: '#ffa8a8' }) ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().toggleHighlight().run()}
+ className={editorState.isHighlight ? 'is-active' : ''}
+ >
+ Toggle highlight
+
+ editor.chain().focus().toggleHighlight({ color: '#ffc078' }).run()}
+ className={editorState.isOrange ? 'is-active' : ''}
+ >
+ Orange
+
+ editor.chain().focus().toggleHighlight({ color: '#8ce99a' }).run()}
+ className={editorState.isGreen ? 'is-active' : ''}
+ >
+ Green
+
+ editor.chain().focus().toggleHighlight({ color: '#74c0fc' }).run()}
+ className={editorState.isBlue ? 'is-active' : ''}
+ >
+ Blue
+
+ editor.chain().focus().toggleHighlight({ color: '#b197fc' }).run()}
+ className={editorState.isPurple ? 'is-active' : ''}
+ >
+ Purple
+
+ editor.chain().focus().toggleHighlight({ color: 'red' }).run()}
+ className={editorState.isRed ? 'is-active' : ''}
+ >
+ Red ('red')
+
+ editor.chain().focus().toggleHighlight({ color: '#ffa8a8' }).run()}
+ className={editorState.isLightRed ? 'is-active' : ''}
+ >
+ Red (#ffa8a8)
+
+ editor.chain().focus().unsetHighlight().run()}
+ disabled={!editorState.isHighlight}
+ >
+ Unset highlight
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Marks/Highlight/React/styles.scss b/demos/src/Marks/Highlight/React/styles.scss
new file mode 100644
index 0000000..5f8ac39
--- /dev/null
+++ b/demos/src/Marks/Highlight/React/styles.scss
@@ -0,0 +1,13 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ mark {
+ background-color: #faf594;
+ border-radius: 0.4rem;
+ box-decoration-break: clone;
+ padding: 0.1rem 0.3rem;
+ }
+}
diff --git a/demos/src/Marks/Highlight/Vue/index.html b/demos/src/Marks/Highlight/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/Highlight/Vue/index.vue b/demos/src/Marks/Highlight/Vue/index.vue
new file mode 100644
index 0000000..ee8b42b
--- /dev/null
+++ b/demos/src/Marks/Highlight/Vue/index.vue
@@ -0,0 +1,109 @@
+
+
+
+
+
+ Toggle highlight
+
+
+ Orange
+
+
+ Green
+
+
+ Blue
+
+
+ Purple
+
+
+ Red ('red')
+
+
+ Red (#ffa8a8)
+
+
+ Unset highlight
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Marks/Highlight/index.spec.ts b/demos/src/Marks/Highlight/index.spec.ts
new file mode 100644
index 0000000..eccdd61
--- /dev/null
+++ b/demos/src/Marks/Highlight/index.spec.ts
@@ -0,0 +1,127 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'Highlight'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Marks'
+
+const isMac = process.platform === 'darwin'
+const mod = isMac ? 'Meta' : 'Control'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => {
+ el.editor.chain().setContent('Example Text
').selectAll().run()
+ })
+ })
+
+ test('button highlights selected text', async ({ page }) => {
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap mark')).toContainText('Example Text')
+ })
+
+ test('highlights text in a specific color', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.toggleHighlight({ color: 'red' }))
+ await expect(page.locator('.tiptap mark')).toHaveAttribute('data-color', 'red')
+ await expect(page.locator('.tiptap mark')).toContainText('Example Text')
+ })
+
+ test('updates attributes of existing marks', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => {
+ el.editor
+ .chain()
+ .setContent('Example Text
')
+ .selectAll()
+ .toggleHighlight({ color: 'rgb(255, 0, 0)' })
+ .run()
+ })
+ await expect(page.locator('.tiptap mark')).toHaveCSS('background-color', 'rgb(255, 0, 0)')
+ })
+
+ test('removes existing marks with same attributes', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => {
+ el.editor
+ .chain()
+ .setContent(
+ 'Example Text
',
+ )
+ .selectAll()
+ .toggleHighlight({ color: 'rgb(255, 0, 0)' })
+ .run()
+ })
+ await expect(page.locator('.tiptap mark')).toHaveCount(0)
+ })
+
+ test('isActive: any attributes', async ({ page }) => {
+ const editor = await getEditor(page)
+ const isActive = await editor.evaluate((el: any) => {
+ el.editor
+ .chain()
+ .setContent('Example Text
')
+ .selectAll()
+ .run()
+ return el.editor.isActive('highlight')
+ })
+ expect(isActive).toBe(true)
+ })
+
+ test('isActive: same attributes', async ({ page }) => {
+ const editor = await getEditor(page)
+ const isActive = await editor.evaluate((el: any) => {
+ el.editor
+ .chain()
+ .setContent(
+ 'Example Text
',
+ )
+ .selectAll()
+ .run()
+ return el.editor.isActive('highlight', { color: 'rgb(255, 0, 0)' })
+ })
+ expect(isActive).toBe(true)
+ })
+
+ test('isActive: other attributes', async ({ page }) => {
+ const editor = await getEditor(page)
+ const isActive = await editor.evaluate((el: any) => {
+ el.editor
+ .chain()
+ .setContent(
+ 'Example Text
',
+ )
+ .selectAll()
+ .run()
+ return el.editor.isActive('highlight', { color: 'rgb(0, 0, 0)' })
+ })
+ expect(isActive).toBe(false)
+ })
+
+ test('button toggles highlight on selected text', async ({ page }) => {
+ const editor = await getEditor(page)
+ await page.locator('button').first().click()
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap mark')).toHaveCount(0)
+ })
+
+ test('keyboard shortcut highlights selected text', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => {
+ el.editor.commands.focus()
+ el.editor.commands.selectAll()
+ })
+ await editor.press(`${mod}+Shift+h`)
+ await expect(page.locator('.tiptap mark')).toContainText('Example Text')
+ })
+ })
+ })
+})
diff --git a/demos/src/Marks/Italic/React/index.html b/demos/src/Marks/Italic/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/Italic/React/index.jsx b/demos/src/Marks/Italic/React/index.jsx
new file mode 100644
index 0000000..6f2d9aa
--- /dev/null
+++ b/demos/src/Marks/Italic/React/index.jsx
@@ -0,0 +1,52 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Italic from '@tiptap/extension-italic'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, Italic],
+ content: `
+ This isn’t italic.
+ This is italic.
+ And this.
+ This as well.
+ `,
+ })
+ const { isItalic } = useEditorState({
+ editor,
+ selector: ctx => ({
+ isItalic: ctx.editor.isActive('italic') ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().toggleItalic().run()}
+ className={isItalic ? 'is-active' : ''}
+ >
+ Toggle italic
+
+ editor.chain().focus().setItalic().run()} disabled={isItalic}>
+ Set italic
+
+ editor.chain().focus().unsetItalic().run()} disabled={!isItalic}>
+ Unset italic
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Marks/Italic/React/styles.scss b/demos/src/Marks/Italic/React/styles.scss
new file mode 100644
index 0000000..afd5ba2
--- /dev/null
+++ b/demos/src/Marks/Italic/React/styles.scss
@@ -0,0 +1,6 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
diff --git a/demos/src/Marks/Italic/Vue/index.html b/demos/src/Marks/Italic/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/Italic/Vue/index.vue b/demos/src/Marks/Italic/Vue/index.vue
new file mode 100644
index 0000000..e65b581
--- /dev/null
+++ b/demos/src/Marks/Italic/Vue/index.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+ Toggle italic
+
+
+ Set italic
+
+
+ Unset italic
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Marks/Italic/index.spec.ts b/demos/src/Marks/Italic/index.spec.ts
new file mode 100644
index 0000000..bbd0e9b
--- /dev/null
+++ b/demos/src/Marks/Italic/index.spec.ts
@@ -0,0 +1,85 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'Italic'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Marks'
+
+const isMac = process.platform === 'darwin'
+const mod = isMac ? 'Meta' : 'Control'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await setEditorContent(page, 'Example Text
')
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ })
+
+ test('i tags transform to em tags', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text
')
+ return el.editor.getHTML()
+ })
+ expect(html).toBe('Example Text
')
+ })
+
+ test('omits i tags with normal font style', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text
')
+ return el.editor.getHTML()
+ })
+ expect(html).toBe('Example Text
')
+ })
+
+ test('generic tags with italic style transform to em tags', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent(
+ 'Example Text
',
+ )
+ return el.editor.getHTML()
+ })
+ expect(html).toBe('Example Text
')
+ })
+
+ test('button makes selected text italic', async ({ page }) => {
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap em')).toContainText('Example Text')
+ })
+
+ test('button toggles selected text italic', async ({ page }) => {
+ const editor = await getEditor(page)
+ await page.locator('button').first().click()
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap em')).toHaveCount(0)
+ })
+
+ test('keyboard shortcut makes selected text italic', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => {
+ el.editor.commands.focus()
+ el.editor.commands.selectAll()
+ })
+ await editor.press(`${mod}+i`)
+ await expect(page.locator('.tiptap em')).toContainText('Example Text')
+ })
+
+ test('markdown shortcut creates italic text', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('*Italic* ')
+ await expect(page.locator('.tiptap em')).toContainText('Italic')
+ })
+ })
+ })
+})
diff --git a/demos/src/Marks/Link/React/index.html b/demos/src/Marks/Link/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/Link/React/index.jsx b/demos/src/Marks/Link/React/index.jsx
new file mode 100644
index 0000000..354dc50
--- /dev/null
+++ b/demos/src/Marks/Link/React/index.jsx
@@ -0,0 +1,145 @@
+import './styles.scss'
+
+import Code from '@tiptap/extension-code'
+import Document from '@tiptap/extension-document'
+import Link from '@tiptap/extension-link'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React, { useCallback } from 'react'
+
+export default () => {
+ const editor = useEditor({
+ editable: true,
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Code,
+ Link.configure({
+ openOnClick: false,
+ autolink: true,
+ markdownLinks: true,
+ defaultProtocol: 'https',
+ protocols: ['http', 'https'],
+ isAllowedUri: (url, ctx) => {
+ try {
+ // construct URL
+ const parsedUrl = url.includes(':')
+ ? new URL(url)
+ : new URL(`${ctx.defaultProtocol}://${url}`)
+
+ // use default validation
+ if (!ctx.defaultValidate(parsedUrl.href)) {
+ return false
+ }
+
+ // disallowed protocols
+ const disallowedProtocols = ['ftp', 'file', 'mailto']
+ const protocol = parsedUrl.protocol.replace(':', '')
+
+ if (disallowedProtocols.includes(protocol)) {
+ return false
+ }
+
+ // only allow protocols specified in ctx.protocols
+ const allowedProtocols = ctx.protocols.map(p => (typeof p === 'string' ? p : p.scheme))
+
+ if (!allowedProtocols.includes(protocol)) {
+ return false
+ }
+
+ // disallowed domains
+ const disallowedDomains = ['example-phishing.com', 'malicious-site.net']
+ const domain = parsedUrl.hostname
+
+ if (disallowedDomains.includes(domain)) {
+ return false
+ }
+
+ // all checks have passed
+ return true
+ } catch {
+ return false
+ }
+ },
+ shouldAutoLink: url => {
+ try {
+ // construct URL
+ const parsedUrl = url.includes(':') ? new URL(url) : new URL(`https://${url}`)
+
+ // only auto-link if the domain is not in the disallowed list
+ const disallowedDomains = ['example-no-autolink.com', 'another-no-autolink.com']
+ const domain = parsedUrl.hostname
+
+ return !disallowedDomains.includes(domain)
+ } catch {
+ return false
+ }
+ },
+ }),
+ ],
+ content: `
+
+ Wow, this editor has support for links to the whole world wide web . We tested a lot of URLs and I think you can add *every URL* you want. Isn’t that cool? Let’s try another one! Yep, seems to work.
+
+
+ By default every link will get a rel="noopener noreferrer nofollow" attribute. It’s configurable though.
+
+ `,
+ })
+
+ const setLink = useCallback(() => {
+ const previousUrl = editor.getAttributes('link').href
+ const url = window.prompt('URL', previousUrl)
+
+ // cancelled
+ if (url === null) {
+ return
+ }
+
+ // empty
+ if (url === '') {
+ editor.chain().focus().extendMarkRange('link').unsetLink().run()
+
+ return
+ }
+
+ // update link
+ try {
+ editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
+ } catch (e) {
+ alert(e.message)
+ }
+ }, [editor])
+
+ const editorState = useEditorState({
+ editor,
+ selector: ctx => ({
+ isLink: ctx.editor.isActive('link'),
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+
+ Set link
+
+ editor.chain().focus().unsetLink().run()}
+ disabled={!editorState.isLink}
+ >
+ Unset link
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Marks/Link/React/styles.scss b/demos/src/Marks/Link/React/styles.scss
new file mode 100644
index 0000000..b323fe4
--- /dev/null
+++ b/demos/src/Marks/Link/React/styles.scss
@@ -0,0 +1,25 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ /* Link styles */
+ a {
+ color: var(--purple);
+ cursor: pointer;
+
+ &:hover {
+ color: var(--purple-contrast);
+ }
+ }
+}
diff --git a/demos/src/Marks/Link/Vue/index.html b/demos/src/Marks/Link/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/Link/Vue/index.vue b/demos/src/Marks/Link/Vue/index.vue
new file mode 100644
index 0000000..55870e3
--- /dev/null
+++ b/demos/src/Marks/Link/Vue/index.vue
@@ -0,0 +1,115 @@
+
+
+
+
+ Set link
+
+ Unset link
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Marks/Link/index.spec.ts b/demos/src/Marks/Link/index.spec.ts
new file mode 100644
index 0000000..7a57626
--- /dev/null
+++ b/demos/src/Marks/Link/index.spec.ts
@@ -0,0 +1,187 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'Link'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Marks'
+
+async function paste(
+ editor: ReturnType extends Promise ? T : never,
+ payload: string,
+) {
+ await editor.evaluate((el: HTMLElement, text: string) => {
+ const dt = new DataTransfer()
+ dt.setData('text/plain', text)
+ el.dispatchEvent(
+ new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }),
+ )
+ }, payload)
+}
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.addInitScript(() => {
+ ;(window as any).prompt = () => 'https://tiptap.dev'
+ })
+ await page.goto(fullDemoPath)
+ await setEditorContent(page, 'Example TextDEFAULT
')
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ })
+
+ test('parses a tags correctly', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text1
')
+ return el.editor.getHTML()
+ })
+ expect(html).toBe(
+ 'Example Text1
',
+ )
+ })
+
+ test('parses a tags with target attribute', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent(
+ 'Example Text2
',
+ )
+ return el.editor.getHTML()
+ })
+ expect(html).toBe(
+ 'Example Text2
',
+ )
+ })
+
+ test('parses a tags with rel attribute', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent(
+ 'Example Text3
',
+ )
+ return el.editor.getHTML()
+ })
+ expect(html).toBe(
+ 'Example Text3
',
+ )
+ })
+
+ test('button adds a link to selected text', async ({ page }) => {
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap a')).toContainText('Example TextDEFAULT')
+ await expect(page.locator('.tiptap a')).toHaveAttribute('href', 'https://tiptap.dev')
+ })
+
+ test('detects autolinking', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('https://example.com ')
+ await expect(page.locator('.tiptap a')).toHaveAttribute('href', 'https://example.com')
+ })
+
+ test('detects autolinking with numbers', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('https://tiptap4u.com ')
+ await expect(page.locator('.tiptap a')).toHaveAttribute('href', 'https://tiptap4u.com')
+ })
+
+ test('uses the default protocol', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('example.com ')
+ await expect(page.locator('.tiptap a')).toHaveAttribute('href', 'https://example.com')
+ })
+
+ test('uses a non-default protocol if present', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('http://example.com ')
+ await expect(page.locator('.tiptap a')).toHaveAttribute('href', 'http://example.com')
+ })
+
+ test('detects a pasted URL within text', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await paste(editor, 'some text https://example1.com around an url')
+ await expect(page.locator('.tiptap a')).toHaveAttribute('href', 'https://example1.com')
+ })
+
+ test('detects a pasted URL with query params', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await paste(editor, 'https://example.com?paramA=nice¶mB=cool')
+ await expect(page.locator('.tiptap a')).toHaveAttribute(
+ 'href',
+ 'https://example.com?paramA=nice¶mB=cool',
+ )
+ })
+
+ test('converts typed Markdown link syntax into a link', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('[Tiptap](https://example.com)')
+ await expect(page.locator('.tiptap a')).toContainText('Tiptap')
+ await expect(page.locator('.tiptap a')).toHaveAttribute('href', 'https://example.com')
+ await expect(page.locator('.tiptap')).not.toContainText('[')
+ })
+
+ test('converts typed Markdown link syntax with a title into a link', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('[Tiptap](https://example.com "Rich text editor")')
+ await expect(page.locator('.tiptap a')).toContainText('Tiptap')
+ await expect(page.locator('.tiptap a')).toHaveAttribute('href', 'https://example.com')
+ await expect(page.locator('.tiptap a')).toHaveAttribute('title', 'Rich text editor')
+ })
+
+ test('converts a pasted Markdown link within text', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await paste(editor, 'Check out [Tiptap](https://example.com) today')
+ await expect(page.locator('.tiptap a')).toContainText('Tiptap')
+ await expect(page.locator('.tiptap a')).toHaveAttribute('href', 'https://example.com')
+ await expect(page.locator('.tiptap')).toContainText('Check out Tiptap today')
+ })
+
+ if (frameworkPath === 'React') {
+ test('disallows links with disallowed protocols', async ({ page }) => {
+ const editor = await getEditor(page)
+ const disallowed = ['ftp://example.com', 'file:///example.txt', 'mailto:test@example.com']
+ const htmls = await editor.evaluate((el: any, urls: string[]) => {
+ return urls.map(url => {
+ el.editor.commands.setContent(`Example Text
`)
+ return el.editor.getHTML()
+ })
+ }, disallowed)
+ htmls.forEach((html, i) => {
+ expect(html).not.toContain(disallowed[i])
+ })
+ })
+
+ test('does not auto-link a URL from a disallowed domain', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('https://example-phishing.com ')
+ await expect(page.locator('.tiptap a')).toHaveCount(0)
+ await expect(page.locator('.tiptap')).toContainText('https://example-phishing.com')
+ })
+ }
+ })
+ })
+})
diff --git a/demos/src/Marks/RubyText/React/index.html b/demos/src/Marks/RubyText/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/RubyText/React/index.jsx b/demos/src/Marks/RubyText/React/index.jsx
new file mode 100644
index 0000000..c7c719e
--- /dev/null
+++ b/demos/src/Marks/RubyText/React/index.jsx
@@ -0,0 +1,69 @@
+// fallow-ignore-file unused-file
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import RubyText from '@tiptap/extension-ruby-text'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const [rt, setRt] = React.useState('')
+
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, RubyText],
+ content: `
+ 東京とうきょう は日本の首都です。
+ 漢字かんじ の上にルビを表示できます。
+ `,
+ onSelectionUpdate: ({ editor: currentEditor }) => {
+ setRt(currentEditor.getAttributes('rubyText').rt ?? '')
+ },
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Marks/RubyText/React/styles.scss b/demos/src/Marks/RubyText/React/styles.scss
new file mode 100644
index 0000000..22998e4
--- /dev/null
+++ b/demos/src/Marks/RubyText/React/styles.scss
@@ -0,0 +1,24 @@
+// fallow-ignore-file unused-file
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ ruby {
+ ruby-position: over;
+ }
+
+ rt {
+ cursor: text;
+ pointer-events: auto;
+
+ input {
+ font: inherit;
+ margin: 0;
+ }
+ }
+}
+
+.control-group input {
+ margin-left: 0.5rem;
+}
diff --git a/demos/src/Marks/RubyText/Vue/index.html b/demos/src/Marks/RubyText/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/RubyText/Vue/index.vue b/demos/src/Marks/RubyText/Vue/index.vue
new file mode 100644
index 0000000..dbfae9a
--- /dev/null
+++ b/demos/src/Marks/RubyText/Vue/index.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
diff --git a/demos/src/Marks/RubyText/index.spec.ts b/demos/src/Marks/RubyText/index.spec.ts
new file mode 100644
index 0000000..fdb5129
--- /dev/null
+++ b/demos/src/Marks/RubyText/index.spec.ts
@@ -0,0 +1,71 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'RubyText'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Marks'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test('renders the annotation as non-editable text', async ({ page }) => {
+ await setEditorContent(page, '漢字かんじ
')
+
+ const rt = page.locator('.tiptap rt').first()
+
+ await expect(rt).toHaveText('かんじ')
+ await expect(rt).toHaveAttribute('contenteditable', 'false')
+ })
+
+ test('edits an annotation inline', async ({ page }) => {
+ await setEditorContent(page, '漢字かんじ
')
+
+ const editor = await getEditor(page)
+ const rt = page.locator('.tiptap rt').first()
+
+ await rt.click()
+ const input = rt.locator('input')
+ await input.fill('かんじ(新)')
+ await input.press('Enter')
+
+ expect(await editor.evaluate((el: any) => el.editor.getHTML())).toContain('かんじ(新)')
+ await expect(rt).toHaveText('かんじ(新)')
+ })
+
+ test('submitting ruby text applies annotation to selection', async ({ page }) => {
+ await setEditorContent(page, '東京
')
+
+ const editor = await getEditor(page)
+
+ await editor.click()
+ await page.keyboard.press('Home')
+ await page.keyboard.press('Shift+ArrowRight')
+ await page.keyboard.press('Shift+ArrowRight')
+
+ await expect
+ .poll(() =>
+ editor.evaluate(
+ (el: any) => el.editor.state.selection.to - el.editor.state.selection.from,
+ ),
+ )
+ .toBe(2)
+
+ const rubyTextInput = page.locator('[data-test-id="ruby-text-input"]')
+
+ await rubyTextInput.fill('とうきょう')
+ await rubyTextInput.press('Enter')
+
+ const html = await editor.evaluate((el: any) => el.editor.getHTML())
+ expect(html).toContain('とうきょう')
+ expect(html).toContain('東京')
+ })
+ })
+ })
+})
diff --git a/demos/src/Marks/Strike/React/index.html b/demos/src/Marks/Strike/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/Strike/React/index.jsx b/demos/src/Marks/Strike/React/index.jsx
new file mode 100644
index 0000000..ecfe879
--- /dev/null
+++ b/demos/src/Marks/Strike/React/index.jsx
@@ -0,0 +1,53 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Strike from '@tiptap/extension-strike'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, Strike],
+ content: `
+ This isn’t striked through.
+ But that’s striked through.
+ And this.
+ This too.
+ This as well.
+ `,
+ })
+ const { isStrike } = useEditorState({
+ editor,
+ selector: ctx => ({
+ isStrike: ctx.editor.isActive('strike') ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().toggleStrike().run()}
+ className={isStrike ? 'is-active' : ''}
+ >
+ Toggle strike
+
+ editor.chain().focus().setStrike().run()} disabled={isStrike}>
+ Set strike
+
+ editor.chain().focus().unsetStrike().run()} disabled={!isStrike}>
+ Unset strike
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Marks/Strike/React/styles.scss b/demos/src/Marks/Strike/React/styles.scss
new file mode 100644
index 0000000..afd5ba2
--- /dev/null
+++ b/demos/src/Marks/Strike/React/styles.scss
@@ -0,0 +1,6 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
diff --git a/demos/src/Marks/Strike/Vue/index.html b/demos/src/Marks/Strike/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/Strike/Vue/index.vue b/demos/src/Marks/Strike/Vue/index.vue
new file mode 100644
index 0000000..92cda8e
--- /dev/null
+++ b/demos/src/Marks/Strike/Vue/index.vue
@@ -0,0 +1,64 @@
+
+
+
+
+
+ Toggle strike
+
+
+ Set strike
+
+
+ Unset strike
+
+
+
+
+
+
+
+
diff --git a/demos/src/Marks/Strike/index.spec.ts b/demos/src/Marks/Strike/index.spec.ts
new file mode 100644
index 0000000..673baaa
--- /dev/null
+++ b/demos/src/Marks/Strike/index.spec.ts
@@ -0,0 +1,74 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'Strike'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Marks'
+
+const isMac = process.platform === 'darwin'
+const mod = isMac ? 'Meta' : 'Control'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await setEditorContent(page, 'Example Text
')
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ })
+ ;[
+ ['Example Text
', 'Example Text
'],
+ ['Example Text
', 'Example Text
'],
+ ['Example Text
', 'Example Text
'],
+ [
+ 'Example Text
',
+ 'Example Text
',
+ ],
+ ].forEach(([input, expected]) => {
+ test(`parses ${input}`, async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any, content: string) => {
+ el.editor.commands.setContent(content)
+ return el.editor.getHTML()
+ }, input)
+ expect(html).toBe(expected)
+ })
+ })
+
+ test('button strikes selected text', async ({ page }) => {
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap s')).toContainText('Example Text')
+ })
+
+ test('button toggles strike on selected text', async ({ page }) => {
+ const editor = await getEditor(page)
+ await page.locator('button').first().click()
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap s')).toHaveCount(0)
+ })
+
+ test('keyboard shortcut strikes selected text', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => {
+ el.editor.commands.focus()
+ el.editor.commands.selectAll()
+ })
+ await editor.press(`${mod}+Shift+s`)
+ await expect(page.locator('.tiptap s')).toContainText('Example Text')
+ })
+
+ test('markdown shortcut creates striked text', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('~~Strike~~')
+ await expect(page.locator('.tiptap s')).toContainText('Strike')
+ })
+ })
+ })
+})
diff --git a/demos/src/Marks/Subscript/React/index.html b/demos/src/Marks/Subscript/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/Subscript/React/index.jsx b/demos/src/Marks/Subscript/React/index.jsx
new file mode 100644
index 0000000..ac90873
--- /dev/null
+++ b/demos/src/Marks/Subscript/React/index.jsx
@@ -0,0 +1,58 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Subscript from '@tiptap/extension-subscript'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, Subscript],
+ content: `
+ This is regular text.
+ This is subscript.
+ And this.
+ `,
+ })
+ const { isSubscript } = useEditorState({
+ editor,
+ selector: ctx => ({
+ isSubscript: ctx.editor.isActive('subscript') ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().toggleSubscript().run()}
+ className={isSubscript ? 'is-active' : ''}
+ >
+ Toggle subscript
+
+ editor.chain().focus().setSubscript().run()}
+ disabled={isSubscript}
+ >
+ Set subscript
+
+ editor.chain().focus().unsetSubscript().run()}
+ disabled={!isSubscript}
+ >
+ Unset subscript
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Marks/Subscript/React/styles.scss b/demos/src/Marks/Subscript/React/styles.scss
new file mode 100644
index 0000000..afd5ba2
--- /dev/null
+++ b/demos/src/Marks/Subscript/React/styles.scss
@@ -0,0 +1,6 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
diff --git a/demos/src/Marks/Subscript/Vue/index.html b/demos/src/Marks/Subscript/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/Subscript/Vue/index.vue b/demos/src/Marks/Subscript/Vue/index.vue
new file mode 100644
index 0000000..f848ec5
--- /dev/null
+++ b/demos/src/Marks/Subscript/Vue/index.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+ Toggle subscript
+
+
+ Set subscript
+
+
+ Unset subscript
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Marks/Superscript/React/index.html b/demos/src/Marks/Superscript/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/Superscript/React/index.jsx b/demos/src/Marks/Superscript/React/index.jsx
new file mode 100644
index 0000000..aeae26d
--- /dev/null
+++ b/demos/src/Marks/Superscript/React/index.jsx
@@ -0,0 +1,58 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Superscript from '@tiptap/extension-superscript'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, Superscript],
+ content: `
+ This is regular text.
+ This is superscript.
+ And this.
+ `,
+ })
+ const { isSuperscript } = useEditorState({
+ editor,
+ selector: ctx => ({
+ isSuperscript: ctx.editor.isActive('superscript') ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().toggleSuperscript().run()}
+ className={isSuperscript ? 'is-active' : ''}
+ >
+ Toggle superscript
+
+ editor.chain().focus().setSuperscript().run()}
+ disabled={isSuperscript}
+ >
+ Set superscript
+
+ editor.chain().focus().unsetSuperscript().run()}
+ disabled={!isSuperscript}
+ >
+ Unset superscript
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Marks/Superscript/React/styles.scss b/demos/src/Marks/Superscript/React/styles.scss
new file mode 100644
index 0000000..afd5ba2
--- /dev/null
+++ b/demos/src/Marks/Superscript/React/styles.scss
@@ -0,0 +1,6 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
diff --git a/demos/src/Marks/Superscript/Vue/index.html b/demos/src/Marks/Superscript/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/Superscript/Vue/index.vue b/demos/src/Marks/Superscript/Vue/index.vue
new file mode 100644
index 0000000..9aff8f2
--- /dev/null
+++ b/demos/src/Marks/Superscript/Vue/index.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+ Toggle superscript
+
+
+ Set superscript
+
+
+ Unset superscript
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Marks/TextStyle/React/index.html b/demos/src/Marks/TextStyle/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/TextStyle/React/index.jsx b/demos/src/Marks/TextStyle/React/index.jsx
new file mode 100644
index 0000000..fd283cd
--- /dev/null
+++ b/demos/src/Marks/TextStyle/React/index.jsx
@@ -0,0 +1,95 @@
+import './styles.scss'
+
+import Bold from '@tiptap/extension-bold'
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { TextStyleKit } from '@tiptap/extension-text-style'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, TextStyleKit, Bold],
+ content: `
+ This has a <span> tag without a style attribute, so it’s thrown away.
+ But this one is wrapped in a <span> tag with an inline style attribute, so it’s kept - even if it’s empty for now.
+
+
+ --- merge nested span styles option enabled ---
+
+
+
+
+ red serif
+
+
+
+
+
+
+
+
+ blue serif
+
+
+
+
+
+
+
+ green serif
+ red serif
+
+
+
+
+
+ plain
+ blue
+ plain
+
+ green
+ green serif
+
+ plain
+
+
+
+
+
+ blue
+
+ green
+
+ green serif
+ blue serif
+
+
+
+
+
+
+
+ strong
+
+
+ strong blue
+ strong blue serif
+
+ strong green
+ strong green serif
+
+
+
+
+
+ `,
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return
+}
diff --git a/demos/src/Marks/TextStyle/React/styles.scss b/demos/src/Marks/TextStyle/React/styles.scss
new file mode 100644
index 0000000..afd5ba2
--- /dev/null
+++ b/demos/src/Marks/TextStyle/React/styles.scss
@@ -0,0 +1,6 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
diff --git a/demos/src/Marks/TextStyle/Vue/index.html b/demos/src/Marks/TextStyle/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/TextStyle/Vue/index.vue b/demos/src/Marks/TextStyle/Vue/index.vue
new file mode 100644
index 0000000..e85f53c
--- /dev/null
+++ b/demos/src/Marks/TextStyle/Vue/index.vue
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Marks/Underline/React/index.html b/demos/src/Marks/Underline/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/Underline/React/index.jsx b/demos/src/Marks/Underline/React/index.jsx
new file mode 100644
index 0000000..fefff0d
--- /dev/null
+++ b/demos/src/Marks/Underline/React/index.jsx
@@ -0,0 +1,58 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import Underline from '@tiptap/extension-underline'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, Underline],
+ content: `
+ There is no underline here.
+ This is underlined though.
+ And this as well.
+ `,
+ })
+ const { isUnderline } = useEditorState({
+ editor,
+ selector: ctx => ({
+ isUnderline: ctx.editor.isActive('underline') ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().toggleUnderline().run()}
+ className={isUnderline ? 'is-active' : ''}
+ >
+ Toggle underline
+
+ editor.chain().focus().setUnderline().run()}
+ disabled={isUnderline}
+ >
+ Set underline
+
+ editor.chain().focus().unsetUnderline().run()}
+ disabled={!isUnderline}
+ >
+ Unset underline
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Marks/Underline/React/styles.scss b/demos/src/Marks/Underline/React/styles.scss
new file mode 100644
index 0000000..afd5ba2
--- /dev/null
+++ b/demos/src/Marks/Underline/React/styles.scss
@@ -0,0 +1,6 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
diff --git a/demos/src/Marks/Underline/Vue/index.html b/demos/src/Marks/Underline/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Marks/Underline/Vue/index.vue b/demos/src/Marks/Underline/Vue/index.vue
new file mode 100644
index 0000000..e2a4be7
--- /dev/null
+++ b/demos/src/Marks/Underline/Vue/index.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+ Toggle underline
+
+
+ Set underline
+
+
+ Unset underline
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Marks/Underline/index.spec.ts b/demos/src/Marks/Underline/index.spec.ts
new file mode 100644
index 0000000..5c1a8bc
--- /dev/null
+++ b/demos/src/Marks/Underline/index.spec.ts
@@ -0,0 +1,68 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'Underline'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Marks'
+
+const isMac = process.platform === 'darwin'
+const mod = isMac ? 'Meta' : 'Control'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await setEditorContent(page, 'Example Text
')
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ })
+
+ test('parses u tags', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text
')
+ return el.editor.getHTML()
+ })
+ expect(html).toBe('Example Text
')
+ })
+
+ test('transforms any tag with text-decoration underline to u tags', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent(
+ 'Example Text
',
+ )
+ return el.editor.getHTML()
+ })
+ expect(html).toBe('Example Text
')
+ })
+
+ test('button underlines selected text', async ({ page }) => {
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap u')).toContainText('Example Text')
+ })
+
+ test('button toggles underline on selected text', async ({ page }) => {
+ const editor = await getEditor(page)
+ await page.locator('button').first().click()
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap u')).toHaveCount(0)
+ })
+
+ test('keyboard shortcut underlines selected text', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => {
+ el.editor.commands.focus()
+ el.editor.commands.selectAll()
+ })
+ await editor.press(`${mod}+u`)
+ await expect(page.locator('.tiptap u')).toContainText('Example Text')
+ })
+ })
+ })
+})
diff --git a/demos/src/Nodes/Audio/React/index.html b/demos/src/Nodes/Audio/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Audio/React/index.jsx b/demos/src/Nodes/Audio/React/index.jsx
new file mode 100644
index 0000000..6061d79
--- /dev/null
+++ b/demos/src/Nodes/Audio/React/index.jsx
@@ -0,0 +1,191 @@
+import './styles.scss'
+
+import Audio from '@tiptap/extension-audio'
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+const DEFAULT_AUDIO_SRC = 'https://www.w3schools.com/html/horse.ogg'
+
+const Control = ({ children }) => {children}
+
+export default () => {
+ const [src, setSrc] = React.useState(DEFAULT_AUDIO_SRC)
+ const [autoplay, setAutoplay] = React.useState(false)
+ const [loop, setLoop] = React.useState(true)
+ const [muted, setMuted] = React.useState(false)
+ const [controls, setControls] = React.useState(true)
+ const [preload, setPreload] = React.useState('metadata')
+ const [controlsList, setControlsList] = React.useState('nodownload')
+ const [crossorigin, setCrossorigin] = React.useState('')
+ const [disableRemotePlayback, setDisableRemotePlayback] = React.useState(false)
+
+ const editor = useEditor({
+ extensions: [
+ StarterKit.configure({
+ history: false,
+ }),
+ Audio.configure({
+ controls: true,
+ preload: 'metadata',
+ }),
+ ],
+ content: `
+ Use the controls above to insert audio tracks with native elements.
+
+ `,
+ editorProps: {
+ attributes: {
+ spellcheck: 'false',
+ },
+ },
+ })
+
+ const insertAudio = () => {
+ if (!editor || !src) {
+ return
+ }
+
+ editor
+ .chain()
+ .focus()
+ .setAudio({
+ src,
+ controls,
+ autoplay,
+ loop,
+ muted,
+ preload: preload || null,
+ controlslist: controlsList || undefined,
+ crossorigin: crossorigin || undefined,
+ disableremoteplayback: disableRemotePlayback || undefined,
+ })
+ .run()
+ }
+
+ return (
+ <>
+
+ setSrc(event.target.value)}
+ placeholder="https://.../audio.mp3"
+ aria-label="Audio source"
+ />
+
+
+ Insert audio
+
+
+
+ Additional Options
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Nodes/Audio/React/styles.scss b/demos/src/Nodes/Audio/React/styles.scss
new file mode 100644
index 0000000..6ae37f5
--- /dev/null
+++ b/demos/src/Nodes/Audio/React/styles.scss
@@ -0,0 +1,60 @@
+:root {
+ color-scheme: light;
+}
+
+.control-group {
+ display: grid;
+ gap: 0.75rem;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ width: min(100%, 64rem);
+ margin-bottom: 1rem;
+}
+
+.control {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ font-size: 0.9rem;
+}
+
+.control input,
+.control select {
+ padding: 0.45rem 0.55rem;
+ border-radius: 0.45rem;
+ border: 1px solid #e5e7eb;
+ background: #fff;
+ font-size: 0.9rem;
+}
+
+.control label {
+ color: #4b5563;
+ font-weight: 600;
+}
+
+.additional-options-popover {
+ border: 0;
+ border-radius: 0.75rem;
+ inset: auto;
+ margin: 0.5rem 0 0;
+ max-width: calc(100vw - 3rem);
+ padding: 0;
+ position-area: bottom span-left;
+}
+
+.additional-options-group {
+ margin: 0;
+}
+
+.additional-options-group .control label {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-weight: 500;
+}
+
+.tiptap audio {
+ width: 100%;
+ max-width: 64rem;
+ display: block;
+ margin: 0.75rem 0;
+}
diff --git a/demos/src/Nodes/Audio/Vue/index.html b/demos/src/Nodes/Audio/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Audio/Vue/index.vue b/demos/src/Nodes/Audio/Vue/index.vue
new file mode 100644
index 0000000..97d76cc
--- /dev/null
+++ b/demos/src/Nodes/Audio/Vue/index.vue
@@ -0,0 +1,226 @@
+
+
+
+
+
+ Insert audio
+
+
+ Additional Options
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/Blockquote/React/index.html b/demos/src/Nodes/Blockquote/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Blockquote/React/index.jsx b/demos/src/Nodes/Blockquote/React/index.jsx
new file mode 100644
index 0000000..3456e39
--- /dev/null
+++ b/demos/src/Nodes/Blockquote/React/index.jsx
@@ -0,0 +1,61 @@
+import './styles.scss'
+
+import Blockquote from '@tiptap/extension-blockquote'
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, Blockquote],
+ content: `
+
+ Nothing is impossible, the word itself says “I’m possible!”
+
+ Audrey Hepburn
+ `,
+ })
+ const editorState = useEditorState({
+ editor,
+ selector: ctx => ({
+ isBlockquote: ctx.editor.isActive('blockquote') ?? false,
+ canSetBlockquote: ctx.editor.can().setBlockquote() ?? false,
+ canUnsetBlockquote: ctx.editor.can().unsetBlockquote() ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().toggleBlockquote().run()}
+ className={editorState.isBlockquote ? 'is-active' : ''}
+ >
+ Toggle blockquote
+
+ editor.chain().focus().setBlockquote().run()}
+ disabled={!editorState.canSetBlockquote}
+ >
+ Set blockquote
+
+ editor.chain().focus().unsetBlockquote().run()}
+ disabled={!editorState.canUnsetBlockquote}
+ >
+ Unset blockquote
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Nodes/Blockquote/React/styles.scss b/demos/src/Nodes/Blockquote/React/styles.scss
new file mode 100644
index 0000000..8680100
--- /dev/null
+++ b/demos/src/Nodes/Blockquote/React/styles.scss
@@ -0,0 +1,12 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+}
diff --git a/demos/src/Nodes/Blockquote/Vue/index.html b/demos/src/Nodes/Blockquote/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Blockquote/Vue/index.vue b/demos/src/Nodes/Blockquote/Vue/index.vue
new file mode 100644
index 0000000..3eb33cd
--- /dev/null
+++ b/demos/src/Nodes/Blockquote/Vue/index.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+ Toggle blockquote
+
+
+ Set blockquote
+
+
+ Unset blockquote
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/Blockquote/index.spec.ts b/demos/src/Nodes/Blockquote/index.spec.ts
new file mode 100644
index 0000000..e22c567
--- /dev/null
+++ b/demos/src/Nodes/Blockquote/index.spec.ts
@@ -0,0 +1,160 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'Blockquote'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Nodes'
+
+const isMac = process.platform === 'darwin'
+const mod = isMac ? 'Meta' : 'Control'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await setEditorContent(page, 'Example Text
')
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ })
+
+ test('parses blockquote tags correctly', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text
')
+ return el.editor.getHTML()
+ })
+ expect(html).toBe('Example Text
')
+ })
+
+ test('parses blockquote tags without paragraphs correctly', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text ')
+ return el.editor.getHTML()
+ })
+ expect(html).toBe('Example Text
')
+ })
+
+ test('button makes the selected line a blockquote', async ({ page }) => {
+ await expect(page.locator('.tiptap blockquote')).toHaveCount(0)
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap blockquote')).toContainText('Example Text')
+ })
+
+ test('button wraps all nodes in one blockquote', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text
Example Text
')
+ el.editor.commands.selectAll()
+ })
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap blockquote')).toHaveCount(1)
+ })
+
+ test('button toggles the blockquote', async ({ page }) => {
+ const editor = await getEditor(page)
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap blockquote')).toContainText('Example Text')
+ const isActive = await editor.evaluate((el: any) => el.editor.isActive('blockquote'))
+ expect(isActive).toBe(true)
+ })
+
+ test('keyboard shortcut makes selected line a blockquote', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => {
+ el.editor.commands.focus()
+ el.editor.commands.selectAll()
+ })
+ await editor.press(`${mod}+Shift+b`)
+ await expect(page.locator('.tiptap blockquote')).toContainText('Example Text')
+ })
+
+ test('markdown shortcut creates blockquote', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('> Quote')
+ await expect(page.locator('.tiptap blockquote')).toContainText('Quote')
+ })
+
+ const placeCaretBefore = async (
+ editor: Awaited>,
+ content: string,
+ target: string,
+ ) => {
+ await editor.evaluate(
+ (el: any, { content: innerContent, target: innerTarget }: any) => {
+ el.editor.commands.setContent(innerContent)
+ let caret = -1
+ el.editor.state.doc.descendants((node: any, pos: number) => {
+ if (caret !== -1 || !node.isText) return
+ const offset = node.text.indexOf(innerTarget)
+ if (offset !== -1) caret = pos + offset
+ })
+ if (caret === -1) throw new Error(`Could not find "${innerTarget}" in the document`)
+ el.editor.chain().focus().setTextSelection({ from: caret, to: caret }).run()
+ },
+ { content, target },
+ )
+ }
+
+ test('backspace at the start of a non-first child lifts it out of the blockquote', async ({
+ page,
+ }) => {
+ const editor = await getEditor(page)
+ await placeCaretBefore(editor, 'A
B
', 'B')
+ await editor.press('Backspace')
+ const html = await editor.evaluate((el: any) => el.editor.getHTML())
+ expect(html).toBe('A
B
')
+ })
+
+ test('backspace at the start of a middle child splits the blockquote around it', async ({
+ page,
+ }) => {
+ const editor = await getEditor(page)
+ await placeCaretBefore(editor, 'A
B
C
', 'B')
+ await editor.press('Backspace')
+ const html = await editor.evaluate((el: any) => el.editor.getHTML())
+ expect(html).toBe(
+ 'A
B
C
',
+ )
+ })
+
+ test('backspace at the start of a paragraph after a blockquote merges into its last textblock', async ({
+ page,
+ }) => {
+ const editor = await getEditor(page)
+ await placeCaretBefore(editor, 'A
B
', 'B')
+ await editor.press('Backspace')
+ const html = await editor.evaluate((el: any) => el.editor.getHTML())
+ expect(html).toBe('AB
')
+ })
+
+ test('lift then merge: two backspaces collapse a child back into the previous text', async ({
+ page,
+ }) => {
+ const editor = await getEditor(page)
+ await placeCaretBefore(editor, 'A
B
', 'B')
+ await editor.press('Backspace')
+ await editor.press('Backspace')
+ const html = await editor.evaluate((el: any) => el.editor.getHTML())
+ expect(html).toBe('AB
')
+ })
+
+ test('backspace after merge: should continue to delete text inside the blockquote', async ({
+ page,
+ }) => {
+ const editor = await getEditor(page)
+ await placeCaretBefore(editor, 'A
B
', 'B')
+ await editor.press('Backspace') // merges B into A -> "AB"
+ await editor.press('Backspace') // deletes 'B' -> "A"
+ const html = await editor.evaluate((el: any) => el.editor.getHTML())
+ expect(html).toBe('A
')
+ })
+ })
+ })
+})
diff --git a/demos/src/Nodes/BulletList/React/index.html b/demos/src/Nodes/BulletList/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/BulletList/React/index.jsx b/demos/src/Nodes/BulletList/React/index.jsx
new file mode 100644
index 0000000..8caeb7f
--- /dev/null
+++ b/demos/src/Nodes/BulletList/React/index.jsx
@@ -0,0 +1,68 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import { BulletList, ListItem } from '@tiptap/extension-list'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, BulletList, ListItem],
+ content: `
+
+ A list item
+ And another one
+
+ `,
+ })
+ const editorState = useEditorState({
+ editor,
+ selector: ctx => ({
+ isBulletList: ctx.editor.isActive('bulletList') ?? false,
+ canSplitListItem: ctx.editor.can().splitListItem('listItem') ?? false,
+ canSinkListItem: ctx.editor.can().sinkListItem('listItem') ?? false,
+ canLiftListItem: ctx.editor.can().liftListItem('listItem') ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().toggleBulletList().run()}
+ className={editorState.isBulletList ? 'is-active' : ''}
+ >
+ Toggle bullet list
+
+ editor.chain().focus().splitListItem('listItem').run()}
+ disabled={!editorState.canSplitListItem}
+ >
+ Split list item
+
+ editor.chain().focus().sinkListItem('listItem').run()}
+ disabled={!editorState.canSinkListItem}
+ >
+ Sink list item
+
+ editor.chain().focus().liftListItem('listItem').run()}
+ disabled={!editorState.canLiftListItem}
+ >
+ Lift list item
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Nodes/BulletList/React/styles.scss b/demos/src/Nodes/BulletList/React/styles.scss
new file mode 100644
index 0000000..95fe10c
--- /dev/null
+++ b/demos/src/Nodes/BulletList/React/styles.scss
@@ -0,0 +1,18 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+}
diff --git a/demos/src/Nodes/BulletList/Vue/index.html b/demos/src/Nodes/BulletList/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/BulletList/Vue/index.vue b/demos/src/Nodes/BulletList/Vue/index.vue
new file mode 100644
index 0000000..a07e9af
--- /dev/null
+++ b/demos/src/Nodes/BulletList/Vue/index.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+ Toggle bullet list
+
+
+ Split list item
+
+
+ Sink list item
+
+
+ Lift list item
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/BulletList/index.spec.ts b/demos/src/Nodes/BulletList/index.spec.ts
new file mode 100644
index 0000000..202537b
--- /dev/null
+++ b/demos/src/Nodes/BulletList/index.spec.ts
@@ -0,0 +1,92 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'BulletList'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Nodes'
+
+const isMac = process.platform === 'darwin'
+const mod = isMac ? 'Meta' : 'Control'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await setEditorContent(page, 'Example Text
')
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ })
+
+ test('parses unordered lists correctly', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('')
+ return el.editor.getHTML()
+ })
+ expect(html).toBe('')
+ })
+
+ test('parses unordered lists without paragraphs correctly', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('')
+ return el.editor.getHTML()
+ })
+ expect(html).toBe('')
+ })
+
+ test('button makes selected line a bullet list item', async ({ page }) => {
+ await expect(page.locator('.tiptap ul')).toHaveCount(0)
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap ul li')).toContainText('Example Text')
+ })
+
+ test('button toggles the bullet list', async ({ page }) => {
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap ul')).toContainText('Example Text')
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap ul')).toHaveCount(0)
+ })
+
+ test('keyboard shortcut creates a bullet list', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => {
+ el.editor.commands.focus()
+ el.editor.commands.selectAll()
+ })
+ await editor.press(`${mod}+Shift+8`)
+ await expect(page.locator('.tiptap ul li')).toContainText('Example Text')
+ })
+ ;[
+ ['asterisk', '*'],
+ ['dash', '-'],
+ ['plus', '+'],
+ ].forEach(([name, char]) => {
+ test(`creates a bullet list from a ${name}`, async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type(`${char} List Item 1`)
+ await page.keyboard.press('Enter')
+ await editor.type('List Item 2')
+ await expect(page.locator('.tiptap li').nth(0)).toContainText('List Item 1')
+ await expect(page.locator('.tiptap li').nth(1)).toContainText('List Item 2')
+ })
+ })
+
+ test('removes the bullet list after pressing backspace', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('* ')
+ await page.keyboard.press('Backspace')
+ await editor.type('Example')
+ await expect(page.locator('.tiptap p')).toContainText('* Example')
+ })
+ })
+ })
+})
diff --git a/demos/src/Nodes/CodeBlock/React/index.html b/demos/src/Nodes/CodeBlock/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/CodeBlock/React/index.jsx b/demos/src/Nodes/CodeBlock/React/index.jsx
new file mode 100644
index 0000000..a538bb1
--- /dev/null
+++ b/demos/src/Nodes/CodeBlock/React/index.jsx
@@ -0,0 +1,68 @@
+import './styles.scss'
+
+import CodeBlock from '@tiptap/extension-code-block'
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, CodeBlock],
+ content: `
+
+ That’s a boring paragraph followed by a fenced code block:
+
+ for (var i=1; i <= 20; i++)
+{
+ if (i % 15 == 0)
+ console.log("FizzBuzz");
+ else if (i % 3 == 0)
+ console.log("Fizz");
+ else if (i % 5 == 0)
+ console.log("Buzz");
+ else
+ console.log(i);
+}
+
+ Press Command/Ctrl + Enter to leave the fenced code block and continue typing in boring paragraphs.
+
+ `,
+ })
+
+ const { isCodeBlock, canSetCodeBlock } = useEditorState({
+ editor,
+ selector: ctx => ({
+ isCodeBlock: ctx.editor.isActive('codeBlock') ?? false,
+ canSetCodeBlock: ctx.editor.can().chain().focus().setCodeBlock().run() ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().toggleCodeBlock().run()}
+ className={isCodeBlock ? 'is-active' : ''}
+ >
+ Toggle code block
+
+ editor.chain().focus().setCodeBlock().run()}
+ disabled={isCodeBlock || !canSetCodeBlock}
+ >
+ Set code block
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Nodes/CodeBlock/React/styles.scss b/demos/src/Nodes/CodeBlock/React/styles.scss
new file mode 100644
index 0000000..3350ce5
--- /dev/null
+++ b/demos/src/Nodes/CodeBlock/React/styles.scss
@@ -0,0 +1,22 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+}
diff --git a/demos/src/Nodes/CodeBlock/Vue/index.html b/demos/src/Nodes/CodeBlock/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/CodeBlock/Vue/index.vue b/demos/src/Nodes/CodeBlock/Vue/index.vue
new file mode 100644
index 0000000..164553d
--- /dev/null
+++ b/demos/src/Nodes/CodeBlock/Vue/index.vue
@@ -0,0 +1,95 @@
+
+
+
+
+
+ Toggle code block
+
+
+ Set code block
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/CodeBlock/index.spec.ts b/demos/src/Nodes/CodeBlock/index.spec.ts
new file mode 100644
index 0000000..1fb1fca
--- /dev/null
+++ b/demos/src/Nodes/CodeBlock/index.spec.ts
@@ -0,0 +1,110 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'CodeBlock'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Nodes'
+
+const isMac = process.platform === 'darwin'
+const mod = isMac ? 'Meta' : 'Control'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await setEditorContent(page, 'Example Text
')
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ })
+
+ test('parses code blocks correctly', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text ')
+ return el.editor.getHTML()
+ })
+ expect(html).toBe('Example Text ')
+ })
+
+ test('parses code blocks with language correctly', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text ')
+ return el.editor.getHTML()
+ })
+ expect(html).toBe('Example Text ')
+ })
+
+ test('button makes the selected line a code block', async ({ page }) => {
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap pre')).toContainText('Example Text')
+ })
+
+ test('button toggles the code block', async ({ page }) => {
+ const editor = await getEditor(page)
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap pre')).toContainText('Example Text')
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap pre')).toHaveCount(0)
+ })
+
+ test('keyboard shortcut creates a code block', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => {
+ el.editor.commands.focus()
+ el.editor.commands.selectAll()
+ })
+ await editor.press(`${mod}+Alt+c`)
+ await expect(page.locator('.tiptap pre')).toContainText('Example Text')
+ })
+
+ test('parses language from HTML code block', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) =>
+ el.editor.commands.setContent(
+ 'body { display: none; } ',
+ ),
+ )
+ await expect(page.locator('.tiptap pre>code.language-css')).toHaveCount(1)
+ })
+
+ test('creates code block from backtick markdown shortcut', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('``` Code')
+ await expect(page.locator('.tiptap pre>code')).toContainText('Code')
+ })
+
+ test('creates code block from tilde markdown shortcut', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('~~~ Code')
+ await expect(page.locator('.tiptap pre>code')).toContainText('Code')
+ })
+
+ test('creates code block for js with backticks', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('```js Code')
+ await expect(page.locator('.tiptap pre>code.language-js')).toContainText('Code')
+ })
+
+ test('reverts the markdown shortcut when pressing backspace', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('``` ')
+ await page.keyboard.press('Backspace')
+ await expect(page.locator('.tiptap pre')).toHaveCount(0)
+ })
+ })
+ })
+})
diff --git a/demos/src/Nodes/CodeBlockLowlight/React/index.html b/demos/src/Nodes/CodeBlockLowlight/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/CodeBlockLowlight/React/index.jsx b/demos/src/Nodes/CodeBlockLowlight/React/index.jsx
new file mode 100644
index 0000000..e9296f2
--- /dev/null
+++ b/demos/src/Nodes/CodeBlockLowlight/React/index.jsx
@@ -0,0 +1,100 @@
+import './styles.scss'
+
+import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import css from 'highlight.js/lib/languages/css'
+import js from 'highlight.js/lib/languages/javascript'
+import ts from 'highlight.js/lib/languages/typescript'
+import html from 'highlight.js/lib/languages/xml'
+// load all languages with "all" or common languages with "common"
+import { all, createLowlight } from 'lowlight'
+import React from 'react'
+
+// create a lowlight instance with all languages loaded
+const lowlight = createLowlight(all)
+
+// This is only an example, all supported languages are already loaded above
+// but you can also register only specific languages to reduce bundle-size
+lowlight.register('html', html)
+lowlight.register('css', css)
+lowlight.register('js', js)
+lowlight.register('ts', ts)
+
+/**
+ * Lowlight version 2.x had a different API
+ * import { lowlight } from 'lowlight'
+ *
+ * lowlight.registerLanguage('html', html)
+ * lowlight.registerLanguage('css', css)
+ * lowlight.registerLanguage('js', js)
+ * lowlight.registerLanguage('ts', ts)
+ */
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ CodeBlockLowlight.configure({
+ lowlight,
+ }),
+ ],
+ content: `
+
+ That's a boring paragraph followed by a fenced code block:
+
+ for (var i=1; i <= 20; i++)
+{
+ if (i % 15 == 0)
+ console.log("FizzBuzz");
+ else if (i % 3 == 0)
+ console.log("Fizz");
+ else if (i % 5 == 0)
+ console.log("Buzz");
+ else
+ console.log(i);
+}
+
+ Press Command/Ctrl + Enter to leave the fenced code block and continue typing in boring paragraphs.
+
+ `,
+ })
+
+ const { isCodeBlock } = useEditorState({
+ editor,
+ selector: ctx => ({
+ isCodeBlock: ctx.editor.isActive('codeBlock') ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().toggleCodeBlock().run()}
+ className={isCodeBlock ? 'is-active' : ''}
+ >
+ Toggle code block
+
+ editor.chain().focus().setCodeBlock().run()}
+ disabled={isCodeBlock}
+ >
+ Set code block
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Nodes/CodeBlockLowlight/React/styles.scss b/demos/src/Nodes/CodeBlockLowlight/React/styles.scss
new file mode 100644
index 0000000..9e31838
--- /dev/null
+++ b/demos/src/Nodes/CodeBlockLowlight/React/styles.scss
@@ -0,0 +1,74 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+
+ /* Code styling */
+ .hljs-comment,
+ .hljs-quote {
+ color: #616161;
+ }
+
+ .hljs-variable,
+ .hljs-template-variable,
+ .hljs-attribute,
+ .hljs-tag,
+ .hljs-regexp,
+ .hljs-link,
+ .hljs-name,
+ .hljs-selector-id,
+ .hljs-selector-class {
+ color: #f98181;
+ }
+
+ .hljs-number,
+ .hljs-meta,
+ .hljs-built_in,
+ .hljs-builtin-name,
+ .hljs-literal,
+ .hljs-type,
+ .hljs-params {
+ color: #fbbc88;
+ }
+
+ .hljs-string,
+ .hljs-symbol,
+ .hljs-bullet {
+ color: #b9f18d;
+ }
+
+ .hljs-title,
+ .hljs-section {
+ color: #faf594;
+ }
+
+ .hljs-keyword,
+ .hljs-selector-tag {
+ color: #70cff8;
+ }
+
+ .hljs-emphasis {
+ font-style: italic;
+ }
+
+ .hljs-strong {
+ font-weight: 700;
+ }
+ }
+}
diff --git a/demos/src/Nodes/CodeBlockLowlight/Vue/index.html b/demos/src/Nodes/CodeBlockLowlight/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/CodeBlockLowlight/Vue/index.vue b/demos/src/Nodes/CodeBlockLowlight/Vue/index.vue
new file mode 100644
index 0000000..ee1d139
--- /dev/null
+++ b/demos/src/Nodes/CodeBlockLowlight/Vue/index.vue
@@ -0,0 +1,171 @@
+
+
+
+
+
+ Toggle code block
+
+
+ Set code block
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/Details/React/index.html b/demos/src/Nodes/Details/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Details/React/index.jsx b/demos/src/Nodes/Details/React/index.jsx
new file mode 100644
index 0000000..a68ff96
--- /dev/null
+++ b/demos/src/Nodes/Details/React/index.jsx
@@ -0,0 +1,97 @@
+import './styles.scss'
+
+import { Details, DetailsContent, DetailsSummary } from '@tiptap/extension-details'
+import { Placeholder } from '@tiptap/extensions'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ Details.configure({
+ persist: true,
+ HTMLAttributes: {
+ class: 'details',
+ },
+ }),
+ DetailsSummary,
+ DetailsContent,
+ Placeholder.configure({
+ includeChildren: true,
+ placeholder: ({ node }) => {
+ if (node.type.name === 'detailsSummary') {
+ return 'Summary'
+ }
+
+ return null
+ },
+ }),
+ ],
+ content: `
+ Look at these details
+
+ This is a summary
+ Surprise!
+
+ Nested details are also supported
+
+ This is another summary
+ And there is even more.
+
+ We need to go deeper
+ Booya!
+
+
+ `,
+ })
+
+ const { canSetDetails, canUnsetDetails } = useEditorState({
+ editor,
+ selector: ctx => ({
+ canSetDetails: ctx.editor.can().setDetails() ?? false,
+ canUnsetDetails: ctx.editor.can().unsetDetails() ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().setDetails().run()}
+ disabled={!canSetDetails}
+ >
+ Set details
+
+ editor.chain().focus().unsetDetails().run()}
+ disabled={!canUnsetDetails}
+ >
+ Unset details
+
+
+ editor
+ .chain()
+ .focus()
+ .command(({ tr }) => {
+ tr.setNodeAttribute(23, 'open', true)
+ return true
+ })
+ .run()
+ }
+ >
+ Force open first details
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Nodes/Details/React/styles.scss b/demos/src/Nodes/Details/React/styles.scss
new file mode 100644
index 0000000..afd5ba2
--- /dev/null
+++ b/demos/src/Nodes/Details/React/styles.scss
@@ -0,0 +1,6 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
diff --git a/demos/src/Nodes/Details/Vue/index.html b/demos/src/Nodes/Details/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Details/Vue/index.vue b/demos/src/Nodes/Details/Vue/index.vue
new file mode 100644
index 0000000..5847fb9
--- /dev/null
+++ b/demos/src/Nodes/Details/Vue/index.vue
@@ -0,0 +1,150 @@
+
+
+
+
+
+ Set details
+
+
+ Unset details
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/Document/React/index.html b/demos/src/Nodes/Document/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Document/React/index.jsx b/demos/src/Nodes/Document/React/index.jsx
new file mode 100644
index 0000000..0494e83
--- /dev/null
+++ b/demos/src/Nodes/Document/React/index.jsx
@@ -0,0 +1,22 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text],
+ content: `
+ The Document extension is required. Though, you can write your own implementation, e. g. to give it custom name.
+ `,
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return
+}
diff --git a/demos/src/Nodes/Document/React/styles.scss b/demos/src/Nodes/Document/React/styles.scss
new file mode 100644
index 0000000..afd5ba2
--- /dev/null
+++ b/demos/src/Nodes/Document/React/styles.scss
@@ -0,0 +1,6 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
diff --git a/demos/src/Nodes/Document/Vue/index.html b/demos/src/Nodes/Document/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Document/Vue/index.vue b/demos/src/Nodes/Document/Vue/index.vue
new file mode 100644
index 0000000..ee15f90
--- /dev/null
+++ b/demos/src/Nodes/Document/Vue/index.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/Emoji/React/EmojiList.jsx b/demos/src/Nodes/Emoji/React/EmojiList.jsx
new file mode 100644
index 0000000..fd88bf5
--- /dev/null
+++ b/demos/src/Nodes/Emoji/React/EmojiList.jsx
@@ -0,0 +1,67 @@
+import './EmojiList.scss'
+
+import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
+
+export const EmojiList = forwardRef((props, ref) => {
+ const [selectedIndex, setSelectedIndex] = useState(0)
+
+ const selectItem = index => {
+ const item = props.items[index]
+
+ if (item) {
+ props.command({ name: item.name })
+ }
+ }
+
+ const upHandler = () => {
+ setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length)
+ }
+
+ const downHandler = () => {
+ setSelectedIndex((selectedIndex + 1) % props.items.length)
+ }
+
+ const enterHandler = () => {
+ selectItem(selectedIndex)
+ }
+
+ useEffect(() => setSelectedIndex(0), [props.items])
+
+ useImperativeHandle(ref, () => {
+ return {
+ onKeyDown: x => {
+ if (x.event.key === 'ArrowUp') {
+ upHandler()
+ return true
+ }
+
+ if (x.event.key === 'ArrowDown') {
+ downHandler()
+ return true
+ }
+
+ if (x.event.key === 'Enter') {
+ enterHandler()
+ return true
+ }
+
+ return false
+ },
+ }
+ }, [upHandler, downHandler, enterHandler])
+
+ return (
+
+ {props.items.map((item, index) => (
+
selectItem(index)}
+ >
+ {item.fallbackImage ? : item.emoji}:
+ {item.name}:
+
+ ))}
+
+ )
+})
diff --git a/demos/src/Nodes/Emoji/React/EmojiList.scss b/demos/src/Nodes/Emoji/React/EmojiList.scss
new file mode 100644
index 0000000..a873e9e
--- /dev/null
+++ b/demos/src/Nodes/Emoji/React/EmojiList.scss
@@ -0,0 +1,36 @@
+/* Dropdown menu */
+.dropdown-menu {
+ background: var(--white);
+ border: 1px solid var(--gray-1);
+ border-radius: 0.7rem;
+ box-shadow: var(--shadow);
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ overflow: auto;
+ padding: 0.4rem;
+ position: relative;
+
+ button {
+ align-items: center;
+ background-color: transparent;
+ display: flex;
+ gap: 0.25rem;
+ text-align: left;
+ width: 100%;
+
+ &:hover,
+ &:hover.is-selected {
+ background-color: var(--gray-3);
+ }
+
+ &.is-selected {
+ background-color: var(--gray-2);
+ }
+
+ img {
+ height: 1em;
+ width: 1em;
+ }
+ }
+}
diff --git a/demos/src/Nodes/Emoji/React/index.html b/demos/src/Nodes/Emoji/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Emoji/React/index.jsx b/demos/src/Nodes/Emoji/React/index.jsx
new file mode 100644
index 0000000..18b70df
--- /dev/null
+++ b/demos/src/Nodes/Emoji/React/index.jsx
@@ -0,0 +1,61 @@
+import './styles.scss'
+
+import Emoji, { gitHubEmojis } from '@tiptap/extension-emoji'
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import React from 'react'
+
+import suggestion from './suggestion.js'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ Emoji.configure({
+ emojis: gitHubEmojis,
+ enableEmoticons: true,
+ suggestion,
+ }),
+ ],
+ content: `
+
+ These
+ are
+ some
+ emojis
+ rendered
+ as
+ inline
+ nodes.
+
+
+ Type : to open the autocomplete.
+
+
+ Even
+ custom
+ emojis
+ are
+ supported.
+
+
+ And unsupported emojis (without a fallback image) are rendered as just the shortcode .
+
+ In code blocks all emojis are rendered as plain text. 👩💻👨💻
+
+ There is also support for emoticons. Try typing <3.
+
+ `,
+ })
+
+ return (
+ <>
+
+
+ editor.chain().focus().setEmoji('zap').run()}>Insert ⚡
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Nodes/Emoji/React/styles.scss b/demos/src/Nodes/Emoji/React/styles.scss
new file mode 100644
index 0000000..7508761
--- /dev/null
+++ b/demos/src/Nodes/Emoji/React/styles.scss
@@ -0,0 +1,14 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ // Emoji extension styles
+ [data-type='emoji'] {
+ img {
+ height: 1em;
+ width: 1em;
+ }
+ }
+}
diff --git a/demos/src/Nodes/Emoji/React/suggestion.js b/demos/src/Nodes/Emoji/React/suggestion.js
new file mode 100644
index 0000000..7716d4f
--- /dev/null
+++ b/demos/src/Nodes/Emoji/React/suggestion.js
@@ -0,0 +1,68 @@
+import { ReactRenderer } from '@tiptap/react'
+
+import { updatePosition } from '../../../utils/updatePosition.js'
+import { EmojiList } from './EmojiList.jsx'
+
+export default {
+ items: ({ editor, query }) => {
+ return editor.storage.emoji.emojis
+ .filter(({ shortcodes, tags }) => {
+ return (
+ shortcodes.find(shortcode => shortcode.startsWith(query.toLowerCase())) ||
+ tags.find(tag => tag.startsWith(query.toLowerCase()))
+ )
+ })
+ .slice(0, 5)
+ },
+
+ allowSpaces: false,
+
+ render: () => {
+ let component
+
+ function repositionComponent(editor) {
+ updatePosition({
+ editor,
+ element: component?.element,
+ placement: 'bottom-start',
+ strategy: 'absolute',
+ middleware: [],
+ })
+ }
+
+ return {
+ onStart: props => {
+ component = new ReactRenderer(EmojiList, {
+ props,
+ editor: props.editor,
+ })
+
+ document.body.appendChild(component.element)
+ repositionComponent(props.editor)
+ },
+
+ onUpdate(props) {
+ component.updateProps(props)
+ repositionComponent(props.editor)
+ },
+
+ onKeyDown(props) {
+ if (props.event.key === 'Escape') {
+ document.body.removeChild(component.element)
+ component.destroy()
+
+ return true
+ }
+
+ return component.ref?.onKeyDown(props)
+ },
+
+ onExit() {
+ if (document.body.contains(component.element)) {
+ document.body.removeChild(component.element)
+ }
+ component.destroy()
+ },
+ }
+ },
+}
diff --git a/demos/src/Nodes/Emoji/Vue/EmojiList.vue b/demos/src/Nodes/Emoji/Vue/EmojiList.vue
new file mode 100644
index 0000000..0121bb5
--- /dev/null
+++ b/demos/src/Nodes/Emoji/Vue/EmojiList.vue
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/Emoji/Vue/index.html b/demos/src/Nodes/Emoji/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Emoji/Vue/index.vue b/demos/src/Nodes/Emoji/Vue/index.vue
new file mode 100644
index 0000000..e534520
--- /dev/null
+++ b/demos/src/Nodes/Emoji/Vue/index.vue
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/Emoji/Vue/suggestion.js b/demos/src/Nodes/Emoji/Vue/suggestion.js
new file mode 100644
index 0000000..a04e49c
--- /dev/null
+++ b/demos/src/Nodes/Emoji/Vue/suggestion.js
@@ -0,0 +1,66 @@
+import { VueRenderer } from '@tiptap/vue'
+
+import { updatePosition } from '../../../utils/updatePosition.js'
+import EmojiList from './EmojiList.vue'
+
+export default {
+ items: ({ editor, query }) => {
+ return editor.storage.emoji.emojis
+ .filter(({ shortcodes, tags }) => {
+ return (
+ shortcodes.find(shortcode => shortcode.startsWith(query.toLowerCase())) ||
+ tags.find(tag => tag.startsWith(query.toLowerCase()))
+ )
+ })
+ .slice(0, 5)
+ },
+
+ render: () => {
+ let component
+
+ function repositionComponent(editor) {
+ updatePosition({
+ editor,
+ element: component?.element,
+ placement: 'bottom-start',
+ strategy: 'absolute',
+ middleware: [],
+ })
+ }
+
+ return {
+ onStart: props => {
+ component = new VueRenderer(EmojiList, {
+ props,
+ editor: props.editor,
+ })
+
+ document.body.appendChild(component.element)
+ repositionComponent(props.editor)
+ },
+
+ onUpdate(props) {
+ component.updateProps(props)
+ repositionComponent(props.editor)
+ },
+
+ onKeyDown(props) {
+ if (props.event.key === 'Escape') {
+ document.body.removeChild(component.element)
+ component.destroy()
+
+ return true
+ }
+
+ return component.ref?.onKeyDown(props)
+ },
+
+ onExit() {
+ if (document.body.contains(component.element)) {
+ document.body.removeChild(component.element)
+ }
+ component.destroy()
+ },
+ }
+ },
+}
diff --git a/demos/src/Nodes/Emoji/index.spec.ts b/demos/src/Nodes/Emoji/index.spec.ts
new file mode 100644
index 0000000..ff22e2e
--- /dev/null
+++ b/demos/src/Nodes/Emoji/index.spec.ts
@@ -0,0 +1,80 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'Emoji'
+const frameworkPaths = ['React']
+const demoPath = '/src/Nodes'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await setEditorContent(page, '
')
+ const editor = await getEditor(page)
+ await editor.click()
+ })
+
+ test('inserts :smile: via typing', async ({ page }) => {
+ await page.keyboard.type(':smile:')
+ await expect(page.locator('.tiptap [data-type="emoji"][data-name="smile"]')).toHaveCount(1)
+ })
+
+ test('insert button inserts an emoji node', async ({ page }) => {
+ await page.getByRole('button', { name: 'Insert ⚡' }).click()
+ await expect(page.locator('.tiptap [data-type="emoji"][data-name="zap"]')).toHaveCount(1)
+ })
+
+ test('arrow left navigates past emoji at start of paragraph', async ({ page }) => {
+ // Set content with an emoji at the start followed by text
+ await setEditorContent(
+ page,
+ '😄 hello
',
+ )
+ const editor = await getEditor(page)
+
+ // Place cursor right after the emoji (before "hello")
+ await editor.click()
+ await page.keyboard.press('Home')
+ await page.keyboard.press('ArrowRight')
+
+ // Now press ArrowLeft — cursor should move before the emoji (position 0)
+ await page.keyboard.press('ArrowLeft')
+
+ // Type a character to verify cursor is at position 0 (before emoji)
+ await page.keyboard.type('X')
+
+ // The "X" should appear before the emoji
+ const html = await editor.evaluate((el: any) => el.editor.getHTML())
+ expect(html).toMatch(/X.*data-type="emoji"/)
+ })
+
+ test('arrow right navigates past emoji node', async ({ page }) => {
+ // Set content with text followed by an emoji
+ await setEditorContent(
+ page,
+ 'hi⚡ there
',
+ )
+ const editor = await getEditor(page)
+
+ // Place cursor at start and move right past "hi" to be just before the emoji
+ await editor.click()
+ await page.keyboard.press('Home')
+ await page.keyboard.press('ArrowRight')
+ await page.keyboard.press('ArrowRight')
+
+ // Now press ArrowRight — cursor should skip over the emoji
+ await page.keyboard.press('ArrowRight')
+
+ // Type a character to verify cursor is after the emoji (before "there")
+ await page.keyboard.type('Y')
+
+ const html = await editor.evaluate((el: any) => el.editor.getHTML())
+ expect(html).toMatch(/data-type="emoji".*Y.*there/)
+ })
+ })
+ })
+})
diff --git a/demos/src/Nodes/HardBreak/React/index.html b/demos/src/Nodes/HardBreak/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/HardBreak/React/index.jsx b/demos/src/Nodes/HardBreak/React/index.jsx
new file mode 100644
index 0000000..10215a9
--- /dev/null
+++ b/demos/src/Nodes/HardBreak/React/index.jsx
@@ -0,0 +1,43 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import HardBreak from '@tiptap/extension-hard-break'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, HardBreak],
+ content: `
+
+ This
+ is
+ a
+ single
+ paragraph
+ with
+ line
+ breaks.
+
+ `,
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().setHardBreak().run()}>
+ Set hard break
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Nodes/HardBreak/React/styles.scss b/demos/src/Nodes/HardBreak/React/styles.scss
new file mode 100644
index 0000000..afd5ba2
--- /dev/null
+++ b/demos/src/Nodes/HardBreak/React/styles.scss
@@ -0,0 +1,6 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
diff --git a/demos/src/Nodes/HardBreak/Vue/index.html b/demos/src/Nodes/HardBreak/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/HardBreak/Vue/index.vue b/demos/src/Nodes/HardBreak/Vue/index.vue
new file mode 100644
index 0000000..e19912a
--- /dev/null
+++ b/demos/src/Nodes/HardBreak/Vue/index.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/HardBreak/index.spec.ts b/demos/src/Nodes/HardBreak/index.spec.ts
new file mode 100644
index 0000000..20337c9
--- /dev/null
+++ b/demos/src/Nodes/HardBreak/index.spec.ts
@@ -0,0 +1,64 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'HardBreak'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Nodes'
+
+const isMac = process.platform === 'darwin'
+const mod = isMac ? 'Meta' : 'Control'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await setEditorContent(page, 'Example Text
')
+ })
+
+ test('parses hard breaks correctly', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text
')
+ return el.editor.getHTML()
+ })
+ expect(html).toBe('Example Text
')
+ })
+
+ test('parses hard breaks with self-closing tag', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text
')
+ return el.editor.getHTML()
+ })
+ expect(html).toBe('Example Text
')
+ })
+
+ test('button adds a line break', async ({ page }) => {
+ await expect(page.locator('.tiptap br')).toHaveCount(0)
+ await page.locator('button').first().click()
+ const count = await page.locator('.tiptap br').count()
+ expect(count).toBeGreaterThan(0)
+ })
+
+ test('Shift+Enter adds a line break', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.click()
+ await page.keyboard.press('Shift+Enter')
+ const count = await page.locator('.tiptap br').count()
+ expect(count).toBeGreaterThan(0)
+ })
+
+ test('Mod+Enter adds a line break', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.click()
+ await page.keyboard.press(`${mod}+Enter`)
+ const count = await page.locator('.tiptap br').count()
+ expect(count).toBeGreaterThan(0)
+ })
+ })
+ })
+})
diff --git a/demos/src/Nodes/Heading/React/index.html b/demos/src/Nodes/Heading/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Heading/React/index.jsx b/demos/src/Nodes/Heading/React/index.jsx
new file mode 100644
index 0000000..bd9e588
--- /dev/null
+++ b/demos/src/Nodes/Heading/React/index.jsx
@@ -0,0 +1,68 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Heading from '@tiptap/extension-heading'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Heading.configure({
+ levels: [1, 2, 3],
+ }),
+ ],
+ content: `
+ This is a 1st level heading
+ This is a 2nd level heading
+ This is a 3rd level heading
+ This 4th level heading will be converted to a paragraph, because levels are configured to be only 1, 2 or 3.
+ `,
+ })
+ const editorState = useEditorState({
+ editor,
+ selector: ctx => ({
+ isHeading1: ctx.editor.isActive('heading', { level: 1 }) ?? false,
+ isHeading2: ctx.editor.isActive('heading', { level: 2 }) ?? false,
+ isHeading3: ctx.editor.isActive('heading', { level: 3 }) ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().toggleHeading({ level: 1 }).run()}
+ className={editorState.isHeading1 ? 'is-active' : ''}
+ >
+ H1
+
+ editor.chain().focus().toggleHeading({ level: 2 }).run()}
+ className={editorState.isHeading2 ? 'is-active' : ''}
+ >
+ H2
+
+ editor.chain().focus().toggleHeading({ level: 3 }).run()}
+ className={editorState.isHeading3 ? 'is-active' : ''}
+ >
+ H3
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Nodes/Heading/React/styles.scss b/demos/src/Nodes/Heading/React/styles.scss
new file mode 100644
index 0000000..4655151
--- /dev/null
+++ b/demos/src/Nodes/Heading/React/styles.scss
@@ -0,0 +1,42 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+}
diff --git a/demos/src/Nodes/Heading/Vue/index.html b/demos/src/Nodes/Heading/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Heading/Vue/index.vue b/demos/src/Nodes/Heading/Vue/index.vue
new file mode 100644
index 0000000..31dc85d
--- /dev/null
+++ b/demos/src/Nodes/Heading/Vue/index.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+ H1
+
+
+ H2
+
+
+ H3
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/Heading/index.spec.ts b/demos/src/Nodes/Heading/index.spec.ts
new file mode 100644
index 0000000..4472d4f
--- /dev/null
+++ b/demos/src/Nodes/Heading/index.spec.ts
@@ -0,0 +1,81 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'Heading'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Nodes'
+
+const isMac = process.platform === 'darwin'
+const mod = isMac ? 'Meta' : 'Control'
+
+const headings = ['Example Text ', 'Example Text ', 'Example Text ']
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await setEditorContent(page, 'Example Text
')
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ })
+
+ headings.forEach(html => {
+ test(`parses ${html} correctly`, async ({ page }) => {
+ const editor = await getEditor(page)
+ const result = await editor.evaluate((el: any, content: string) => {
+ el.editor.commands.setContent(content)
+ return el.editor.getHTML()
+ }, html)
+ expect(result).toBe(html)
+ })
+ })
+
+ test('omits disabled heading levels', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text ')
+ return el.editor.getHTML()
+ })
+ expect(html).toBe('Example Text
')
+ })
+ ;[1, 2, 3].forEach(level => {
+ test(`button makes selected line h${level}`, async ({ page }) => {
+ await page
+ .locator('button')
+ .nth(level - 1)
+ .click()
+ await expect(page.locator(`.tiptap h${level}`)).toContainText('Example Text')
+ })
+
+ test(`keyboard shortcut makes paragraph h${level}`, async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => {
+ el.editor.commands.focus()
+ el.editor.commands.selectAll()
+ })
+ await editor.press(`${mod}+Alt+${level}`)
+ await expect(page.locator(`.tiptap h${level}`)).toContainText('Example Text')
+ })
+
+ test(`creates h${level} from markdown shortcut`, async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type(`${'#'.repeat(level)} Headline`)
+ await expect(page.locator(`.tiptap h${level}`)).toContainText('Headline')
+ })
+ })
+
+ test('button toggles the heading', async ({ page }) => {
+ await page.locator('button').nth(0).click()
+ await expect(page.locator('.tiptap h1')).toContainText('Example Text')
+ await page.locator('button').nth(0).click()
+ await expect(page.locator('.tiptap h1')).toHaveCount(0)
+ })
+ })
+ })
+})
diff --git a/demos/src/Nodes/HorizontalRule/React/index.html b/demos/src/Nodes/HorizontalRule/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/HorizontalRule/React/index.jsx b/demos/src/Nodes/HorizontalRule/React/index.jsx
new file mode 100644
index 0000000..a7cbed7
--- /dev/null
+++ b/demos/src/Nodes/HorizontalRule/React/index.jsx
@@ -0,0 +1,39 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import HorizontalRule from '@tiptap/extension-horizontal-rule'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, HorizontalRule],
+ content: `
+ This is a paragraph.
+
+ And this is another paragraph.
+
+ But between those paragraphs are horizontal rules.
+ `,
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().setHorizontalRule().run()}>
+ Set horizontal rule
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Nodes/HorizontalRule/React/styles.scss b/demos/src/Nodes/HorizontalRule/React/styles.scss
new file mode 100644
index 0000000..4f89805
--- /dev/null
+++ b/demos/src/Nodes/HorizontalRule/React/styles.scss
@@ -0,0 +1,16 @@
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ cursor: pointer;
+ margin: 2rem 0;
+
+ &.ProseMirror-selectednode {
+ border-top: 1px solid var(--purple);
+ }
+ }
+}
diff --git a/demos/src/Nodes/HorizontalRule/Vue/index.html b/demos/src/Nodes/HorizontalRule/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/HorizontalRule/Vue/index.vue b/demos/src/Nodes/HorizontalRule/Vue/index.vue
new file mode 100644
index 0000000..172e7f2
--- /dev/null
+++ b/demos/src/Nodes/HorizontalRule/Vue/index.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+ Set horizontal rule
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/HorizontalRule/index.spec.ts b/demos/src/Nodes/HorizontalRule/index.spec.ts
new file mode 100644
index 0000000..f3a5eab
--- /dev/null
+++ b/demos/src/Nodes/HorizontalRule/index.spec.ts
@@ -0,0 +1,79 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'HorizontalRule'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Nodes'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await setEditorContent(page, 'Example Text
')
+ })
+
+ test('parses horizontal rules correctly', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text
')
+ return el.editor.getHTML()
+ })
+ expect(html).toBe('Example Text
')
+ })
+
+ test('parses horizontal rules with self-closing tag', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text
')
+ return el.editor.getHTML()
+ })
+ expect(html).toBe('Example Text
')
+ })
+
+ test('button adds a horizontal rule', async ({ page }) => {
+ await expect(page.locator('.tiptap hr')).toHaveCount(0)
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap hr')).toHaveCount(1)
+ })
+
+ test('markdown shortcut --- adds a horizontal rule', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('---')
+ await expect(page.locator('.tiptap hr')).toHaveCount(1)
+ })
+
+ test('markdown shortcut ___ adds a horizontal rule', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('___ ')
+ await expect(page.locator('.tiptap hr')).toHaveCount(1)
+ })
+
+ test('replaces selection correctly', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html1 = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text
Example Text
')
+ el.editor.commands.setTextSelection({ from: 0, to: 15 })
+ el.editor.commands.setHorizontalRule()
+ return el.editor.getHTML()
+ })
+ expect(html1).toBe('Example Text
')
+
+ const html2 = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text
Example Text
')
+ el.editor.commands.setTextSelection({ from: 13, to: 15 })
+ el.editor.commands.setHorizontalRule()
+ return el.editor.getHTML()
+ })
+ expect(html2).toBe('Example Text
Example Text
')
+ })
+ })
+ })
+})
diff --git a/demos/src/Nodes/Image/React/index.html b/demos/src/Nodes/Image/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Image/React/index.jsx b/demos/src/Nodes/Image/React/index.jsx
new file mode 100644
index 0000000..1ae8b12
--- /dev/null
+++ b/demos/src/Nodes/Image/React/index.jsx
@@ -0,0 +1,43 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Image from '@tiptap/extension-image'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { Dropcursor } from '@tiptap/extensions'
+import { EditorContent, useEditor } from '@tiptap/react'
+import { useCallback } from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, Image, Dropcursor],
+ content: `
+ This is a basic example of implementing images. Drag to re-order.
+
+
+ `,
+ })
+
+ const addImage = useCallback(() => {
+ const url = window.prompt('URL')
+
+ if (url) {
+ editor.chain().focus().setImage({ src: url }).run()
+ }
+ }, [editor])
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/demos/src/Nodes/Image/React/styles.scss b/demos/src/Nodes/Image/React/styles.scss
new file mode 100644
index 0000000..51aa105
--- /dev/null
+++ b/demos/src/Nodes/Image/React/styles.scss
@@ -0,0 +1,17 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ img {
+ display: block;
+ height: auto;
+ margin: 1.5rem 0;
+ max-width: 100%;
+
+ &.ProseMirror-selectednode {
+ outline: 3px solid var(--purple);
+ }
+ }
+}
diff --git a/demos/src/Nodes/Image/Vue/index.html b/demos/src/Nodes/Image/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Image/Vue/index.vue b/demos/src/Nodes/Image/Vue/index.vue
new file mode 100644
index 0000000..0678e66
--- /dev/null
+++ b/demos/src/Nodes/Image/Vue/index.vue
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/ListItem/React/index.html b/demos/src/Nodes/ListItem/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/ListItem/React/index.jsx b/demos/src/Nodes/ListItem/React/index.jsx
new file mode 100644
index 0000000..76f2b76
--- /dev/null
+++ b/demos/src/Nodes/ListItem/React/index.jsx
@@ -0,0 +1,90 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import { BulletList, ListItem, OrderedList } from '@tiptap/extension-list'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, BulletList, OrderedList, ListItem],
+ content: `
+
+ I like lists. Let’s add one:
+
+
+ This is a bullet list.
+ And it has three list items.
+ Here is the third one.
+
+
+ Do you want to see one more? I bet! Here is another one:
+
+
+ That’s a different list, actually it’s an ordered list.
+ It also has three list items.
+ And all of them are numbered.
+
+
+ Lists would be nothing without list items.
+
+ `,
+ })
+ const editorState = useEditorState({
+ editor,
+ selector: ctx => ({
+ isBulletList: ctx.editor.isActive('bulletList') ?? false,
+ isOrderedList: ctx.editor.isActive('orderedList') ?? false,
+ canSplitListItem: ctx.editor.can().splitListItem('listItem') ?? false,
+ canSinkListItem: ctx.editor.can().sinkListItem('listItem') ?? false,
+ canLiftListItem: ctx.editor.can().liftListItem('listItem') ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().toggleBulletList().run()}
+ className={editorState.isBulletList ? 'is-active' : ''}
+ >
+ Toggle bullet list
+
+ editor.chain().focus().toggleOrderedList().run()}
+ className={editorState.isOrderedList ? 'is-active' : ''}
+ >
+ Toggle ordered list
+
+ editor.chain().focus().splitListItem('listItem').run()}
+ disabled={!editorState.canSplitListItem}
+ >
+ Split list item
+
+ editor.chain().focus().sinkListItem('listItem').run()}
+ disabled={!editorState.canSinkListItem}
+ >
+ Sink list item
+
+ editor.chain().focus().liftListItem('listItem').run()}
+ disabled={!editorState.canLiftListItem}
+ >
+ Lift list item
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Nodes/ListItem/React/styles.scss b/demos/src/Nodes/ListItem/React/styles.scss
new file mode 100644
index 0000000..95fe10c
--- /dev/null
+++ b/demos/src/Nodes/ListItem/React/styles.scss
@@ -0,0 +1,18 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+}
diff --git a/demos/src/Nodes/ListItem/Vue/index.html b/demos/src/Nodes/ListItem/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/ListItem/Vue/index.vue b/demos/src/Nodes/ListItem/Vue/index.vue
new file mode 100644
index 0000000..2586739
--- /dev/null
+++ b/demos/src/Nodes/ListItem/Vue/index.vue
@@ -0,0 +1,111 @@
+
+
+
+
+
+ Toggle bullet list
+
+
+ Toggle ordered list
+
+
+ Split list item
+
+
+ Sink list item
+
+
+ Lift list item
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/ListItem/index.spec.ts b/demos/src/Nodes/ListItem/index.spec.ts
new file mode 100644
index 0000000..dbff954
--- /dev/null
+++ b/demos/src/Nodes/ListItem/index.spec.ts
@@ -0,0 +1,48 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'ListItem'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Nodes'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await setEditorContent(page, '')
+ const editor = await getEditor(page)
+ await editor.click()
+ await editor.evaluate((el: any) => el.editor.commands.focus('end'))
+ })
+
+ test('adds a new list item on Enter', async ({ page }) => {
+ const editor = await getEditor(page)
+ await page.keyboard.press('Enter')
+ await editor.type('2nd Item')
+ await expect(page.locator('.tiptap li').nth(0)).toContainText('Example Text')
+ await expect(page.locator('.tiptap li').nth(1)).toContainText('2nd Item')
+ })
+
+ test('sinks the list item on Tab', async ({ page }) => {
+ const editor = await getEditor(page)
+ await page.keyboard.press('Enter')
+ await page.keyboard.press('Tab')
+ await editor.type('2nd Level')
+ await expect(page.locator('.tiptap li:nth-child(1) li')).toContainText('2nd Level')
+ })
+
+ test('lifts the list item on Shift+Tab', async ({ page }) => {
+ const editor = await getEditor(page)
+ await page.keyboard.press('Enter')
+ await page.keyboard.press('Tab')
+ await page.keyboard.press('Shift+Tab')
+ await editor.type('1st Level')
+ await expect(page.locator('.tiptap li').nth(1)).toContainText('1st Level')
+ })
+ })
+ })
+})
diff --git a/demos/src/Nodes/Mention/React/MentionList.jsx b/demos/src/Nodes/Mention/React/MentionList.jsx
new file mode 100644
index 0000000..4686906
--- /dev/null
+++ b/demos/src/Nodes/Mention/React/MentionList.jsx
@@ -0,0 +1,75 @@
+import './MentionList.scss'
+
+import React, { useEffect, useImperativeHandle, useState } from 'react'
+
+export default props => {
+ const [selectedIndex, setSelectedIndex] = useState(0)
+
+ const selectItem = index => {
+ const item = props.items[index]
+
+ if (item) {
+ props.command({ id: item })
+ }
+ }
+
+ const upHandler = () => {
+ setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length)
+ }
+
+ const downHandler = () => {
+ setSelectedIndex((selectedIndex + 1) % props.items.length)
+ }
+
+ const enterHandler = () => {
+ selectItem(selectedIndex)
+ }
+
+ useEffect(() => setSelectedIndex(0), [props.items])
+
+ useImperativeHandle(props.ref, () => ({
+ onKeyDown: ({ event }) => {
+ if (event.key === 'ArrowUp') {
+ upHandler()
+ return true
+ }
+
+ if (event.key === 'ArrowDown') {
+ downHandler()
+ return true
+ }
+
+ if (event.key === 'Enter') {
+ enterHandler()
+ return true
+ }
+
+ return false
+ },
+ }))
+
+ let content
+
+ if (props.loading) {
+ content = (
+
+
+ Loading…
+
+ )
+ } else if (props.items.length) {
+ content = props.items.map((item, index) => (
+ selectItem(index)}
+ >
+ {item}
+
+ ))
+ } else {
+ content = No result
+ }
+
+ return {content}
+}
diff --git a/demos/src/Nodes/Mention/React/MentionList.scss b/demos/src/Nodes/Mention/React/MentionList.scss
new file mode 100644
index 0000000..945b091
--- /dev/null
+++ b/demos/src/Nodes/Mention/React/MentionList.scss
@@ -0,0 +1,63 @@
+/* Dropdown menu */
+.dropdown-menu {
+ background: var(--white);
+ border: 1px solid var(--gray-1);
+ border-radius: 0.7rem;
+ box-shadow: var(--shadow);
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ overflow: auto;
+ padding: 0.4rem;
+ position: relative;
+
+ button {
+ align-items: center;
+ background-color: transparent;
+ display: flex;
+ gap: 0.25rem;
+ text-align: left;
+ width: 100%;
+
+ &:hover,
+ &:hover.is-selected {
+ background-color: var(--gray-3);
+ }
+
+ &.is-selected {
+ background-color: var(--gray-2);
+ }
+ }
+
+ .item {
+ &.loading {
+ align-items: center;
+ display: flex;
+ gap: 0.5rem;
+ padding: 0.25rem 0.5rem;
+ }
+ }
+}
+
+/* Loading dot animation */
+.loading-dot {
+ animation: loading-pulse 1.2s ease-in-out infinite;
+ background-color: var(--gray-4);
+ border-radius: 50%;
+ display: inline-block;
+ height: 0.5rem;
+ width: 0.5rem;
+}
+
+@keyframes loading-pulse {
+ 0%,
+ 80%,
+ 100% {
+ opacity: 0.3;
+ transform: scale(0.8);
+ }
+ 40% {
+ opacity: 1;
+ transform: scale(1.2);
+ }
+}
diff --git a/demos/src/Nodes/Mention/React/index.html b/demos/src/Nodes/Mention/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Mention/React/index.jsx b/demos/src/Nodes/Mention/React/index.jsx
new file mode 100644
index 0000000..8d40a8d
--- /dev/null
+++ b/demos/src/Nodes/Mention/React/index.jsx
@@ -0,0 +1,39 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Mention from '@tiptap/extension-mention'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+import suggestion from './suggestion.js'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Mention.configure({
+ HTMLAttributes: {
+ class: 'mention',
+ },
+ suggestion,
+ }),
+ ],
+ content: `
+ Hi everyone! Don’t forget the daily stand up at 8 AM.
+ Would you mind to share what you’ve been working on lately? We fear not much happened since Dirty Dancing.
+
Let’s go through your most important points quickly.
+ I have a meeting with and don’t want to come late.
+ – Thanks, your big boss
+ `,
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return
+}
diff --git a/demos/src/Nodes/Mention/React/styles.scss b/demos/src/Nodes/Mention/React/styles.scss
new file mode 100644
index 0000000..10fcd73
--- /dev/null
+++ b/demos/src/Nodes/Mention/React/styles.scss
@@ -0,0 +1,17 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ .mention {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ box-decoration-break: clone;
+ color: var(--purple);
+ padding: 0.1rem 0.3rem;
+ &::after {
+ content: '\200B';
+ }
+ }
+}
diff --git a/demos/src/Nodes/Mention/React/suggestion.js b/demos/src/Nodes/Mention/React/suggestion.js
new file mode 100644
index 0000000..49dfa7b
--- /dev/null
+++ b/demos/src/Nodes/Mention/React/suggestion.js
@@ -0,0 +1,103 @@
+import { ReactRenderer } from '@tiptap/react'
+
+import { updatePosition } from '../../../utils/updatePosition.js'
+import MentionList from './MentionList.jsx'
+
+const allItems = [
+ 'Lea Thompson',
+ 'Cyndi Lauper',
+ 'Tom Cruise',
+ 'Madonna',
+ 'Jerry Hall',
+ 'Joan Collins',
+ 'Winona Ryder',
+ 'Christina Applegate',
+ 'Alyssa Milano',
+ 'Molly Ringwald',
+ 'Ally Sheedy',
+ 'Debbie Harry',
+ 'Olivia Newton-John',
+ 'Elton John',
+ 'Michael J. Fox',
+ 'Axl Rose',
+ 'Emilio Estevez',
+ 'Ralph Macchio',
+ 'Rob Lowe',
+ 'Jennifer Grey',
+ 'Mickey Rourke',
+ 'John Cusack',
+ 'Matthew Broderick',
+ 'Justine Bateman',
+ 'Lisa Bonet',
+]
+
+export default {
+ items: async ({ query, signal }) => {
+ // Simulate an async API call
+ await new Promise(resolve => {
+ setTimeout(resolve, 300)
+ })
+
+ // Bail out if the request was aborted (e.g. user kept typing or closed the popup)
+ if (signal.aborted) {
+ return []
+ }
+
+ // find items that include this character query
+ return allItems.filter(item => item.toLowerCase().includes(query.toLowerCase())).slice(0, 5)
+ },
+
+ minQueryLength: 2,
+
+ debounce: 300,
+
+ initialItems: ['Lea Thompson', 'Cyndi Lauper', 'Tom Cruise'],
+
+ render: () => {
+ let component
+
+ return {
+ onStart: props => {
+ component = new ReactRenderer(MentionList, {
+ props,
+ editor: props.editor,
+ })
+
+ if (!props.clientRect) {
+ return
+ }
+
+ component.element.style.position = 'absolute'
+
+ document.body.appendChild(component.element)
+
+ updatePosition({ editor: props.editor, element: component.element })
+ },
+
+ onUpdate(props) {
+ component.updateProps(props)
+
+ if (!props.clientRect) {
+ return
+ }
+
+ updatePosition({ editor: props.editor, element: component.element })
+ },
+
+ onKeyDown(props) {
+ if (props.event.key === 'Escape') {
+ component.destroy()
+
+ return true
+ }
+
+ return component.ref?.onKeyDown(props)
+ },
+
+ onExit() {
+ component.element.remove()
+ component.destroy()
+ },
+ }
+ },
+}
diff --git a/demos/src/Nodes/Mention/Vue/MentionList.vue b/demos/src/Nodes/Mention/Vue/MentionList.vue
new file mode 100644
index 0000000..a3f9205
--- /dev/null
+++ b/demos/src/Nodes/Mention/Vue/MentionList.vue
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/Mention/Vue/index.html b/demos/src/Nodes/Mention/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Mention/Vue/index.vue b/demos/src/Nodes/Mention/Vue/index.vue
new file mode 100644
index 0000000..340a8d9
--- /dev/null
+++ b/demos/src/Nodes/Mention/Vue/index.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/Mention/Vue/suggestion.js b/demos/src/Nodes/Mention/Vue/suggestion.js
new file mode 100644
index 0000000..3b1ec66
--- /dev/null
+++ b/demos/src/Nodes/Mention/Vue/suggestion.js
@@ -0,0 +1,106 @@
+import { VueRenderer } from '@tiptap/vue'
+
+import { updatePosition } from '../../../utils/updatePosition.js'
+import MentionList from './MentionList.vue'
+
+const allItems = [
+ 'Lea Thompson',
+ 'Cyndi Lauper',
+ 'Tom Cruise',
+ 'Madonna',
+ 'Jerry Hall',
+ 'Joan Collins',
+ 'Winona Ryder',
+ 'Christina Applegate',
+ 'Alyssa Milano',
+ 'Molly Ringwald',
+ 'Ally Sheedy',
+ 'Debbie Harry',
+ 'Olivia Newton-John',
+ 'Elton John',
+ 'Michael J. Fox',
+ 'Axl Rose',
+ 'Emilio Estevez',
+ 'Ralph Macchio',
+ 'Rob Lowe',
+ 'Jennifer Grey',
+ 'Mickey Rourke',
+ 'John Cusack',
+ 'Matthew Broderick',
+ 'Justine Bateman',
+ 'Lisa Bonet',
+]
+
+export default {
+ items: async ({ query, signal }) => {
+ // Simulate an async API call
+ await new Promise(resolve => {
+ setTimeout(resolve, 300)
+ })
+
+ // Bail out if the request was aborted (e.g. user kept typing or closed the popup)
+ if (signal.aborted) {
+ return []
+ }
+
+ return allItems.filter(item => item.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5)
+ },
+
+ minQueryLength: 2,
+
+ debounce: 300,
+
+ initialItems: ['Lea Thompson', 'Cyndi Lauper', 'Tom Cruise'],
+
+ render: () => {
+ let component
+
+ return {
+ onStart: props => {
+ if (component) {
+ component.destroy()
+ }
+
+ component = new VueRenderer(MentionList, {
+ props,
+ editor: props.editor,
+ })
+
+ if (!props.clientRect) {
+ return
+ }
+
+ component.element.style.position = 'absolute'
+
+ document.body.appendChild(component.element)
+
+ updatePosition({ editor: props.editor, element: component.element })
+ },
+
+ onUpdate(props) {
+ component.updateProps(props)
+
+ if (!props.clientRect) {
+ return
+ }
+
+ updatePosition({ editor: props.editor, element: component.element })
+ },
+
+ onKeyDown(props) {
+ if (props.event.key === 'Escape') {
+ component.destroy()
+
+ return true
+ }
+
+ return component.ref?.onKeyDown(props)
+ },
+
+ onExit() {
+ component.element.remove()
+ component.destroy()
+ },
+ }
+ },
+}
diff --git a/demos/src/Nodes/Mention/index.spec.ts b/demos/src/Nodes/Mention/index.spec.ts
new file mode 100644
index 0000000..c4d7bd1
--- /dev/null
+++ b/demos/src/Nodes/Mention/index.spec.ts
@@ -0,0 +1,172 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoPath = '/src/Nodes'
+const demoName = 'Mention'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ test.describe('React', () => {
+ const fullDemoPath = `${demoPath}/${demoName}/React/`
+
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ })
+
+ test('inserts a mention', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) =>
+ el.editor.commands.setContent(
+ '@John Doe
',
+ ),
+ )
+ await expect(page.locator('.tiptap span.mention')).toHaveAttribute('data-id', '1')
+ await expect(page.locator('.tiptap span.mention')).toContainText('@John Doe')
+ })
+
+ test('opens a dropdown menu when @ is typed', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('@')
+ await expect(page.locator('.dropdown-menu')).toBeVisible()
+ })
+
+ test('shows initial items in the dropdown menu', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('@')
+ // With minQueryLength=2 and empty query, initialItems are shown immediately
+ await expect(page.locator('.dropdown-menu button')).toHaveCount(3)
+ await expect(page.locator('.dropdown-menu button').nth(0)).toContainText('Lea Thompson')
+ await expect(page.locator('.dropdown-menu button').nth(0)).toHaveClass(/is-selected/)
+ })
+
+ test('inserts Cyndi Lauper mention when clicked', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('@')
+ await page.locator('.dropdown-menu button').nth(1).click()
+ await expect(page.locator('.tiptap span.mention')).toHaveAttribute('data-id', 'Cyndi Lauper')
+ })
+
+ test('closes the dropdown menu on Escape', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('@')
+ await expect(page.locator('.dropdown-menu')).toBeVisible()
+ await page.keyboard.press('Escape')
+ await expect(page.locator('.dropdown-menu')).toHaveCount(0)
+ })
+
+ test('inserts Tom Cruise via arrow keys and Enter', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('@')
+ await page.keyboard.press('ArrowDown')
+ await page.keyboard.press('ArrowDown')
+ await page.keyboard.press('Enter')
+ await expect(page.locator('.tiptap span.mention')).toHaveAttribute('data-id', 'Tom Cruise')
+ })
+
+ test('shows loading state while fetching then results', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ // Type a query that satisfies minQueryLength (>= 2 chars)
+ // '@al' matches both 'Alyssa Milano' and 'Ally Sheedy'
+ await page.keyboard.type('@al')
+ // Wait for async fetch to complete (debounce 300ms + fetch 300ms)
+ await expect(page.locator('.dropdown-menu button')).toHaveCount(5, { timeout: 2000 })
+ await expect(page.locator('.dropdown-menu button')).toContainText([
+ 'Jerry Hall',
+ 'Alyssa Milano',
+ 'Molly Ringwald',
+ 'Ally Sheedy',
+ 'Ralph Macchio',
+ ])
+ })
+
+ test('shows "No result" for unknown query', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('@nonexistent')
+ // Wait for async fetch to complete
+ await expect(page.locator('.dropdown-menu')).toContainText('No result', { timeout: 2000 })
+ })
+
+ test('filters to a single match for "@mado"', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('@mado')
+ // Wait for async fetch to complete
+ await expect(page.locator('.dropdown-menu button')).toHaveCount(1, { timeout: 2000 })
+ await expect(page.locator('.dropdown-menu button').first()).toContainText('Madonna')
+ })
+
+ test('filters to a single match for "@molly"', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('@molly')
+ // Wait for async fetch to complete
+ await expect(page.locator('.dropdown-menu button')).toHaveCount(1, { timeout: 2000 })
+ await expect(page.locator('.dropdown-menu button').first()).toContainText('Molly Ringwald')
+ })
+
+ test('inserts Madonna for "@mado" and Enter', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('@mado')
+ // Wait for async results to appear before pressing Enter
+ await expect(page.locator('.dropdown-menu button')).toHaveCount(1, { timeout: 2000 })
+ await page.keyboard.press('Enter')
+ await expect(page.locator('.tiptap span.mention')).toHaveAttribute('data-id', 'Madonna')
+ })
+
+ test('respects minQueryLength — short query shows initial items', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ // Type a single character (below minQueryLength=2) → shows initialItems, not filtered results
+ await page.keyboard.type('@a')
+ await expect(page.locator('.dropdown-menu button')).toHaveCount(3)
+ await expect(page.locator('.dropdown-menu button').first()).toContainText('Lea Thompson')
+ })
+
+ test('shows filtered results after reaching minQueryLength', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ // Start with short query (initial items)
+ await page.keyboard.type('@a')
+ await expect(page.locator('.dropdown-menu button')).toHaveCount(3)
+ // Extend query past minQueryLength → async fetch triggers
+ // '@al' matches both 'Alyssa Milano' and 'Ally Sheedy'
+ await page.keyboard.type('l')
+ await expect(page.locator('.dropdown-menu button')).toHaveCount(5, { timeout: 2000 })
+ await expect(page.locator('.dropdown-menu button')).toContainText([
+ 'Jerry Hall',
+ 'Alyssa Milano',
+ 'Molly Ringwald',
+ 'Ally Sheedy',
+ 'Ralph Macchio',
+ ])
+ })
+ })
+
+ test.describe('Vue', () => {
+ test('loads the editor', async ({ page }) => {
+ await page.goto(`${demoPath}/${demoName}/Vue/`)
+ const editor = await getEditor(page)
+ await expect(editor).toBeVisible()
+ })
+ })
+})
diff --git a/demos/src/Nodes/OrderedList/React/index.html b/demos/src/Nodes/OrderedList/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/OrderedList/React/index.jsx b/demos/src/Nodes/OrderedList/React/index.jsx
new file mode 100644
index 0000000..2ea0953
--- /dev/null
+++ b/demos/src/Nodes/OrderedList/React/index.jsx
@@ -0,0 +1,83 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import { ListItem, OrderedList } from '@tiptap/extension-list'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, OrderedList, ListItem],
+ content: `
+
+ A list item
+ And another one
+
+
+
+ This item starts at 5
+ And another one
+
+
+
+ Lowercase alphabetical list
+ Second item
+
+
+
+ Uppercase roman numerals
+ Second item
+
+ `,
+ })
+ const editorState = useEditorState({
+ editor,
+ selector: ctx => ({
+ isOrderedList: ctx.editor.isActive('orderedList') ?? false,
+ canSplitListItem: ctx.editor.can().splitListItem('listItem') ?? false,
+ canSinkListItem: ctx.editor.can().sinkListItem('listItem') ?? false,
+ canLiftListItem: ctx.editor.can().liftListItem('listItem') ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().toggleOrderedList().run()}
+ className={editorState.isOrderedList ? 'is-active' : ''}
+ >
+ Toggle ordered list
+
+ editor.chain().focus().splitListItem('listItem').run()}
+ disabled={!editorState.canSplitListItem}
+ >
+ Split list item
+
+ editor.chain().focus().sinkListItem('listItem').run()}
+ disabled={!editorState.canSinkListItem}
+ >
+ Sink list item
+
+ editor.chain().focus().liftListItem('listItem').run()}
+ disabled={!editorState.canLiftListItem}
+ >
+ Lift list item
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Nodes/OrderedList/React/styles.scss b/demos/src/Nodes/OrderedList/React/styles.scss
new file mode 100644
index 0000000..95fe10c
--- /dev/null
+++ b/demos/src/Nodes/OrderedList/React/styles.scss
@@ -0,0 +1,18 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+}
diff --git a/demos/src/Nodes/OrderedList/Vue/index.html b/demos/src/Nodes/OrderedList/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/OrderedList/Vue/index.vue b/demos/src/Nodes/OrderedList/Vue/index.vue
new file mode 100644
index 0000000..d47abf1
--- /dev/null
+++ b/demos/src/Nodes/OrderedList/Vue/index.vue
@@ -0,0 +1,105 @@
+
+
+
+
+
+ Toggle ordered list
+
+
+ Split list item
+
+
+ Sink list item
+
+
+ Lift list item
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/OrderedList/index.spec.ts b/demos/src/Nodes/OrderedList/index.spec.ts
new file mode 100644
index 0000000..de494d2
--- /dev/null
+++ b/demos/src/Nodes/OrderedList/index.spec.ts
@@ -0,0 +1,118 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'OrderedList'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Nodes'
+
+const isMac = process.platform === 'darwin'
+const mod = isMac ? 'Meta' : 'Control'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await setEditorContent(page, 'Example Text
')
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ })
+
+ test('parses ordered lists correctly', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text
')
+ return el.editor.getHTML()
+ })
+ expect(html).toBe('Example Text
')
+ })
+
+ test('parses ordered lists without paragraphs correctly', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Example Text ')
+ return el.editor.getHTML()
+ })
+ expect(html).toBe('Example Text
')
+ })
+
+ test('button makes the selected line an ordered list item', async ({ page }) => {
+ await expect(page.locator('.tiptap ol')).toHaveCount(0)
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap ol li')).toContainText('Example Text')
+ })
+
+ test('button toggles the ordered list', async ({ page }) => {
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap ol')).toContainText('Example Text')
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap ol')).toHaveCount(0)
+ })
+
+ test('keyboard shortcut creates an ordered list', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => {
+ el.editor.commands.focus()
+ el.editor.commands.selectAll()
+ })
+ await editor.press(`${mod}+Shift+7`)
+ await expect(page.locator('.tiptap ol li')).toContainText('Example Text')
+ })
+
+ test('creates an ordered list from "1."', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('1. List Item 1')
+ await page.keyboard.press('Enter')
+ await editor.type('List Item 2')
+ await expect(page.locator('.tiptap li').nth(0)).toContainText('List Item 1')
+ await expect(page.locator('.tiptap li').nth(1)).toContainText('List Item 2')
+ })
+
+ test('removes the ordered list after backspace', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('1. ')
+ await page.keyboard.press('Backspace')
+ await editor.type('Example')
+ await expect(page.locator('.tiptap p')).toContainText('1. Example')
+ })
+
+ // ── Type attribute tests ──
+
+ test('preserves type="a" attribute in HTML output', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent(
+ 'Item A
Item B
',
+ )
+ return el.editor.getHTML()
+ })
+ expect(html).toContain('type="a"')
+ })
+
+ test('preserves type="I" attribute in HTML output', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Item 1
')
+ return el.editor.getHTML()
+ })
+ expect(html).toContain('type="I"')
+ })
+
+ test('does not render type attribute for default type', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Item
')
+ return el.editor.getHTML()
+ })
+ expect(html).not.toContain('type')
+ })
+ })
+ })
+})
diff --git a/demos/src/Nodes/Paragraph/React/index.html b/demos/src/Nodes/Paragraph/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Paragraph/React/index.jsx b/demos/src/Nodes/Paragraph/React/index.jsx
new file mode 100644
index 0000000..ebee981
--- /dev/null
+++ b/demos/src/Nodes/Paragraph/React/index.jsx
@@ -0,0 +1,22 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text],
+ content: `
+ The Paragraph extension is not required, but it’s very likely you want to use it. It’s needed to write paragraphs of text. 🤓
+ `,
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return
+}
diff --git a/demos/src/Nodes/Paragraph/React/styles.scss b/demos/src/Nodes/Paragraph/React/styles.scss
new file mode 100644
index 0000000..afd5ba2
--- /dev/null
+++ b/demos/src/Nodes/Paragraph/React/styles.scss
@@ -0,0 +1,6 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
diff --git a/demos/src/Nodes/Paragraph/Vue/index.html b/demos/src/Nodes/Paragraph/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Paragraph/Vue/index.vue b/demos/src/Nodes/Paragraph/Vue/index.vue
new file mode 100644
index 0000000..12e3bd9
--- /dev/null
+++ b/demos/src/Nodes/Paragraph/Vue/index.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/Paragraph/index.spec.ts b/demos/src/Nodes/Paragraph/index.spec.ts
new file mode 100644
index 0000000..b13933c
--- /dev/null
+++ b/demos/src/Nodes/Paragraph/index.spec.ts
@@ -0,0 +1,60 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'Paragraph'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Nodes'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ })
+
+ test('parses paragraphs correctly', async ({ page }) => {
+ const editor = await getEditor(page)
+ const results = await editor.evaluate((el: any) => {
+ const inputs = [
+ 'Example Text
',
+ 'Example Text
',
+ 'Example Text
',
+ ]
+ return inputs.map(input => {
+ el.editor.commands.setContent(input)
+ return el.editor.getHTML()
+ })
+ })
+ results.forEach(html => expect(html).toBe('Example Text
'))
+ })
+
+ test('wraps text in a paragraph by default', async ({ page }) => {
+ await page.keyboard.type('Example Text')
+ await expect(page.locator('.tiptap p')).toContainText('Example Text')
+ })
+
+ test('enter creates a new paragraph', async ({ page }) => {
+ const editor = await getEditor(page)
+ await page.keyboard.type('First Paragraph')
+ await page.keyboard.press('Enter')
+ await editor.type('Second Paragraph')
+ await expect(page.locator('.tiptap p')).toHaveCount(2)
+ await expect(page.locator('.tiptap p').nth(0)).toContainText('First Paragraph')
+ await expect(page.locator('.tiptap p').nth(1)).toContainText('Second Paragraph')
+ })
+
+ test('backspace removes the second paragraph', async ({ page }) => {
+ await page.keyboard.press('Enter')
+ await expect(page.locator('.tiptap p')).toHaveCount(2)
+ await page.keyboard.press('Backspace')
+ await expect(page.locator('.tiptap p')).toHaveCount(1)
+ })
+ })
+ })
+})
diff --git a/demos/src/Nodes/Table/React/index.html b/demos/src/Nodes/Table/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Table/React/index.jsx b/demos/src/Nodes/Table/React/index.jsx
new file mode 100644
index 0000000..70dede7
--- /dev/null
+++ b/demos/src/Nodes/Table/React/index.jsx
@@ -0,0 +1,98 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import { TableKit } from '@tiptap/extension-table'
+import Text from '@tiptap/extension-text'
+import { Gapcursor } from '@tiptap/extensions'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Gapcursor,
+ TableKit.configure({
+ table: { resizable: true },
+ }),
+ ],
+ content: `
+
+
+
+ Name
+ Description
+
+
+ Cyndi Lauper
+ Singer
+ Songwriter
+ Actress
+
+
+
+ `,
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+
+ editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
+ }
+ >
+ Insert table
+
+ editor.chain().focus().addColumnBefore().run()}>
+ Add column before
+
+ editor.chain().focus().addColumnAfter().run()}>
+ Add column after
+
+ editor.chain().focus().deleteColumn().run()}>Delete column
+ editor.chain().focus().addRowBefore().run()}>
+ Add row before
+
+ editor.chain().focus().addRowAfter().run()}>Add row after
+ editor.chain().focus().deleteRow().run()}>Delete row
+ editor.chain().focus().deleteTable().run()}>Delete table
+ editor.chain().focus().mergeCells().run()}>Merge cells
+ editor.chain().focus().splitCell().run()}>Split cell
+ editor.chain().focus().toggleHeaderColumn().run()}>
+ Toggle header column
+
+ editor.chain().focus().toggleHeaderRow().run()}>
+ Toggle header row
+
+ editor.chain().focus().toggleHeaderCell().run()}>
+ Toggle header cell
+
+ editor.chain().focus().mergeOrSplit().run()}>
+ Merge or split
+
+ editor.chain().focus().setCellAttribute('colspan', 2).run()}>
+ Set cell attribute
+
+ editor.chain().focus().fixTables().run()}>Fix tables
+ editor.chain().focus().goToNextCell().run()}>
+ Go to next cell
+
+ editor.chain().focus().goToPreviousCell().run()}>
+ Go to previous cell
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Nodes/Table/React/styles.scss b/demos/src/Nodes/Table/React/styles.scss
new file mode 100644
index 0000000..c6d6752
--- /dev/null
+++ b/demos/src/Nodes/Table/React/styles.scss
@@ -0,0 +1,67 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* Table-specific styling */
+ table {
+ border-collapse: collapse;
+ margin: 0;
+ overflow: hidden;
+ table-layout: fixed;
+ width: 100%;
+
+ td,
+ th {
+ border: 1px solid var(--gray-3);
+ box-sizing: border-box;
+ min-width: 1em;
+ padding: 6px 8px;
+ position: relative;
+ vertical-align: top;
+
+ > * {
+ margin-bottom: 0;
+ }
+ }
+
+ th {
+ background-color: var(--gray-1);
+ font-weight: bold;
+ text-align: left;
+ }
+
+ .selectedCell:after {
+ background: var(--gray-2);
+ content: '';
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ pointer-events: none;
+ position: absolute;
+ z-index: 2;
+ }
+
+ .column-resize-handle {
+ background-color: var(--purple);
+ bottom: -2px;
+ pointer-events: none;
+ position: absolute;
+ right: -2px;
+ top: 0;
+ width: 4px;
+ }
+ }
+
+ .tableWrapper {
+ margin: 1.5rem 0;
+ overflow-x: auto;
+ }
+
+ &.resize-cursor {
+ cursor: ew-resize;
+ cursor: col-resize;
+ }
+}
diff --git a/demos/src/Nodes/Table/Vue/index.html b/demos/src/Nodes/Table/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Table/Vue/index.vue b/demos/src/Nodes/Table/Vue/index.vue
new file mode 100644
index 0000000..ba38301
--- /dev/null
+++ b/demos/src/Nodes/Table/Vue/index.vue
@@ -0,0 +1,164 @@
+
+
+
+
+
+ Insert table
+
+ Add column before
+ Add column after
+ Delete column
+ Add row before
+ Add row after
+ Delete row
+ Delete table
+ Merge cells
+ Split cell
+
+ Toggle header column
+
+ Toggle header row
+ Toggle header cell
+ Merge or split
+
+ Set cell attribute
+
+ Fix tables
+ Go to next cell
+
+ Go to previous cell
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/TaskItem/React/index.html b/demos/src/Nodes/TaskItem/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/TaskItem/React/index.jsx b/demos/src/Nodes/TaskItem/React/index.jsx
new file mode 100644
index 0000000..639a171
--- /dev/null
+++ b/demos/src/Nodes/TaskItem/React/index.jsx
@@ -0,0 +1,76 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import { TaskItem, TaskList } from '@tiptap/extension-list'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ TaskList,
+ TaskItem.configure({
+ nested: true,
+ }),
+ ],
+ content: `
+
+ A list item
+ And another one
+
+ `,
+ })
+ const editorState = useEditorState({
+ editor,
+ selector: ctx => ({
+ isTaskList: ctx.editor.isActive('taskList') ?? false,
+ canSplitListItem: ctx.editor.can().splitListItem('taskItem') ?? false,
+ canSinkListItem: ctx.editor.can().sinkListItem('taskItem') ?? false,
+ canLiftListItem: ctx.editor.can().liftListItem('taskItem') ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().toggleTaskList().run()}
+ className={editorState.isTaskList ? 'is-active' : ''}
+ >
+ Toggle task list
+
+ editor.chain().focus().splitListItem('taskItem').run()}
+ disabled={!editorState.canSplitListItem}
+ >
+ Split list item
+
+ editor.chain().focus().sinkListItem('taskItem').run()}
+ disabled={!editorState.canSinkListItem}
+ >
+ Sink list item
+
+ editor.chain().focus().liftListItem('taskItem').run()}
+ disabled={!editorState.canLiftListItem}
+ >
+ Lift list item
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Nodes/TaskItem/React/styles.scss b/demos/src/Nodes/TaskItem/React/styles.scss
new file mode 100644
index 0000000..98fdc62
--- /dev/null
+++ b/demos/src/Nodes/TaskItem/React/styles.scss
@@ -0,0 +1,48 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Task list specific styles */
+ ul[data-type='taskList'] {
+ list-style: none;
+ margin-left: 0;
+ padding: 0;
+
+ li {
+ align-items: flex-start;
+ display: flex;
+
+ > label {
+ flex: 0 0 auto;
+ margin-right: 0.5rem;
+ user-select: none;
+ }
+
+ > div {
+ flex: 1 1 auto;
+ }
+ }
+
+ input[type='checkbox'] {
+ cursor: pointer;
+ }
+
+ ul[data-type='taskList'] {
+ margin: 0;
+ }
+ }
+}
diff --git a/demos/src/Nodes/TaskItem/Vue/index.html b/demos/src/Nodes/TaskItem/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/TaskItem/Vue/index.vue b/demos/src/Nodes/TaskItem/Vue/index.vue
new file mode 100644
index 0000000..bd8ae9a
--- /dev/null
+++ b/demos/src/Nodes/TaskItem/Vue/index.vue
@@ -0,0 +1,128 @@
+
+
+
+
+
+ Toggle task list
+
+
+ Split list item
+
+
+ Sink list item
+
+
+ Lift list item
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/TaskList/React/index.html b/demos/src/Nodes/TaskList/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/TaskList/React/index.jsx b/demos/src/Nodes/TaskList/React/index.jsx
new file mode 100644
index 0000000..639a171
--- /dev/null
+++ b/demos/src/Nodes/TaskList/React/index.jsx
@@ -0,0 +1,76 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import { TaskItem, TaskList } from '@tiptap/extension-list'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ TaskList,
+ TaskItem.configure({
+ nested: true,
+ }),
+ ],
+ content: `
+
+ A list item
+ And another one
+
+ `,
+ })
+ const editorState = useEditorState({
+ editor,
+ selector: ctx => ({
+ isTaskList: ctx.editor.isActive('taskList') ?? false,
+ canSplitListItem: ctx.editor.can().splitListItem('taskItem') ?? false,
+ canSinkListItem: ctx.editor.can().sinkListItem('taskItem') ?? false,
+ canLiftListItem: ctx.editor.can().liftListItem('taskItem') ?? false,
+ }),
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ editor.chain().focus().toggleTaskList().run()}
+ className={editorState.isTaskList ? 'is-active' : ''}
+ >
+ Toggle task list
+
+ editor.chain().focus().splitListItem('taskItem').run()}
+ disabled={!editorState.canSplitListItem}
+ >
+ Split list item
+
+ editor.chain().focus().sinkListItem('taskItem').run()}
+ disabled={!editorState.canSinkListItem}
+ >
+ Sink list item
+
+ editor.chain().focus().liftListItem('taskItem').run()}
+ disabled={!editorState.canLiftListItem}
+ >
+ Lift list item
+
+
+
+
+
+ >
+ )
+}
diff --git a/demos/src/Nodes/TaskList/React/styles.scss b/demos/src/Nodes/TaskList/React/styles.scss
new file mode 100644
index 0000000..98fdc62
--- /dev/null
+++ b/demos/src/Nodes/TaskList/React/styles.scss
@@ -0,0 +1,48 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Task list specific styles */
+ ul[data-type='taskList'] {
+ list-style: none;
+ margin-left: 0;
+ padding: 0;
+
+ li {
+ align-items: flex-start;
+ display: flex;
+
+ > label {
+ flex: 0 0 auto;
+ margin-right: 0.5rem;
+ user-select: none;
+ }
+
+ > div {
+ flex: 1 1 auto;
+ }
+ }
+
+ input[type='checkbox'] {
+ cursor: pointer;
+ }
+
+ ul[data-type='taskList'] {
+ margin: 0;
+ }
+ }
+}
diff --git a/demos/src/Nodes/TaskList/Vue/index.html b/demos/src/Nodes/TaskList/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/TaskList/Vue/index.vue b/demos/src/Nodes/TaskList/Vue/index.vue
new file mode 100644
index 0000000..bd8ae9a
--- /dev/null
+++ b/demos/src/Nodes/TaskList/Vue/index.vue
@@ -0,0 +1,128 @@
+
+
+
+
+
+ Toggle task list
+
+
+ Split list item
+
+
+ Sink list item
+
+
+ Lift list item
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/TaskList/index.spec.ts b/demos/src/Nodes/TaskList/index.spec.ts
new file mode 100644
index 0000000..f1d8c15
--- /dev/null
+++ b/demos/src/Nodes/TaskList/index.spec.ts
@@ -0,0 +1,86 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor, setEditorContent } from '../../../test/helpers.js'
+
+const demoName = 'TaskList'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Nodes'
+
+const isMac = process.platform === 'darwin'
+const mod = isMac ? 'Meta' : 'Control'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ await setEditorContent(page, 'Example Text
')
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.selectAll())
+ })
+
+ test('parses task lists correctly', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent(
+ '',
+ )
+ return el.editor.getHTML()
+ })
+ expect(html).toBe(
+ '',
+ )
+ })
+
+ test('button makes selected line a task list item', async ({ page }) => {
+ await expect(page.locator('.tiptap ul')).toHaveCount(0)
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap ul[data-type="taskList"] li')).toContainText(
+ 'Example Text',
+ )
+ })
+
+ test('button toggles the task list', async ({ page }) => {
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap ul[data-type="taskList"]')).toContainText('Example Text')
+ await page.locator('button').first().click()
+ await expect(page.locator('.tiptap ul')).toHaveCount(0)
+ })
+
+ test('keyboard shortcut creates a task list', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => {
+ el.editor.commands.focus()
+ el.editor.commands.selectAll()
+ })
+ await editor.press(`${mod}+Shift+9`)
+ await expect(page.locator('.tiptap ul li')).toContainText('Example Text')
+ })
+
+ test('creates a task list from "[ ]"', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('[ ] List Item 1')
+ await page.keyboard.press('Enter')
+ await editor.type('List Item 2')
+ await expect(page.locator('.tiptap li').nth(0)).toContainText('List Item 1')
+ await expect(page.locator('.tiptap li').nth(0)).toHaveAttribute('data-checked', 'false')
+ await expect(page.locator('.tiptap li').nth(1)).toContainText('List Item 2')
+ })
+
+ test('creates a checked task list from "[x]"', async ({ page }) => {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ await page.keyboard.type('[x] List Item 1')
+ await page.keyboard.press('Enter')
+ await editor.type('List Item 2')
+ await expect(page.locator('.tiptap li').nth(0)).toContainText('List Item 1')
+ await expect(page.locator('.tiptap li').nth(0)).toHaveAttribute('data-checked', 'true')
+ })
+ })
+ })
+})
diff --git a/demos/src/Nodes/Text/React/index.html b/demos/src/Nodes/Text/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Text/React/index.jsx b/demos/src/Nodes/Text/React/index.jsx
new file mode 100644
index 0000000..ad53c71
--- /dev/null
+++ b/demos/src/Nodes/Text/React/index.jsx
@@ -0,0 +1,22 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text],
+ content: `
+ The Text extension is required, at least if you want to have text in your text editor and that’s very likely.
+ `,
+ })
+
+ if (!editor) {
+ return null
+ }
+
+ return
+}
diff --git a/demos/src/Nodes/Text/React/styles.scss b/demos/src/Nodes/Text/React/styles.scss
new file mode 100644
index 0000000..afd5ba2
--- /dev/null
+++ b/demos/src/Nodes/Text/React/styles.scss
@@ -0,0 +1,6 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
diff --git a/demos/src/Nodes/Text/Vue/index.html b/demos/src/Nodes/Text/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Text/Vue/index.vue b/demos/src/Nodes/Text/Vue/index.vue
new file mode 100644
index 0000000..e3e7a2a
--- /dev/null
+++ b/demos/src/Nodes/Text/Vue/index.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/Text/index.spec.ts b/demos/src/Nodes/Text/index.spec.ts
new file mode 100644
index 0000000..c0b7b28
--- /dev/null
+++ b/demos/src/Nodes/Text/index.spec.ts
@@ -0,0 +1,27 @@
+import { expect, test } from '@playwright/test'
+
+import { getEditor } from '../../../test/helpers.js'
+
+const demoName = 'Text'
+const frameworkPaths = ['React', 'Vue']
+const demoPath = '/src/Nodes'
+
+test.describe(`${demoPath}/${demoName}`, () => {
+ frameworkPaths.forEach(frameworkPath => {
+ const fullDemoPath = `${demoPath}/${demoName}/${frameworkPath}/`
+
+ test.describe(`${frameworkPath}`, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fullDemoPath)
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => el.editor.commands.clearContent())
+ await editor.click()
+ })
+
+ test('wraps typed text in a paragraph', async ({ page }) => {
+ await page.keyboard.type('Example Text')
+ await expect(page.locator('.tiptap p')).toContainText('Example Text')
+ })
+ })
+ })
+})
diff --git a/demos/src/Nodes/Twitch/React/index.html b/demos/src/Nodes/Twitch/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Twitch/React/index.jsx b/demos/src/Nodes/Twitch/React/index.jsx
new file mode 100644
index 0000000..0d715c2
--- /dev/null
+++ b/demos/src/Nodes/Twitch/React/index.jsx
@@ -0,0 +1,122 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Heading from '@tiptap/extension-heading'
+import { ListKit } from '@tiptap/extension-list'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import Twitch from '@tiptap/extension-twitch'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+const MenuBar = ({ editor }) => {
+ const [height, setHeight] = React.useState(480)
+ const [width, setWidth] = React.useState(640)
+
+ if (!editor) {
+ return null
+ }
+
+ const addTwitchVideo = () => {
+ const url = prompt('Enter Twitch video URL')
+
+ if (url) {
+ editor.commands.setTwitchVideo({
+ src: url,
+ width: Math.max(320, parseInt(width, 10)) || 640,
+ height: Math.max(180, parseInt(height, 10)) || 480,
+ })
+ }
+ }
+
+ return (
+
+ )
+}
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Heading,
+ Paragraph,
+ ListKit,
+ Text,
+ Twitch.configure({
+ parent: window.location.hostname,
+ allowFullscreen: true,
+ }),
+ ],
+ content: `
+ Tiptap now supports Twitch embeds! Awesome!
+ It supports
+
+ Channel
+
+
+
+ Videos
+
+
+
+ Clips
+
+
+
+
+ `,
+ editorProps: {
+ attributes: {
+ spellcheck: 'false',
+ },
+ },
+ })
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/demos/src/Nodes/Twitch/React/styles.scss b/demos/src/Nodes/Twitch/React/styles.scss
new file mode 100644
index 0000000..01bbbaa
--- /dev/null
+++ b/demos/src/Nodes/Twitch/React/styles.scss
@@ -0,0 +1,25 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* Twitch embed */
+ div[data-twitch-video] {
+ cursor: move;
+ padding-right: 1.5rem;
+
+ iframe {
+ border: 0.5rem solid var(--black-contrast);
+ display: block;
+ min-height: 200px;
+ min-width: 200px;
+ outline: 0px solid transparent;
+ }
+
+ &.ProseMirror-selectednode iframe {
+ outline: 3px solid var(--purple);
+ transition: outline 0.15s;
+ }
+ }
+}
diff --git a/demos/src/Nodes/Twitch/Vue/index.html b/demos/src/Nodes/Twitch/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Twitch/Vue/index.vue b/demos/src/Nodes/Twitch/Vue/index.vue
new file mode 100644
index 0000000..06476e5
--- /dev/null
+++ b/demos/src/Nodes/Twitch/Vue/index.vue
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Nodes/Youtube/React/index.html b/demos/src/Nodes/Youtube/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Youtube/React/index.jsx b/demos/src/Nodes/Youtube/React/index.jsx
new file mode 100644
index 0000000..ec2ad3d
--- /dev/null
+++ b/demos/src/Nodes/Youtube/React/index.jsx
@@ -0,0 +1,90 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import Youtube from '@tiptap/extension-youtube'
+import { EditorContent, useEditor } from '@tiptap/react'
+import React from 'react'
+
+const MenuBar = ({ editor }) => {
+ const [height, setHeight] = React.useState(480)
+ const [width, setWidth] = React.useState(640)
+
+ if (!editor) {
+ return null
+ }
+
+ const addYoutubeVideo = () => {
+ const url = prompt('Enter YouTube URL')
+
+ if (url) {
+ editor.commands.setYoutubeVideo({
+ src: url,
+ width: Math.max(320, parseInt(width, 10)) || 640,
+ height: Math.max(180, parseInt(height, 10)) || 480,
+ })
+ }
+ }
+
+ return (
+
+ )
+}
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Youtube.configure({
+ controls: false,
+ nocookie: true,
+ }),
+ ],
+ content: `
+ Tiptap now supports YouTube embeds! Awesome!
+
+ VIDEO
+
+ Try adding your own video to this editor!
+ `,
+ editorProps: {
+ attributes: {
+ spellcheck: 'false',
+ },
+ },
+ })
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/demos/src/Nodes/Youtube/React/styles.scss b/demos/src/Nodes/Youtube/React/styles.scss
new file mode 100644
index 0000000..e1258ea
--- /dev/null
+++ b/demos/src/Nodes/Youtube/React/styles.scss
@@ -0,0 +1,25 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* Youtube embed */
+ div[data-youtube-video] {
+ cursor: move;
+ padding-right: 1.5rem;
+
+ iframe {
+ border: 0.5rem solid var(--black-contrast);
+ display: block;
+ min-height: 200px;
+ min-width: 200px;
+ outline: 0px solid transparent;
+ }
+
+ &.ProseMirror-selectednode iframe {
+ outline: 3px solid var(--purple);
+ transition: outline 0.15s;
+ }
+ }
+}
diff --git a/demos/src/Nodes/Youtube/Vue/index.html b/demos/src/Nodes/Youtube/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Nodes/Youtube/Vue/index.vue b/demos/src/Nodes/Youtube/Vue/index.vue
new file mode 100644
index 0000000..fcd29ea
--- /dev/null
+++ b/demos/src/Nodes/Youtube/Vue/index.vue
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Overview/Installation/Vue/index.html b/demos/src/Overview/Installation/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Overview/Installation/Vue/index.vue b/demos/src/Overview/Installation/Vue/index.vue
new file mode 100644
index 0000000..00f1452
--- /dev/null
+++ b/demos/src/Overview/Installation/Vue/index.vue
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Tutorials/1-1-textarea/React/Note.tsx b/demos/src/Tutorials/1-1-textarea/React/Note.tsx
new file mode 100644
index 0000000..368b678
--- /dev/null
+++ b/demos/src/Tutorials/1-1-textarea/React/Note.tsx
@@ -0,0 +1,9 @@
+import React, { useState } from 'react'
+
+import type { TNote } from './types.js'
+
+export default ({ note }: { note: TNote }) => {
+ const [modelValue, setModelValue] = useState(note.content)
+
+ return
+}
diff --git a/demos/src/Tutorials/1-1-textarea/React/index.html b/demos/src/Tutorials/1-1-textarea/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Tutorials/1-1-textarea/React/index.tsx b/demos/src/Tutorials/1-1-textarea/React/index.tsx
new file mode 100644
index 0000000..ab06a56
--- /dev/null
+++ b/demos/src/Tutorials/1-1-textarea/React/index.tsx
@@ -0,0 +1,27 @@
+import './styles.scss'
+
+import React from 'react'
+
+import Note from './Note.jsx'
+import type { TNote } from './types.js'
+
+const notes: TNote[] = [
+ {
+ id: 'note-1',
+ content: 'Some random note text',
+ },
+ {
+ id: 'note-2',
+ content: 'Some really random note text',
+ },
+]
+
+export default () => {
+ return (
+ <>
+ {notes.map(note => (
+
+ ))}
+ >
+ )
+}
diff --git a/demos/src/Tutorials/1-1-textarea/React/styles.scss b/demos/src/Tutorials/1-1-textarea/React/styles.scss
new file mode 100644
index 0000000..99f7e4f
--- /dev/null
+++ b/demos/src/Tutorials/1-1-textarea/React/styles.scss
@@ -0,0 +1,68 @@
+/* Demo styling */
+#app {
+ gap: 1rem;
+ padding: 1.5rem;
+}
+
+/* Textarea styling */
+textarea,
+.textarea {
+ border-radius: 0.5rem;
+ border: 1px solid var(--gray-3);
+ color: var(--black);
+ font-family: inherit;
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: 1.15;
+ margin: 0;
+ padding: 0.375rem 0.625rem;
+ transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1);
+
+ &:hover {
+ border-color: var(--gray-4);
+ color: var(--black-contrast);
+ }
+
+ &[disabled] {
+ background: var(--gray-1);
+ color: var(--gray-4);
+ }
+
+ &:checked {
+ accent-color: var(--purple);
+ }
+
+ &::placeholder {
+ color: var(--gray-4);
+ }
+
+ &:focus-visible,
+ &:focus {
+ border-color: var(--purple);
+ outline: none;
+ }
+
+ &.primary {
+ background: var(--black);
+ color: var(--white);
+
+ &:hover {
+ background-color: var(--black-contrast);
+ }
+
+ &[disabled] {
+ background: var(--gray-1);
+ color: var(--gray-4);
+ }
+ }
+
+ &.is-active {
+ background: var(--purple);
+ color: var(--white);
+
+ &:hover {
+ background-color: var(--purple-contrast);
+ color: var(--white);
+ }
+ }
+}
diff --git a/demos/src/Tutorials/1-1-textarea/React/types.ts b/demos/src/Tutorials/1-1-textarea/React/types.ts
new file mode 100644
index 0000000..b6318bd
--- /dev/null
+++ b/demos/src/Tutorials/1-1-textarea/React/types.ts
@@ -0,0 +1,4 @@
+export type TNote = {
+ id: string
+ content: string
+}
diff --git a/demos/src/Tutorials/1-1-textarea/Vue/Note.vue b/demos/src/Tutorials/1-1-textarea/Vue/Note.vue
new file mode 100644
index 0000000..20a16f1
--- /dev/null
+++ b/demos/src/Tutorials/1-1-textarea/Vue/Note.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/demos/src/Tutorials/1-1-textarea/Vue/index.html b/demos/src/Tutorials/1-1-textarea/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Tutorials/1-1-textarea/Vue/index.vue b/demos/src/Tutorials/1-1-textarea/Vue/index.vue
new file mode 100644
index 0000000..a990ba4
--- /dev/null
+++ b/demos/src/Tutorials/1-1-textarea/Vue/index.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Tutorials/1-1-textarea/Vue/styles.scss b/demos/src/Tutorials/1-1-textarea/Vue/styles.scss
new file mode 100644
index 0000000..99f7e4f
--- /dev/null
+++ b/demos/src/Tutorials/1-1-textarea/Vue/styles.scss
@@ -0,0 +1,68 @@
+/* Demo styling */
+#app {
+ gap: 1rem;
+ padding: 1.5rem;
+}
+
+/* Textarea styling */
+textarea,
+.textarea {
+ border-radius: 0.5rem;
+ border: 1px solid var(--gray-3);
+ color: var(--black);
+ font-family: inherit;
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: 1.15;
+ margin: 0;
+ padding: 0.375rem 0.625rem;
+ transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1);
+
+ &:hover {
+ border-color: var(--gray-4);
+ color: var(--black-contrast);
+ }
+
+ &[disabled] {
+ background: var(--gray-1);
+ color: var(--gray-4);
+ }
+
+ &:checked {
+ accent-color: var(--purple);
+ }
+
+ &::placeholder {
+ color: var(--gray-4);
+ }
+
+ &:focus-visible,
+ &:focus {
+ border-color: var(--purple);
+ outline: none;
+ }
+
+ &.primary {
+ background: var(--black);
+ color: var(--white);
+
+ &:hover {
+ background-color: var(--black-contrast);
+ }
+
+ &[disabled] {
+ background: var(--gray-1);
+ color: var(--gray-4);
+ }
+ }
+
+ &.is-active {
+ background: var(--purple);
+ color: var(--white);
+
+ &:hover {
+ background-color: var(--purple-contrast);
+ color: var(--white);
+ }
+ }
+}
diff --git a/demos/src/Tutorials/1-1-textarea/Vue/types.ts b/demos/src/Tutorials/1-1-textarea/Vue/types.ts
new file mode 100644
index 0000000..b6318bd
--- /dev/null
+++ b/demos/src/Tutorials/1-1-textarea/Vue/types.ts
@@ -0,0 +1,4 @@
+export type TNote = {
+ id: string
+ content: string
+}
diff --git a/demos/src/Tutorials/1-2-tiptap/React/Note.tsx b/demos/src/Tutorials/1-2-tiptap/React/Note.tsx
new file mode 100644
index 0000000..38b5f1a
--- /dev/null
+++ b/demos/src/Tutorials/1-2-tiptap/React/Note.tsx
@@ -0,0 +1,27 @@
+import { EditorContent, useEditor } from '@tiptap/react'
+import { StarterKit } from '@tiptap/starter-kit'
+import React, { useState } from 'react'
+
+import type { TNote } from './types.js'
+
+export default ({ note }: { note: TNote }) => {
+ const [modelValue, setModelValue] = useState(note.content)
+
+ const editor = useEditor({
+ content: modelValue,
+ editorProps: {
+ attributes: {
+ class: 'textarea',
+ },
+ },
+ onUpdate() {
+ setModelValue(editor?.getText() ?? '')
+ },
+ extensions: [StarterKit],
+ })
+
+ return (
+ // @ts-ignore
+
+ )
+}
diff --git a/demos/src/Tutorials/1-2-tiptap/React/index.html b/demos/src/Tutorials/1-2-tiptap/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Tutorials/1-2-tiptap/React/index.tsx b/demos/src/Tutorials/1-2-tiptap/React/index.tsx
new file mode 100644
index 0000000..ab06a56
--- /dev/null
+++ b/demos/src/Tutorials/1-2-tiptap/React/index.tsx
@@ -0,0 +1,27 @@
+import './styles.scss'
+
+import React from 'react'
+
+import Note from './Note.jsx'
+import type { TNote } from './types.js'
+
+const notes: TNote[] = [
+ {
+ id: 'note-1',
+ content: 'Some random note text',
+ },
+ {
+ id: 'note-2',
+ content: 'Some really random note text',
+ },
+]
+
+export default () => {
+ return (
+ <>
+ {notes.map(note => (
+
+ ))}
+ >
+ )
+}
diff --git a/demos/src/Tutorials/1-2-tiptap/React/styles.scss b/demos/src/Tutorials/1-2-tiptap/React/styles.scss
new file mode 100644
index 0000000..e956e89
--- /dev/null
+++ b/demos/src/Tutorials/1-2-tiptap/React/styles.scss
@@ -0,0 +1,160 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
+
+/* Demo styling */
+#app {
+ gap: 1rem;
+ padding: 1.5rem;
+}
+
+/* Textarea styling */
+textarea,
+.textarea {
+ border-radius: 0.5rem;
+ border: 1px solid var(--gray-3);
+ color: var(--black);
+ font-family: inherit;
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: 1.15;
+ margin: 0;
+ padding: 0.375rem 0.625rem;
+ transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1);
+
+ &:hover {
+ border-color: var(--gray-4);
+ color: var(--black-contrast);
+ }
+
+ &[disabled] {
+ background: var(--gray-1);
+ color: var(--gray-4);
+ }
+
+ &:checked {
+ accent-color: var(--purple);
+ }
+
+ &::placeholder {
+ color: var(--gray-4);
+ }
+
+ &:focus-visible,
+ &:focus {
+ border-color: var(--purple);
+ outline: none;
+ }
+
+ &.primary {
+ background: var(--black);
+ color: var(--white);
+
+ &:hover {
+ background-color: var(--black-contrast);
+ }
+
+ &[disabled] {
+ background: var(--gray-1);
+ color: var(--gray-4);
+ }
+ }
+
+ &.is-active {
+ background: var(--purple);
+ color: var(--white);
+
+ &:hover {
+ background-color: var(--purple-contrast);
+ color: var(--white);
+ }
+ }
+}
diff --git a/demos/src/Tutorials/1-2-tiptap/React/types.ts b/demos/src/Tutorials/1-2-tiptap/React/types.ts
new file mode 100644
index 0000000..b6318bd
--- /dev/null
+++ b/demos/src/Tutorials/1-2-tiptap/React/types.ts
@@ -0,0 +1,4 @@
+export type TNote = {
+ id: string
+ content: string
+}
diff --git a/demos/src/Tutorials/1-2-tiptap/Vue/Note.vue b/demos/src/Tutorials/1-2-tiptap/Vue/Note.vue
new file mode 100644
index 0000000..97cd656
--- /dev/null
+++ b/demos/src/Tutorials/1-2-tiptap/Vue/Note.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/demos/src/Tutorials/1-2-tiptap/Vue/index.html b/demos/src/Tutorials/1-2-tiptap/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Tutorials/1-2-tiptap/Vue/index.vue b/demos/src/Tutorials/1-2-tiptap/Vue/index.vue
new file mode 100644
index 0000000..a990ba4
--- /dev/null
+++ b/demos/src/Tutorials/1-2-tiptap/Vue/index.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Tutorials/1-2-tiptap/Vue/styles.scss b/demos/src/Tutorials/1-2-tiptap/Vue/styles.scss
new file mode 100644
index 0000000..e956e89
--- /dev/null
+++ b/demos/src/Tutorials/1-2-tiptap/Vue/styles.scss
@@ -0,0 +1,160 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
+
+/* Demo styling */
+#app {
+ gap: 1rem;
+ padding: 1.5rem;
+}
+
+/* Textarea styling */
+textarea,
+.textarea {
+ border-radius: 0.5rem;
+ border: 1px solid var(--gray-3);
+ color: var(--black);
+ font-family: inherit;
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: 1.15;
+ margin: 0;
+ padding: 0.375rem 0.625rem;
+ transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1);
+
+ &:hover {
+ border-color: var(--gray-4);
+ color: var(--black-contrast);
+ }
+
+ &[disabled] {
+ background: var(--gray-1);
+ color: var(--gray-4);
+ }
+
+ &:checked {
+ accent-color: var(--purple);
+ }
+
+ &::placeholder {
+ color: var(--gray-4);
+ }
+
+ &:focus-visible,
+ &:focus {
+ border-color: var(--purple);
+ outline: none;
+ }
+
+ &.primary {
+ background: var(--black);
+ color: var(--white);
+
+ &:hover {
+ background-color: var(--black-contrast);
+ }
+
+ &[disabled] {
+ background: var(--gray-1);
+ color: var(--gray-4);
+ }
+ }
+
+ &.is-active {
+ background: var(--purple);
+ color: var(--white);
+
+ &:hover {
+ background-color: var(--purple-contrast);
+ color: var(--white);
+ }
+ }
+}
diff --git a/demos/src/Tutorials/1-2-tiptap/Vue/types.ts b/demos/src/Tutorials/1-2-tiptap/Vue/types.ts
new file mode 100644
index 0000000..b6318bd
--- /dev/null
+++ b/demos/src/Tutorials/1-2-tiptap/Vue/types.ts
@@ -0,0 +1,4 @@
+export type TNote = {
+ id: string
+ content: string
+}
diff --git a/demos/src/Tutorials/1-2-tiptap_lexical/Lexical-React/Note.tsx b/demos/src/Tutorials/1-2-tiptap_lexical/Lexical-React/Note.tsx
new file mode 100644
index 0000000..6740681
--- /dev/null
+++ b/demos/src/Tutorials/1-2-tiptap_lexical/Lexical-React/Note.tsx
@@ -0,0 +1,37 @@
+import './styles.css'
+
+import type { InitialConfigType } from '@lexical/react/LexicalComposer'
+import { LexicalComposer } from '@lexical/react/LexicalComposer'
+import { ContentEditable } from '@lexical/react/LexicalContentEditable'
+import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
+import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
+import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
+import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
+import type { EditorState } from 'lexical/LexicalEditorState'
+import React, { useRef } from 'react'
+
+import type { TNote } from './types.js'
+
+export default ({ note }: { note: TNote }) => {
+ const editorStateRef = useRef()
+
+ const initialConfig: InitialConfigType = {
+ onError(error: Error): void {
+ throw error
+ },
+ namespace: 'myeditor',
+ editable: true,
+ }
+
+ return (
+
+ }
+ placeholder={{note.content}
}
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+
+ (editorStateRef.current = editorState)} />
+
+ )
+}
diff --git a/demos/src/Tutorials/1-2-tiptap_lexical/Lexical-React/index.html b/demos/src/Tutorials/1-2-tiptap_lexical/Lexical-React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Tutorials/1-2-tiptap_lexical/Lexical-React/index.tsx b/demos/src/Tutorials/1-2-tiptap_lexical/Lexical-React/index.tsx
new file mode 100644
index 0000000..c56e1fd
--- /dev/null
+++ b/demos/src/Tutorials/1-2-tiptap_lexical/Lexical-React/index.tsx
@@ -0,0 +1,27 @@
+import './styles.css'
+
+import React from 'react'
+
+import Note from './Note.jsx'
+import type { TNote } from './types.js'
+
+const notes: TNote[] = [
+ {
+ id: 'note-1',
+ content: 'some random note text',
+ },
+ {
+ id: 'note-2',
+ content: 'some really random note text',
+ },
+]
+
+export default () => {
+ return (
+ <>
+ {notes.map(note => (
+
+ ))}
+ >
+ )
+}
diff --git a/demos/src/Tutorials/1-2-tiptap_lexical/Lexical-React/styles.css b/demos/src/Tutorials/1-2-tiptap_lexical/Lexical-React/styles.css
new file mode 100644
index 0000000..a31e444
--- /dev/null
+++ b/demos/src/Tutorials/1-2-tiptap_lexical/Lexical-React/styles.css
@@ -0,0 +1,3 @@
+@import 'tailwindcss/base';
+@import 'tailwindcss/components';
+@import 'tailwindcss/utilities';
diff --git a/demos/src/Tutorials/1-2-tiptap_lexical/Lexical-React/types.ts b/demos/src/Tutorials/1-2-tiptap_lexical/Lexical-React/types.ts
new file mode 100644
index 0000000..b6318bd
--- /dev/null
+++ b/demos/src/Tutorials/1-2-tiptap_lexical/Lexical-React/types.ts
@@ -0,0 +1,4 @@
+export type TNote = {
+ id: string
+ content: string
+}
diff --git a/demos/src/Tutorials/1-3-yjs/React/Note.tsx b/demos/src/Tutorials/1-3-yjs/React/Note.tsx
new file mode 100644
index 0000000..1e78001
--- /dev/null
+++ b/demos/src/Tutorials/1-3-yjs/React/Note.tsx
@@ -0,0 +1,33 @@
+import { Collaboration } from '@tiptap/extension-collaboration'
+import { EditorContent, useEditor } from '@tiptap/react'
+import { StarterKit } from '@tiptap/starter-kit'
+import React from 'react'
+import * as Y from 'yjs'
+
+import type { TNote } from './types.js'
+
+export default ({ note }: { note: TNote }) => {
+ const doc = new Y.Doc()
+
+ const editor = useEditor({
+ content: note.defaultContent,
+ editorProps: {
+ attributes: {
+ class: 'textarea',
+ },
+ },
+ extensions: [
+ StarterKit.configure({
+ history: false, // important because history will now be handled by Y.js
+ }),
+ Collaboration.configure({
+ document: doc,
+ }),
+ ],
+ })
+
+ return (
+ // @ts-ignore
+
+ )
+}
diff --git a/demos/src/Tutorials/1-3-yjs/React/index.html b/demos/src/Tutorials/1-3-yjs/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Tutorials/1-3-yjs/React/index.tsx b/demos/src/Tutorials/1-3-yjs/React/index.tsx
new file mode 100644
index 0000000..c841503
--- /dev/null
+++ b/demos/src/Tutorials/1-3-yjs/React/index.tsx
@@ -0,0 +1,27 @@
+import './styles.scss'
+
+import React from 'react'
+
+import Note from './Note.jsx'
+import type { TNote } from './types.js'
+
+const notes: TNote[] = [
+ {
+ id: 'note-1',
+ defaultContent: 'Some random note text',
+ },
+ {
+ id: 'note-2',
+ defaultContent: 'Some really random note text',
+ },
+]
+
+export default () => {
+ return (
+ <>
+ {notes.map(note => (
+
+ ))}
+ >
+ )
+}
diff --git a/demos/src/Tutorials/1-3-yjs/React/styles.scss b/demos/src/Tutorials/1-3-yjs/React/styles.scss
new file mode 100644
index 0000000..e956e89
--- /dev/null
+++ b/demos/src/Tutorials/1-3-yjs/React/styles.scss
@@ -0,0 +1,160 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
+
+/* Demo styling */
+#app {
+ gap: 1rem;
+ padding: 1.5rem;
+}
+
+/* Textarea styling */
+textarea,
+.textarea {
+ border-radius: 0.5rem;
+ border: 1px solid var(--gray-3);
+ color: var(--black);
+ font-family: inherit;
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: 1.15;
+ margin: 0;
+ padding: 0.375rem 0.625rem;
+ transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1);
+
+ &:hover {
+ border-color: var(--gray-4);
+ color: var(--black-contrast);
+ }
+
+ &[disabled] {
+ background: var(--gray-1);
+ color: var(--gray-4);
+ }
+
+ &:checked {
+ accent-color: var(--purple);
+ }
+
+ &::placeholder {
+ color: var(--gray-4);
+ }
+
+ &:focus-visible,
+ &:focus {
+ border-color: var(--purple);
+ outline: none;
+ }
+
+ &.primary {
+ background: var(--black);
+ color: var(--white);
+
+ &:hover {
+ background-color: var(--black-contrast);
+ }
+
+ &[disabled] {
+ background: var(--gray-1);
+ color: var(--gray-4);
+ }
+ }
+
+ &.is-active {
+ background: var(--purple);
+ color: var(--white);
+
+ &:hover {
+ background-color: var(--purple-contrast);
+ color: var(--white);
+ }
+ }
+}
diff --git a/demos/src/Tutorials/1-3-yjs/React/types.ts b/demos/src/Tutorials/1-3-yjs/React/types.ts
new file mode 100644
index 0000000..eba38af
--- /dev/null
+++ b/demos/src/Tutorials/1-3-yjs/React/types.ts
@@ -0,0 +1,4 @@
+export type TNote = {
+ id: string
+ defaultContent: string
+}
diff --git a/demos/src/Tutorials/1-3-yjs/Vue/Note.vue b/demos/src/Tutorials/1-3-yjs/Vue/Note.vue
new file mode 100644
index 0000000..31fc66c
--- /dev/null
+++ b/demos/src/Tutorials/1-3-yjs/Vue/Note.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
diff --git a/demos/src/Tutorials/1-3-yjs/Vue/index.html b/demos/src/Tutorials/1-3-yjs/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Tutorials/1-3-yjs/Vue/index.vue b/demos/src/Tutorials/1-3-yjs/Vue/index.vue
new file mode 100644
index 0000000..983fa91
--- /dev/null
+++ b/demos/src/Tutorials/1-3-yjs/Vue/index.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Tutorials/1-3-yjs/Vue/styles.scss b/demos/src/Tutorials/1-3-yjs/Vue/styles.scss
new file mode 100644
index 0000000..e956e89
--- /dev/null
+++ b/demos/src/Tutorials/1-3-yjs/Vue/styles.scss
@@ -0,0 +1,160 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
+
+/* Demo styling */
+#app {
+ gap: 1rem;
+ padding: 1.5rem;
+}
+
+/* Textarea styling */
+textarea,
+.textarea {
+ border-radius: 0.5rem;
+ border: 1px solid var(--gray-3);
+ color: var(--black);
+ font-family: inherit;
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: 1.15;
+ margin: 0;
+ padding: 0.375rem 0.625rem;
+ transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1);
+
+ &:hover {
+ border-color: var(--gray-4);
+ color: var(--black-contrast);
+ }
+
+ &[disabled] {
+ background: var(--gray-1);
+ color: var(--gray-4);
+ }
+
+ &:checked {
+ accent-color: var(--purple);
+ }
+
+ &::placeholder {
+ color: var(--gray-4);
+ }
+
+ &:focus-visible,
+ &:focus {
+ border-color: var(--purple);
+ outline: none;
+ }
+
+ &.primary {
+ background: var(--black);
+ color: var(--white);
+
+ &:hover {
+ background-color: var(--black-contrast);
+ }
+
+ &[disabled] {
+ background: var(--gray-1);
+ color: var(--gray-4);
+ }
+ }
+
+ &.is-active {
+ background: var(--purple);
+ color: var(--white);
+
+ &:hover {
+ background-color: var(--purple-contrast);
+ color: var(--white);
+ }
+ }
+}
diff --git a/demos/src/Tutorials/1-3-yjs/Vue/types.ts b/demos/src/Tutorials/1-3-yjs/Vue/types.ts
new file mode 100644
index 0000000..eba38af
--- /dev/null
+++ b/demos/src/Tutorials/1-3-yjs/Vue/types.ts
@@ -0,0 +1,4 @@
+export type TNote = {
+ id: string
+ defaultContent: string
+}
diff --git a/demos/src/Tutorials/1-3-yjs_lexical/Lexical-React/Note.tsx b/demos/src/Tutorials/1-3-yjs_lexical/Lexical-React/Note.tsx
new file mode 100644
index 0000000..79f4b92
--- /dev/null
+++ b/demos/src/Tutorials/1-3-yjs_lexical/Lexical-React/Note.tsx
@@ -0,0 +1,56 @@
+import './styles.css'
+
+import { TiptapCollabProvider } from '@hocuspocus/provider'
+import { LexicalCollaboration } from '@lexical/react/LexicalCollaborationContext'
+import { CollaborationPlugin } from '@lexical/react/LexicalCollaborationPlugin'
+import type { InitialConfigType } from '@lexical/react/LexicalComposer'
+import { LexicalComposer } from '@lexical/react/LexicalComposer'
+import { ContentEditable } from '@lexical/react/LexicalContentEditable'
+import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
+import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
+import React from 'react'
+import * as Y from 'yjs'
+
+import type { TNote } from './types.js'
+
+export default ({ note }: { note: TNote }) => {
+ const initialConfig: InitialConfigType = {
+ onError(error: Error): void {
+ throw error
+ },
+ namespace: 'myeditor',
+ editable: true,
+ }
+
+ return (
+
+
+ }
+ placeholder={{note.defaultContent}
}
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+ {
+ const doc = new Y.Doc()
+
+ yjsDocMap.set(id, doc)
+
+ const provider = new TiptapCollabProvider({
+ name: note.id, // any identifier - all connections sharing the same identifier will be synced
+ appId: '7j9y6m10', // replace with YOUR_APP_ID
+ token: 'notoken', // replace with your JWT
+ document: doc,
+ })
+
+ return provider
+ }}
+ shouldBootstrap={true}
+ />
+
+
+ )
+}
diff --git a/demos/src/Tutorials/1-3-yjs_lexical/Lexical-React/index.html b/demos/src/Tutorials/1-3-yjs_lexical/Lexical-React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Tutorials/1-3-yjs_lexical/Lexical-React/index.tsx b/demos/src/Tutorials/1-3-yjs_lexical/Lexical-React/index.tsx
new file mode 100644
index 0000000..ee0036f
--- /dev/null
+++ b/demos/src/Tutorials/1-3-yjs_lexical/Lexical-React/index.tsx
@@ -0,0 +1,27 @@
+import './styles.css'
+
+import React from 'react'
+
+import Note from './Note.jsx'
+import type { TNote } from './types.js'
+
+const notes: TNote[] = [
+ {
+ id: 'note-1',
+ defaultContent: 'some random note text',
+ },
+ {
+ id: 'note-2',
+ defaultContent: 'some really random note text',
+ },
+]
+
+export default () => {
+ return (
+ <>
+ {notes.map(note => (
+
+ ))}
+ >
+ )
+}
diff --git a/demos/src/Tutorials/1-3-yjs_lexical/Lexical-React/styles.css b/demos/src/Tutorials/1-3-yjs_lexical/Lexical-React/styles.css
new file mode 100644
index 0000000..a31e444
--- /dev/null
+++ b/demos/src/Tutorials/1-3-yjs_lexical/Lexical-React/styles.css
@@ -0,0 +1,3 @@
+@import 'tailwindcss/base';
+@import 'tailwindcss/components';
+@import 'tailwindcss/utilities';
diff --git a/demos/src/Tutorials/1-3-yjs_lexical/Lexical-React/types.ts b/demos/src/Tutorials/1-3-yjs_lexical/Lexical-React/types.ts
new file mode 100644
index 0000000..eba38af
--- /dev/null
+++ b/demos/src/Tutorials/1-3-yjs_lexical/Lexical-React/types.ts
@@ -0,0 +1,4 @@
+export type TNote = {
+ id: string
+ defaultContent: string
+}
diff --git a/demos/src/Tutorials/1-4-collab/React/Note.tsx b/demos/src/Tutorials/1-4-collab/React/Note.tsx
new file mode 100644
index 0000000..d893330
--- /dev/null
+++ b/demos/src/Tutorials/1-4-collab/React/Note.tsx
@@ -0,0 +1,50 @@
+import { TiptapCollabProvider } from '@hocuspocus/provider'
+import { Collaboration } from '@tiptap/extension-collaboration'
+import { EditorContent, useEditor } from '@tiptap/react'
+import { StarterKit } from '@tiptap/starter-kit'
+import React, { useEffect } from 'react'
+import * as Y from 'yjs'
+
+import type { TNote } from './types.js'
+
+export default ({ note }: { note: TNote }) => {
+ const doc = new Y.Doc()
+
+ useEffect(() => {
+ const provider = new TiptapCollabProvider({
+ name: note.id, // any identifier - all connections sharing the same identifier will be synced
+ appId: '7j9y6m10', // replace with YOUR_APP_ID
+ token: 'notoken', // replace with your JWT
+ document: doc,
+ })
+
+ return () => {
+ provider.destroy()
+ }
+ }, [note.id])
+
+ const editor = useEditor({
+ // make sure that you don't use `content` property anymore!
+ // If you want to add default content, feel free to just write text to the Tiptap editor (i.e. editor.setContent (https://tiptap.dev/api/commands/set-content), but make sure that
+ // you do this only once per document, otherwise the content will
+ // be added again, and again, and again ..
+ editorProps: {
+ attributes: {
+ class: 'textarea',
+ },
+ },
+ extensions: [
+ StarterKit.configure({
+ history: false, // important because history will now be handled by Y.js
+ }),
+ Collaboration.configure({
+ document: doc,
+ }),
+ ],
+ })
+
+ return (
+ // @ts-ignore
+
+ )
+}
diff --git a/demos/src/Tutorials/1-4-collab/React/index.html b/demos/src/Tutorials/1-4-collab/React/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Tutorials/1-4-collab/React/index.tsx b/demos/src/Tutorials/1-4-collab/React/index.tsx
new file mode 100644
index 0000000..c841503
--- /dev/null
+++ b/demos/src/Tutorials/1-4-collab/React/index.tsx
@@ -0,0 +1,27 @@
+import './styles.scss'
+
+import React from 'react'
+
+import Note from './Note.jsx'
+import type { TNote } from './types.js'
+
+const notes: TNote[] = [
+ {
+ id: 'note-1',
+ defaultContent: 'Some random note text',
+ },
+ {
+ id: 'note-2',
+ defaultContent: 'Some really random note text',
+ },
+]
+
+export default () => {
+ return (
+ <>
+ {notes.map(note => (
+
+ ))}
+ >
+ )
+}
diff --git a/demos/src/Tutorials/1-4-collab/React/styles.scss b/demos/src/Tutorials/1-4-collab/React/styles.scss
new file mode 100644
index 0000000..e956e89
--- /dev/null
+++ b/demos/src/Tutorials/1-4-collab/React/styles.scss
@@ -0,0 +1,160 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
+
+/* Demo styling */
+#app {
+ gap: 1rem;
+ padding: 1.5rem;
+}
+
+/* Textarea styling */
+textarea,
+.textarea {
+ border-radius: 0.5rem;
+ border: 1px solid var(--gray-3);
+ color: var(--black);
+ font-family: inherit;
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: 1.15;
+ margin: 0;
+ padding: 0.375rem 0.625rem;
+ transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1);
+
+ &:hover {
+ border-color: var(--gray-4);
+ color: var(--black-contrast);
+ }
+
+ &[disabled] {
+ background: var(--gray-1);
+ color: var(--gray-4);
+ }
+
+ &:checked {
+ accent-color: var(--purple);
+ }
+
+ &::placeholder {
+ color: var(--gray-4);
+ }
+
+ &:focus-visible,
+ &:focus {
+ border-color: var(--purple);
+ outline: none;
+ }
+
+ &.primary {
+ background: var(--black);
+ color: var(--white);
+
+ &:hover {
+ background-color: var(--black-contrast);
+ }
+
+ &[disabled] {
+ background: var(--gray-1);
+ color: var(--gray-4);
+ }
+ }
+
+ &.is-active {
+ background: var(--purple);
+ color: var(--white);
+
+ &:hover {
+ background-color: var(--purple-contrast);
+ color: var(--white);
+ }
+ }
+}
diff --git a/demos/src/Tutorials/1-4-collab/React/types.ts b/demos/src/Tutorials/1-4-collab/React/types.ts
new file mode 100644
index 0000000..eba38af
--- /dev/null
+++ b/demos/src/Tutorials/1-4-collab/React/types.ts
@@ -0,0 +1,4 @@
+export type TNote = {
+ id: string
+ defaultContent: string
+}
diff --git a/demos/src/Tutorials/1-4-collab/Vue/Note.vue b/demos/src/Tutorials/1-4-collab/Vue/Note.vue
new file mode 100644
index 0000000..e777ac9
--- /dev/null
+++ b/demos/src/Tutorials/1-4-collab/Vue/Note.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
diff --git a/demos/src/Tutorials/1-4-collab/Vue/index.html b/demos/src/Tutorials/1-4-collab/Vue/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/demos/src/Tutorials/1-4-collab/Vue/index.vue b/demos/src/Tutorials/1-4-collab/Vue/index.vue
new file mode 100644
index 0000000..26a8483
--- /dev/null
+++ b/demos/src/Tutorials/1-4-collab/Vue/index.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Tutorials/1-4-collab/Vue/styles.scss b/demos/src/Tutorials/1-4-collab/Vue/styles.scss
new file mode 100644
index 0000000..e956e89
--- /dev/null
+++ b/demos/src/Tutorials/1-4-collab/Vue/styles.scss
@@ -0,0 +1,160 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+
+ /* List styles */
+ ul,
+ ol {
+ padding: 0 1rem;
+ margin: 1.25rem 1rem 1.25rem 0.4rem;
+
+ li p {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
+ }
+ }
+
+ /* Heading styles */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ margin-top: 2.5rem;
+ text-wrap: pretty;
+ }
+
+ h1,
+ h2 {
+ margin-top: 3.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ h1 {
+ font-size: 1.4rem;
+ }
+
+ h2 {
+ font-size: 1.2rem;
+ }
+
+ h3 {
+ font-size: 1.1rem;
+ }
+
+ h4,
+ h5,
+ h6 {
+ font-size: 1rem;
+ }
+
+ /* Code and preformatted text styles */
+ code {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ color: var(--black);
+ font-size: 0.85rem;
+ padding: 0.25em 0.3em;
+ }
+
+ pre {
+ background: var(--black);
+ border-radius: 0.5rem;
+ color: var(--white);
+ font-family: 'JetBrainsMono', monospace;
+ margin: 1.5rem 0;
+ padding: 0.75rem 1rem;
+
+ code {
+ background: none;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ }
+ }
+
+ blockquote {
+ border-left: 3px solid var(--gray-3);
+ margin: 1.5rem 0;
+ padding-left: 1rem;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--gray-2);
+ margin: 2rem 0;
+ }
+}
+
+/* Demo styling */
+#app {
+ gap: 1rem;
+ padding: 1.5rem;
+}
+
+/* Textarea styling */
+textarea,
+.textarea {
+ border-radius: 0.5rem;
+ border: 1px solid var(--gray-3);
+ color: var(--black);
+ font-family: inherit;
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: 1.15;
+ margin: 0;
+ padding: 0.375rem 0.625rem;
+ transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1);
+
+ &:hover {
+ border-color: var(--gray-4);
+ color: var(--black-contrast);
+ }
+
+ &[disabled] {
+ background: var(--gray-1);
+ color: var(--gray-4);
+ }
+
+ &:checked {
+ accent-color: var(--purple);
+ }
+
+ &::placeholder {
+ color: var(--gray-4);
+ }
+
+ &:focus-visible,
+ &:focus {
+ border-color: var(--purple);
+ outline: none;
+ }
+
+ &.primary {
+ background: var(--black);
+ color: var(--white);
+
+ &:hover {
+ background-color: var(--black-contrast);
+ }
+
+ &[disabled] {
+ background: var(--gray-1);
+ color: var(--gray-4);
+ }
+ }
+
+ &.is-active {
+ background: var(--purple);
+ color: var(--white);
+
+ &:hover {
+ background-color: var(--purple-contrast);
+ color: var(--white);
+ }
+ }
+}
diff --git a/demos/src/Tutorials/1-4-collab/Vue/types.ts b/demos/src/Tutorials/1-4-collab/Vue/types.ts
new file mode 100644
index 0000000..d78176b
--- /dev/null
+++ b/demos/src/Tutorials/1-4-collab/Vue/types.ts
@@ -0,0 +1,3 @@
+export type TNote = {
+ id: string
+}
diff --git a/demos/src/utils/updatePosition.js b/demos/src/utils/updatePosition.js
new file mode 100644
index 0000000..b8ff7d7
--- /dev/null
+++ b/demos/src/utils/updatePosition.js
@@ -0,0 +1,47 @@
+import { computePosition, flip, offset, shift } from '@floating-ui/dom'
+
+function getSelectionClientRect(editor) {
+ const { from } = editor.state.selection
+ const { left, right, top, bottom } = editor.view.coordsAtPos(from)
+
+ return new DOMRect(left, top, right - left, bottom - top)
+}
+
+export function updatePosition({
+ editor,
+ clientRect,
+ element,
+ placement = 'bottom-start',
+ strategy = 'absolute',
+ middleware = [shift(), flip()],
+ offsetValue,
+}) {
+ if (!element) {
+ return
+ }
+
+ const rect = clientRect || (editor ? getSelectionClientRect(editor) : null)
+
+ if (!rect) {
+ return
+ }
+
+ const virtualElement = {
+ getBoundingClientRect: () => rect,
+ }
+
+ const resolvedMiddleware = offsetValue ? [offset(offsetValue), ...middleware] : middleware
+
+ return computePosition(virtualElement, element, {
+ placement,
+ strategy,
+ middleware: resolvedMiddleware,
+ }).then(({ x, y, strategy: resolvedStrategy }) => {
+ Object.assign(element.style, {
+ width: 'max-content',
+ position: resolvedStrategy,
+ left: `${x}px`,
+ top: `${y}px`,
+ })
+ })
+}
diff --git a/demos/src/variables.js b/demos/src/variables.js
new file mode 100644
index 0000000..b647a26
--- /dev/null
+++ b/demos/src/variables.js
@@ -0,0 +1,4 @@
+// This is a workaround as vite failes to resolves the environment variables
+export const variables = {
+ collabRooms: import.meta.env.VITE_COLLAB_ROOMS,
+}
diff --git a/demos/tailwind.config.js b/demos/tailwind.config.js
new file mode 100644
index 0000000..b36ced6
--- /dev/null
+++ b/demos/tailwind.config.js
@@ -0,0 +1,106 @@
+const defaultTheme = require('tailwindcss/defaultTheme')
+
+module.exports = {
+ content: ['./preview/**/*.{vue,js,ts,jsx,tsx}', './src/**/*.{vue,js,ts,jsx,tsx}'],
+
+ theme: {
+ extend: {
+ borderWidth: {
+ 3: '3px',
+ },
+ colors: {
+ code: {
+ attrName: '#faf594',
+ attrValue: '##b9f18d',
+ doctype: '#616161',
+ keyword: '##958df1',
+ punctuation: '##70cff8',
+ string: '#b9f18d',
+ tag: '#f98181',
+ },
+ transparency: {
+ box: {
+ 3: 'rgba(13, 13, 13, 0.03)',
+ 5: 'rgba(13, 13, 13, 0.05)',
+ },
+ },
+ gray: {
+ DEFAULT: '',
+ 900: '#0d0d0d',
+ 800: '#262626',
+ 700: '#3a3a3a',
+ 600: '#4e4e4e',
+ 500: '#616161',
+ 400: '#737373',
+ 300: '#919191',
+ 200: '#b3b3b3',
+ 100: '#d6d6d6',
+ 50: '#e8e8e8',
+ },
+ accent: {
+ DEFAULT: '#faf594',
+ 50: '#fffffa',
+ 100: '#fffef4',
+ 200: '#fefde4',
+ 300: '#fdfbd4',
+ 400: '#fcf8b4',
+ 500: '#faf594',
+ 600: '#e1dd85',
+ 700: '#bcb86f',
+ 800: '#969359',
+ 900: '#7b7849',
+ },
+ success: {
+ DEFAULT: '#b9f18d',
+ 50: '#fcfef9',
+ 100: '#f8fef4',
+ 200: '#eefce3',
+ 300: '#e3f9d1',
+ 400: '#cef5af',
+ 500: '#b9f18d',
+ 600: '#a7d97f',
+ 700: '#8bb56a',
+ 800: '#6f9155',
+ 900: '#5b7645',
+ },
+ },
+ spacing: {},
+ borderRadius: {
+ xxs: '0.4rem',
+ box: '0.75rem',
+ },
+ fontSize: {
+ '2xl': '2.75rem',
+ xl: '1.5rem',
+ lg: '1.17rem',
+ sm: '0.85rem',
+ },
+ lineHeight: {
+ DEFAULT: '1.7rem',
+ '2xl': '3.16rem',
+ xl: '1.8rem',
+ lg: '1.6rem',
+ },
+ transition: {},
+ height: {
+ 18: '4.5rem',
+ },
+ padding: {
+ 18: '4.5rem',
+ },
+ zIndex: {},
+ boxShadow: {},
+ textShadow: {},
+ fontFamily: {
+ sans: ['Inter', ...defaultTheme.fontFamily.sans],
+ mono: ['JetBrains Mono', ...defaultTheme.fontFamily.mono],
+ },
+ },
+ },
+
+ variants: {
+ extend: {
+ opacity: ['disabled'],
+ },
+ },
+}
diff --git a/demos/test/helpers.ts b/demos/test/helpers.ts
new file mode 100644
index 0000000..ba1bfd6
--- /dev/null
+++ b/demos/test/helpers.ts
@@ -0,0 +1,45 @@
+import type { Page } from '@playwright/test'
+
+/**
+ * Get the editor element from the page
+ * @param page The page to get the editor from
+ * @returns The editor element Locator
+ */
+export async function getEditor(page: Page) {
+ const locator = page.locator('.tiptap')
+ await locator.waitFor()
+ return locator
+}
+
+/**
+ * Set default content for the editor
+ * @param page The page containing the editor
+ * @param content The HTML content to set
+ */
+export async function setEditorContent(page: Page, content: string) {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any, innerContent: string) => {
+ el.editor.commands.setContent(innerContent)
+ }, content)
+}
+
+/**
+ * Place the caret at the end of the document.
+ * Clicking the editor and pressing Ctrl+End is flaky, the caret can stay at the start.
+ * @param page The page containing the editor
+ */
+export async function focusEditorEnd(page: Page) {
+ const editor = await getEditor(page)
+ await editor.evaluate((el: any) => {
+ el.editor.commands.focus('end')
+ })
+}
+
+/**
+ * Click a button in the editor
+ * @param page The page containing the editor
+ * @param buttonLabel The label of the button to click
+ */
+export async function clickButton(page: Page, buttonLabel: string) {
+ await page.getByRole('button', { name: buttonLabel }).click()
+}
diff --git a/demos/tsconfig.json b/demos/tsconfig.json
new file mode 100644
index 0000000..b3dc4e8
--- /dev/null
+++ b/demos/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "skipLibCheck": true,
+ "types": ["node"],
+ "module": "nodenext",
+ "moduleResolution": "nodenext",
+ "paths": {
+ "@tiptap/pm/*": ["../packages/pm/*/index.ts"],
+ "@tiptap/static-renderer/pm/*": ["../packages/static-renderer/src/pm/*"],
+ "@tiptap/static-renderer/json/*": ["../packages/static-renderer/src/json/*"],
+ "@tiptap/*": ["../packages/*/src/index.ts"]
+ }
+ },
+ "include": [
+ "../packages/*/src/**/*.d.ts",
+ "../packages/*/src/**/*.ts",
+ "../packages/*/src/**/*.tsx",
+ "../packages/*/src/**/*.vue",
+ "./src/**/*"
+ ]
+}
diff --git a/demos/vite.config.ts b/demos/vite.config.ts
new file mode 100644
index 0000000..6764003
--- /dev/null
+++ b/demos/vite.config.ts
@@ -0,0 +1,346 @@
+import { svelte } from '@sveltejs/vite-plugin-svelte'
+import react from '@vitejs/plugin-react'
+import vue from '@vitejs/plugin-vue'
+import fg from 'fast-glob'
+import fs from 'fs'
+import { basename, dirname, join, resolve } from 'path'
+import { v4 as uuid } from 'uuid'
+import { defineConfig } from 'vite-plus'
+
+const getPackageDependencies = () => {
+ const paths: Array<{ find: string; replacement: any }> = []
+
+ function collectPackageInformation(path: string) {
+ const packagesPath = resolve(import.meta.dirname, '..', path)
+
+ fg.sync(`${packagesPath}/*`, { onlyDirectories: true })
+ .map(name => basename(name))
+ .forEach(name => {
+ if (name === 'pm') {
+ fg.sync(`${packagesPath}/${name}/*`, { onlyDirectories: true }).forEach(subName => {
+ const subPkgName = basename(subName)
+
+ if (subPkgName === 'dist' || subPkgName === 'node_modules') {
+ return
+ }
+
+ paths.push({
+ find: `@tiptap/${name}/${subPkgName}`,
+ replacement: resolve(packagesPath, name, subPkgName, 'index.ts'),
+ })
+ })
+ } else if (
+ name === 'extension-text-style' ||
+ name === 'extension-table' ||
+ name === 'extensions' ||
+ name === 'extension-list' ||
+ name === 'react' ||
+ name === 'vue'
+ ) {
+ fg.sync(`${packagesPath}/${name}/src/*`, { onlyDirectories: true }).forEach(subName => {
+ const subPkgName = basename(subName)
+
+ paths.push({
+ find: `@tiptap/${name}/${subPkgName}`,
+ replacement: resolve(packagesPath, name, 'src', subPkgName, 'index.ts'),
+ })
+ })
+ paths.push({
+ find: `@tiptap/${name}`,
+ replacement: resolve(packagesPath, name, 'src/index.ts'),
+ })
+ } else {
+ paths.push({
+ find: `@tiptap/${name}`,
+ replacement: resolve(packagesPath, name, 'src/index.ts'),
+ })
+ }
+ })
+ }
+
+ collectPackageInformation('packages')
+
+ // Handle the JSX runtime alias
+ paths.unshift({
+ find: '@tiptap/core/jsx-runtime',
+ replacement: resolve(import.meta.dirname, '../packages/core/src/jsx-runtime.ts'),
+ })
+ paths.unshift({
+ find: '@tiptap/core/jsx-dev-runtime',
+ replacement: resolve(import.meta.dirname, '../packages/core/src/jsx-runtime.ts'),
+ })
+
+ return paths
+}
+
+const dedupeDeps = fs
+ .readFileSync(resolve(import.meta.dirname, 'dedupeDeps.txt'))
+ .toString()
+ .replace(/\r\n/g, '\n')
+ .split('\n')
+ .filter(value => value)
+
+export default defineConfig({
+ server: {
+ port: 3000,
+ },
+ preview: {
+ port: 3000,
+ },
+
+ build: {
+ rolldownOptions: {
+ input: fg.sync('./**/index.html', {
+ absolute: true,
+ cwd: import.meta.dirname,
+ ignore: ['dist', 'node_modules'],
+ }),
+ },
+ },
+
+ worker: {
+ format: 'es',
+ },
+
+ plugins: [
+ // checker({ typescript: { tsconfigPath: './tsconfig.base.json' } }),
+ // checker({ typescript: { tsconfigPath: './tsconfig.react.json' } }),
+ // @ts-ignore
+ vue(),
+ // @ts-ignore
+ react(),
+ // @ts-ignore
+ svelte(),
+
+ {
+ name: 'html-transform',
+ transformIndexHtml: {
+ order: 'pre',
+ handler(html: string, context) {
+ const dir = dirname(context.path)
+ const data = dir.split('/')
+
+ const demoCategory = data[2]
+ const demoName = data[3]
+ const frameworkName = data[4]
+
+ if (dir.endsWith('/JS') || dir.endsWith('-JS')) {
+ return {
+ html: `
+
+
+
+
+
+
+
+ ${html}
+
+
+
+ `,
+ tags: [],
+ }
+ }
+
+ if (dir.endsWith('/Vue') || dir.endsWith('-Vue')) {
+ return {
+ html: `
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ tags: [],
+ }
+ }
+
+ if (dir.endsWith('/Svelte') || dir.endsWith('-Svelte')) {
+ return {
+ html: `
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ tags: [],
+ }
+ }
+
+ if (dir.endsWith('/React') || dir.endsWith('-React')) {
+ return {
+ html: `
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ tags: [],
+ }
+ }
+ },
+ },
+ },
+
+ {
+ name: 'raw',
+ resolveId(id, importer) {
+ if (id.startsWith('raw!') && importer) {
+ const [, relativePath] = id.split('raw!')
+ const fullPath = join(dirname(importer), relativePath)
+
+ return `virtual!${fullPath}!!${uuid()}`
+ }
+ },
+ load(id) {
+ if (id.startsWith('virtual!')) {
+ const path = id.split('!!')[0].replace('virtual!', '')
+ const data = fs.readFileSync(path, 'utf8')
+
+ return `export default ${JSON.stringify(data)}`
+ }
+ },
+ },
+
+ {
+ name: 'demos',
+ resolveId(id) {
+ if (id === '@demos') {
+ return '@demos'
+ }
+ },
+ load(id) {
+ if (id === '@demos') {
+ const demos = fg.sync('./src/*/*', { onlyDirectories: true }).map(demoPath => {
+ const name = demoPath.replace('./src/', '')
+ const tabs = fg.sync(`./src/${name}/*`, { onlyDirectories: true }).map(tabPath => ({
+ name: basename(tabPath),
+ }))
+
+ return {
+ name,
+ tabs,
+ }
+ })
+
+ return `export const demos = ${JSON.stringify(demos)}`
+ }
+ },
+ },
+
+ {
+ name: 'source',
+ resolveId(id, importer) {
+ if (id === '@source' && importer) {
+ return `source!${dirname(importer)}!!${uuid()}`
+ }
+ },
+ load(id) {
+ if (id.startsWith('source!')) {
+ const path = id.split('!!')[0].replace('source!', '')
+ const ignore = ['**/*.spec.js', '**/*.spec.ts', 'node_modules/**']
+
+ if (!path.endsWith('/JS')) {
+ ignore.push('**/index.html')
+ }
+
+ const files = fg
+ .sync(`${path}/**/*`, { ignore })
+ .map(filePath => {
+ const name = filePath.replace(`${path}/`, '')
+
+ return {
+ name,
+ content: fs.readFileSync(`${path}/${name}`, 'utf8'),
+ }
+ })
+ .sort((a, b) => {
+ const depthA = a.name.split('/').length
+ const depthB = b.name.split('/').length
+
+ if (depthA > depthB) {
+ return 1
+ }
+
+ if (depthA < depthB) {
+ return -1
+ }
+
+ const aIsIndex = basename(a.name).includes('index.')
+ const bIsIndex = basename(b.name).includes('index.')
+
+ if (aIsIndex) {
+ return -1
+ }
+
+ if (bIsIndex) {
+ return 1
+ }
+
+ return 0
+ })
+
+ return `export default ${JSON.stringify(files)}`
+ }
+ },
+ },
+
+ {
+ name: 'middleware',
+ apply: 'serve',
+ configureServer(viteDevServer) {
+ return () => {
+ viteDevServer.middlewares.use(async (req, res, next) => {
+ if (req?.originalUrl?.startsWith('/preview')) {
+ req.url = '/preview/index.html'
+ }
+
+ next()
+ })
+ }
+ },
+ },
+ ],
+
+ resolve: {
+ alias: getPackageDependencies(),
+ dedupe: dedupeDeps,
+ },
+})
diff --git a/env.d.ts b/env.d.ts
new file mode 100644
index 0000000..5747f54
--- /dev/null
+++ b/env.d.ts
@@ -0,0 +1 @@
+declare module '*.vue' {}
diff --git a/pack.config.mts b/pack.config.mts
new file mode 100644
index 0000000..9036daf
--- /dev/null
+++ b/pack.config.mts
@@ -0,0 +1,29 @@
+import type { OutExtensionFactory, PackUserConfig } from 'vite-plus/pack'
+
+/**
+ * Shared output extension mapping that matches tsup's naming so the
+ * published package.json fields (main/module/types/exports) keep working.
+ *
+ * tsup emits `index.js`/`index.d.ts` for ESM and `index.cjs`/`index.d.cts`
+ * for CJS. tsdown defaults to `.mjs`/`.d.mts`, so we override it here.
+ */
+export const tsupCompatibleExtensions: OutExtensionFactory = ({ format }) => {
+ if (format === 'es') {
+ return { js: '.js', dts: '.d.ts' }
+ }
+ return { js: '.cjs', dts: '.d.cts' }
+}
+
+/**
+ * Base pack config for a single-entry package, matching the old tsup config.
+ */
+export const basePackConfig = (): PackUserConfig => ({
+ tsconfig: '../../tsconfig.build.json',
+ outDir: 'dist',
+ dts: { sourcemap: true },
+ clean: true,
+ sourcemap: true,
+ target: 'es2019',
+ format: ['esm', 'cjs'],
+ outExtensions: tsupCompatibleExtensions,
+})
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..3303af4
--- /dev/null
+++ b/package.json
@@ -0,0 +1,90 @@
+{
+ "name": "tiptap",
+ "private": true,
+ "publishConfig": {
+ "provenance": true
+ },
+ "scripts": {
+ "cz": "cz",
+ "start": "vp run --filter ./demos start",
+ "dev": "vp run start",
+ "check": "vp check",
+ "check:fix": "vp check --fix",
+ "format": "vp fmt --check",
+ "format:fix": "vp fmt",
+ "lint": "vp lint",
+ "lint:fix": "vp lint --fix",
+ "lint:staged": "vp staged",
+ "fallow": "fallow",
+ "fallow:audit": "fallow audit",
+ "fallow:health": "fallow health --score --targets",
+ "test:unit": "vp test",
+ "test:unit:dev": "vp test --watch",
+ "test:e2e": "playwright test --project=chromium",
+ "test:e2e:firefox": "playwright test --project=firefox",
+ "test:e2e:all": "playwright test",
+ "test:e2e:open": "playwright test --ui --project=chromium",
+ "test:e2e:open:firefox": "playwright test --ui --project=firefox",
+ "test:e2e:open:all": "playwright test --ui",
+ "test:e2e:report": "playwright show-report",
+ "test:publish-config": "node --test .github/scripts/__tests__/*.test.mjs",
+ "check:package-exports": "node scripts/check-package-exports.mjs",
+ "test": "vp run -r build && vp run test:unit",
+ "build": "vp run -r build",
+ "build:demos": "vp run --filter ./demos --fail-if-no-match build:demos",
+ "serve": "vp run build:demos && http-server ./demos/dist -s -p 3000",
+ "build:ci": "vp run -r build",
+ "publish": "vp run build && vp exec changeset publish",
+ "clean:packages": "rm -rf ./packages/*/dist && rm -rf ./packages/pm/*/dist",
+ "clean:packs": "rm -rf ./packages/*/*.tgz",
+ "make:demo": "sh ./scripts/make-demo.sh",
+ "reset": "vp run clean:packages && vp run clean:packs && rm -rf ./**/.cache && rm -rf ./**/node_modules && rm -rf ./package-lock.json && vp install",
+ "prepare": "vp config --no-agent",
+ "changeset": "changeset",
+ "changelog": "node ./scripts/aggregate-changeset.js",
+ "version": "vp run changelog && vp run changeset -- version && vp install --no-frozen-lockfile",
+ "publish:markdown": "vp run build && vp exec changeset publish --tag markdown",
+ "version:markdown": "vp run changeset version --snapshot markdown"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.29.6",
+ "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
+ "@babel/plugin-proposal-optional-chaining": "^7.21.0",
+ "@babel/preset-env": "^7.26.0",
+ "@babel/preset-react": "^7.26.3",
+ "@changesets/changelog-github": "^0.6.0",
+ "@changesets/cli": "^2.27.11",
+ "@changesets/get-release-plan": "^4.0.13",
+ "@commitlint/cli": "^19.6.1",
+ "@commitlint/config-conventional": "^19.6.0",
+ "@playwright/test": "^1.54.2",
+ "@testing-library/react": "16.2.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "babel-loader": "^9.2.1",
+ "cz-conventional-changelog": "^3.3.0",
+ "fallow": "^2.98.0",
+ "fast-glob": "^3.3.2",
+ "happy-dom": "^20.8.9",
+ "http-server": "14.1.1",
+ "lowlight": "^3.3.0",
+ "minimist": "^1.2.8",
+ "pkg-pr-new": "0.0.66",
+ "react": "^19.0.0",
+ "tinyglobby": "0.2.10",
+ "ts-loader": "9.3.1",
+ "tsconfig-paths": "^4.2.0",
+ "typescript": "^5.7.3",
+ "vite": "catalog:",
+ "vite-plus": "catalog:",
+ "webpack": "^5.97.1"
+ },
+ "browserslist": [
+ "defaults",
+ "not IE 11"
+ ],
+ "engines": {
+ "node": ">=24"
+ },
+ "packageManager": "pnpm@11.2.2"
+}
diff --git a/packages/ai-toolkit/CHANGELOG.md b/packages/ai-toolkit/CHANGELOG.md
new file mode 100644
index 0000000..aa32bac
--- /dev/null
+++ b/packages/ai-toolkit/CHANGELOG.md
@@ -0,0 +1,13 @@
+# @tiptap/ai-toolkit
+
+## 0.4.0
+
+### Minor Changes
+
+- 32b7d87: Add the `AiInsertReveal` extension (`@tiptap/ai-toolkit/streaming-reveal`) to fade in text as the AI streams it into a collaborative document.
+
+## 0.3.0
+
+### Minor Changes
+
+- c8c174e: Rename `@tiptap/server-ai-toolkit` to `@tiptap/ai-toolkit`. The former package remains available as a deprecated alias; update imports to use `@tiptap/ai-toolkit`.
diff --git a/packages/ai-toolkit/README.md b/packages/ai-toolkit/README.md
new file mode 100644
index 0000000..7ffeadc
--- /dev/null
+++ b/packages/ai-toolkit/README.md
@@ -0,0 +1,18 @@
+# @tiptap/ai-toolkit
+
+[](https://www.npmjs.com/package/@tiptap/ai-toolkit)
+[](https://npmcharts.com/compare/tiptap?minimal=true)
+[](https://www.npmjs.com/package/@tiptap/ai-toolkit)
+[](https://github.com/sponsors/ueberdosis)
+
+## Introduction
+
+Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
+
+## Official Documentation
+
+Documentation can be found on the [Tiptap website](https://tiptap.dev).
+
+## License
+
+Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).
diff --git a/packages/ai-toolkit/package.json b/packages/ai-toolkit/package.json
new file mode 100644
index 0000000..8ee1f80
--- /dev/null
+++ b/packages/ai-toolkit/package.json
@@ -0,0 +1,80 @@
+{
+ "name": "@tiptap/ai-toolkit",
+ "version": "0.4.0",
+ "description": "Integrate AI capabilities into the Tiptap editor. This package contains the SDK for connecting to the Server AI Toolkit service.",
+ "keywords": [
+ "ai toolkit",
+ "server",
+ "server ai toolkit",
+ "tiptap",
+ "tiptap ai"
+ ],
+ "homepage": "https://tiptap.dev/docs/editor/extensions/functionality/ai-toolkit",
+ "bugs": {
+ "url": "https://github.com/ueberdosis/tiptap/issues"
+ },
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/ueberdosis/tiptap",
+ "directory": "packages/ai-toolkit"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "files": [
+ "src",
+ "dist"
+ ],
+ "type": "module",
+ "main": "dist/index.cjs",
+ "module": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": {
+ "import": "./dist/index.d.ts",
+ "require": "./dist/index.d.cts"
+ },
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ },
+ "./streaming-reveal": {
+ "types": {
+ "import": "./dist/streaming-reveal.d.ts",
+ "require": "./dist/streaming-reveal.d.cts"
+ },
+ "import": "./dist/streaming-reveal.js",
+ "require": "./dist/streaming-reveal.cjs"
+ }
+ },
+ "scripts": {
+ "build": "vp pack",
+ "dev": "vp pack --watch"
+ },
+ "dependencies": {
+ "es-toolkit": "^1.41.0",
+ "zod": "^4.1.8"
+ },
+ "devDependencies": {
+ "@tiptap/core": "workspace:^",
+ "@tiptap/pm": "workspace:^",
+ "@tiptap/y-tiptap": "^3.0.7",
+ "yjs": "^13.6.23"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.0.1",
+ "@tiptap/pm": "^3.0.1",
+ "@tiptap/y-tiptap": "^3.0.0",
+ "yjs": "^13.6.23"
+ },
+ "peerDependenciesMeta": {
+ "@tiptap/y-tiptap": {
+ "optional": true
+ },
+ "yjs": {
+ "optional": true
+ }
+ }
+}
diff --git a/packages/ai-toolkit/src/hash-extension/server-ai-toolkit-hash-extension.ts b/packages/ai-toolkit/src/hash-extension/server-ai-toolkit-hash-extension.ts
new file mode 100644
index 0000000..9b8d26e
--- /dev/null
+++ b/packages/ai-toolkit/src/hash-extension/server-ai-toolkit-hash-extension.ts
@@ -0,0 +1,77 @@
+import { Extension } from '@tiptap/core'
+
+/**
+ * The attribute name used to store the hash on nodes.
+ */
+const ATTRIBUTE_NAME = '_hash'
+
+/**
+ * Checks whether the editor already includes the full {@link AiToolkit} extension.
+ *
+ * @param extensionNames - Extension names available in the current editor setup.
+ * @return `true` when the AI Toolkit extension is installed.
+ */
+function hasAiToolkitExtension(extensionNames: string[]): boolean {
+ return extensionNames.includes('aiToolkit')
+}
+
+/**
+ * A Tiptap extension that registers `_hash` on non-inline nodes.
+ *
+ * The server package only contributes the attribute definition when AI Toolkit
+ * itself is not already installed.
+ */
+export const ServerAiToolkitHashExtension = Extension.create({
+ name: 'serverAiToolkitHash',
+
+ /**
+ * Registers the hash attribute on all non-inline, non-text, non-doc node types.
+ *
+ * @return The global attribute configuration.
+ */
+ addGlobalAttributes() {
+ if (hasAiToolkitExtension(this.extensions.map(extension => extension.name))) {
+ return []
+ }
+
+ const types = this.extensions
+ .filter(ext => {
+ if (
+ // Only add hashes to nodes, not to marks
+ ext.type !== 'node' ||
+ // Exclude certain node types
+ ext.name === 'text' ||
+ ext.name === 'doc' ||
+ ext.name === 'tableHeader' ||
+ ext.name === 'tableCell' ||
+ // Exclude inline nodes
+ (typeof ext.config?.group === 'string' && ext.config.group.includes('inline'))
+ ) {
+ return false
+ }
+ return true
+ })
+ .map(ext => ext.name)
+
+ return [
+ {
+ types,
+ attributes: {
+ [ATTRIBUTE_NAME]: {
+ default: null,
+ parseHTML: (element: HTMLElement) => element.getAttribute(ATTRIBUTE_NAME),
+ renderHTML: (attributes: Record) => {
+ if (!attributes[ATTRIBUTE_NAME]) {
+ return {}
+ }
+
+ return {
+ [ATTRIBUTE_NAME]: attributes[ATTRIBUTE_NAME],
+ }
+ },
+ },
+ },
+ },
+ ]
+ },
+})
diff --git a/packages/ai-toolkit/src/index.ts b/packages/ai-toolkit/src/index.ts
new file mode 100644
index 0000000..0d5ec20
--- /dev/null
+++ b/packages/ai-toolkit/src/index.ts
@@ -0,0 +1,2 @@
+export * from './schema-awareness/index.js'
+export * from './server-ai-toolkit-extension.js'
diff --git a/packages/ai-toolkit/src/schema-awareness/get-editor-context.ts b/packages/ai-toolkit/src/schema-awareness/get-editor-context.ts
new file mode 100644
index 0000000..8119ed3
--- /dev/null
+++ b/packages/ai-toolkit/src/schema-awareness/get-editor-context.ts
@@ -0,0 +1,86 @@
+import { type Editor, getExtensionField, Mark } from '@tiptap/core'
+import { z } from 'zod'
+
+import type { EditorContext } from './types/editor-context.js'
+import type { AddJsonSchemaAwareness, JsonItem } from './types/json-item.js'
+import { defaultJsonItems } from './utils/default-json-items.js'
+import { mergeJsonItems } from './utils/merge-json-items.js'
+import { serializeSchema } from './utils/serialize-schema.js'
+
+/**
+ * Options for {@link getEditorContext}.
+ */
+export interface GetEditorContextOptions {
+ /**
+ * Custom schema awareness items to include in addition to the default ones.
+ *
+ * This lets callers extend the serialized editor context with custom node
+ * definitions or overrides.
+ * @default []
+ */
+ customNodes?: JsonItem[]
+}
+
+/**
+ * Returns editor context data including a serialized schema and merged custom nodes.
+ *
+ * This function collects schema information from extensions and converts it to a format
+ * suitable for AI model consumption. Zod schemas in attributes are converted to JSON schemas.
+ *
+ * @param editor - The Tiptap editor instance
+ * @param options - Configuration options for the editor context
+ * @return An object containing the serialized schema and merged custom nodes with JSON schema attributes
+ */
+export function getEditorContext(
+ editor: Editor,
+ options: GetEditorContextOptions = {},
+): EditorContext {
+ const extensionNamesSet = new Set(editor.extensionManager.extensions.map(ext => ext.name))
+
+ const customNodesFromExtensions = editor.extensionManager.extensions.flatMap(extension => {
+ const context = {
+ name: extension.name,
+ options: extension.options,
+ storage: extension.storage,
+ editor,
+ }
+ const addJsonSchemaAwareness = getExtensionField(
+ extension,
+ 'addJsonSchemaAwareness',
+ context,
+ )
+ if (addJsonSchemaAwareness) {
+ return {
+ ...addJsonSchemaAwareness(),
+ isMark: extension instanceof Mark,
+ extensionName: extension.name,
+ }
+ }
+ return []
+ })
+
+ const items = [defaultJsonItems, customNodesFromExtensions, options.customNodes ?? []]
+ .reduce(mergeJsonItems, [])
+ .filter((item: JsonItem) => extensionNamesSet.has(item.extensionName))
+
+ // Convert Zod schemas to JSON schemas
+ const mergedCustomNodes = items.map((item: JsonItem) => {
+ const { attributes, ...rest } = item
+ if (!attributes) {
+ return rest
+ }
+
+ const attributesSchema = z.object(attributes)
+ const jsonSchema = z.toJSONSchema(attributesSchema)
+
+ return {
+ ...rest,
+ attributes: jsonSchema,
+ }
+ })
+
+ return {
+ serializedSchema: serializeSchema(editor.schema),
+ items: mergedCustomNodes,
+ }
+}
diff --git a/packages/ai-toolkit/src/schema-awareness/index.ts b/packages/ai-toolkit/src/schema-awareness/index.ts
new file mode 100644
index 0000000..fe6954c
--- /dev/null
+++ b/packages/ai-toolkit/src/schema-awareness/index.ts
@@ -0,0 +1,2 @@
+export * from './get-editor-context.js'
+export * from './types/index.js'
diff --git a/packages/ai-toolkit/src/schema-awareness/types/editor-context.ts b/packages/ai-toolkit/src/schema-awareness/types/editor-context.ts
new file mode 100644
index 0000000..2ecda58
--- /dev/null
+++ b/packages/ai-toolkit/src/schema-awareness/types/editor-context.ts
@@ -0,0 +1,17 @@
+import type { SerializedSchema } from '../utils/serialize-schema.js'
+import type { SerializedJsonItem } from './json-item.js'
+
+/**
+ * Result of {@link getEditorContext} containing information about the
+ * editor's extensions that the Server AI Toolkit needs to work.
+ */
+export interface EditorContext {
+ /**
+ * The serialized ProseMirror schema
+ */
+ serializedSchema: SerializedSchema
+ /**
+ * The merged custom nodes with Zod schemas converted to JSON schemas
+ */
+ items: SerializedJsonItem[]
+}
diff --git a/packages/ai-toolkit/src/schema-awareness/types/index.ts b/packages/ai-toolkit/src/schema-awareness/types/index.ts
new file mode 100644
index 0000000..b40b15e
--- /dev/null
+++ b/packages/ai-toolkit/src/schema-awareness/types/index.ts
@@ -0,0 +1,2 @@
+export * from './editor-context.js'
+export * from './json-item.js'
diff --git a/packages/ai-toolkit/src/schema-awareness/types/json-item.ts b/packages/ai-toolkit/src/schema-awareness/types/json-item.ts
new file mode 100644
index 0000000..cf7354d
--- /dev/null
+++ b/packages/ai-toolkit/src/schema-awareness/types/json-item.ts
@@ -0,0 +1,51 @@
+import type { Editor } from '@tiptap/core'
+import type { z } from 'zod'
+
+/**
+ * Represents a JSON schema awareness item for AI model understanding
+ *
+ * This interface defines the structure of schema information that is provided
+ * to AI models to help them understand what JSON elements and attributes are
+ * available in the editor's schema.
+ */
+export interface JsonItem {
+ /**
+ * The name of the extension that provides this element
+ */
+ extensionName: string
+ /**
+ * If `true`, the item is a mark instead of a node.
+ */
+ isMark?: boolean
+ /**
+ * The human-readable name of the element in English
+ */
+ name: string
+ /**
+ * Explanation of the element in English for the AI model
+ */
+ description?: string | null
+ /**
+ * Possible attributes of the JSON element. If `undefined`, there are no attributes.
+ * The keys are attribute names and the values are Zod schemas that define the attribute structure.
+ */
+ attributes?: Record
+}
+
+export type SerializedJsonItem = Omit & { attributes?: unknown }
+
+export type AddJsonSchemaAwareness = (this: {
+ name: string
+ options: Options
+ storage: Storage
+ editor?: Editor
+}) => Omit
+
+declare module '@tiptap/core' {
+ interface NodeConfig {
+ addJsonSchemaAwareness?: AddJsonSchemaAwareness
+ }
+ interface MarkConfig {
+ addJsonSchemaAwareness?: AddJsonSchemaAwareness
+ }
+}
diff --git a/packages/ai-toolkit/src/schema-awareness/utils/default-json-items.ts b/packages/ai-toolkit/src/schema-awareness/utils/default-json-items.ts
new file mode 100644
index 0000000..be4ab0f
--- /dev/null
+++ b/packages/ai-toolkit/src/schema-awareness/utils/default-json-items.ts
@@ -0,0 +1,283 @@
+import { z } from 'zod'
+
+import type { JsonItem } from '../types/json-item.js'
+
+export const defaultJsonItems: JsonItem[] = [
+ // Core nodes
+ // Omit the root 'doc' node because the AI does not read it or generate it.
+ {
+ extensionName: 'paragraph',
+ name: 'Paragraph',
+ },
+ {
+ extensionName: 'text',
+ name: 'Text',
+ },
+ {
+ extensionName: 'heading',
+ name: 'Heading',
+ description: 'h1 to h6',
+ attributes: {
+ level: z.number().min(1).max(6),
+ },
+ },
+ {
+ extensionName: 'blockquote',
+ name: 'Blockquote',
+ description: 'for quoted content',
+ },
+ {
+ extensionName: 'codeBlock',
+ name: 'Code Block',
+ attributes: {
+ language: z.string().nullable().optional(),
+ },
+ },
+ {
+ extensionName: 'hardBreak',
+ name: 'Line Break',
+ },
+ {
+ extensionName: 'horizontalRule',
+ name: 'Horizontal Rule',
+ description: 'horizontal divider line',
+ },
+ {
+ extensionName: 'image',
+ name: 'Image',
+ attributes: {
+ src: z.string(),
+ alt: z.string().nullable().optional(),
+ title: z.string().nullable().optional(),
+ width: z.number().nullable().optional(),
+ height: z.number().nullable().optional(),
+ },
+ },
+ // List nodes
+ {
+ extensionName: 'bulletList',
+ name: 'Bullet List',
+ description: 'Unordered list with bullet points',
+ },
+ {
+ extensionName: 'orderedList',
+ name: 'Ordered List',
+ description: 'Numbered list',
+ attributes: {
+ start: z.number().optional(),
+ type: z
+ .string()
+ .nullable()
+ .optional()
+ .describe(
+ 'Numbering style: "1" for numbers, "a" for lowercase letters, "A" for uppercase letters, "i" for lowercase roman numerals, "I" for uppercase roman numerals',
+ ),
+ },
+ },
+ {
+ extensionName: 'listItem',
+ name: 'List Item',
+ description: 'Item within a bulletList or orderedList',
+ },
+ {
+ extensionName: 'taskList',
+ name: 'Task List',
+ description: 'Checklist with task items',
+ },
+ {
+ extensionName: 'taskItem',
+ name: 'Task Item',
+ description: 'Item within a taskList',
+ attributes: {
+ checked: z.boolean(),
+ },
+ },
+ // Table nodes
+ {
+ extensionName: 'table',
+ name: 'Table',
+ description: 'Table container node',
+ },
+ {
+ extensionName: 'tableRow',
+ name: 'Table Row',
+ description: 'Row within a table',
+ },
+ {
+ extensionName: 'tableCell',
+ name: 'Table Cell',
+ description: 'Cell within a tableRow',
+ },
+ {
+ extensionName: 'tableHeader',
+ name: 'Table Header',
+ description: 'Header cell within a tableRow',
+ },
+ // Special nodes
+ {
+ extensionName: 'mention',
+ name: 'Mention',
+ description: 'Mention node for @mentions and similar',
+ attributes: {
+ id: z.string(),
+ label: z.string().nullable().optional(),
+ mentionSuggestionChar: z
+ .string()
+ .optional()
+ .describe(
+ 'The character that triggers this mention type (e.g., "@" for users, "#" for tags). Used to distinguish between multiple mention types',
+ ),
+ },
+ },
+ {
+ extensionName: 'emoji',
+ name: 'Emoji',
+ attributes: {
+ name: z
+ .string()
+ .describe(
+ 'The unique name/shortcode identifier for the emoji (e.g., "smile", "heart", "thumbs_up")',
+ ),
+ },
+ },
+ {
+ extensionName: 'youtube',
+ name: 'YouTube',
+ attributes: {
+ src: z.string(),
+ start: z
+ .number()
+ .optional()
+ .describe('Start time in seconds for when the video should begin playing'),
+ width: z.number().optional(),
+ height: z.number().optional(),
+ },
+ },
+ {
+ extensionName: 'details',
+ name: 'Details',
+ description: 'Collapsible details/summary',
+ attributes: {
+ open: z.boolean().optional(),
+ },
+ },
+ {
+ extensionName: 'detailsSummary',
+ name: 'Details Summary',
+ description: 'Summary text for a details node',
+ },
+ {
+ extensionName: 'detailsContent',
+ name: 'Details Content',
+ description: 'Collapsible content within a details node',
+ },
+ {
+ extensionName: 'blockMath',
+ name: 'Block Math',
+ attributes: {
+ latex: z.string(),
+ },
+ },
+ {
+ extensionName: 'inlineMath',
+ name: 'Inline Math',
+ attributes: {
+ latex: z.string(),
+ },
+ },
+ // Marks
+ {
+ extensionName: 'bold',
+ name: 'Bold',
+ isMark: true,
+ },
+ {
+ extensionName: 'italic',
+ name: 'Italic',
+ isMark: true,
+ },
+ {
+ extensionName: 'code',
+ name: 'Code',
+ isMark: true,
+ },
+ {
+ extensionName: 'link',
+ name: 'Link',
+ isMark: true,
+ attributes: {
+ href: z.string(),
+ target: z.string().nullable().optional(),
+ rel: z.string().nullable().optional(),
+ class: z.string().nullable().optional(),
+ },
+ },
+ {
+ extensionName: 'strike',
+ name: 'Strike',
+ isMark: true,
+ },
+ {
+ extensionName: 'underline',
+ name: 'Underline',
+ isMark: true,
+ },
+ {
+ extensionName: 'highlight',
+ name: 'Highlight',
+ isMark: true,
+ attributes: {
+ color: z.string().nullable().optional(),
+ },
+ },
+ {
+ extensionName: 'subscript',
+ name: 'Subscript',
+ isMark: true,
+ },
+ {
+ extensionName: 'superscript',
+ name: 'Superscript',
+ isMark: true,
+ },
+ {
+ extensionName: 'textStyle',
+ name: 'Text Style',
+ isMark: true,
+ attributes: {
+ fontFamily: z.string(),
+ fontSize: z.string(),
+ lineHeight: z.string(),
+ color: z.string(),
+ backgroundColor: z.string(),
+ },
+ },
+ // tiptap-pro nodes
+ {
+ extensionName: 'iframely',
+ name: 'Iframely',
+ description: 'Embed content from URLs using Iframely service',
+ attributes: {
+ url: z.string().nullable(),
+ data: z.any().nullable().optional(),
+ },
+ },
+ {
+ extensionName: 'blockThread',
+ name: 'Block Thread',
+ description: 'Block-level comment thread',
+ attributes: {
+ 'data-thread-id': z.string(),
+ },
+ },
+ // tiptap-pro marks
+ {
+ extensionName: 'inlineThread',
+ name: 'Inline Thread',
+ isMark: true,
+ description: 'Inline comment thread',
+ attributes: {
+ 'data-thread-id': z.string(),
+ },
+ },
+]
diff --git a/packages/ai-toolkit/src/schema-awareness/utils/merge-json-items.ts b/packages/ai-toolkit/src/schema-awareness/utils/merge-json-items.ts
new file mode 100644
index 0000000..d26014f
--- /dev/null
+++ b/packages/ai-toolkit/src/schema-awareness/utils/merge-json-items.ts
@@ -0,0 +1,14 @@
+import type { JsonItem } from '../types/json-item.js'
+
+/**
+ * When new JSON items are added, we need to remove from `items` the items that
+ * are duplicated in `newItems`
+ * @param items - The original array of JSON items
+ * @param newItems - The new array of JSON items to add
+ * @returns A new array of JSON items where the items from `newItems` are added
+ * and the items from `items` that are duplicated in `newItems` are removed
+ */
+export function mergeJsonItems(items: JsonItem[], newItems: JsonItem[]): JsonItem[] {
+ const newItemsSet = new Set(newItems.map(item => item.extensionName))
+ return [...items.filter(item => !newItemsSet.has(item.extensionName)), ...newItems]
+}
diff --git a/packages/ai-toolkit/src/schema-awareness/utils/serialize-schema.ts b/packages/ai-toolkit/src/schema-awareness/utils/serialize-schema.ts
new file mode 100644
index 0000000..fb11b6d
--- /dev/null
+++ b/packages/ai-toolkit/src/schema-awareness/utils/serialize-schema.ts
@@ -0,0 +1,123 @@
+import type { AttributeSpec, MarkSpec, NodeSpec, Schema } from '@tiptap/pm/model'
+import { omit } from 'es-toolkit'
+
+/**
+ * A serialized version of AttributeSpec where the validate function is converted to a string.
+ * This allows the attribute specification to be JSON-serializable.
+ */
+export type SerializedAttributeSpec = Omit & {
+ validate?: string
+}
+
+/**
+ * A type representing serialized attributes as a record of attribute names to their serialized specs.
+ */
+type SerializedAttributes = { attrs?: Record }
+
+/**
+ * A serialized version of NodeSpec with non-serializable properties
+ * (toDOM, parseDOM, toDebugString, leafText, toText) removed and attrs converted to
+ * SerializedAttributeSpec format.
+ */
+export type SerializedNodeSpec = Omit<
+ NodeSpec,
+ 'toDOM' | 'parseDOM' | 'toDebugString' | 'leafText' | 'toText' | 'attrs'
+> &
+ SerializedAttributes
+
+/**
+ * A serialized version of MarkSpec with non-serializable properties (toDOM, parseDOM) removed
+ * and attrs converted to SerializedAttributeSpec format.
+ */
+export type SerializedMarkSpec = Omit &
+ SerializedAttributes
+
+/**
+ * A JSON-serializable representation of a ProseMirror Schema.
+ * Contains the top node name, and serialized node and mark specifications.
+ */
+export interface SerializedSchema {
+ topNode?: string
+ nodes: Record
+ marks: Record
+}
+
+/**
+ * Serializes an AttributeSpec by converting the validate function to a string if it exists.
+ * This makes the attribute specification JSON-serializable.
+ *
+ * @param attributeSpec - The AttributeSpec to serialize
+ * @returns A SerializedAttributeSpec with validate as a string or undefined
+ */
+function serializeAttributeSpec(attributeSpec: AttributeSpec): SerializedAttributeSpec {
+ return {
+ ...omit(attributeSpec, ['validate']),
+ validate: typeof attributeSpec.validate === 'string' ? attributeSpec.validate : undefined,
+ }
+}
+
+/**
+ * Serializes a record of AttributeSpec objects by converting each one using serializeAttributeSpec.
+ *
+ * @param attributeSpecs - A record of attribute names to their AttributeSpec definitions
+ * @returns A record of attribute names to their SerializedAttributeSpec definitions
+ */
+function serializeAttributeSpecs(
+ attributeSpecs: Record,
+): Record {
+ return Object.fromEntries(
+ Object.entries(attributeSpecs).map(([key, value]) => [key, serializeAttributeSpec(value)]),
+ )
+}
+
+/**
+ * Serializes a NodeSpec by removing non-serializable properties
+ * (toDOM, parseDOM, toDebugString, leafText, toText) and converting the attrs to
+ * SerializedAttributeSpec format.
+ *
+ * @param nodeSpec - The NodeSpec to serialize
+ * @returns A SerializedNodeSpec with only JSON-serializable properties
+ */
+function serializeNodeSpec(nodeSpec: NodeSpec): SerializedNodeSpec {
+ return {
+ ...omit(nodeSpec, ['toDOM', 'parseDOM', 'toDebugString', 'leafText', 'toText', 'attrs']),
+ attrs: nodeSpec.attrs ? serializeAttributeSpecs(nodeSpec.attrs) : undefined,
+ }
+}
+
+/**
+ * Serializes a MarkSpec by removing non-serializable properties (toDOM, parseDOM)
+ * and converting the attrs to SerializedAttributeSpec format.
+ *
+ * @param markSpec - The MarkSpec to serialize
+ * @returns A SerializedMarkSpec with only JSON-serializable properties
+ */
+function serializeMarkSpec(markSpec: MarkSpec): SerializedMarkSpec {
+ return {
+ ...omit(markSpec, ['toDOM', 'parseDOM', 'attrs']),
+ attrs: markSpec.attrs ? serializeAttributeSpecs(markSpec.attrs) : undefined,
+ }
+}
+
+/**
+ * Serializes a ProseMirror Schema to a JSON-serializable format.
+ * Converts all node and mark specifications by removing non-serializable properties
+ * and converting attribute validation functions to strings.
+ *
+ * Non-serializable properties are properties related to DOM serialization and parsing,
+ * and functions that validate attribute values. These are just removed from the schema.
+ *
+ * @param schema - The ProseMirror Schema to serialize
+ * @returns A SerializedSchema containing the top node name and serialized node/mark specs
+ */
+export function serializeSchema(schema: Schema): SerializedSchema {
+ return {
+ topNode: schema.spec.topNode,
+ nodes: Object.fromEntries(
+ Object.values(schema.nodes).map(node => [node.name, serializeNodeSpec(node.spec)]),
+ ),
+ marks: Object.fromEntries(
+ Object.values(schema.marks).map(mark => [mark.name, serializeMarkSpec(mark.spec)]),
+ ),
+ }
+}
diff --git a/packages/ai-toolkit/src/server-ai-toolkit-extension.ts b/packages/ai-toolkit/src/server-ai-toolkit-extension.ts
new file mode 100644
index 0000000..6841998
--- /dev/null
+++ b/packages/ai-toolkit/src/server-ai-toolkit-extension.ts
@@ -0,0 +1,23 @@
+import { Extension } from '@tiptap/core'
+
+import { ServerAiToolkitHashExtension } from './hash-extension/server-ai-toolkit-hash-extension.js'
+
+/**
+ * Server AI Toolkit extension.
+ *
+ * This package-level extension registers the internal hash extension used by
+ * the Server AI Toolkit so server-side read and edit flows can rely on stable
+ * `_hash` attributes already stored in the document.
+ */
+export const ServerAiToolkit = Extension.create({
+ name: 'serverAiToolkit',
+
+ /**
+ * Registers internal extensions required by the Server AI Toolkit.
+ *
+ * @return The list of internal extensions.
+ */
+ addExtensions() {
+ return [ServerAiToolkitHashExtension]
+ },
+})
diff --git a/packages/ai-toolkit/src/streaming-reveal.test.ts b/packages/ai-toolkit/src/streaming-reveal.test.ts
new file mode 100644
index 0000000..074a319
--- /dev/null
+++ b/packages/ai-toolkit/src/streaming-reveal.test.ts
@@ -0,0 +1,509 @@
+// @vitest-environment happy-dom
+
+import { Editor } from '@tiptap/core'
+import { Collaboration } from '@tiptap/extension-collaboration'
+import StarterKit from '@tiptap/starter-kit'
+import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
+import * as Y from 'yjs'
+
+import { AiInsertReveal } from './streaming-reveal.js'
+
+const AI_CLIENT_ID = 111111
+const HUMAN_CLIENT_ID = 222222
+
+/** The awareness payload the Tiptap AI server publishes while it streams. */
+const AI_USER = { name: 'AI', color: '#8B5CF6', aiInstanceId: 'ai-instance-1' }
+
+/** Stands in for a collab provider, exposing only the awareness surface we read. */
+function createProvider() {
+ const states = new Map>()
+ const listeners = new Set<() => void>()
+
+ return {
+ awareness: {
+ getStates: () => states,
+ on: (_event: string, listener: () => void) => listeners.add(listener),
+ off: (_event: string, listener: () => void) => listeners.delete(listener),
+ },
+ announce(clientId: number, user: Record) {
+ states.set(clientId, { user })
+ listeners.forEach(listener => listener())
+ },
+ }
+}
+
+/** Creates an editor with {@link AiInsertReveal} but no collaboration (no y-sync plugin). */
+function createEditor(): Promise {
+ return new Promise(resolve => {
+ const editor = new Editor({
+ element: document.createElement('div'),
+ extensions: [StarterKit, AiInsertReveal.configure({ provider: createProvider() })],
+ content: {
+ type: 'doc',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }],
+ },
+ onCreate: () => {
+ resolve(editor)
+ },
+ })
+ })
+}
+
+/** A remote peer that keeps its clientID across writes. */
+function createPeer(ydoc: Y.Doc, clientID: number): Y.Doc {
+ const peer = new Y.Doc()
+ peer.clientID = clientID
+ Y.applyUpdate(peer, Y.encodeStateAsUpdate(ydoc))
+ return peer
+}
+
+/** Creates a collaborative editor seeded with one `Hello` paragraph, plus two remote peers. */
+function createCollabEditor(options?: {
+ durationMs?: number
+}): Promise<{ editor: Editor; ydoc: Y.Doc; ai: Y.Doc; human: Y.Doc }> {
+ const ydoc = new Y.Doc()
+ const provider = createProvider()
+ provider.announce(AI_CLIENT_ID, AI_USER)
+ provider.announce(HUMAN_CLIENT_ID, { name: 'Someone else', color: '#0EA5E9' })
+
+ return new Promise(resolve => {
+ new Editor({
+ element: document.createElement('div'),
+ extensions: [
+ StarterKit.configure({ undoRedo: false }),
+ Collaboration.configure({ document: ydoc }),
+ AiInsertReveal.configure({
+ provider,
+ ...(options?.durationMs === undefined ? {} : { durationMs: options.durationMs }),
+ }),
+ ],
+ onCreate: ({ editor }) => {
+ // Collaboration ignores the `content` prop (the empty Y.Doc wins when the
+ // y-sync plugin binds), so seed the shared doc with a local edit instead.
+ editor.commands.setContent('Hello
')
+ resolve({
+ editor,
+ ydoc,
+ ai: createPeer(ydoc, AI_CLIENT_ID),
+ human: createPeer(ydoc, HUMAN_CLIENT_ID),
+ })
+ },
+ })
+ })
+}
+
+/** Applies an insert from `peer`, so it lands as a remote transaction authored by it. */
+function remoteInsert(peer: Y.Doc, ydoc: Y.Doc, index: number, text: string): void {
+ Y.applyUpdate(peer, Y.encodeStateAsUpdate(ydoc, Y.encodeStateVector(peer)))
+ const paragraph = peer.getXmlFragment('default').get(0) as Y.XmlElement
+ const xmlText = paragraph.get(0) as Y.XmlText
+ xmlText.insert(index, text)
+ Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(peer, Y.encodeStateVector(ydoc)))
+}
+
+/** Applies a whole new block from `peer`, the way the AI writes a fresh paragraph. */
+function remoteInsertBlock(peer: Y.Doc, ydoc: Y.Doc, text: string): void {
+ Y.applyUpdate(peer, Y.encodeStateAsUpdate(ydoc, Y.encodeStateVector(peer)))
+ const fragment = peer.getXmlFragment('default')
+ const block = new Y.XmlElement('paragraph')
+ const blockText = new Y.XmlText()
+ block.insert(0, [blockText])
+ fragment.insert(0, [block])
+ if (text.length > 0) blockText.insert(0, text)
+ Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(peer, Y.encodeStateVector(ydoc)))
+}
+
+/** Collects the extension's current reveal decorations, resolved against `state`. */
+function revealDecorations(
+ editor: Editor,
+ state: unknown = editor.state,
+): Array<{ from: number; to: number; style: string }> {
+ for (const plugin of editor.state.plugins) {
+ const set = (plugin as any).props?.decorations?.call(plugin, state)
+ const found = (set?.find?.() ?? []).filter(
+ (d: any) => d.type?.attrs?.class === 'ai-insert-reveal',
+ )
+ if (found.length > 0) {
+ return found.map((d: any) => ({ from: d.from, to: d.to, style: d.type.attrs.style ?? '' }))
+ }
+ }
+ return []
+}
+
+describe('AiInsertReveal', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('is a named Tiptap extension', () => {
+ expect(AiInsertReveal.name).toBe('aiInsertReveal')
+ })
+
+ it('warns and degrades to a no-op when no collaboration y-sync plugin is present', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const editor = await createEditor()
+
+ expect(editor.extensionManager.extensions.some(e => e.name === 'aiInsertReveal')).toBe(true)
+ // Without a y-sync plugin the decorations source resolves to nothing, so the
+ // editor renders normally rather than throwing.
+ expect(editor.getText()).toBe('Hello')
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('Collaboration extension'))
+
+ warn.mockRestore()
+ editor.destroy()
+ })
+
+ it('applies configured className and durationMs', async () => {
+ const editor = await new Promise(resolve => {
+ const created = new Editor({
+ element: document.createElement('div'),
+ extensions: [
+ StarterKit,
+ AiInsertReveal.configure({
+ className: 'custom-reveal',
+ durationMs: 300,
+ provider: createProvider(),
+ }),
+ ],
+ onCreate: () => resolve(created),
+ })
+ })
+
+ const reveal = editor.extensionManager.extensions.find(e => e.name === 'aiInsertReveal')
+ expect(reveal?.options).toMatchObject({ className: 'custom-reveal', durationMs: 300 })
+
+ editor.destroy()
+ })
+
+ it('reveals a remote insert as a decoration over exactly the inserted run', async () => {
+ const { editor, ydoc, ai } = await createCollabEditor()
+
+ remoteInsert(ai, ydoc, 5, ' WORLD')
+
+ expect(editor.getText()).toBe('Hello WORLD')
+ const decorations = revealDecorations(editor)
+ expect(decorations).toHaveLength(1)
+ expect(decorations[0].to - decorations[0].from).toBe(' WORLD'.length)
+ // The age-seeded animation-delay is present so the fade survives re-renders.
+ expect(decorations[0].style).toMatch(/animation-delay: -\d+ms/)
+
+ editor.destroy()
+ })
+
+ it('reveals a block that arrives as a whole new node', async () => {
+ const { editor, ydoc, ai } = await createCollabEditor()
+
+ remoteInsertBlock(ai, ydoc, 'A brand new title')
+
+ const decorations = revealDecorations(editor)
+ expect(decorations).toHaveLength(1)
+ expect(decorations[0].to - decorations[0].from).toBe('A brand new title'.length)
+
+ editor.destroy()
+ })
+
+ it('reveals text streamed into a block that was created empty', async () => {
+ const { editor, ydoc, ai } = await createCollabEditor()
+
+ remoteInsertBlock(ai, ydoc, '')
+ remoteInsert(ai, ydoc, 0, 'Streamed title')
+
+ const decorations = revealDecorations(editor)
+ expect(decorations).toHaveLength(1)
+ expect(decorations[0].to - decorations[0].from).toBe('Streamed title'.length)
+
+ editor.destroy()
+ })
+
+ it('merges touching runs that share an animation offset into one decoration', async () => {
+ const { editor, ydoc, ai } = await createCollabEditor()
+ // Only the clock is faked: the editor is created through a real `setTimeout`.
+ vi.useFakeTimers({ toFake: ['Date'] })
+
+ remoteInsert(ai, ydoc, 5, ' one')
+ remoteInsert(ai, ydoc, 9, ' two')
+
+ expect(editor.getText()).toBe('Hello one two')
+ const decorations = revealDecorations(editor)
+ expect(decorations).toHaveLength(1)
+ expect(decorations[0].to - decorations[0].from).toBe(' one two'.length)
+
+ editor.destroy()
+ })
+
+ it('reveals an insert at the very end of the document', async () => {
+ const { editor, ydoc, ai } = await createCollabEditor()
+
+ remoteInsert(ai, ydoc, 5, '!')
+
+ expect(editor.getText()).toBe('Hello!')
+ const decorations = revealDecorations(editor)
+ expect(decorations).toHaveLength(1)
+ expect(decorations[0].to - decorations[0].from).toBe(1)
+ expect(decorations[0].to).toBeLessThanOrEqual(editor.state.doc.content.size)
+
+ editor.destroy()
+ })
+
+ it('clamps a decoration that resolves past the end of the document', async () => {
+ const { editor, ydoc, ai } = await createCollabEditor()
+
+ remoteInsert(ai, ydoc, 5, ' WORLD')
+ expect(revealDecorations(editor)).toHaveLength(1)
+
+ // Y.Doc can be ahead of the PM doc mid-sync, so resolve against a shorter doc.
+ const shortDoc = editor.schema.nodeFromJSON({
+ type: 'doc',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello!' }] }],
+ })
+ const decorations = revealDecorations(editor, { ...editor.state, doc: shortDoc })
+
+ expect(decorations).toHaveLength(1)
+ expect(decorations[0].to).toBe(shortDoc.content.size)
+
+ editor.destroy()
+ })
+
+ it("does not reveal another collaborator's remote insert", async () => {
+ const { editor, ydoc, human } = await createCollabEditor()
+
+ remoteInsert(human, ydoc, 5, ' WORLD')
+
+ // The insert lands like any remote edit, it just must not fade.
+ expect(editor.getText()).toBe('Hello WORLD')
+ expect(revealDecorations(editor)).toHaveLength(0)
+
+ editor.destroy()
+ })
+
+ it('reveals the AI while a collaborator types alongside it', async () => {
+ const { editor, ydoc, ai, human } = await createCollabEditor()
+
+ remoteInsert(human, ydoc, 5, ' HUMAN')
+ remoteInsert(ai, ydoc, 11, ' AI')
+
+ expect(editor.getText()).toBe('Hello HUMAN AI')
+ const decorations = revealDecorations(editor)
+ expect(decorations).toHaveLength(1)
+ expect(decorations[0].to - decorations[0].from).toBe(' AI'.length)
+
+ editor.destroy()
+ })
+
+ it('reveals a run that arrived before awareness identified the AI', async () => {
+ const ydoc = new Y.Doc()
+ const provider = createProvider()
+
+ const editor = await new Promise(resolve => {
+ new Editor({
+ element: document.createElement('div'),
+ extensions: [
+ StarterKit.configure({ undoRedo: false }),
+ Collaboration.configure({ document: ydoc }),
+ AiInsertReveal.configure({ provider }),
+ ],
+ onCreate: ({ editor: created }) => {
+ created.commands.setContent('Hello
')
+ resolve(created)
+ },
+ })
+ })
+
+ // The AI's first tokens can land before its awareness entry does.
+ remoteInsert(createPeer(ydoc, AI_CLIENT_ID), ydoc, 5, ' WORLD')
+ expect(revealDecorations(editor)).toHaveLength(0)
+
+ provider.announce(AI_CLIENT_ID, AI_USER)
+
+ expect(revealDecorations(editor)).toHaveLength(1)
+
+ editor.destroy()
+ })
+
+ it('reveals a multi-block structure the AI writes as one node', async () => {
+ const { editor, ydoc, ai } = await createCollabEditor()
+
+ Y.applyUpdate(ai, Y.encodeStateAsUpdate(ydoc, Y.encodeStateVector(ai)))
+ const list = new Y.XmlElement('bulletList')
+ for (let item = 0; item < 3; item++) {
+ const listItem = new Y.XmlElement('listItem')
+ const paragraph = new Y.XmlElement('paragraph')
+ const text = new Y.XmlText()
+ paragraph.insert(0, [text])
+ listItem.insert(0, [paragraph])
+ list.insert(list.length, [listItem])
+ text.insert(0, `Item ${item}`)
+ }
+ ai.getXmlFragment('default').insert(0, [list])
+ Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ai, Y.encodeStateVector(ydoc)))
+
+ // One structure, so the block cap must not mistake it for a document sync.
+ expect(revealDecorations(editor)).toHaveLength(3)
+
+ editor.destroy()
+ })
+
+ it('reveals nothing when a whole AI-authored document arrives at once', async () => {
+ const ydoc = new Y.Doc()
+ const provider = createProvider()
+ provider.announce(AI_CLIENT_ID, AI_USER)
+
+ const editor = await new Promise(resolve => {
+ const created = new Editor({
+ element: document.createElement('div'),
+ extensions: [
+ StarterKit.configure({ undoRedo: false }),
+ Collaboration.configure({ document: ydoc }),
+ AiInsertReveal.configure({ provider }),
+ ],
+ onCreate: () => resolve(created),
+ })
+ })
+
+ // Joining a room hands over everything the AI wrote before, in one update.
+ const server = new Y.Doc()
+ server.clientID = AI_CLIENT_ID
+ const fragment = server.getXmlFragment('default')
+ for (let block = 0; block < 5; block++) {
+ const element = new Y.XmlElement('paragraph')
+ const text = new Y.XmlText()
+ element.insert(0, [text])
+ fragment.insert(fragment.length, [element])
+ text.insert(0, `Paragraph ${block} written earlier by the AI.`)
+ }
+ Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(server))
+
+ expect(editor.getText().length).toBeGreaterThan(0)
+ expect(revealDecorations(editor)).toHaveLength(0)
+
+ editor.destroy()
+ })
+
+ it('warns when the provider has no awareness', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+
+ const editor = await new Promise(resolve => {
+ const created = new Editor({
+ element: document.createElement('div'),
+ extensions: [StarterKit, AiInsertReveal.configure({ provider: {} })],
+ onCreate: () => resolve(created),
+ })
+ })
+
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('no awareness'))
+
+ warn.mockRestore()
+ editor.destroy()
+ })
+
+ it('warns when durationMs cannot hold a reveal', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+
+ const editor = await new Promise(resolve => {
+ const created = new Editor({
+ element: document.createElement('div'),
+ extensions: [
+ StarterKit,
+ AiInsertReveal.configure({ provider: createProvider(), durationMs: 0 }),
+ ],
+ onCreate: () => resolve(created),
+ })
+ })
+
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('durationMs'))
+
+ warn.mockRestore()
+ editor.destroy()
+ })
+
+ it('warns and reveals nothing when no provider is configured', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const ydoc = new Y.Doc()
+
+ const editor = await new Promise(resolve => {
+ new Editor({
+ element: document.createElement('div'),
+ extensions: [
+ StarterKit.configure({ undoRedo: false }),
+ Collaboration.configure({ document: ydoc }),
+ AiInsertReveal,
+ ],
+ onCreate: ({ editor: created }) => {
+ created.commands.setContent('Hello
')
+ resolve(created)
+ },
+ })
+ })
+
+ remoteInsert(createPeer(ydoc, AI_CLIENT_ID), ydoc, 5, ' WORLD')
+
+ // Without awareness the AI cannot be identified, so it fails closed.
+ expect(editor.getText()).toBe('Hello WORLD')
+ expect(revealDecorations(editor)).toHaveLength(0)
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('"provider" option is required'))
+
+ warn.mockRestore()
+ editor.destroy()
+ })
+
+ it("does not reveal the local user's own typing", async () => {
+ const { editor } = await createCollabEditor()
+
+ // A local transaction (transaction.local === true) must be ignored.
+ editor.commands.insertContentAt(6, 'X')
+
+ expect(editor.getText()).toBe('HelloX')
+ expect(revealDecorations(editor)).toHaveLength(0)
+
+ editor.destroy()
+ })
+
+ it('drops the reveal once its duration has elapsed', async () => {
+ const { editor, ydoc, ai } = await createCollabEditor({ durationMs: 30 })
+ vi.useFakeTimers({ toFake: ['Date'] })
+
+ remoteInsert(ai, ydoc, 5, ' WORLD')
+ expect(revealDecorations(editor)).toHaveLength(1)
+
+ vi.setSystemTime(Date.now() + 60)
+ expect(revealDecorations(editor)).toHaveLength(0)
+
+ editor.destroy()
+ })
+
+ it('clamps a run longer than the max reveal range to that many characters', async () => {
+ const { editor, ydoc, ai } = await createCollabEditor()
+
+ remoteInsert(ai, ydoc, 5, 'x'.repeat(401))
+
+ expect(editor.getText()).toBe(`Hello${'x'.repeat(401)}`)
+ const decorations = revealDecorations(editor)
+ expect(decorations).toHaveLength(1)
+ // The whole run inserts; only the fade is capped (MAX_REVEAL_RANGE = 400).
+ expect(decorations[0].to - decorations[0].from).toBe(400)
+
+ editor.destroy()
+ })
+
+ it('drops a run whose resolved span no longer matches its inserted length', async () => {
+ const { editor, ydoc, ai } = await createCollabEditor()
+
+ remoteInsert(ai, ydoc, 5, ' WORLD')
+ // 'XYZ' lands inside the first run, so its span drifts from 6 to 9 and drops as stale.
+ remoteInsert(ai, ydoc, 8, 'XYZ')
+
+ const decorations = revealDecorations(editor)
+ expect(decorations).toHaveLength(1)
+ expect(decorations[0].to - decorations[0].from).toBe(3)
+
+ editor.destroy()
+ })
+
+ it('tears down cleanly after a reveal without throwing', async () => {
+ const { editor, ydoc, ai } = await createCollabEditor()
+ remoteInsert(ai, ydoc, 5, ' WORLD')
+ expect(revealDecorations(editor)).toHaveLength(1)
+
+ expect(() => editor.destroy()).not.toThrow()
+ })
+})
diff --git a/packages/ai-toolkit/src/streaming-reveal.ts b/packages/ai-toolkit/src/streaming-reveal.ts
new file mode 100644
index 0000000..1404418
--- /dev/null
+++ b/packages/ai-toolkit/src/streaming-reveal.ts
@@ -0,0 +1,392 @@
+import { Extension } from '@tiptap/core'
+import type { Node as PMNode } from '@tiptap/pm/model'
+import { Plugin, PluginKey } from '@tiptap/pm/state'
+import { Decoration, DecorationSet } from '@tiptap/pm/view'
+import { relativePositionToAbsolutePosition, ySyncPluginKey } from '@tiptap/y-tiptap'
+import * as Y from 'yjs'
+
+/**
+ * Configuration for {@link AiInsertReveal}.
+ */
+export type AiInsertRevealOptions = {
+ /** CSS class on each revealed run; style it in your app to define the fade. */
+ className: string
+ /**
+ * How long (ms) each run keeps its reveal decoration before it is dropped.
+ * Keep it at or above your CSS animation duration so the fade can finish.
+ */
+ durationMs: number
+ /**
+ * The collaboration provider, e.g. `HocuspocusProvider` or `TiptapCloudProvider`.
+ * Its awareness is what identifies the AI, so nothing is revealed without it.
+ */
+ provider: RevealProvider | null
+}
+
+/** The awareness surface this extension reads from the provider. */
+export type RevealProvider = {
+ awareness?: {
+ getStates: () => Map | undefined>
+ on: (event: 'change', listener: () => void) => void
+ off: (event: 'change', listener: () => void) => void
+ }
+}
+
+/** Caps the fade to a run's first N chars; the rest reveals instantly. */
+const MAX_REVEAL_RANGE = 400
+
+/** ProseMirror compares decoration attrs by value, so an unrounded offset rebuilds the DOM. */
+const REVEAL_AGE_STEP = 100
+
+/** Above this, an update is a document sync, such as joining a room, not live streaming. */
+const MAX_BLOCKS_PER_UPDATE = 2
+
+/** Yjs relative positions so each run survives y-tiptap doc rebuilds. */
+type RevealEntry = {
+ start: Y.RelativePosition
+ end: Y.RelativePosition
+ at: number
+ /** Inserted char count; a resolved span that drifts from it is stale. */
+ length: number
+ /** Author, resolved against awareness at render time rather than on arrival. */
+ client: number
+}
+
+/** Minimal shape of the y-sync plugin state we read. */
+type YSyncState = {
+ doc: Y.Doc
+ type: Y.XmlFragment
+ binding: { mapping: Map, PMNode | PMNode[]> } | null
+}
+
+const aiInsertRevealKey = new PluginKey('aiInsertReveal')
+
+function resolveRevealRange(
+ ystate: YSyncState,
+ entry: RevealEntry,
+ now: number,
+ durationMs: number,
+ docSize: number,
+): { from: number; to: number; age: number } | null {
+ const age = now - entry.at
+ if (age >= durationMs) return null
+
+ const span = resolveSpan(ystate, entry, docSize)
+ return span === null ? null : { ...span, age }
+}
+
+function resolveSpan(
+ ystate: YSyncState,
+ entry: RevealEntry,
+ docSize: number,
+): { from: number; to: number } | null {
+ if (!ystate.binding) return null
+
+ const from = relativePositionToAbsolutePosition(
+ ystate.doc,
+ ystate.type,
+ entry.start,
+ ystate.binding.mapping,
+ )
+ const to = relativePositionToAbsolutePosition(
+ ystate.doc,
+ ystate.type,
+ entry.end,
+ ystate.binding.mapping,
+ )
+ return from === null || to === null ? null : clampSpan(from, to, entry.length, docSize)
+}
+
+/** Drops a span whose width drifted from the insert, so stale mappings never paint. */
+function clampSpan(
+ from: number,
+ to: number,
+ length: number,
+ docSize: number,
+): { from: number; to: number } | null {
+ const lo = Math.min(from, to)
+ const hi = Math.max(from, to)
+ if (hi - lo !== length) return null
+
+ const start = Math.max(0, lo)
+ const end = Math.min(docSize, hi, start + MAX_REVEAL_RANGE)
+ return start >= end ? null : { from: start, to: end }
+}
+
+/** Merges touching runs with the same offset, so a stream paints a few spans, not one per token. */
+function mergeRuns(
+ ranges: Array<{ from: number; to: number; age: number }>,
+): Array<{ from: number; to: number; delay: number }> {
+ const merged: Array<{ from: number; to: number; delay: number }> = []
+
+ for (const range of ranges) {
+ const delay = Math.round(range.age / REVEAL_AGE_STEP) * REVEAL_AGE_STEP
+ const last = merged[merged.length - 1]
+
+ if (last !== undefined && last.delay === delay && last.to === range.from) {
+ last.to = range.to
+ } else {
+ merged.push({ from: range.from, to: range.to, delay })
+ }
+ }
+
+ return merged
+}
+
+/** The Tiptap AI server tags the identity it publishes with an instance id. */
+function isAiUser(user: Record | undefined): boolean {
+ return Boolean(user?.aiInstanceId)
+}
+
+/** Clients whose clock advanced in this transaction, i.e. who authored it. */
+function transactionAuthors(transaction: Y.Transaction): number[] {
+ const authors: number[] = []
+ transaction.afterState.forEach((clock, client) => {
+ if ((transaction.beforeState.get(client) ?? 0) < clock) authors.push(client)
+ })
+ return authors
+}
+
+function collectInsertedRuns(
+ event: Y.YEvent>,
+ now: number,
+ client: number,
+): RevealEntry[] {
+ const target = event.target
+ const runs: RevealEntry[] = []
+
+ if (target instanceof Y.XmlText) {
+ let index = 0
+ for (const op of event.delta) {
+ const { advance, inserted } = scanDeltaOp(op)
+ if (inserted > 0) runs.push(makeRun(target, index, inserted, now, client))
+ index += advance
+ }
+ return runs
+ }
+
+ // Whole nodes arrive when the AI writes a heading or a fresh block, so their
+ // text never shows up as an insert into existing content.
+ for (const op of event.delta) {
+ if (!Array.isArray(op.insert)) continue
+ if (op.insert.length > MAX_BLOCKS_PER_UPDATE) continue
+ for (const node of op.insert) collectNodeText(node, now, client, runs)
+ }
+ return runs
+}
+
+function collectNodeText(node: unknown, now: number, client: number, runs: RevealEntry[]): void {
+ if (node instanceof Y.XmlText) {
+ if (node.length > 0) runs.push(makeRun(node, 0, node.length, now, client))
+ return
+ }
+ if (node instanceof Y.XmlElement) {
+ for (const child of node.toArray()) collectNodeText(child, now, client, runs)
+ }
+}
+
+function scanDeltaOp(op: { retain?: number; insert?: unknown }): {
+ advance: number
+ inserted: number
+} {
+ if (typeof op.retain === 'number') return { advance: op.retain, inserted: 0 }
+ if (typeof op.insert === 'string')
+ return { advance: op.insert.length, inserted: op.insert.length }
+ // A non-string insert (embed) advances one position but is not a revealed run.
+ return op.insert === undefined ? { advance: 0, inserted: 0 } : { advance: 1, inserted: 0 }
+}
+
+/**
+ * End anchor assoc < 0 so an appended token starts a new run, not extends it.
+ * At index 0 the start needs the same assoc, or it resolves to nothing.
+ */
+function makeRun(
+ target: Y.XmlText,
+ index: number,
+ length: number,
+ now: number,
+ client: number,
+): RevealEntry {
+ return {
+ start: Y.createRelativePositionFromTypeIndex(target, index, index === 0 ? -1 : 0),
+ end: Y.createRelativePositionFromTypeIndex(target, index + length, -1),
+ at: now,
+ length,
+ client,
+ }
+}
+
+/**
+ * Fades in text the AI streams into the document, using view-only decorations.
+ * It never mutates the document, so it is inert to accept/reject and persistence.
+ * Requires Collaboration, a provider, and CSS for `className` (default `ai-insert-reveal`).
+ */
+export const AiInsertReveal = Extension.create({
+ name: 'aiInsertReveal',
+
+ addOptions() {
+ return {
+ className: 'ai-insert-reveal',
+ durationMs: 550,
+ provider: null,
+ }
+ },
+
+ onCreate() {
+ const { provider, durationMs } = this.options
+
+ if (!provider) {
+ console.warn(
+ '[tiptap warn]: The "provider" option is required for "AiInsertReveal" to tell the AI apart from other collaborators. Nothing is revealed without it.',
+ )
+ } else if (!provider.awareness) {
+ console.warn(
+ '[tiptap warn]: The provider passed to "AiInsertReveal" has no awareness, so the AI cannot be identified. Nothing is revealed.',
+ )
+ }
+
+ if (durationMs <= 0) {
+ console.warn(
+ `[tiptap warn]: "AiInsertReveal" needs a positive "durationMs" to hold a reveal, got ${durationMs}. Nothing is revealed.`,
+ )
+ }
+ },
+
+ addProseMirrorPlugins() {
+ const { className, durationMs, provider } = this.options
+
+ // Runs in insertion (time) order, so expired entries are always a prefix.
+ const entries: RevealEntry[] = []
+
+ // Never pruned: the AI clears its awareness entry when it stops, and its first
+ // tokens can arrive before that entry lands.
+ const aiClients = new Set()
+
+ const dropExpired = (now: number) => {
+ let firstActive = 0
+ while (firstActive < entries.length && now - entries[firstActive].at >= durationMs) {
+ firstActive += 1
+ }
+ if (firstActive > 0) entries.splice(0, firstActive)
+ }
+
+ return [
+ new Plugin({
+ key: aiInsertRevealKey,
+
+ props: {
+ decorations: state => {
+ const ystate = ySyncPluginKey.getState(state) as YSyncState | undefined
+ if (entries.length === 0 || !ystate?.binding) return null
+
+ const now = Date.now()
+ const ranges = entries
+ .filter(entry => aiClients.has(entry.client))
+ .map(entry =>
+ resolveRevealRange(ystate, entry, now, durationMs, state.doc.content.size),
+ )
+ .filter((range): range is NonNullable => range !== null)
+
+ // y-tiptap rebuilds the whole doc per token, restarting the CSS
+ // animation; offset by the run's age to resume the fade instead.
+ const decorations = mergeRuns(ranges).map(range =>
+ Decoration.inline(range.from, range.to, {
+ class: className,
+ style: `animation-delay: -${range.delay}ms`,
+ }),
+ )
+
+ return DecorationSet.create(state.doc, decorations)
+ },
+ },
+
+ view: view => {
+ const initialState = ySyncPluginKey.getState(view.state) as YSyncState | undefined
+ const fragment = initialState?.type ?? null
+ const awareness = provider?.awareness ?? null
+
+ if (fragment === null) {
+ console.warn(
+ '[tiptap warn]: "AiInsertReveal" needs the Collaboration extension to see incoming text. Nothing is revealed.',
+ )
+ }
+
+ const trackAiClients = () => {
+ const known = aiClients.size
+ awareness
+ ?.getStates()
+ .forEach((state: Record | undefined, clientId: number) => {
+ if (isAiUser(state?.user)) aiClients.add(clientId)
+ })
+ // Runs that arrived before the AI was identified can now be revealed.
+ if (aiClients.size > known) scheduleRerender()
+ }
+
+ let raf: number | null = null
+ let pruneTimer: ReturnType | null = null
+
+ const rerender = () => {
+ if (view.isDestroyed) return
+ // Empty transaction: re-runs `decorations` so new entries paint and expired ones drop.
+ view.dispatch(view.state.tr.setMeta('addToHistory', false))
+ }
+
+ // Coalesce bursts of tokens landing in the same frame into one render.
+ const scheduleRerender = () => {
+ if (raf !== null) return
+ raf = requestAnimationFrame(() => {
+ raf = null
+ rerender()
+ })
+ }
+
+ // After the stream pauses, one delayed render removes the last runs'
+ // decorations once their fades have completed.
+ const schedulePrune = () => {
+ if (pruneTimer !== null) clearTimeout(pruneTimer)
+ pruneTimer = setTimeout(() => {
+ pruneTimer = null
+ dropExpired(Date.now())
+ rerender()
+ }, durationMs + 80)
+ }
+
+ const onChange = (
+ events: Array>>,
+ transaction: Y.Transaction,
+ ) => {
+ if (transaction.local) return
+
+ // A batch mixing several authors cannot be attributed run by run.
+ const authors = transactionAuthors(transaction)
+ if (authors.length !== 1) return
+
+ const now = Date.now()
+ dropExpired(now)
+
+ const runs = events.flatMap(event => collectInsertedRuns(event, now, authors[0]))
+ if (runs.length === 0) return
+
+ entries.push(...runs)
+ scheduleRerender()
+ schedulePrune()
+ }
+
+ trackAiClients()
+ awareness?.on('change', trackAiClients)
+ fragment?.observeDeep(onChange)
+
+ return {
+ destroy: () => {
+ awareness?.off('change', trackAiClients)
+ fragment?.unobserveDeep(onChange)
+ if (raf !== null) cancelAnimationFrame(raf)
+ if (pruneTimer !== null) clearTimeout(pruneTimer)
+ entries.length = 0
+ },
+ }
+ },
+ }),
+ ]
+ },
+})
diff --git a/packages/ai-toolkit/tests/integration/serverAiToolkit.test.ts b/packages/ai-toolkit/tests/integration/serverAiToolkit.test.ts
new file mode 100644
index 0000000..f8f1f8e
--- /dev/null
+++ b/packages/ai-toolkit/tests/integration/serverAiToolkit.test.ts
@@ -0,0 +1,140 @@
+// @vitest-environment happy-dom
+
+import { Editor, Extension } from '@tiptap/core'
+import StarterKit from '@tiptap/starter-kit'
+import { describe, expect, it } from 'vite-plus/test'
+
+import { ServerAiToolkit } from '@tiptap/ai-toolkit'
+
+/**
+ * Options for creating a test editor.
+ */
+interface CreateEditorOptions {
+ /**
+ * Initial document JSON.
+ */
+ content?: Record
+
+ /**
+ * Additional extensions to include alongside the default ones.
+ */
+ extensions?: Extension[]
+}
+
+/**
+ * Creates an editor configured with the {@link ServerAiToolkit} extension.
+ *
+ * @param options - Editor configuration options.
+ * @return Promise resolving once the editor create lifecycle has finished.
+ */
+function createEditor(options: CreateEditorOptions): Promise {
+ return new Promise(resolve => {
+ const editor = new Editor({
+ element: document.createElement('div'),
+ content: options.content,
+ extensions: [StarterKit, ServerAiToolkit, ...(options.extensions ?? [])],
+ onCreate: () => {
+ resolve(editor)
+ },
+ })
+ })
+}
+
+/**
+ * Creates an editor with an explicit extension order.
+ *
+ * @param extensions - Extensions to register in the desired order.
+ * @param content - Initial document JSON.
+ * @return Promise resolving once the editor create lifecycle has finished.
+ */
+function createEditorWithExplicitExtensions(
+ extensions: Extension[],
+ content?: Record,
+): Promise {
+ return new Promise(resolve => {
+ const editor = new Editor({
+ element: document.createElement('div'),
+ content,
+ extensions,
+ onCreate: () => {
+ resolve(editor)
+ },
+ })
+ })
+}
+
+const MockAiToolkit = Extension.create({
+ name: 'aiToolkit',
+})
+
+describe('ServerAiToolkit', () => {
+ it('registers _hash attributes without generating values on creation', async () => {
+ const editor = await createEditor({
+ content: {
+ type: 'doc',
+ content: [
+ { type: 'paragraph', content: [{ type: 'text', text: 'First' }] },
+ { type: 'paragraph', content: [{ type: 'text', text: 'Second' }] },
+ ],
+ },
+ })
+
+ const paragraphs = editor.getJSON().content ?? []
+
+ expect(paragraphs).toHaveLength(2)
+ // oxlint-disable-next-line no-underscore-dangle
+ expect(paragraphs[0]?.attrs?._hash ?? null).toBeNull()
+ // oxlint-disable-next-line no-underscore-dangle
+ expect(paragraphs[1]?.attrs?._hash ?? null).toBeNull()
+
+ editor.destroy()
+ })
+
+ it('preserves provided hashes through a JSON round-trip', async () => {
+ const firstEditor = await createEditor({
+ content: {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ attrs: { _hash: 'ABC123' },
+ content: [{ type: 'text', text: 'Hello' }],
+ },
+ ],
+ },
+ })
+
+ const firstJson = firstEditor.getJSON()
+ // oxlint-disable-next-line no-underscore-dangle
+ const firstHash = firstJson.content?.[0]?.attrs?._hash
+
+ firstEditor.destroy()
+
+ const secondEditor = await createEditor({
+ content: firstJson,
+ })
+
+ const secondJson = secondEditor.getJSON()
+ // oxlint-disable-next-line no-underscore-dangle
+ const secondHash = secondJson.content?.[0]?.attrs?._hash
+
+ expect(secondHash).toBe(firstHash)
+
+ secondEditor.destroy()
+ })
+
+ it('does not synthesize hashes when the AI Toolkit extension is present', async () => {
+ const editor = await createEditorWithExplicitExtensions(
+ [StarterKit, ServerAiToolkit, MockAiToolkit],
+ {
+ type: 'doc',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] }],
+ },
+ )
+
+ // oxlint-disable-next-line no-underscore-dangle
+ expect(editor.getJSON().content?.[0]?.attrs?._hash ?? null).toBeNull()
+
+ editor.destroy()
+ })
+})
diff --git a/packages/ai-toolkit/vite.config.ts b/packages/ai-toolkit/vite.config.ts
new file mode 100644
index 0000000..c256193
--- /dev/null
+++ b/packages/ai-toolkit/vite.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vite-plus'
+import { basePackConfig } from '../../pack.config.mjs'
+
+export default defineConfig({
+ pack: {
+ entry: ['src/index.ts', 'src/streaming-reveal.ts'],
+ ...basePackConfig(),
+ },
+})
diff --git a/packages/core/README.md b/packages/core/README.md
new file mode 100644
index 0000000..e78ff4d
--- /dev/null
+++ b/packages/core/README.md
@@ -0,0 +1,18 @@
+# @tiptap/core
+
+[](https://www.npmjs.com/package/@tiptap/core)
+[](https://npmcharts.com/compare/tiptap?minimal=true)
+[](https://www.npmjs.com/package/@tiptap/core)
+[](https://github.com/sponsors/ueberdosis)
+
+## Introduction
+
+Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
+
+## Official Documentation
+
+Documentation can be found on the [Tiptap website](https://tiptap.dev).
+
+## License
+
+Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).
diff --git a/packages/core/jsx-dev-runtime/index.cjs b/packages/core/jsx-dev-runtime/index.cjs
new file mode 100644
index 0000000..dc486a3
--- /dev/null
+++ b/packages/core/jsx-dev-runtime/index.cjs
@@ -0,0 +1 @@
+module.exports = require('../dist/jsx-runtime/jsx-runtime.cjs')
diff --git a/packages/core/jsx-dev-runtime/index.d.cts b/packages/core/jsx-dev-runtime/index.d.cts
new file mode 100644
index 0000000..75d9136
--- /dev/null
+++ b/packages/core/jsx-dev-runtime/index.d.cts
@@ -0,0 +1 @@
+export * from '../src/jsx-runtime.ts'
diff --git a/packages/core/jsx-dev-runtime/index.d.ts b/packages/core/jsx-dev-runtime/index.d.ts
new file mode 100644
index 0000000..644513c
--- /dev/null
+++ b/packages/core/jsx-dev-runtime/index.d.ts
@@ -0,0 +1 @@
+export type * from '../src/jsx-runtime.js'
diff --git a/packages/core/jsx-dev-runtime/index.js b/packages/core/jsx-dev-runtime/index.js
new file mode 100644
index 0000000..856e5ed
--- /dev/null
+++ b/packages/core/jsx-dev-runtime/index.js
@@ -0,0 +1 @@
+export * from '../dist/jsx-runtime/jsx-runtime.js'
diff --git a/packages/core/jsx-runtime/index.cjs b/packages/core/jsx-runtime/index.cjs
new file mode 100644
index 0000000..dc486a3
--- /dev/null
+++ b/packages/core/jsx-runtime/index.cjs
@@ -0,0 +1 @@
+module.exports = require('../dist/jsx-runtime/jsx-runtime.cjs')
diff --git a/packages/core/jsx-runtime/index.d.cts b/packages/core/jsx-runtime/index.d.cts
new file mode 100644
index 0000000..75d9136
--- /dev/null
+++ b/packages/core/jsx-runtime/index.d.cts
@@ -0,0 +1 @@
+export * from '../src/jsx-runtime.ts'
diff --git a/packages/core/jsx-runtime/index.d.ts b/packages/core/jsx-runtime/index.d.ts
new file mode 100644
index 0000000..5a1c2c9
--- /dev/null
+++ b/packages/core/jsx-runtime/index.d.ts
@@ -0,0 +1 @@
+export type * from '../src/jsx-runtime.ts'
diff --git a/packages/core/jsx-runtime/index.js b/packages/core/jsx-runtime/index.js
new file mode 100644
index 0000000..856e5ed
--- /dev/null
+++ b/packages/core/jsx-runtime/index.js
@@ -0,0 +1 @@
+export * from '../dist/jsx-runtime/jsx-runtime.js'
diff --git a/packages/core/package.json b/packages/core/package.json
new file mode 100644
index 0000000..7ed0d29
--- /dev/null
+++ b/packages/core/package.json
@@ -0,0 +1,73 @@
+{
+ "name": "@tiptap/core",
+ "version": "3.30.3",
+ "description": "headless rich text editor",
+ "keywords": [
+ "headless",
+ "prosemirror",
+ "text editor",
+ "tiptap",
+ "wysiwyg"
+ ],
+ "homepage": "https://tiptap.dev",
+ "bugs": {
+ "url": "https://github.com/ueberdosis/tiptap/issues"
+ },
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/ueberdosis/tiptap",
+ "directory": "packages/core"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "files": [
+ "src",
+ "dist",
+ "jsx-runtime",
+ "jsx-dev-runtime"
+ ],
+ "type": "module",
+ "sideEffects": false,
+ "main": "dist/index.cjs",
+ "module": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": {
+ "import": "./dist/index.d.ts",
+ "require": "./dist/index.d.cts"
+ },
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ },
+ "./jsx-runtime": {
+ "types": {
+ "import": "./jsx-runtime/index.d.ts",
+ "require": "./jsx-runtime/index.d.cts"
+ },
+ "import": "./jsx-runtime/index.js",
+ "require": "./jsx-runtime/index.cjs"
+ },
+ "./jsx-dev-runtime": {
+ "types": {
+ "import": "./jsx-dev-runtime/index.d.ts",
+ "require": "./jsx-dev-runtime/index.d.cts"
+ },
+ "import": "./jsx-dev-runtime/index.js",
+ "require": "./jsx-dev-runtime/index.cjs"
+ }
+ },
+ "scripts": {
+ "build": "vp pack"
+ },
+ "devDependencies": {
+ "@tiptap/pm": "workspace:^",
+ "prosemirror-model": "^1.25.11"
+ },
+ "peerDependencies": {
+ "@tiptap/pm": "workspace:*"
+ }
+}
diff --git a/packages/core/src/CommandManager.test.ts b/packages/core/src/CommandManager.test.ts
new file mode 100644
index 0000000..ba36fe8
--- /dev/null
+++ b/packages/core/src/CommandManager.test.ts
@@ -0,0 +1,220 @@
+import Bold from '@tiptap/extension-bold'
+import Code from '@tiptap/extension-code'
+import CodeBlock from '@tiptap/extension-code-block'
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { UndoRedo } from '@tiptap/extensions'
+import { afterEach, describe, expect, it } from 'vite-plus/test'
+
+import { Editor } from './Editor.js'
+
+describe('can', () => {
+ let editor: Editor
+
+ afterEach(() => {
+ editor?.destroy()
+ })
+
+ it('not undo', () => {
+ editor = new Editor({
+ extensions: [Document, Paragraph, Text, UndoRedo],
+ })
+
+ const canUndo = editor.can().undo()
+
+ expect(canUndo).toBe(false)
+ })
+
+ it('undo', () => {
+ editor = new Editor({
+ extensions: [Document, Paragraph, Text, UndoRedo],
+ })
+
+ editor.commands.setContent('foo')
+
+ const canUndo = editor.can().undo()
+
+ expect(canUndo).toBe(true)
+ })
+
+ it('not chain undo', () => {
+ editor = new Editor({
+ extensions: [Document, Paragraph, Text, UndoRedo],
+ })
+
+ const canUndo = editor.can().chain().undo().run()
+
+ expect(canUndo).toBe(false)
+ })
+
+ it('chain undo', () => {
+ editor = new Editor({
+ extensions: [Document, Paragraph, Text, UndoRedo],
+ })
+
+ editor.commands.setContent('foo')
+
+ const canUndo = editor.can().chain().undo().run()
+
+ expect(canUndo).toBe(true)
+ })
+
+ it('returns false for non-applicable marks when cursor is positioned in node that disallows marks', () => {
+ editor = new Editor({
+ extensions: [Document, Paragraph, Text, UndoRedo, CodeBlock, Bold],
+ })
+
+ // Set up a code block and position cursor inside it (no selection)
+ editor
+ .chain()
+ .setCodeBlock()
+ .insertContent('Test code block')
+ .setTextSelection(5) // Position cursor in the middle of the text
+ .run()
+
+ const canSetMarkToBold = editor.can().setMark('bold')
+
+ expect(canSetMarkToBold).toBe(false)
+ })
+
+ it('returns false for non-applicable marks when selection contains node in conflict', () => {
+ editor = new Editor({
+ extensions: [Document, Paragraph, Text, UndoRedo, CodeBlock, Bold],
+ })
+
+ editor
+ .chain()
+ .setCodeBlock()
+ .insertContent('Test code block')
+ .setTextSelection({ from: 2, to: 3 })
+ .selectAll()
+ .run()
+
+ const canSetMarkToBold = editor.can().setMark('bold')
+
+ expect(canSetMarkToBold).toBe(false)
+ })
+
+ it('returns false for non-applicable marks when selection contains marks in conflict', () => {
+ editor = new Editor({
+ extensions: [Document, Paragraph, Text, UndoRedo, Code, Bold],
+ })
+
+ editor.chain().setContent('test').setTextSelection({ from: 2, to: 3 }).run()
+
+ const canSetMarkToBold = editor.can().setMark('bold')
+
+ expect(canSetMarkToBold).toBe(false)
+ })
+
+ it('returns false for non-applicable marks when stored marks in conflict', () => {
+ editor = new Editor({
+ extensions: [Document, Paragraph, Text, UndoRedo, Code, Bold],
+ })
+
+ editor.chain().setContent('test').run()
+
+ const canSetMarkToBold = editor.can().setMark('bold')
+
+ expect(canSetMarkToBold).toBe(false)
+ })
+
+ it('returns false for non-applicable marks when selecting multiple nodes in conflict', () => {
+ editor = new Editor({
+ extensions: [Document, Paragraph, Text, UndoRedo, Code, Bold],
+ })
+
+ editor.chain().setContent('test123').selectAll().run()
+
+ const canSetMarkToBold = editor.can().setMark('bold')
+
+ expect(canSetMarkToBold).toBe(false)
+ })
+
+ it('returns true for applicable marks when selection does not contain nodes in conflict', () => {
+ editor = new Editor({
+ extensions: [Document, Paragraph, Text, UndoRedo, CodeBlock, Bold],
+ })
+
+ editor
+ .chain()
+ .setCodeBlock()
+ .insertContent('Test code block')
+ .exitCode()
+ .insertContent('Additional paragraph node')
+ .selectAll()
+ .run()
+
+ const canSetMarkToBold = editor.can().setMark('bold')
+
+ expect(canSetMarkToBold).toBe(true)
+ })
+
+ it('returns true for applicable marks when stored marks are not in conflict', () => {
+ editor = new Editor({
+ extensions: [Document, Paragraph, Text, UndoRedo, Code, Bold],
+ })
+
+ editor.chain().setContent('test').toggleCode().run()
+
+ const canSetMarkToBold = editor.can().setMark('bold')
+
+ expect(canSetMarkToBold).toBe(true)
+ })
+
+ it('returns true for applicable marks when selection does not contain marks in conflict', () => {
+ editor = new Editor({
+ extensions: [Document, Paragraph, Text, UndoRedo, Code, Bold],
+ })
+
+ editor
+ .chain()
+ .setContent('test')
+ .setTextSelection({ from: 2, to: 3 })
+ .toggleCode()
+ .run()
+
+ const canSetMarkToBold = editor.can().setMark('bold')
+
+ expect(canSetMarkToBold).toBe(true)
+ })
+
+ it('returns true for applicable marks if at least one node in selection has no marks in conflict', () => {
+ editor = new Editor({
+ extensions: [Document, Paragraph, Text, UndoRedo, Code, Bold],
+ })
+
+ editor.chain().setContent('test123 ').selectAll().run()
+
+ const canSetMarkToBold = editor.can().setMark('bold')
+
+ expect(canSetMarkToBold).toBe(true)
+ })
+
+ it('builds and passes down an undefined dispatch for nested "can" chain', () => {
+ editor = new Editor({
+ extensions: [Document, Paragraph, Text, UndoRedo],
+ })
+
+ let capturedOuterDispatch: ((args?: any) => any) | undefined
+ let capturedInnerDispatch: ((args?: any) => any) | undefined
+
+ editor
+ .can()
+ .chain()
+ .command(({ chain, dispatch: outterDispatch }) => {
+ capturedOuterDispatch = outterDispatch
+ return chain()
+ .command(({ dispatch: innerDispatch }) => {
+ capturedInnerDispatch = innerDispatch
+ return true
+ })
+ .run()
+ })
+ .run()
+
+ expect(capturedOuterDispatch).toBe(undefined)
+ expect(capturedInnerDispatch).toBe(undefined)
+ })
+})
diff --git a/packages/core/src/CommandManager.ts b/packages/core/src/CommandManager.ts
new file mode 100644
index 0000000..7757a2c
--- /dev/null
+++ b/packages/core/src/CommandManager.ts
@@ -0,0 +1,208 @@
+import type { EditorState, Transaction } from '@tiptap/pm/state'
+
+import type { Editor } from './Editor.js'
+import { createChainableState } from './helpers/createChainableState.js'
+import type {
+ AnyCommands,
+ CanCommands,
+ ChainedCommands,
+ CommandProps,
+ SingleCommands,
+} from './types.js'
+
+export class CommandManager {
+ editor: Editor
+
+ rawCommands: AnyCommands
+
+ customState?: EditorState
+
+ constructor(props: { editor: Editor; state?: EditorState }) {
+ this.editor = props.editor
+ this.rawCommands = this.editor.extensionManager.commands
+ this.customState = props.state
+ }
+
+ get hasCustomState(): boolean {
+ return !!this.customState
+ }
+
+ get state(): EditorState {
+ return this.customState || this.editor.state
+ }
+
+ get commands(): SingleCommands {
+ const { rawCommands, editor, state } = this
+ const { view } = editor
+ const { tr } = state
+ const props = this.buildProps(tr)
+
+ return Object.fromEntries(
+ Object.entries(rawCommands).map(([name, command]) => {
+ const method = (...args: any[]) => {
+ const callback = command(...args)(props)
+
+ if (!tr.getMeta('preventDispatch') && !this.hasCustomState) {
+ view.dispatch(tr)
+ }
+
+ return callback
+ }
+
+ return [name, method]
+ }),
+ ) as unknown as SingleCommands
+ }
+
+ get chain(): () => ChainedCommands {
+ return () => this.createChain()
+ }
+
+ get can(): () => CanCommands {
+ return () => this.createCan()
+ }
+
+ public createChain(startTr?: Transaction, shouldDispatch = true): ChainedCommands {
+ const { rawCommands, editor, state } = this
+ const { view } = editor
+ const callbacks: boolean[] = []
+ const hasStartTransaction = !!startTr
+ const tr = startTr || state.tr
+
+ const run = () => {
+ if (
+ !hasStartTransaction &&
+ shouldDispatch &&
+ !tr.getMeta('preventDispatch') &&
+ !this.hasCustomState
+ ) {
+ view.dispatch(tr)
+ }
+
+ return callbacks.every(callback => callback === true)
+ }
+
+ const chain = {
+ ...Object.fromEntries(
+ Object.entries(rawCommands).map(([name, command]) => {
+ const chainedCommand = (...args: never[]) => {
+ const props = this.buildProps(tr, shouldDispatch)
+ const callback = command(...args)(props)
+
+ callbacks.push(callback)
+
+ return chain
+ }
+
+ return [name, chainedCommand]
+ }),
+ ),
+ run,
+ } as unknown as ChainedCommands
+
+ return chain
+ }
+
+ /**
+ * Creates a chain that safely returns `false` when run.
+ * @returns A non-dispatching command chain.
+ * @example
+ * const chain = CommandManager.createFakeChain()
+ * chain.focus().run() // false
+ */
+ public static createFakeChain(): ChainedCommands {
+ const chain = new Proxy(
+ {},
+ {
+ get: (_target, property) => {
+ if (property === 'then') {
+ return undefined
+ }
+
+ if (property === 'run') {
+ return () => false
+ }
+
+ return () => chain
+ },
+ },
+ ) as ChainedCommands
+
+ return chain
+ }
+
+ public createCan(startTr?: Transaction): CanCommands {
+ const { rawCommands, state } = this
+ const dispatch = false
+ const tr = startTr || state.tr
+ const props = this.buildProps(tr, dispatch)
+ const formattedCommands = Object.fromEntries(
+ Object.entries(rawCommands).map(([name, command]) => {
+ return [name, (...args: never[]) => command(...args)({ ...props, dispatch: undefined })]
+ }),
+ ) as unknown as SingleCommands
+
+ return {
+ ...formattedCommands,
+ chain: () => this.createChain(tr, dispatch),
+ } as CanCommands
+ }
+
+ /**
+ * Creates capability checks that safely return `false`.
+ * @returns A non-dispatching capability checker.
+ * @example
+ * const can = CommandManager.createFallbackCan()
+ * can.focus() // false
+ */
+ public static createFallbackCan(): CanCommands {
+ const chain = CommandManager.createFakeChain()
+ const can = new Proxy(
+ {
+ chain: () => chain,
+ },
+ {
+ get: (target, property) => {
+ if (property === 'then') {
+ return undefined
+ }
+
+ if (property === 'chain') {
+ return target.chain
+ }
+
+ return () => false
+ },
+ },
+ ) as CanCommands
+
+ return can
+ }
+
+ public buildProps(tr: Transaction, shouldDispatch = true): CommandProps {
+ const { rawCommands, editor, state } = this
+ const { view } = editor
+
+ const props: CommandProps = {
+ tr,
+ editor,
+ view,
+ state: createChainableState({
+ state,
+ transaction: tr,
+ }),
+ dispatch: shouldDispatch ? () => undefined : undefined,
+ chain: () => this.createChain(tr, shouldDispatch),
+ can: () => this.createCan(tr),
+ get commands() {
+ return Object.fromEntries(
+ Object.entries(rawCommands).map(([name, command]) => {
+ return [name, (...args: never[]) => command(...args)(props)]
+ }),
+ ) as unknown as SingleCommands
+ },
+ }
+
+ return props
+ }
+}
diff --git a/packages/core/src/Editor.test.ts b/packages/core/src/Editor.test.ts
new file mode 100644
index 0000000..4ff5d3b
--- /dev/null
+++ b/packages/core/src/Editor.test.ts
@@ -0,0 +1,681 @@
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import StarterKit from '@tiptap/starter-kit'
+import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
+
+import { Editor } from './Editor.js'
+import { Extension } from './Extension.js'
+
+const createTestEditor = () =>
+ new Editor({
+ element: document.createElement('div'),
+ extensions: [StarterKit],
+ })
+
+describe('Editor', () => {
+ it('keeps command fallbacks available while commands initialize', async () => {
+ let chain: ReturnType | undefined
+ let can: ReturnType | undefined
+
+ const editor = new Editor({
+ element: document.createElement('div'),
+ extensions: [
+ StarterKit,
+ Extension.create({
+ name: 'initialization-test',
+ addCommands() {
+ chain = this.editor.chain()
+ can = this.editor.can()
+
+ return {}
+ },
+ }),
+ ],
+ })
+
+ expect(chain?.focus().run()).toBe(false)
+ expect(can?.focus()).toBe(false)
+ expect(await Promise.resolve(chain)).toBe(chain)
+ expect(await Promise.resolve(can)).toBe(can)
+
+ editor.destroy()
+ })
+
+ describe('destroy', () => {
+ it('should keep the command chain accessible after the editor is destroyed', () => {
+ const editor = createTestEditor()
+
+ expect(editor.isDestroyed).toBe(false)
+ expect(editor.chain).not.toBe(null)
+
+ editor.destroy()
+
+ expect(editor.isDestroyed).toBe(true)
+ expect(editor.chain).not.toBe(null)
+ expect(editor.chain().setContent('').run()).toBe(false)
+ })
+
+ it('should keep the command can accessible after the editor is destroyed', () => {
+ const editor = createTestEditor()
+
+ expect(editor.isDestroyed).toBe(false)
+ expect(editor.can).not.toBe(null)
+
+ editor.destroy()
+
+ expect(editor.isDestroyed).toBe(true)
+ expect(editor.can).not.toBe(null)
+
+ // can on command
+ expect(editor.can().setContent('')).toBe(false)
+
+ // can on chain
+ expect(editor.can().chain().setContent('').blur().run()).toBe(false)
+ })
+ })
+})
+
+describe('editorProps', () => {
+ const editors: Editor[] = []
+
+ afterEach(() => {
+ editors.forEach(editor => editor.destroy())
+ editors.length = 0
+ })
+
+ it('editorProps can be set while constructing Editor', () => {
+ function transformPastedHTML(html: string) {
+ return html
+ }
+
+ const editor = new Editor({
+ extensions: [Document, Paragraph, Text],
+ editorProps: { transformPastedHTML },
+ })
+ editors.push(editor)
+
+ expect(transformPastedHTML).toBe(editor.view.props.transformPastedHTML)
+ })
+
+ it('editorProps can be set through setOptions', () => {
+ function transformPastedHTML(html: string) {
+ return html
+ }
+
+ const editor = new Editor({
+ extensions: [Document, Paragraph, Text],
+ })
+ editors.push(editor)
+
+ editor.setOptions({ editorProps: { transformPastedHTML } })
+
+ expect(transformPastedHTML).toBe(editor.view.props.transformPastedHTML)
+ })
+})
+
+describe('editor.getHTML / editor.getJSON', () => {
+ let editor: Editor
+
+ beforeEach(() => {
+ const element = document.createElement('div')
+ document.body.appendChild(element)
+ editor = new Editor({
+ element,
+ extensions: [StarterKit],
+ content: 'Example Text
',
+ })
+ })
+
+ afterEach(() => {
+ editor.destroy()
+ })
+
+ it('returns html', () => {
+ expect(editor.getHTML()).toBe('Example Text
')
+ })
+
+ it('returns json', () => {
+ expect(editor.getJSON()).toEqual({
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Example Text' }],
+ },
+ ],
+ })
+ })
+})
+
+describe('onContentError', () => {
+ it('does not emit a contentError on invalid content (by default)', () => {
+ const json = {
+ invalid: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Example Text',
+ },
+ ],
+ },
+ ],
+ }
+
+ const editor = new Editor({
+ content: json,
+ extensions: [Document, Paragraph, Text],
+ onContentError: () => {
+ expect(false).toBe(true)
+ },
+ })
+
+ expect(editor.getText()).toBe('')
+ })
+ it('does not emit a contentError on invalid content (when enableContentCheck = false)', () => {
+ const json = {
+ invalid: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Example Text',
+ },
+ ],
+ },
+ ],
+ }
+
+ const editor = new Editor({
+ content: json,
+ extensions: [Document, Paragraph, Text],
+ enableContentCheck: false,
+ onContentError: () => {
+ expect(false).toBe(true)
+ },
+ })
+
+ expect(editor.getText()).toBe('')
+ })
+ it('emits a contentError on invalid content (when enableContentCheck = true)', async () => {
+ const json = {
+ invalid: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Example Text',
+ },
+ ],
+ },
+ ],
+ }
+
+ let contentErrorCalled = false
+ let errorMessage = ''
+
+ const editor = new Editor({
+ content: json,
+ extensions: [Document, Paragraph, Text],
+ enableContentCheck: true,
+ onContentError: ({ error }) => {
+ contentErrorCalled = true
+ errorMessage = error.message
+ },
+ })
+
+ // Wait for async initialization to complete
+ await new Promise(resolve => {
+ setTimeout(resolve, 0)
+ })
+
+ expect(contentErrorCalled).toBe(true)
+ expect(errorMessage).toBe('[tiptap error]: Invalid JSON content')
+ expect(editor.getText()).toBe('')
+ })
+
+ it('does not emit a contentError on valid content', () => {
+ const json = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Example Text',
+ },
+ ],
+ },
+ ],
+ }
+
+ const editor = new Editor({
+ content: json,
+ extensions: [Document, Paragraph, Text],
+ enableContentCheck: true,
+ onContentError: () => {
+ expect(false).toBe(true)
+ },
+ })
+
+ expect(editor.getText()).toBe('Example Text')
+ })
+
+ it('removes the collaboration extension when has invalid content (when enableContentCheck = true)', () => {
+ const json = {
+ invalid: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Example Text',
+ },
+ ],
+ },
+ ],
+ }
+
+ const editor = new Editor({
+ content: json,
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Extension.create({
+ name: 'collaboration',
+ addStorage() {
+ return {
+ isDisabled: false,
+ }
+ },
+ }),
+ ],
+ enableContentCheck: true,
+ onContentError: args => {
+ args.disableCollaboration()
+ expect(args.editor.storage.collaboration).toBe(undefined)
+ },
+ })
+
+ expect(editor.getText()).toBe('')
+ expect(editor.storage.collaboration).toBe(undefined)
+ })
+
+ it('does not remove the collaboration extension when has valid content (when enableContentCheck = true)', () => {
+ const json = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Example Text',
+ },
+ ],
+ },
+ ],
+ }
+
+ const editor = new Editor({
+ content: json,
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Extension.create({
+ name: 'collaboration',
+ addStorage() {
+ return {
+ isDisabled: false,
+ }
+ },
+ }),
+ ],
+ enableContentCheck: true,
+ onContentError: () => {
+ // Should not be called, so we fail the test
+ expect(true).toBe(false)
+ },
+ })
+
+ expect(editor.getText()).toBe('Example Text')
+ expect(editor.storage.collaboration.isDisabled).toBe(false)
+ })
+
+ it('does not throw when calling setContent(valid) from the onContentError handler', () => {
+ const json = {
+ invalid: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Example Text',
+ },
+ ],
+ },
+ ],
+ }
+
+ const validJson = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Recovered',
+ },
+ ],
+ },
+ ],
+ }
+
+ let editor: Editor | undefined
+
+ expect(() => {
+ editor = new Editor({
+ content: json,
+ extensions: [Document, Paragraph, Text],
+ enableContentCheck: true,
+ onContentError: ({ editor: contentErrorEditor }) => {
+ contentErrorEditor.commands.setContent(validJson)
+ },
+ })
+ }).not.toThrow()
+
+ expect(editor?.getText()).toBe('Recovered')
+ })
+
+ // Invalid HTML whose fallback keeps a distinguishable "keepme" paragraph, so these
+ // tests fail if the fallback doc is ever discarded rather than preserved.
+ const invalidHtmlWithSalvageableText = 'keepme
'
+
+ it('preserves the fallback content when the handler runs a non-setContent command', () => {
+ const editor = new Editor({
+ content: invalidHtmlWithSalvageableText,
+ extensions: [Document, Paragraph, Text],
+ enableContentCheck: true,
+ onContentError: ({ editor: contentErrorEditor }) => {
+ contentErrorEditor.commands.setTextSelection(0)
+ },
+ })
+
+ expect(editor.getText()).toBe('keepme')
+ })
+
+ it('keeps the stripped fallback doc when the handler does nothing', () => {
+ const editor = new Editor({
+ content: invalidHtmlWithSalvageableText,
+ extensions: [Document, Paragraph, Text],
+ enableContentCheck: true,
+ onContentError: () => {},
+ })
+
+ expect(editor.getText()).toBe('keepme')
+ })
+})
+
+describe('unmounted', () => {
+ it('should not throw an error when the editor is unmounted', () => {
+ expect(() => {
+ const editor = new Editor({
+ element: null,
+ extensions: [Document, Paragraph, Text],
+ content: 'Hello
',
+ })
+
+ expect(!!editor).toBe(true)
+ }).not.toThrow()
+ })
+
+ it('should have a view property that is not null', () => {
+ const editor = new Editor({
+ element: null,
+ extensions: [Document, Paragraph, Text],
+ content: 'Hello
',
+ })
+ expect(!!editor.view).toBe(true)
+ })
+
+ it('should emit a mount event when the editor is mounted', async () => {
+ const editor = new Editor({
+ element: null,
+ extensions: [Document, Paragraph, Text],
+ content: 'Hello
',
+ })
+ let called = false
+ editor.on('mount', () => {
+ called = true
+ expect(called).toBe(true)
+ })
+ editor.mount(document.createElement('div'))
+ await new Promise(resolve => {
+ setTimeout(resolve, 0)
+ })
+ expect(called).toBe(true)
+ editor.unmount()
+ })
+
+ it('should inject CSS when the editor is mounted', async () => {
+ const editor = new Editor({
+ element: null,
+ extensions: [Document, Paragraph, Text],
+ content: 'Hello
',
+ })
+ let called = false
+ editor.on('mount', () => {
+ called = true
+ expect(document.head.querySelectorAll('style[data-tiptap-style]')).toHaveLength(1)
+ })
+
+ expect(document.head.querySelectorAll('style[data-tiptap-style]')).toHaveLength(0)
+ editor.mount(document.createElement('div'))
+ await new Promise(resolve => {
+ setTimeout(resolve, 0)
+ })
+ expect(called).toBe(true)
+ editor.unmount()
+ })
+
+ it('should emit an unmount event when the editor is unmounted', async () => {
+ const editor = new Editor({
+ element: null,
+ extensions: [Document, Paragraph, Text],
+ content: 'Hello
',
+ })
+ editor.mount(document.createElement('div'))
+
+ let called = false
+ editor.on('unmount', () => {
+ called = true
+ expect(called).toBe(true)
+ })
+ editor.unmount()
+ await new Promise(resolve => {
+ setTimeout(resolve, 0)
+ })
+ expect(called).toBe(true)
+ })
+
+ it('should only remove injected CSS when the editor is unmounted if no other editors exist', async () => {
+ const elementA = document.createElement('div')
+ document.body.appendChild(elementA)
+
+ const elementB = document.createElement('div')
+ document.body.appendChild(elementB)
+
+ const editorA = new Editor({
+ element: elementA,
+ extensions: [Document, Paragraph, Text],
+ content: 'Hello
',
+ })
+
+ const editorB = new Editor({
+ element: elementB,
+ extensions: [Document, Paragraph, Text],
+ content: 'Hello
',
+ })
+
+ let called = false
+ editorA.on('unmount', () => {
+ expect(document.head.querySelectorAll('style[data-tiptap-style]')).toHaveLength(1)
+ editorB.unmount()
+ })
+ editorB.on('unmount', () => {
+ called = true
+ expect(document.head.querySelectorAll('style[data-tiptap-style]')).toHaveLength(0)
+ })
+
+ editorA.unmount()
+ await new Promise(resolve => {
+ setTimeout(resolve, 0)
+ })
+ expect(called).toBe(true)
+
+ elementA.remove()
+ elementB.remove()
+ })
+
+ it('should emit a destroy event when the editor is destroyed', async () => {
+ const editor = new Editor({
+ element: null,
+ extensions: [Document, Paragraph, Text],
+ content: 'Hello
',
+ })
+
+ let called = false
+ editor.on('destroy', () => {
+ called = true
+ expect(called).toBe(true)
+ })
+ editor.destroy()
+ await new Promise(resolve => {
+ setTimeout(resolve, 0)
+ })
+ expect(called).toBe(true)
+ expect(editor.isDestroyed).toBe(true)
+ })
+
+ it('should emit an update event when the editor is updated', async () => {
+ const editor = new Editor({
+ element: null,
+ extensions: [Document, Paragraph, Text],
+ content: 'Hello
',
+ })
+ let called = false
+ editor.on('update', () => {
+ called = true
+ expect(called).toBe(true)
+ })
+ editor.chain().setContent('Test
').run()
+ await new Promise(resolve => {
+ setTimeout(resolve, 0)
+ })
+ expect(called).toBe(true)
+ })
+
+ it('should emit a transaction event when the editor is updated', async () => {
+ const editor = new Editor({
+ element: null,
+ extensions: [Document, Paragraph, Text],
+ content: 'Hello
',
+ })
+ let called = false
+ editor.on('transaction', () => {
+ called = true
+ expect(called).toBe(true)
+ })
+ editor.chain().setContent('Test
').run()
+ await new Promise(resolve => {
+ setTimeout(resolve, 0)
+ })
+ expect(called).toBe(true)
+ })
+
+ it('should emit a selectionUpdate event when the editor is updated', async () => {
+ const editor = new Editor({
+ element: null,
+ extensions: [Document, Paragraph, Text],
+ content: 'Hello
',
+ })
+ let called = false
+ editor.on('selectionUpdate', () => {
+ called = true
+ expect(called).toBe(true)
+ })
+ editor.chain().setContent('Test
').run()
+ await new Promise(resolve => {
+ setTimeout(resolve, 0)
+ })
+ expect(called).toBe(true)
+ })
+
+ it('should be able to make changes to the editor', () => {
+ const editor = new Editor({
+ element: null,
+ extensions: [Document, Paragraph, Text],
+ content: 'Hello
',
+ })
+ editor.chain().setContent('Test
').run()
+
+ expect(editor.state.doc.toJSON()).toEqual({
+ type: 'doc',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Test' }] }],
+ })
+ })
+
+ it('should be able to make multiple changes to the editor', () => {
+ const editor = new Editor({
+ element: null,
+ extensions: [Document, Paragraph, Text],
+ content: 'Hello
',
+ })
+
+ editor.chain().setContent('Test
').run()
+ editor.chain().setContent('Test 2
').run()
+
+ expect(editor.state.doc.toJSON()).toEqual({
+ type: 'doc',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Test 2' }] }],
+ })
+ })
+
+ it('should be able to read state from the editor view', () => {
+ const editor = new Editor({
+ element: null,
+ extensions: [Document, Paragraph, Text],
+ content: 'Hello
',
+ })
+ editor.chain().setContent('Test
').run()
+ expect(editor.view.state.doc.toJSON()).toEqual({
+ type: 'doc',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Test' }] }],
+ })
+ })
+
+ it('should have some commonly accessed properties that are not null', () => {
+ const editor = new Editor({
+ element: null,
+ extensions: [Document, Paragraph, Text],
+ content: 'Hello
',
+ })
+ expect(editor.view.editable).toBe(true)
+ expect(editor.view.composing).toBe(false)
+ expect(editor.view.dragging).toBe(null)
+ expect(editor.view.isDestroyed).toBe(false)
+ })
+})
diff --git a/packages/core/src/Editor.ts b/packages/core/src/Editor.ts
new file mode 100644
index 0000000..009df36
--- /dev/null
+++ b/packages/core/src/Editor.ts
@@ -0,0 +1,888 @@
+/* oslint-disableno-empty-object-type */
+import type { MarkType, Node as PMNode, NodeType, Schema } from '@tiptap/pm/model'
+import type { Plugin, PluginKey, Transaction } from '@tiptap/pm/state'
+import { EditorState } from '@tiptap/pm/state'
+import { type DirectEditorProps, EditorView } from '@tiptap/pm/view'
+
+import { CommandManager } from './CommandManager.js'
+import { isInDecorationApplyScope } from './decorations/decorationApplyScope.js'
+import { EventEmitter } from './EventEmitter.js'
+import { ExtensionManager } from './ExtensionManager.js'
+import {
+ ClipboardTextSerializer,
+ Commands,
+ Delete,
+ Drop,
+ Editable,
+ FocusEvents,
+ Keymap,
+ Paste,
+ Tabindex,
+ TextDirection,
+} from './extensions/index.js'
+import { createDocument } from './helpers/createDocument.js'
+import { getAttributes } from './helpers/getAttributes.js'
+import { getHTMLFromFragment } from './helpers/getHTMLFromFragment.js'
+import { getText } from './helpers/getText.js'
+import { getTextSerializersFromSchema } from './helpers/getTextSerializersFromSchema.js'
+import { isActive } from './helpers/isActive.js'
+import { isNodeEmpty } from './helpers/isNodeEmpty.js'
+import { createMappablePosition, getUpdatedPosition } from './helpers/MappablePosition.js'
+import { resolveFocusPosition } from './helpers/resolveFocusPosition.js'
+import { warnOnDuplicatedProseMirrorModel } from './helpers/warnOnDuplicatedProseMirrorModel.js'
+import type { Storage } from './index.js'
+import { NodePos } from './NodePos.js'
+import { style } from './style.js'
+import type {
+ CanCommands,
+ ChainedCommands,
+ DocumentType,
+ EditorEvents,
+ EditorOptions,
+ NodeType as TNodeType,
+ SingleCommands,
+ TextSerializer,
+ TextType as TTextType,
+ Utils,
+} from './types.js'
+import { createStyleTag } from './utilities/createStyleTag.js'
+import { isDev } from './utilities/isDev.js'
+import { isFunction } from './utilities/isFunction.js'
+
+export * as extensions from './extensions/index.js'
+
+// @ts-ignore
+export interface TiptapEditorHTMLElement extends HTMLElement {
+ editor?: Editor
+}
+
+export class Editor extends EventEmitter {
+ private commandManager!: CommandManager
+
+ public extensionManager!: ExtensionManager
+
+ private css: HTMLStyleElement | null = null
+
+ private className = 'tiptap'
+
+ public schema!: Schema
+
+ private editorView: EditorView | null = null
+
+ public isFocused = false
+
+ private destroyed = false
+
+ private editorState!: EditorState
+
+ /**
+ * The editor is considered initialized after the `create` event has been emitted.
+ */
+ public isInitialized = false
+
+ public extensionStorage: Storage = {} as Storage
+
+ /**
+ * A unique ID for this editor instance.
+ */
+ public instanceId = Math.random().toString(36).slice(2, 9)
+
+ private hasWarnedStaleDecorationRead = false
+
+ public options: EditorOptions = {
+ element: typeof document !== 'undefined' ? document.createElement('div') : null,
+ content: '',
+ injectCSS: true,
+ injectNonce: undefined,
+ extensions: [],
+ autofocus: false,
+ editable: true,
+ textDirection: undefined,
+ editorProps: {},
+ parseOptions: {},
+ coreExtensionOptions: {},
+ enableInputRules: true,
+ enablePasteRules: true,
+ enableCoreExtensions: true,
+ enableContentCheck: false,
+ emitContentError: false,
+ onBeforeCreate: () => null,
+ onCreate: () => null,
+ onMount: () => null,
+ onUnmount: () => null,
+ onUpdate: () => null,
+ onSelectionUpdate: () => null,
+ onTransaction: () => null,
+ onFocus: () => null,
+ onBlur: () => null,
+ onDestroy: () => null,
+ onContentError: ({ error }) => {
+ throw error
+ },
+ onPaste: () => null,
+ onDrop: () => null,
+ onDelete: () => null,
+ enableExtensionDispatchTransaction: true,
+ }
+
+ constructor(options: Partial = {}) {
+ super()
+ this.setOptions(options)
+ this.createExtensionManager()
+ this.createCommandManager()
+ this.createSchema()
+ this.on('beforeCreate', this.options.onBeforeCreate)
+ this.emit('beforeCreate', { editor: this })
+ this.on('mount', this.options.onMount)
+ this.on('unmount', this.options.onUnmount)
+ this.on('contentError', this.options.onContentError)
+ this.on('create', this.options.onCreate)
+ this.on('update', this.options.onUpdate)
+ this.on('selectionUpdate', this.options.onSelectionUpdate)
+ this.on('transaction', this.options.onTransaction)
+ this.on('focus', this.options.onFocus)
+ this.on('blur', this.options.onBlur)
+ this.on('destroy', this.options.onDestroy)
+ this.on('drop', ({ event, slice, moved }) => this.options.onDrop(event, slice, moved))
+ this.on('paste', ({ event, slice }) => this.options.onPaste(event, slice))
+ this.on('delete', this.options.onDelete)
+
+ const initialDoc = this.createDoc()
+
+ // createDoc() already seeds editorState from the fallback doc on a content error
+ if (!this.editorState) {
+ const selection = resolveFocusPosition(initialDoc, this.options.autofocus)
+
+ this.editorState = EditorState.create({
+ doc: initialDoc,
+ schema: this.schema,
+ selection: selection || undefined,
+ })
+ }
+
+ warnOnDuplicatedProseMirrorModel(this.schema)
+
+ if (this.options.element) {
+ this.mount(this.options.element)
+ }
+ }
+
+ /**
+ * Attach the editor to the DOM, creating a new editor view.
+ */
+ public mount(el: NonNullable & {}) {
+ if (typeof document === 'undefined') {
+ throw new Error(
+ `[tiptap error]: The editor cannot be mounted because there is no 'document' defined in this environment.`,
+ )
+ }
+ this.createView(el)
+ this.emit('mount', { editor: this })
+
+ if (this.css && !document.head.contains(this.css)) {
+ document.head.appendChild(this.css)
+ }
+
+ window.setTimeout(() => {
+ if (this.isDestroyed) {
+ return
+ }
+
+ if (this.options.autofocus !== false && this.options.autofocus !== null) {
+ this.commands.focus(this.options.autofocus)
+ }
+ this.emit('create', { editor: this })
+ this.isInitialized = true
+ }, 0)
+ }
+
+ /**
+ * Remove the editor from the DOM, but still allow remounting at a different point in time
+ */
+ public unmount() {
+ if (this.editorView) {
+ // Keep the cached state in sync so plugin state stays available after unmount.
+ this.editorState = this.editorView.state
+
+ // Cleanup our reference to prevent circular references which caused memory leaks
+ // @ts-ignore
+ const dom = this.editorView.dom as TiptapEditorHTMLElement
+
+ if (dom?.editor) {
+ delete dom.editor
+ }
+ this.editorView.destroy()
+ }
+ this.editorView = null
+ this.isInitialized = false
+
+ // Safely remove CSS element with fallback for test environments
+ // Only remove CSS if no other editors exist in the document after unmount
+ if (this.css && !document.querySelectorAll(`.${this.className}`).length) {
+ try {
+ if (typeof this.css.remove === 'function') {
+ this.css.remove()
+ } else if (this.css.parentNode) {
+ this.css.parentNode.removeChild(this.css)
+ }
+ } catch (error) {
+ // Silently handle any unexpected DOM removal errors in test environments
+ console.warn('Failed to remove CSS element:', error)
+ }
+ }
+ this.css = null
+ this.emit('unmount', { editor: this })
+ }
+
+ /**
+ * Returns the editor storage.
+ */
+ public get storage(): Storage {
+ return this.extensionStorage
+ }
+
+ /**
+ * An object of all registered commands.
+ */
+ public get commands(): SingleCommands {
+ return this.commandManager.commands
+ }
+
+ /**
+ * Create a command chain to call multiple commands at once.
+ */
+ public chain(): ChainedCommands {
+ if (!this.commandManager) {
+ return CommandManager.createFakeChain()
+ }
+
+ return this.commandManager.chain()
+ }
+
+ /**
+ * Check if a command or a command chain can be executed. Without executing it.
+ */
+ public can(): CanCommands {
+ if (!this.commandManager) {
+ return CommandManager.createFallbackCan()
+ }
+
+ return this.commandManager.can()
+ }
+
+ /**
+ * Inject CSS styles.
+ */
+ private injectCSS(): void {
+ if (this.options.injectCSS && typeof document !== 'undefined') {
+ this.css = createStyleTag(style, this.options.injectNonce)
+ }
+ }
+
+ /**
+ * Update editor options.
+ *
+ * @param options A list of options
+ */
+ public setOptions(options: Partial = {}): void {
+ this.options = {
+ ...this.options,
+ ...options,
+ }
+
+ if (!this.editorView || !this.state || this.isDestroyed) {
+ return
+ }
+
+ if (this.options.editorProps) {
+ this.view.setProps(this.options.editorProps)
+ }
+
+ this.view.updateState(this.state)
+ }
+
+ /**
+ * Update editable state of the editor.
+ */
+ public setEditable(editable: boolean, emitUpdate = true): void {
+ this.setOptions({ editable })
+
+ if (emitUpdate) {
+ this.emit('update', { editor: this, transaction: this.state.tr, appendedTransactions: [] })
+ }
+ }
+
+ /**
+ * Returns whether the editor is editable.
+ */
+ public get isEditable(): boolean {
+ // since plugins are applied after creating the view
+ // `editable` is always `true` for one tick.
+ // that’s why we also have to check for `options.editable`
+ return this.options.editable && this.view && this.view.editable
+ }
+
+ /**
+ * Returns the editor view.
+ */
+ public get view(): EditorView {
+ if (this.editorView) {
+ return this.editorView
+ }
+
+ return new Proxy(
+ {
+ state: this.editorState,
+ updateState: (state: EditorState): ReturnType => {
+ this.editorState = state
+ },
+ dispatch: (tr: Transaction): ReturnType => {
+ this.dispatchTransaction(tr)
+ },
+
+ // Stub some commonly accessed properties to prevent errors
+ composing: false,
+ dragging: null,
+ editable: true,
+ isDestroyed: false,
+ } as EditorView,
+ {
+ get: (obj, key) => {
+ if (this.editorView) {
+ // If the editor view is available, but the caller has a stale reference to the proxy,
+ // Just return what the editor view has.
+ return this.editorView[key as keyof EditorView]
+ }
+ // Specifically always return the most recent editorState
+ if (key === 'state') {
+ return this.editorState
+ }
+ if (key in obj) {
+ return Reflect.get(obj, key)
+ }
+
+ // We throw an error here, because we know the view is not available
+ throw new Error(
+ `[tiptap error]: The editor view is not available. Cannot access view['${key as string}']. The editor may not be mounted yet.`,
+ )
+ },
+ },
+ ) as EditorView
+ }
+
+ /**
+ * Returns the editor state.
+ */
+ public get state(): EditorState {
+ if (isDev && !this.hasWarnedStaleDecorationRead && isInDecorationApplyScope(this)) {
+ // Warn once per editor. The same read repeats on every transaction, and
+ // a decoration that dispatches from `create()` would flood the console.
+ this.hasWarnedStaleDecorationRead = true
+
+ console.warn(
+ '[tiptap warn]: `editor.state` was read while decoration `create()` was running. ' +
+ 'It returns the pre-transaction document. Use the `state` argument passed to ' +
+ '`create()` instead. Helpers like `editor.isActive()` read `editor.state` too, ' +
+ 'so pass `state` to their standalone versions instead of calling them on the editor.',
+ )
+ }
+
+ if (this.editorView) {
+ this.editorState = this.view.state
+ }
+
+ return this.editorState
+ }
+
+ /**
+ * Register a ProseMirror plugin.
+ *
+ * @param plugin A ProseMirror plugin
+ * @param handlePlugins Control how to merge the plugin into the existing plugins.
+ * @returns The new editor state
+ */
+ public registerPlugin(
+ plugin: Plugin,
+ handlePlugins?: (newPlugin: Plugin, plugins: Plugin[]) => Plugin[],
+ ): EditorState {
+ const plugins = isFunction(handlePlugins)
+ ? handlePlugins(plugin, [...this.state.plugins])
+ : [...this.state.plugins, plugin]
+
+ const state = this.state.reconfigure({ plugins })
+
+ this.view.updateState(state)
+
+ return state
+ }
+
+ /**
+ * Unregister a ProseMirror plugin.
+ *
+ * @param nameOrPluginKeyToRemove The plugins name
+ * @returns The new editor state or undefined if the editor is destroyed
+ */
+ public unregisterPlugin(
+ nameOrPluginKeyToRemove: string | PluginKey | (string | PluginKey)[],
+ ): EditorState | undefined {
+ if (this.isDestroyed) {
+ return undefined
+ }
+
+ const prevPlugins = this.state.plugins
+ let plugins = prevPlugins
+
+ ;([] as (string | PluginKey)[]).concat(nameOrPluginKeyToRemove).forEach(nameOrPluginKey => {
+ // @ts-ignore
+ const name = typeof nameOrPluginKey === 'string' ? `${nameOrPluginKey}$` : nameOrPluginKey.key
+
+ // @ts-ignore
+ plugins = plugins.filter(plugin => !plugin.key.startsWith(name))
+ })
+
+ if (prevPlugins.length === plugins.length) {
+ // No plugin was removed, so we don’t need to update the state
+ return undefined
+ }
+
+ const state = this.state.reconfigure({
+ plugins,
+ })
+
+ this.view.updateState(state)
+
+ return state
+ }
+
+ /**
+ * Creates an extension manager.
+ */
+ private createExtensionManager(): void {
+ const coreExtensions = this.options.enableCoreExtensions
+ ? [
+ Editable,
+ ClipboardTextSerializer.configure({
+ blockSeparator:
+ this.options.coreExtensionOptions?.clipboardTextSerializer?.blockSeparator,
+ }),
+ Commands,
+ FocusEvents,
+ Keymap,
+ Tabindex.configure({
+ value: this.options.coreExtensionOptions?.tabindex?.value,
+ }),
+ Drop,
+ Paste,
+ Delete,
+ TextDirection.configure({
+ direction: this.options.textDirection,
+ }),
+ ].filter(ext => {
+ if (typeof this.options.enableCoreExtensions === 'object') {
+ return (
+ this.options.enableCoreExtensions[
+ ext.name as keyof typeof this.options.enableCoreExtensions
+ ] !== false
+ )
+ }
+ return true
+ })
+ : []
+ const allExtensions = [...coreExtensions, ...this.options.extensions].filter(extension => {
+ return ['extension', 'node', 'mark'].includes(extension?.type)
+ })
+
+ this.extensionManager = new ExtensionManager(allExtensions, this)
+ }
+
+ /**
+ * Creates an command manager.
+ */
+ private createCommandManager(): void {
+ this.commandManager = new CommandManager({
+ editor: this,
+ })
+ }
+
+ /**
+ * Creates a ProseMirror schema.
+ */
+ private createSchema(): void {
+ this.schema = this.extensionManager.schema
+ }
+
+ /**
+ * Creates the initial document.
+ */
+ private createDoc(): PMNode {
+ let doc: PMNode
+
+ try {
+ doc = createDocument(this.options.content, this.schema, this.options.parseOptions, {
+ errorOnInvalidContent: this.options.enableContentCheck,
+ })
+ } catch (e) {
+ if (
+ !(e instanceof Error) ||
+ !['[tiptap error]: Invalid JSON content', '[tiptap error]: Invalid HTML content'].includes(
+ e.message,
+ )
+ ) {
+ // Not the content error we were expecting
+ throw e
+ }
+
+ // Content is invalid, but attempt to create it anyway, stripping out the invalid parts
+ const fallbackDoc = createDocument(
+ this.options.content,
+ this.schema,
+ this.options.parseOptions,
+ {
+ errorOnInvalidContent: false,
+ },
+ )
+
+ // Seed editorState with the fallback doc so a handler can safely use `editor.commands`
+ this.editorState = EditorState.create({
+ doc: fallbackDoc,
+ schema: this.schema,
+ selection: resolveFocusPosition(fallbackDoc, this.options.autofocus) || undefined,
+ })
+
+ this.emit('contentError', {
+ editor: this,
+ error: e as Error,
+ disableCollaboration: () => {
+ if (
+ 'collaboration' in this.storage &&
+ typeof this.storage.collaboration === 'object' &&
+ this.storage.collaboration
+ ) {
+ ;(this.storage.collaboration as any).isDisabled = true
+ }
+ // To avoid syncing back invalid content, reinitialize the extensions without the collaboration extension
+ this.options.extensions = this.options.extensions.filter(
+ extension => extension.name !== 'collaboration',
+ )
+
+ // Restart the initialization process by recreating the extension manager with the new set of extensions
+ this.createExtensionManager()
+ },
+ })
+
+ return this.editorState.doc
+ }
+ return doc
+ }
+
+ /**
+ * Creates a ProseMirror view.
+ */
+ private createView(element: NonNullable): void {
+ const { editorProps, enableExtensionDispatchTransaction } = this.options
+ // If a user provided a custom `dispatchTransaction` through `editorProps`,
+ // we use that as the base dispatch function.
+ // Otherwise, we use Tiptap's internal `dispatchTransaction` method.
+ const baseDispatch =
+ (editorProps as DirectEditorProps).dispatchTransaction || this.dispatchTransaction.bind(this)
+ const dispatch = enableExtensionDispatchTransaction
+ ? this.extensionManager.dispatchTransaction(baseDispatch)
+ : baseDispatch
+
+ // Compose transformPastedHTML from extensions and user-provided editorProps
+ const baseTransformPastedHTML = (editorProps as DirectEditorProps).transformPastedHTML
+ const transformPastedHTML = this.extensionManager.transformPastedHTML(baseTransformPastedHTML)
+
+ this.editorView = new EditorView(element, {
+ ...editorProps,
+ attributes: {
+ // add `role="textbox"` to the editor element
+ role: 'textbox',
+ ...editorProps?.attributes,
+ },
+ dispatchTransaction: dispatch,
+ transformPastedHTML,
+ state: this.editorState,
+ markViews: this.extensionManager.markViews,
+ nodeViews: this.extensionManager.nodeViews,
+ })
+
+ // `editor.view` is not yet available at this time.
+ // Therefore we will add all plugins and node views directly afterwards.
+ const newState = this.state.reconfigure({
+ plugins: this.extensionManager.plugins,
+ })
+
+ this.view.updateState(newState)
+
+ this.prependClass()
+ this.injectCSS()
+
+ // Let’s store the editor instance in the DOM element.
+ // So we’ll have access to it for tests.
+ // @ts-ignore
+ const dom = this.view.dom as TiptapEditorHTMLElement
+
+ dom.editor = this
+ }
+
+ /**
+ * Creates all node and mark views.
+ */
+ public createNodeViews(): void {
+ if (this.view.isDestroyed) {
+ return
+ }
+
+ this.view.setProps({
+ markViews: this.extensionManager.markViews,
+ nodeViews: this.extensionManager.nodeViews,
+ })
+ }
+
+ /**
+ * Prepend class name to element.
+ */
+ public prependClass(): void {
+ this.view.dom.className = `${this.className} ${this.view.dom.className}`
+ }
+
+ public isCapturingTransaction = false
+
+ private capturedTransaction: Transaction | null = null
+
+ public captureTransaction(fn: () => void) {
+ this.isCapturingTransaction = true
+ fn()
+ this.isCapturingTransaction = false
+
+ const tr = this.capturedTransaction
+
+ this.capturedTransaction = null
+
+ return tr
+ }
+
+ /**
+ * The callback over which to send transactions (state updates) produced by the view.
+ *
+ * @param transaction An editor state transaction
+ */
+ private dispatchTransaction(transaction: Transaction): void {
+ // if the editor / the view of the editor was destroyed
+ // the transaction should not be dispatched as there is no view anymore.
+ if (this.view.isDestroyed) {
+ return
+ }
+
+ if (this.isCapturingTransaction) {
+ if (!this.capturedTransaction) {
+ this.capturedTransaction = transaction
+
+ return
+ }
+
+ transaction.steps.forEach(step => this.capturedTransaction?.step(step))
+
+ return
+ }
+
+ // Apply transaction and get resulting state and transactions
+ const { state, transactions } = this.state.applyTransaction(transaction)
+ const selectionHasChanged = !this.state.selection.eq(state.selection)
+ const rootTrWasApplied = transactions.includes(transaction)
+ const prevState = this.state
+
+ this.emit('beforeTransaction', {
+ editor: this,
+ transaction,
+ nextState: state,
+ })
+
+ // If transaction was filtered out, we can return early
+ if (!rootTrWasApplied) {
+ return
+ }
+
+ this.view.updateState(state)
+
+ // Emit transaction event with appended transactions info
+ this.emit('transaction', {
+ editor: this,
+ transaction,
+ appendedTransactions: transactions.slice(1),
+ })
+
+ if (selectionHasChanged) {
+ this.emit('selectionUpdate', {
+ editor: this,
+ transaction,
+ })
+ }
+
+ // Only emit the latest between focus and blur events
+ const mostRecentFocusTr = transactions.findLast(tr => tr.getMeta('focus') || tr.getMeta('blur'))
+ const focus = mostRecentFocusTr?.getMeta('focus')
+ const blur = mostRecentFocusTr?.getMeta('blur')
+
+ if (focus) {
+ this.emit('focus', {
+ editor: this,
+ event: focus.event,
+ // oxlint-disable-next-lineno-non-null-assertion
+ transaction: mostRecentFocusTr!,
+ })
+ }
+
+ if (blur) {
+ this.emit('blur', {
+ editor: this,
+ event: blur.event,
+ // oxlint-disable-next-lineno-non-null-assertion
+ transaction: mostRecentFocusTr!,
+ })
+ }
+
+ // Compare states for update event
+ if (
+ transaction.getMeta('preventUpdate') ||
+ !transactions.some(tr => tr.docChanged) ||
+ prevState.doc.eq(state.doc)
+ ) {
+ return
+ }
+
+ this.emit('update', {
+ editor: this,
+ transaction,
+ appendedTransactions: transactions.slice(1),
+ })
+ }
+
+ /**
+ * Get attributes of the currently selected node or mark.
+ */
+ public getAttributes(nameOrType: string | NodeType | MarkType): Record {
+ return getAttributes(this.state, nameOrType)
+ }
+
+ /**
+ * Returns if the currently selected node or mark is active.
+ *
+ * @param name Name of the node or mark
+ * @param attributes Attributes of the node or mark
+ */
+ public isActive(name: string, attributes?: {}): boolean
+ public isActive(attributes: {}): boolean
+ public isActive(nameOrAttributes: string, attributesOrUndefined?: {}): boolean {
+ const name = typeof nameOrAttributes === 'string' ? nameOrAttributes : null
+
+ const attributes =
+ typeof nameOrAttributes === 'string' ? attributesOrUndefined : nameOrAttributes
+
+ return isActive(this.state, name, attributes)
+ }
+
+ /**
+ * Get the document as JSON.
+ */
+ public getJSON(): DocumentType<
+ Record | undefined,
+ TNodeType, any, (TNodeType | TTextType)[]>[]
+ > {
+ return this.state.doc.toJSON()
+ }
+
+ /**
+ * Get the document as HTML.
+ */
+ public getHTML(): string {
+ return getHTMLFromFragment(this.state.doc.content, this.schema)
+ }
+
+ /**
+ * Get the document as text.
+ */
+ public getText(options?: {
+ blockSeparator?: string
+ textSerializers?: Record
+ }): string {
+ const { blockSeparator = '\n\n', textSerializers = {} } = options || {}
+
+ return getText(this.state.doc, {
+ blockSeparator,
+ textSerializers: {
+ ...getTextSerializersFromSchema(this.schema),
+ ...textSerializers,
+ },
+ })
+ }
+
+ /**
+ * Check if there is no content.
+ */
+ public get isEmpty(): boolean {
+ return isNodeEmpty(this.state.doc)
+ }
+
+ /**
+ * Destroy the editor.
+ */
+ public destroy(): void {
+ if (this.destroyed) {
+ return
+ }
+
+ this.destroyed = true
+
+ this.emit('destroy')
+ this.unmount()
+ this.removeAllListeners()
+
+ this.extensionManager.destroy()
+ this.extensionManager = null as any
+ this.schema = null as any
+ this.commandManager = null as unknown as CommandManager
+ this.extensionStorage = {} as Storage
+ }
+
+ /**
+ * Check if the editor is already destroyed.
+ */
+ public get isDestroyed(): boolean {
+ return this.editorView?.isDestroyed ?? true
+ }
+
+ public $node(selector: string, attributes?: { [key: string]: any }): NodePos | null {
+ return this.$doc?.querySelector(selector, attributes) || null
+ }
+
+ public $nodes(selector: string, attributes?: { [key: string]: any }): NodePos[] | null {
+ return this.$doc?.querySelectorAll(selector, attributes) || null
+ }
+
+ public $pos(pos: number) {
+ const $pos = this.state.doc.resolve(pos)
+ // For positions directly before a non-text atom, resolvedPos.node() returns
+ // the parent, so pass the atom explicitly. Container nodes and $pos(0) keep
+ // resolving to the parent.
+ const node =
+ pos > 0 && $pos.nodeAfter && !$pos.nodeAfter.isText && $pos.nodeAfter.isAtom
+ ? $pos.nodeAfter
+ : null
+
+ return new NodePos($pos, this, false, node)
+ }
+
+ get $doc() {
+ return this.$pos(0)
+ }
+
+ /**
+ * Returns a set of utilities for working with positions and ranges.
+ */
+ public utils: Utils = {
+ getUpdatedPosition,
+ createMappablePosition,
+ }
+}
diff --git a/packages/core/src/EventEmitter.ts b/packages/core/src/EventEmitter.ts
new file mode 100644
index 0000000..2dbe08e
--- /dev/null
+++ b/packages/core/src/EventEmitter.ts
@@ -0,0 +1,71 @@
+type StringKeyOf = Extract
+type CallbackType<
+ T extends Record,
+ EventName extends StringKeyOf,
+> = T[EventName] extends any[] ? T[EventName] : [T[EventName]]
+type CallbackFunction, EventName extends StringKeyOf> = (
+ ...props: CallbackType
+) => any
+
+export class EventEmitter> {
+ private callbacks: { [key: string]: Array<(...args: any[]) => void> } = {}
+
+ public on>(
+ event: EventName,
+ fn: CallbackFunction,
+ ): this {
+ if (!this.callbacks[event]) {
+ this.callbacks[event] = []
+ }
+
+ this.callbacks[event].push(fn)
+
+ return this
+ }
+
+ public emit>(
+ event: EventName,
+ ...args: CallbackType
+ ): this {
+ const callbacks = this.callbacks[event]
+
+ if (callbacks) {
+ callbacks.forEach(callback => callback.apply(this, args))
+ }
+
+ return this
+ }
+
+ public off>(
+ event: EventName,
+ fn?: CallbackFunction,
+ ): this {
+ const callbacks = this.callbacks[event]
+
+ if (callbacks) {
+ if (fn) {
+ this.callbacks[event] = callbacks.filter(callback => callback !== fn)
+ } else {
+ delete this.callbacks[event]
+ }
+ }
+
+ return this
+ }
+
+ public once>(
+ event: EventName,
+ fn: CallbackFunction,
+ ): this {
+ const onceFn = (...args: CallbackType) => {
+ this.off(event, onceFn)
+ fn.apply(this, args)
+ }
+
+ return this.on(event, onceFn)
+ }
+
+ public removeAllListeners(): void {
+ this.callbacks = {}
+ }
+}
diff --git a/packages/core/src/Extendable.test.ts b/packages/core/src/Extendable.test.ts
new file mode 100644
index 0000000..e9d9e8c
--- /dev/null
+++ b/packages/core/src/Extendable.test.ts
@@ -0,0 +1,764 @@
+import { describe, expect, it } from 'vite-plus/test'
+
+import { Extension } from './Extension.js'
+import { Mark } from './Mark.js'
+import { Node } from './Node.js'
+import { getExtensionField } from './helpers/getExtensionField.js'
+
+declare module './Extension.js' {
+ // Extension does not have a addAttributes defined, but we just want to test it anyway
+ interface ExtensionConfig {
+ // @ts-ignore - this is a dynamic key
+ [key: string]: any
+ }
+}
+
+describe('extend extensions', () => {
+ ;[Extension, Node, Mark].forEach(Extendable => {
+ describe(Extendable.create().type, () => {
+ it('should define a config', () => {
+ const extension = Extendable.create({
+ addAttributes() {
+ return {
+ foo: {},
+ }
+ },
+ })
+
+ const attributes = getExtensionField(extension, 'addAttributes')()
+
+ expect(attributes).toEqual({
+ foo: {},
+ })
+ })
+
+ it('should overwrite a config', () => {
+ const extension = Extendable.create({
+ addAttributes() {
+ return {
+ foo: {},
+ }
+ },
+ }).extend({
+ addAttributes() {
+ return {
+ bar: {},
+ }
+ },
+ })
+
+ const attributes = getExtensionField(extension, 'addAttributes')()
+
+ expect(attributes).toEqual({
+ bar: {},
+ })
+ })
+
+ it('should have a parent', () => {
+ const extension = Extendable.create({
+ addAttributes() {
+ return {
+ foo: {},
+ }
+ },
+ })
+
+ const newExtension = extension.extend({
+ addAttributes() {
+ return {
+ bar: {},
+ }
+ },
+ })
+
+ const parent = newExtension.parent
+
+ expect(parent).toBe(extension)
+ })
+
+ it('should merge configs', () => {
+ const extension = Extendable.create({
+ addAttributes() {
+ return {
+ foo: {},
+ }
+ },
+ }).extend({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ bar: {},
+ }
+ },
+ })
+
+ const attributes = getExtensionField(extension, 'addAttributes')()
+
+ expect(attributes).toEqual({
+ foo: {},
+ bar: {},
+ })
+ })
+
+ it('should merge configs multiple times', () => {
+ const extension = Extendable.create({
+ addAttributes() {
+ return {
+ foo: {},
+ }
+ },
+ })
+ .extend({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ bar: {},
+ }
+ },
+ })
+ .extend({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ baz: {},
+ }
+ },
+ })
+
+ const attributes = getExtensionField(extension, 'addAttributes')()
+
+ expect(attributes).toEqual({
+ foo: {},
+ bar: {},
+ baz: {},
+ })
+ })
+
+ it('should set parents multiple times', () => {
+ const grandparentExtension = Extendable.create({
+ addAttributes() {
+ return {
+ foo: {},
+ }
+ },
+ })
+
+ const parentExtension = grandparentExtension.extend({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ bar: {},
+ }
+ },
+ })
+
+ const childExtension = parentExtension.extend({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ baz: {},
+ }
+ },
+ })
+
+ expect(parentExtension.parent).toBe(grandparentExtension)
+ expect(childExtension.parent).toBe(parentExtension)
+ })
+
+ it('should merge configs without direct parent configuration', () => {
+ const extension = Extendable.create({
+ addAttributes() {
+ return {
+ foo: {},
+ }
+ },
+ })
+ .extend()
+ .extend({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ bar: {},
+ }
+ },
+ })
+
+ const attributes = getExtensionField(extension, 'addAttributes')()
+
+ expect(attributes).toEqual({
+ foo: {},
+ bar: {},
+ })
+ })
+
+ it('should call ancestors only once', () => {
+ const callCounts = {
+ grandparent: 0,
+ parent: 0,
+ child: 0,
+ }
+
+ const extension = Extendable.create({
+ addAttributes() {
+ callCounts.grandparent += 1
+ return {
+ foo: {},
+ }
+ },
+ })
+ .extend({
+ addAttributes() {
+ callCounts.parent += 1
+ return {
+ ...this.parent?.(),
+ bar: {},
+ }
+ },
+ })
+ .extend({
+ addAttributes() {
+ callCounts.child += 1
+ return {
+ ...this.parent?.(),
+ bar: {},
+ }
+ },
+ })
+
+ getExtensionField(extension, 'addAttributes')()
+
+ expect(callCounts).toEqual({
+ grandparent: 1,
+ parent: 1,
+ child: 1,
+ })
+ })
+
+ it('should call ancestors only once on configure', () => {
+ const callCounts = {
+ grandparent: 0,
+ parent: 0,
+ child: 0,
+ }
+
+ const extension = Extendable.create({
+ addAttributes() {
+ callCounts.grandparent += 1
+ return {
+ foo: {},
+ }
+ },
+ })
+ .extend({
+ addAttributes() {
+ callCounts.parent += 1
+ return {
+ ...this.parent?.(),
+ bar: {},
+ }
+ },
+ })
+ .extend({
+ addAttributes() {
+ callCounts.child += 1
+ return {
+ ...this.parent?.(),
+ bar: {},
+ }
+ },
+ })
+ .configure({
+ baz: {},
+ })
+
+ getExtensionField(extension, 'addAttributes')()
+
+ expect(callCounts).toEqual({
+ grandparent: 1,
+ parent: 1,
+ child: 1,
+ })
+ })
+
+ it('should use grandparent as parent on configure (not parent)', () => {
+ const grandparentExtension = Extendable.create({
+ addAttributes() {
+ return {
+ foo: {},
+ }
+ },
+ })
+
+ const parentExtension = grandparentExtension.extend({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ bar: {},
+ }
+ },
+ })
+
+ const childExtension = parentExtension.configure({
+ baz: {},
+ })
+
+ expect(parentExtension.parent).toBe(grandparentExtension)
+ expect(childExtension.parent).toBe(grandparentExtension)
+ })
+
+ it("should use parent's config on `configure`", () => {
+ const grandparentExtension = Extendable.create({
+ name: 'grandparent',
+ addAttributes() {
+ return {
+ foo: {},
+ }
+ },
+ })
+
+ const parentExtension = grandparentExtension.extend({
+ name: 'parent',
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ bar: {},
+ }
+ },
+ })
+
+ const childExtension = parentExtension.configure({
+ baz: {},
+ })
+
+ expect(childExtension.config.name).toBe('parent')
+ })
+
+ it('should allow extending a configure', () => {
+ const parentExtension = Extendable.create({
+ addAttributes() {
+ return { foo: 'bar' }
+ },
+ })
+
+ const childExtension = parentExtension.configure().extend()
+
+ const attributes = getExtensionField(childExtension, 'addAttributes')()
+
+ expect(attributes).toEqual({
+ foo: 'bar',
+ })
+ })
+
+ it('should allow calling this.parent when extending a configure', () => {
+ const parentExtension = Extendable.create({
+ name: 'parentExtension',
+ addAttributes() {
+ return {
+ foo: {},
+ }
+ },
+ })
+
+ const childExtension = parentExtension.configure({}).extend({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ bar: {},
+ }
+ },
+ })
+
+ const attributes = getExtensionField(childExtension, 'addAttributes')()
+
+ expect(attributes).toEqual({
+ foo: {},
+ bar: {},
+ })
+ })
+
+ it('should configure to be in addition to the parent options', () => {
+ const parentExtension = Extendable.create({
+ name: 'parentExtension',
+ addOptions() {
+ return { parent: 'exists', overwrite: 'parent' }
+ },
+ })
+
+ const childExtension = parentExtension.configure({
+ child: 'exists-too',
+ overwrite: 'child',
+ })
+
+ expect(childExtension.options).toEqual({
+ parent: 'exists',
+ child: 'exists-too',
+ overwrite: 'child',
+ })
+ })
+
+ it('should deeply merge options when extending a configured extension', () => {
+ const parentExtension = Extendable.create({
+ name: 'parentExtension',
+ addOptions() {
+ return { defaultOptions: 'exists', overwrite: 'parent' }
+ },
+ })
+
+ const childExtension = parentExtension
+ .configure({ configuredOptions: 'exists-too', overwrite: 'configure' })
+ .extend({
+ name: 'childExtension',
+ addOptions() {
+ return { ...this.parent?.(), additionalOptions: 'exist-too', overwrite: 'child' }
+ },
+ })
+
+ expect(childExtension.options).toEqual({
+ defaultOptions: 'exists',
+ configuredOptions: 'exists-too',
+ additionalOptions: 'exist-too',
+ overwrite: 'child',
+ })
+ })
+ })
+ })
+})
+describe('parent/child cleanup on destroy', () => {
+ it('should not leak child reference when configure() is called on a singleton', () => {
+ const singleton = Extension.create({
+ name: 'testExtension',
+ addOptions() {
+ return { foo: 'bar' }
+ },
+ })
+
+ const configuredExtension = singleton.configure({ foo: 'baz' })
+
+ expect(singleton.child).toBeNull()
+ expect(configuredExtension.parent).toBeNull()
+ })
+})
+
+describe('extension options', () => {
+ ;[Extension, Node, Mark].forEach(Extendable => {
+ describe(Extendable.create().type, () => {
+ it('should set options', () => {
+ const extension = Extendable.create({
+ addOptions() {
+ return {
+ foo: 1,
+ bar: 1,
+ }
+ },
+ })
+
+ expect(extension.options).toEqual({
+ foo: 1,
+ bar: 1,
+ })
+ })
+
+ it('should pass through', () => {
+ const extension = Extendable.create({
+ addOptions() {
+ return {
+ foo: 1,
+ bar: 1,
+ }
+ },
+ })
+ .extend()
+ .configure()
+
+ expect(extension.options).toEqual({
+ foo: 1,
+ bar: 1,
+ })
+ })
+
+ it('should be configurable', () => {
+ const extension = Extendable.create({
+ addOptions() {
+ return {
+ foo: 1,
+ bar: 1,
+ }
+ },
+ }).configure({
+ bar: 2,
+ })
+
+ expect(extension.options).toEqual({
+ foo: 1,
+ bar: 2,
+ })
+ })
+
+ it('should be extendable', () => {
+ const extension = Extendable.create({
+ addOptions() {
+ return {
+ foo: 1,
+ bar: 1,
+ }
+ },
+ })
+
+ const newExtension = extension.extend({
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ baz: 1,
+ }
+ },
+ })
+
+ expect(newExtension.options).toEqual({
+ foo: 1,
+ bar: 1,
+ baz: 1,
+ })
+ })
+
+ it('should be extendable multiple times', () => {
+ const extension = Extendable.create({
+ addOptions() {
+ return {
+ foo: 1,
+ bar: 1,
+ }
+ },
+ }).extend({
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ baz: 1,
+ }
+ },
+ })
+
+ const newExtension = extension.extend({
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ bax: 1,
+ }
+ },
+ })
+
+ expect(newExtension.options).toEqual({
+ foo: 1,
+ bar: 1,
+ baz: 1,
+ bax: 1,
+ })
+ })
+
+ it('should be overwritable', () => {
+ const extension = Extendable.create({
+ addOptions() {
+ return {
+ foo: 1,
+ bar: 1,
+ }
+ },
+ }).extend({
+ addOptions() {
+ return {
+ baz: 1,
+ }
+ },
+ })
+
+ expect(extension.options).toEqual({
+ baz: 1,
+ })
+ })
+
+ it('should configure nested objects', () => {
+ const extension = Extendable.create<{
+ foo: number[]
+ HTMLAttributes: Record
+ }>({
+ addOptions() {
+ return {
+ foo: [1, 2, 3],
+ HTMLAttributes: {
+ class: 'foo',
+ },
+ }
+ },
+ }).configure({
+ foo: [1],
+ HTMLAttributes: {
+ id: 'bar',
+ },
+ })
+
+ expect(extension.options).toEqual({
+ foo: [1],
+ HTMLAttributes: {
+ class: 'foo',
+ id: 'bar',
+ },
+ })
+ })
+
+ it('should configure retaining existing config', () => {
+ const extension = Extendable.create({
+ name: 'parent',
+ addOptions() {
+ return {
+ foo: 1,
+ bar: 1,
+ }
+ },
+ })
+
+ const newExtension = extension.configure()
+
+ expect(newExtension.config.name).toBe('parent')
+ })
+
+ it('should create its own instance on configure', () => {
+ const extension = Extendable.create({
+ addOptions() {
+ return {
+ foo: 1,
+ bar: 2,
+ }
+ },
+ })
+
+ const extension1 = extension.configure({
+ foo: 2,
+ bar: 4,
+ })
+
+ const extension2 = extension.configure({
+ foo: 3,
+ })
+
+ expect(extension1.options).toEqual({
+ foo: 2,
+ bar: 4,
+ })
+
+ expect(extension2.options).toEqual({
+ foo: 3,
+ bar: 2,
+ })
+ })
+ })
+ })
+})
+
+describe('extension storage', () => {
+ ;[Extension, Node, Mark].forEach(Extendable => {
+ describe(Extendable.create().type, () => {
+ it('should be an empty object if not defined', () => {
+ const extension = Extendable.create({})
+
+ expect(extension.storage).toEqual({})
+ })
+
+ it('should be be the return of `addStorage` if defined', () => {
+ const extension = Extendable.create({
+ addStorage() {
+ return { a: 1 }
+ },
+ })
+
+ expect(extension.storage).toEqual({ a: 1 })
+ })
+
+ it('should be able to be extended', () => {
+ const extension = Extendable.create({
+ addStorage() {
+ return { a: 1 }
+ },
+ }).extend()
+
+ expect(extension.storage).toEqual({ a: 1 })
+ })
+
+ it('should be able to be configured', () => {
+ const extension = Extendable.create({
+ addStorage() {
+ return { a: 1 }
+ },
+ }).configure({
+ anything: 'else',
+ })
+
+ expect(extension.storage).toEqual({ a: 1 })
+ })
+
+ it('should be able to be extended and configured', () => {
+ const extension = Extendable.create({
+ addStorage() {
+ return { a: 1 }
+ },
+ })
+ .extend()
+ .configure({
+ anything: 'else',
+ })
+
+ expect(extension.storage).toEqual({ a: 1 })
+ })
+
+ it('should be overwrite parents addStorage', () => {
+ const extension = Extendable.create({
+ addStorage() {
+ expect(false, 'This should not be called').toBe(true)
+ return { a: 1 }
+ },
+ }).extend({
+ addStorage() {
+ return { b: 1 }
+ },
+ })
+
+ expect(extension.storage).toEqual({ b: 1 })
+ })
+
+ it('grandchild should overwrite grandparent & parents addStorage', () => {
+ const extension = Extendable.create({
+ addStorage() {
+ expect(false, 'This should not be called').toBe(true)
+ return { a: 1 }
+ },
+ })
+ .extend({
+ addStorage() {
+ expect(false, 'This should not be called').toBe(true)
+ return { b: 1 }
+ },
+ })
+ .extend({
+ addStorage() {
+ return { c: 1 }
+ },
+ })
+
+ expect(extension.storage).toEqual({ c: 1 })
+ })
+
+ it('should return a new object on each access', () => {
+ const extension = Extendable.create({
+ addStorage() {
+ return { a: 1 }
+ },
+ })
+
+ const storage1 = extension.storage
+ const storage2 = extension.storage
+
+ expect(storage1).toEqual({ a: 1 })
+ expect(storage2).toEqual({ a: 1 })
+ expect(storage1).not.toBe(storage2)
+ })
+ })
+ })
+})
diff --git a/packages/core/src/Extendable.ts b/packages/core/src/Extendable.ts
new file mode 100644
index 0000000..15a30cb
--- /dev/null
+++ b/packages/core/src/Extendable.ts
@@ -0,0 +1,647 @@
+import type { Plugin } from '@tiptap/pm/state'
+
+import type { Editor } from './Editor.js'
+import type { DecorationSpec } from './decorations/index.js'
+import { getExtensionField } from './helpers/getExtensionField.js'
+import type { ExtensionConfig, MarkConfig, NodeConfig } from './index.js'
+import type { InputRule } from './InputRule.js'
+import type { Mark } from './Mark.js'
+import type { Node } from './Node.js'
+import type { PasteRule } from './PasteRule.js'
+import type {
+ AnyConfig,
+ DispatchTransactionProps,
+ EditorEvents,
+ Extensions,
+ GlobalAttributes,
+ JSONContent,
+ KeyboardShortcutCommand,
+ MarkdownParseHelpers,
+ MarkdownParseResult,
+ MarkdownRendererHelpers,
+ MarkdownToken,
+ MarkdownTokenizer,
+ ParentConfig,
+ RawCommands,
+ RenderContext,
+} from './types.js'
+import { callOrReturn } from './utilities/callOrReturn.js'
+import { mergeDeep } from './utilities/mergeDeep.js'
+
+export interface ExtendableConfig<
+ Options = any,
+ Storage = any,
+ Config extends
+ | ExtensionConfig
+ | NodeConfig
+ | MarkConfig
+ | ExtendableConfig = ExtendableConfig,
+ PMType = any,
+> {
+ /**
+ * The extension name - this must be unique.
+ * It will be used to identify the extension.
+ *
+ * @example 'myExtension'
+ */
+ name: string
+
+ /**
+ * The priority of your extension. The higher, the earlier it will be called
+ * and will take precedence over other extensions with a lower priority.
+ * @default 100
+ * @example 101
+ */
+ priority?: number
+
+ /**
+ * This method will add options to this extension
+ * @see https://tiptap.dev/docs/editor/guide/custom-extensions#settings
+ * @example
+ * addOptions() {
+ * return {
+ * myOption: 'foo',
+ * myOtherOption: 10,
+ * }
+ */
+ addOptions?: (this: { name: string; parent: ParentConfig['addOptions'] }) => Options
+
+ /**
+ * The default storage this extension can save data to.
+ * @see https://tiptap.dev/docs/editor/guide/custom-extensions#storage
+ * @example
+ * defaultStorage: {
+ * prefetchedUsers: [],
+ * loading: false,
+ * }
+ */
+ addStorage?: (this: {
+ name: string
+ options: Options
+ parent: ParentConfig['addStorage']
+ }) => Storage
+
+ /**
+ * This function adds globalAttributes to specific nodes.
+ * @see https://tiptap.dev/docs/editor/guide/custom-extensions#global-attributes
+ * @example
+ * addGlobalAttributes() {
+ * return [
+ * {
+ // Extend the following extensions
+ * types: [
+ * 'heading',
+ * 'paragraph',
+ * ],
+ * // … with those attributes
+ * attributes: {
+ * textAlign: {
+ * default: 'left',
+ * renderHTML: attributes => ({
+ * style: `text-align: ${attributes.textAlign}`,
+ * }),
+ * parseHTML: element => element.style.textAlign || 'left',
+ * },
+ * },
+ * },
+ * ]
+ * }
+ */
+ addGlobalAttributes?: (this: {
+ name: string
+ options: Options
+ storage: Storage
+ extensions: (Node | Mark)[]
+ parent: ParentConfig['addGlobalAttributes']
+ }) => GlobalAttributes
+
+ /**
+ * This function adds commands to the editor
+ * @see https://tiptap.dev/docs/editor/guide/custom-extensions#commands
+ * @example
+ * addCommands() {
+ * return {
+ * myCommand: () => ({ chain }) => chain().setMark('type', 'foo').run(),
+ * }
+ * }
+ */
+ addCommands?: (this: {
+ name: string
+ options: Options
+ storage: Storage
+ editor: Editor
+ type: PMType
+ parent: ParentConfig['addCommands']
+ }) => Partial
+
+ /**
+ * This function registers keyboard shortcuts.
+ * @see https://tiptap.dev/docs/editor/guide/custom-extensions#keyboard-shortcuts
+ * @example
+ * addKeyboardShortcuts() {
+ * return {
+ * 'Mod-l': () => this.editor.commands.toggleBulletList(),
+ * }
+ * },
+ */
+ addKeyboardShortcuts?: (this: {
+ name: string
+ options: Options
+ storage: Storage
+ editor: Editor
+ type: PMType
+ parent: ParentConfig['addKeyboardShortcuts']
+ }) => {
+ [key: string]: KeyboardShortcutCommand
+ }
+
+ /**
+ * This function adds input rules to the editor.
+ * @see https://tiptap.dev/docs/editor/guide/custom-extensions#input-rules
+ * @example
+ * addInputRules() {
+ * return [
+ * markInputRule({
+ * find: inputRegex,
+ * type: this.type,
+ * }),
+ * ]
+ * },
+ */
+ addInputRules?: (this: {
+ name: string
+ options: Options
+ storage: Storage
+ editor: Editor
+ type: PMType
+ parent: ParentConfig['addInputRules']
+ }) => InputRule[]
+
+ /**
+ * This function adds paste rules to the editor.
+ * @see https://tiptap.dev/docs/editor/guide/custom-extensions#paste-rules
+ * @example
+ * addPasteRules() {
+ * return [
+ * markPasteRule({
+ * find: pasteRegex,
+ * type: this.type,
+ * }),
+ * ]
+ * },
+ */
+ addPasteRules?: (this: {
+ name: string
+ options: Options
+ storage: Storage
+ editor: Editor
+ type: PMType
+ parent: ParentConfig['addPasteRules']
+ }) => PasteRule[]
+
+ /**
+ * This function adds Prosemirror plugins to the editor
+ * @see https://tiptap.dev/docs/editor/guide/custom-extensions#prosemirror-plugins
+ * @example
+ * addProseMirrorPlugins() {
+ * return [
+ * customPlugin(),
+ * ]
+ * }
+ */
+ addProseMirrorPlugins?: (this: {
+ name: string
+ options: Options
+ storage: Storage
+ editor: Editor
+ type: PMType
+ parent: ParentConfig['addProseMirrorPlugins']
+ }) => Plugin[]
+
+ /**
+ * Adds editor decorations (node, inline, widget). Return a spec with a `create`
+ * function that builds instances via `Decoration.Node`/`Inline`/`Widget`. Use
+ * `shouldUpdate`, `update: 'changedRanges'` + `createInRange`, or `update: 'manual'` to control recomputation.
+ * @see https://tiptap.dev/docs/editor/core-concepts/decorations
+ * @example
+ * addDecorations() {
+ * return {
+ * create: ({ state }) =>
+ * findChildren(state.doc, node => node.type.name === 'heading').map(
+ * ({ pos, node }) =>
+ * Decoration.Node(pos, pos + node.nodeSize, { class: 'is-heading' }),
+ * ),
+ * }
+ * }
+ *
+ * For framework widgets, use `ReactWidgetRenderer` or `VueWidgetRenderer`
+ * from the matching framework package. Give stateful widgets a stable key.
+ *
+ * `create` and `createInRange` must not throw. An exception escapes through
+ * `editor.commands.*` and stops the document from updating until it stops.
+ */
+ addDecorations?: (this: {
+ name: string
+ options: Options
+ storage: Storage
+ editor: Editor
+ type: PMType
+ parent: ParentConfig['addDecorations']
+ }) => DecorationSpec | null
+
+ /**
+ * This function transforms pasted HTML content before it's parsed.
+ * Extensions can use this to modify or clean up pasted HTML.
+ * The transformations are chained - each extension's transform receives
+ * the output from the previous extension's transform.
+ * @see https://tiptap.dev/docs/editor/guide/custom-extensions#transform-pasted-html
+ * @example
+ * transformPastedHTML(html) {
+ * // Remove all style attributes
+ * return html.replace(/style="[^"]*"/g, '')
+ * }
+ */
+ transformPastedHTML?: (
+ this: {
+ name: string
+ options: Options
+ storage: Storage
+ editor: Editor
+ type: PMType
+ parent: ParentConfig['transformPastedHTML']
+ },
+ html: string,
+ ) => string
+
+ /**
+ * This function adds additional extensions to the editor. This is useful for
+ * building extension kits.
+ * @example
+ * addExtensions() {
+ * return [
+ * BulletList,
+ * OrderedList,
+ * ListItem
+ * ]
+ * }
+ */
+ addExtensions?: (this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig['addExtensions']
+ }) => Extensions
+
+ /**
+ * The markdown token name
+ *
+ * This is the name of the token that this extension uses to parse and render markdown and comes from the Marked Lexer.
+ *
+ * @see https://github.com/markedjs/marked/blob/master/src/Tokens.ts
+ *
+ */
+ markdownTokenName?: string
+
+ /**
+ * The parse function used by the markdown parser to convert markdown tokens to ProseMirror nodes.
+ */
+ parseMarkdown?: (token: MarkdownToken, helpers: MarkdownParseHelpers) => MarkdownParseResult
+
+ /**
+ * The serializer function used by the markdown serializer to convert ProseMirror nodes to markdown tokens.
+ */
+ renderMarkdown?: (
+ node: JSONContent,
+ helpers: MarkdownRendererHelpers,
+ ctx: RenderContext,
+ ) => string
+
+ /**
+ * The markdown tokenizer responsible for turning a markdown string into tokens
+ *
+ * Custom tokenizers are only needed when you want to parse non-standard markdown token.
+ */
+ markdownTokenizer?: MarkdownTokenizer
+
+ /**
+ * Optional markdown options for indentation
+ */
+ markdownOptions?: {
+ /**
+ * Defines if this markdown element should indent it's child elements
+ */
+ indentsContent?: boolean
+
+ /**
+ * Lets a mark tell the Markdown serializer which inline HTML tags it can
+ * safely use when plain markdown delimiters would become ambiguous.
+ *
+ * This is mainly useful for overlapping marks. For example, bold followed
+ * by bold+italic followed by italic cannot always be written back with only
+ * `*` and `**` in a way that still parses correctly. In that case, the
+ * serializer can close the overlapping section with markdown and reopen the
+ * remaining tail with HTML instead.
+ *
+ * Example:
+ * - desired formatting: `**123` + `*456*` + `789 italic`
+ * - serialized result: `**123*456***789 `
+ *
+ * If your extension defines custom mark names, set `htmlReopen` on that
+ * extension so the serializer can reuse its HTML form for overlap cases.
+ */
+ htmlReopen?: {
+ open: string
+ close: string
+ }
+ }
+
+ /**
+ * This function extends the schema of the node.
+ * @example
+ * extendNodeSchema() {
+ * return {
+ * group: 'inline',
+ * selectable: false,
+ * }
+ * }
+ */
+ extendNodeSchema?:
+ | ((
+ this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig['extendNodeSchema']
+ },
+ extension: Node,
+ ) => Record)
+ | null
+
+ /**
+ * This function extends the schema of the mark.
+ * @example
+ * extendMarkSchema() {
+ * return {
+ * group: 'inline',
+ * selectable: false,
+ * }
+ * }
+ */
+ extendMarkSchema?:
+ | ((
+ this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig['extendMarkSchema']
+ },
+ extension: Mark,
+ ) => Record)
+ | null
+
+ /**
+ * The editor is not ready yet.
+ */
+ onBeforeCreate?:
+ | ((
+ this: {
+ name: string
+ options: Options
+ storage: Storage
+ editor: Editor
+ type: PMType
+ parent: ParentConfig['onBeforeCreate']
+ },
+ event: EditorEvents['beforeCreate'],
+ ) => void)
+ | null
+
+ /**
+ * The editor is ready.
+ */
+ onCreate?:
+ | ((
+ this: {
+ name: string
+ options: Options
+ storage: Storage
+ editor: Editor
+ type: PMType
+ parent: ParentConfig['onCreate']
+ },
+ event: EditorEvents['create'],
+ ) => void)
+ | null
+
+ /**
+ * The content has changed.
+ */
+ onUpdate?:
+ | ((
+ this: {
+ name: string
+ options: Options
+ storage: Storage
+ editor: Editor
+ type: PMType
+ parent: ParentConfig['onUpdate']
+ },
+ event: EditorEvents['update'],
+ ) => void)
+ | null
+
+ /**
+ * The selection has changed.
+ */
+ onSelectionUpdate?:
+ | ((
+ this: {
+ name: string
+ options: Options
+ storage: Storage
+ editor: Editor
+ type: PMType
+ parent: ParentConfig['onSelectionUpdate']
+ },
+ event: EditorEvents['selectionUpdate'],
+ ) => void)
+ | null
+
+ /**
+ * The editor state has changed.
+ */
+ onTransaction?:
+ | ((
+ this: {
+ name: string
+ options: Options
+ storage: Storage
+ editor: Editor
+ type: PMType
+ parent: ParentConfig['onTransaction']
+ },
+ event: EditorEvents['transaction'],
+ ) => void)
+ | null
+
+ /**
+ * The editor is focused.
+ */
+ onFocus?:
+ | ((
+ this: {
+ name: string
+ options: Options
+ storage: Storage
+ editor: Editor
+ type: PMType
+ parent: ParentConfig['onFocus']
+ },
+ event: EditorEvents['focus'],
+ ) => void)
+ | null
+
+ /**
+ * The editor isn’t focused anymore.
+ */
+ onBlur?:
+ | ((
+ this: {
+ name: string
+ options: Options
+ storage: Storage
+ editor: Editor
+ type: PMType
+ parent: ParentConfig['onBlur']
+ },
+ event: EditorEvents['blur'],
+ ) => void)
+ | null
+
+ /**
+ * The editor is destroyed.
+ */
+ onDestroy?:
+ | ((
+ this: {
+ name: string
+ options: Options
+ storage: Storage
+ editor: Editor
+ type: PMType
+ parent: ParentConfig['onDestroy']
+ },
+ event: EditorEvents['destroy'],
+ ) => void)
+ | null
+
+ /**
+ * This hook allows you to intercept and modify transactions before they are dispatched.
+ *
+ * Example
+ * ```ts
+ * dispatchTransaction({ transaction, next }) {
+ * console.log('Dispatching transaction:', transaction)
+ * next(transaction)
+ * }
+ * ```
+ *
+ * @param props - The dispatch transaction props
+ */
+ dispatchTransaction?:
+ | ((
+ this: {
+ name: string
+ options: Options
+ storage: Storage
+ editor: Editor
+ type: PMType
+ parent: ParentConfig['dispatchTransaction']
+ },
+ props: DispatchTransactionProps,
+ ) => void)
+ | null
+}
+
+export class Extendable<
+ Options = any,
+ Storage = any,
+ Config =
+ | ExtensionConfig
+ | NodeConfig
+ | MarkConfig,
+> {
+ type = 'extendable'
+ parent: Extendable | null = null
+
+ child: Extendable | null = null
+
+ name = ''
+
+ config: Config = {
+ name: this.name,
+ } as Config
+
+ constructor(config: Partial = {}) {
+ this.config = {
+ ...this.config,
+ ...config,
+ }
+
+ this.name = (this.config as any).name
+ }
+
+ get options(): Options {
+ return {
+ ...callOrReturn(
+ getExtensionField(this as any, 'addOptions', {
+ name: this.name,
+ }),
+ ),
+ }
+ }
+
+ get storage(): Readonly {
+ return {
+ ...callOrReturn(
+ getExtensionField(this as any, 'addStorage', {
+ name: this.name,
+ options: this.options,
+ }),
+ ),
+ }
+ }
+
+ configure(options: Partial = {}) {
+ const extension = this.extend({
+ ...this.config,
+ addOptions: () => {
+ return mergeDeep(this.options as Record, options) as Options
+ },
+ })
+
+ extension.name = this.name
+ extension.parent = this.parent
+
+ this.child = null
+
+ return extension
+ }
+
+ extend<
+ ExtendedOptions = Options,
+ ExtendedStorage = Storage,
+ ExtendedConfig =
+ | ExtensionConfig
+ | NodeConfig
+ | MarkConfig,
+ >(extendedConfig: Partial = {}): Extendable {
+ const extension = new (this.constructor as any)({ ...this.config, ...extendedConfig })
+
+ extension.parent = this
+ this.child = extension
+ extension.name = 'name' in extendedConfig ? extendedConfig.name : extension.parent.name
+
+ return extension
+ }
+}
diff --git a/packages/core/src/Extension.ts b/packages/core/src/Extension.ts
new file mode 100644
index 0000000..03a4306
--- /dev/null
+++ b/packages/core/src/Extension.ts
@@ -0,0 +1,59 @@
+import type { Editor } from './Editor.js'
+import { type ExtendableConfig, Extendable } from './Extendable.js'
+
+// oxlint-disable-next-lineno-empty-object-type
+export interface ExtensionConfig extends ExtendableConfig<
+ Options,
+ Storage,
+ ExtensionConfig,
+ null
+> {}
+
+/**
+ * The Extension class is the base class for all extensions.
+ * @see https://tiptap.dev/api/extensions#create-a-new-extension
+ */
+export class Extension extends Extendable<
+ Options,
+ Storage,
+ ExtensionConfig
+> {
+ type = 'extension'
+
+ /**
+ * Create a new Extension instance
+ * @param config - Extension configuration object or a function that returns a configuration object
+ */
+ static create(
+ config: Partial> | (() => Partial>) = {},
+ ) {
+ // If the config is a function, execute it to get the configuration object
+ const resolvedConfig = typeof config === 'function' ? config() : config
+ return new Extension(resolvedConfig)
+ }
+
+ configure(options?: Partial) {
+ return super.configure(options) as Extension
+ }
+
+ extend<
+ ExtendedOptions = Options,
+ ExtendedStorage = Storage,
+ ExtendedConfig = ExtensionConfig,
+ >(
+ extendedConfig?:
+ | (() => Partial)
+ | (Partial &
+ ThisType<{
+ name: string
+ options: ExtendedOptions
+ storage: ExtendedStorage
+ editor: Editor
+ type: null
+ }>),
+ ): Extension {
+ // If the extended config is a function, execute it to get the configuration object
+ const resolvedConfig = typeof extendedConfig === 'function' ? extendedConfig() : extendedConfig
+ return super.extend(resolvedConfig) as Extension
+ }
+}
diff --git a/packages/core/src/ExtensionManager.test.ts b/packages/core/src/ExtensionManager.test.ts
new file mode 100644
index 0000000..071a4e6
--- /dev/null
+++ b/packages/core/src/ExtensionManager.test.ts
@@ -0,0 +1,852 @@
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
+
+import { Editor } from './Editor.js'
+import { Extension } from './Extension.js'
+
+describe('dispatchTransaction', () => {
+ it('should call dispatchTransaction from an extension', () => {
+ const dispatchTransaction = vi.fn(({ transaction, next }) => next(transaction))
+ const CustomExtension = Extension.create({
+ name: 'custom',
+ dispatchTransaction,
+ })
+
+ const editor = new Editor({
+ extensions: [Document, Paragraph, Text, CustomExtension],
+ })
+
+ editor.commands.insertContent('foo')
+
+ expect(dispatchTransaction).toHaveBeenCalled()
+
+ editor.destroy()
+ })
+
+ it('should call multiple dispatchTransaction hooks in priority order', () => {
+ const order: string[] = []
+ const Extension1 = Extension.create({
+ name: 'extension1',
+ priority: 10,
+ dispatchTransaction({ transaction, next }) {
+ order.push('extension1')
+ next(transaction)
+ },
+ })
+ const Extension2 = Extension.create({
+ name: 'extension2',
+ priority: 20,
+ dispatchTransaction({ transaction, next }) {
+ order.push('extension2')
+ next(transaction)
+ },
+ })
+
+ const editor = new Editor({
+ extensions: [Document, Paragraph, Text, Extension1, Extension2],
+ })
+
+ editor.commands.insertContent('foo')
+
+ expect(order).toEqual(['extension2', 'extension1'])
+
+ editor.destroy()
+ })
+
+ it('should block transaction if next is not called', () => {
+ const Extension1 = Extension.create({
+ name: 'extension1',
+ dispatchTransaction() {
+ // do nothing
+ },
+ })
+
+ const editor = new Editor({
+ extensions: [Document, Paragraph, Text, Extension1],
+ })
+
+ editor.commands.insertContent('foo')
+
+ expect(editor.getText()).toBe('')
+
+ editor.destroy()
+ })
+
+ it('should allow user-provided dispatchTransaction as base', () => {
+ const userDispatch = vi.fn(_tr => {
+ // In a real scenario, the user would update the view state here.
+ // For this test, we just want to see if it's called.
+ })
+
+ const Extension1 = Extension.create({
+ name: 'extension1',
+ dispatchTransaction({ transaction, next }) {
+ next(transaction)
+ },
+ })
+
+ const editor = new Editor({
+ extensions: [Document, Paragraph, Text, Extension1],
+ editorProps: {
+ dispatchTransaction: userDispatch,
+ } as any,
+ })
+
+ editor.commands.insertContent('foo')
+
+ expect(userDispatch).toHaveBeenCalled()
+
+ editor.destroy()
+ })
+
+ it('should bypass extensions if enableExtensionDispatchTransaction is false', () => {
+ const dispatchTransaction = vi.fn(({ transaction, next }) => next(transaction))
+ const CustomExtension = Extension.create({
+ name: 'custom',
+ dispatchTransaction,
+ })
+
+ const editor = new Editor({
+ extensions: [Document, Paragraph, Text, CustomExtension],
+ enableExtensionDispatchTransaction: false,
+ })
+
+ editor.commands.insertContent('foo')
+
+ expect(dispatchTransaction).not.toHaveBeenCalled()
+
+ editor.destroy()
+ })
+})
+
+describe('pluginOrder', () => {
+ let editor: Editor
+
+ afterEach(() => {
+ editor?.destroy()
+ })
+
+ it('runs keyboard shortcuts in correct priority order', () => {
+ const order: number[] = []
+
+ editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Extension.create({
+ priority: 1000,
+ addKeyboardShortcuts() {
+ return {
+ a: () => {
+ order.push(1)
+ return false
+ },
+ }
+ },
+ }),
+ Extension.create({
+ addKeyboardShortcuts() {
+ return {
+ a: () => {
+ order.push(3)
+ return false
+ },
+ }
+ },
+ }),
+ Extension.create({
+ addKeyboardShortcuts() {
+ return {
+ a: () => {
+ order.push(2)
+ return false
+ },
+ }
+ },
+ }),
+ ],
+ })
+
+ editor.view.dom.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }))
+
+ expect(order).toEqual([1, 2, 3])
+ })
+})
+
+describe('transformPastedHTML', () => {
+ describe('priority ordering', () => {
+ it('should execute transforms in priority order (higher priority first)', () => {
+ const executionOrder: number[] = []
+
+ const editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Extension.create({
+ name: 'low-priority',
+ priority: 50,
+ transformPastedHTML(html) {
+ executionOrder.push(3)
+ return html
+ },
+ }),
+ Extension.create({
+ name: 'high-priority',
+ priority: 200,
+ transformPastedHTML(html) {
+ executionOrder.push(1)
+ return html
+ },
+ }),
+ Extension.create({
+ name: 'medium-priority',
+ priority: 100,
+ transformPastedHTML(html) {
+ executionOrder.push(2)
+ return html
+ },
+ }),
+ ],
+ })
+
+ editor.view.props.transformPastedHTML?.('test
')
+
+ expect(executionOrder).toEqual([1, 2, 3])
+
+ editor.destroy()
+ })
+
+ it('should execute transforms in default priority order when priorities are equal', () => {
+ const executionOrder: string[] = []
+
+ const editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Extension.create({
+ name: 'first',
+ transformPastedHTML(html) {
+ executionOrder.push('first')
+ return html
+ },
+ }),
+ Extension.create({
+ name: 'second',
+ transformPastedHTML(html) {
+ executionOrder.push('second')
+ return html
+ },
+ }),
+ ],
+ })
+
+ editor.view.props.transformPastedHTML?.('test
')
+
+ expect(executionOrder).toEqual(['first', 'second'])
+
+ editor.destroy()
+ })
+ })
+
+ describe('transform chaining', () => {
+ it('should chain transforms correctly', () => {
+ const editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Extension.create({
+ name: 'first-transform',
+ priority: 100,
+ transformPastedHTML(html) {
+ return html.replace(/foo/g, 'bar')
+ },
+ }),
+ Extension.create({
+ name: 'second-transform',
+ priority: 90,
+ transformPastedHTML(html) {
+ return html.replace(/bar/g, 'baz')
+ },
+ }),
+ ],
+ })
+
+ const result = editor.view.props.transformPastedHTML?.('foo
')
+
+ expect(result).toBe('baz
')
+
+ editor.destroy()
+ })
+
+ it('should pass transformed HTML through entire chain', () => {
+ const editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Extension.create({
+ name: 'add-prefix',
+ priority: 100,
+ transformPastedHTML(html) {
+ return `PREFIX-${html}`
+ },
+ }),
+ Extension.create({
+ name: 'add-suffix',
+ priority: 90,
+ transformPastedHTML(html) {
+ return `${html}-SUFFIX`
+ },
+ }),
+ Extension.create({
+ name: 'add-wrapper',
+ priority: 80,
+ transformPastedHTML(html) {
+ return `[${html}]`
+ },
+ }),
+ ],
+ })
+
+ const result = editor.view.props.transformPastedHTML?.('TEST')
+
+ expect(result).toBe('[PREFIX-TEST-SUFFIX]')
+
+ editor.destroy()
+ })
+ })
+
+ describe('baseTransform integration', () => {
+ it('should run baseTransform before extension transforms', () => {
+ const editor = new Editor({
+ editorProps: {
+ transformPastedHTML(html) {
+ return html.replace(/original/g, 'base')
+ },
+ },
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Extension.create({
+ name: 'extension-transform',
+ transformPastedHTML(html) {
+ return html.replace(/base/g, 'final')
+ },
+ }),
+ ],
+ })
+
+ const result = editor.view.props.transformPastedHTML?.('original
')
+
+ expect(result).toBe('final
')
+
+ editor.destroy()
+ })
+
+ it('should work when baseTransform is undefined', () => {
+ const editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Extension.create({
+ name: 'extension-transform',
+ transformPastedHTML(html) {
+ return html.replace(/test/g, 'success')
+ },
+ }),
+ ],
+ })
+
+ const result = editor.view.props.transformPastedHTML?.('test
')
+
+ expect(result).toBe('success
')
+
+ editor.destroy()
+ })
+ })
+
+ describe('extensions without transforms', () => {
+ it('should skip extensions without transformPastedHTML', () => {
+ const editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Extension.create({
+ name: 'no-transform',
+ // No transformPastedHTML defined
+ }),
+ Extension.create({
+ name: 'with-transform',
+ transformPastedHTML(html) {
+ return html.replace(/test/g, 'success')
+ },
+ }),
+ Extension.create({
+ name: 'another-no-transform',
+ // No transformPastedHTML defined
+ }),
+ ],
+ })
+
+ const result = editor.view.props.transformPastedHTML?.('test
')
+
+ expect(result).toBe('success
')
+
+ editor.destroy()
+ })
+
+ it('should return original HTML when no transforms are defined', () => {
+ const editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Extension.create({
+ name: 'extension-1',
+ }),
+ Extension.create({
+ name: 'extension-2',
+ }),
+ ],
+ })
+
+ const result = editor.view.props.transformPastedHTML?.('unchanged
')
+
+ expect(result).toBe('unchanged
')
+
+ editor.destroy()
+ })
+ })
+
+ describe('extension context', () => {
+ it('should provide correct context to transformPastedHTML', () => {
+ let capturedContext: any = null
+
+ const editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Extension.create({
+ name: 'test-extension',
+ addOptions() {
+ return {
+ customOption: 'value',
+ }
+ },
+ addStorage() {
+ return {
+ customStorage: 'stored',
+ }
+ },
+ transformPastedHTML(html) {
+ capturedContext = {
+ name: this.name,
+ options: this.options,
+ storage: this.storage,
+ editor: this.editor,
+ }
+ return html
+ },
+ }),
+ ],
+ })
+
+ editor.view.props.transformPastedHTML?.('test
')
+
+ expect(capturedContext).toBeDefined()
+ expect(capturedContext.name).toBe('test-extension')
+ expect(capturedContext.options).toMatchObject({ customOption: 'value' })
+ expect(capturedContext.storage).toMatchObject({ customStorage: 'stored' })
+ expect(capturedContext.editor).toBe(editor)
+
+ editor.destroy()
+ })
+
+ it('should allow accessing editor state in transformPastedHTML', () => {
+ const editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Extension.create({
+ name: 'state-aware',
+ transformPastedHTML(html) {
+ const isEmpty = this.editor.isEmpty
+ return isEmpty ? `${html}` : html
+ },
+ }),
+ ],
+ })
+
+ const result = editor.view.props.transformPastedHTML?.('test
')
+
+ expect(result).toContain('')
+
+ editor.destroy()
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should handle empty HTML string', () => {
+ const editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Extension.create({
+ name: 'transform',
+ transformPastedHTML(html) {
+ return html || 'default
'
+ },
+ }),
+ ],
+ })
+
+ const result = editor.view.props.transformPastedHTML?.('')
+
+ expect(result).toBe('default
')
+
+ editor.destroy()
+ })
+
+ it('should handle HTML with special characters', () => {
+ const editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Extension.create({
+ name: 'preserve-special',
+ transformPastedHTML(html) {
+ return html.replace(/&/g, '&')
+ },
+ }),
+ ],
+ })
+
+ const result = editor.view.props.transformPastedHTML?.('&test&
')
+
+ expect(result).toBe('&test&
')
+
+ editor.destroy()
+ })
+
+ it('should handle very long HTML strings', () => {
+ const editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Extension.create({
+ name: 'transform',
+ transformPastedHTML(html) {
+ return html.replace(/test/g, 'success')
+ },
+ }),
+ ],
+ })
+
+ const longHtml = `${'test '.repeat(10000)}
`
+ const result = editor.view.props.transformPastedHTML?.(longHtml)
+
+ expect(result).toContain('success')
+ expect(result).not.toContain('test')
+
+ editor.destroy()
+ })
+
+ it('should handle malformed HTML gracefully', () => {
+ const editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Extension.create({
+ name: 'transform',
+ transformPastedHTML(html) {
+ return html.replace(/test/g, 'success')
+ },
+ }),
+ ],
+ })
+
+ const malformedHtml = 'test'
+ const result = editor.view.props.transformPastedHTML?.(malformedHtml)
+
+ expect(result).toBe('
success')
+
+ editor.destroy()
+ })
+ })
+
+ describe('view parameter', () => {
+ it('should pass view parameter to baseTransform', () => {
+ let viewReceived: any = null
+
+ const editor = new Editor({
+ editorProps: {
+ transformPastedHTML(html, view) {
+ viewReceived = view
+ return html
+ },
+ },
+ extensions: [Document, Paragraph, Text],
+ })
+
+ editor.view.props.transformPastedHTML?.('
test
', editor.view)
+
+ expect(viewReceived).toBe(editor.view)
+
+ editor.destroy()
+ })
+
+ it('should work when view parameter is undefined', () => {
+ const editor = new Editor({
+ editorProps: {
+ transformPastedHTML(html, view) {
+ return view ? html : `${html}`
+ },
+ },
+ extensions: [Document, Paragraph, Text],
+ })
+
+ const result = editor.view.props.transformPastedHTML?.('test
')
+
+ expect(result).toContain('')
+
+ editor.destroy()
+ })
+ })
+
+ describe('real-world scenarios', () => {
+ it('should remove inline styles and dangerous attributes', () => {
+ const editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Extension.create({
+ name: 'security',
+ priority: 100,
+ transformPastedHTML(html) {
+ return html.replace(/\s+style="[^"]*"/gi, '').replace(/\s+on\w+="[^"]*"/gi, '')
+ },
+ }),
+ ],
+ })
+
+ const result = editor.view.props.transformPastedHTML?.(
+ 'test
',
+ )
+
+ expect(result).toBe('test
')
+
+ editor.destroy()
+ })
+
+ it('should normalize whitespace from word processors', () => {
+ const editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Extension.create({
+ name: 'normalize-whitespace',
+ transformPastedHTML(html) {
+ return html
+ .replace(/\t/g, ' ')
+ .replace(/\u00a0/g, ' ')
+ .replace(/\s+/g, ' ')
+ },
+ }),
+ ],
+ })
+
+ const result = editor.view.props.transformPastedHTML?.(
+ 'test\t\u00a0 multiple spaces
',
+ )
+
+ expect(result).toBe('test multiple spaces
')
+
+ editor.destroy()
+ })
+
+ it('should chain multiple practical transforms', () => {
+ const editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Extension.create({
+ name: 'remove-styles',
+ priority: 100,
+ transformPastedHTML(html) {
+ return html.replace(/\s+style="[^"]*"/gi, '')
+ },
+ }),
+ Extension.create({
+ name: 'normalize-tags',
+ priority: 90,
+ transformPastedHTML(html) {
+ return html.replace(//g, '').replace(/<\/b>/g, ' ')
+ },
+ }),
+ Extension.create({
+ name: 'add-classes',
+ priority: 80,
+ transformPastedHTML(html) {
+ return html.replace(/ /g, '
')
+ },
+ }),
+ ],
+ })
+
+ const result = editor.view.props.transformPastedHTML?.(
+ '
test
',
+ )
+
+ expect(result).toBe('test
')
+
+ editor.destroy()
+ })
+ })
+
+ describe('performance', () => {
+ it('should handle many extensions efficiently', () => {
+ const extensions = [Document, Paragraph, Text]
+
+ // Add 50 extensions with transforms
+ for (let i = 0; i < 50; i += 1) {
+ extensions.push(
+ Extension.create({
+ name: `extension-${i}`,
+ priority: 1000 - i,
+ transformPastedHTML(html) {
+ return html // Pass through
+ },
+ }),
+ )
+ }
+
+ const editor = new Editor({ extensions })
+
+ const start = Date.now()
+ const result = editor.view.props.transformPastedHTML?.('test
')
+ const duration = Date.now() - start
+
+ expect(result).toBe('test
')
+ expect(duration).toBeLessThan(100) // Should complete quickly
+
+ editor.destroy()
+ })
+ })
+})
+
+describe('parent/child cleanup on destroy', () => {
+ it('should break parent/child chain when editor is destroyed (extend path)', () => {
+ const singleton = Extension.create({
+ name: 'testExtension',
+ addOptions() {
+ return { foo: 'bar' }
+ },
+ })
+
+ const childExtension = singleton.extend({
+ addOptions() {
+ return { ...this.parent?.(), foo: 'baz' }
+ },
+ })
+
+ expect(singleton.child).toBe(childExtension)
+ expect(childExtension.parent).toBe(singleton)
+
+ const editor = new Editor({
+ element: null,
+ extensions: [Document, Paragraph, Text, childExtension],
+ })
+
+ editor.destroy()
+
+ expect(singleton.child).toBeNull()
+ })
+
+ it('should clear forward parent.child links on all extensions after editor.destroy()', () => {
+ const singletonA = Extension.create({
+ name: 'extA',
+ addOptions() {
+ return { value: 'a' }
+ },
+ })
+ const singletonB = Extension.create({
+ name: 'extB',
+ addOptions() {
+ return { value: 'b' }
+ },
+ })
+
+ const configuredA = singletonA.configure({ value: 'a-configured' })
+ const childB = singletonB.extend({ name: 'extB-child' })
+
+ const editor = new Editor({
+ element: null,
+ extensions: [Document, Paragraph, Text, configuredA, childB],
+ })
+
+ const { extensions } = editor.extensionManager
+
+ editor.destroy()
+
+ extensions.forEach(ext => {
+ if (ext.parent?.child === ext) {
+ // This should never be true after destroy — the forward link is always broken
+ expect(ext.parent.child).toBeNull()
+ }
+ })
+ })
+
+ it('should break all ancestor child links in a multi-level extend chain after editor.destroy()', () => {
+ const root = Extension.create({
+ name: 'root',
+ addOptions() {
+ return { level: 0 }
+ },
+ })
+
+ const child = root.extend({
+ addOptions() {
+ return { ...this.parent?.(), level: 1 }
+ },
+ })
+
+ const grandchild = child.extend({
+ addOptions() {
+ return { ...this.parent?.(), level: 2 }
+ },
+ })
+
+ expect(root.child).toBe(child)
+ expect(child.child).toBe(grandchild)
+ expect(grandchild.parent).toBe(child)
+ expect(child.parent).toBe(root)
+
+ const editor = new Editor({
+ element: null,
+ extensions: [Document, Paragraph, Text, grandchild],
+ })
+
+ editor.destroy()
+
+ expect(root.child).toBeNull()
+ expect(child.child).toBeNull()
+ })
+})
diff --git a/packages/core/src/ExtensionManager.ts b/packages/core/src/ExtensionManager.ts
new file mode 100644
index 0000000..234bf6f
--- /dev/null
+++ b/packages/core/src/ExtensionManager.ts
@@ -0,0 +1,583 @@
+import { keymap } from '@tiptap/pm/keymap'
+import type { Schema } from '@tiptap/pm/model'
+import type { Plugin, Transaction } from '@tiptap/pm/state'
+import type { EditorView, MarkViewConstructor, NodeViewConstructor } from '@tiptap/pm/view'
+
+import type { Editor } from './Editor.js'
+import { DecorationManager, type DecorationManagerEntry } from './decorations/DecorationManager.js'
+import {
+ flattenExtensions,
+ getAttributesFromExtensions,
+ getExtensionField,
+ getMarkType,
+ getNodeType,
+ getRenderedAttributes,
+ getSchemaByResolvedExtensions,
+ getSchemaTypeByName,
+ isExtensionRulesEnabled,
+ resolveExtensions,
+ sortExtensions,
+ splitExtensions,
+} from './helpers/index.js'
+import { updateMarkViewAttributes } from './MarkView.js'
+import { inputRulesPlugin } from './InputRule.js'
+import { Mark, type MarkConfig } from './Mark.js'
+import { pasteRulesPlugin } from './PasteRule.js'
+import type { AnyConfig, Extensions, RawCommands, Storage } from './types.js'
+import { callOrReturn } from './utilities/callOrReturn.js'
+import { type NodeConfig } from './Node.js'
+
+export class ExtensionManager {
+ editor: Editor
+
+ schema: Schema
+
+ /**
+ * A flattened and sorted array of all extensions
+ */
+ extensions: Extensions
+
+ /**
+ * A non-flattened array of base extensions (no sub-extensions)
+ */
+ baseExtensions: Extensions
+
+ splittableMarks: string[] = []
+
+ nonClearableMarks: string[] = []
+
+ decorationManager: DecorationManager | null = null
+
+ constructor(extensions: Extensions, editor: Editor) {
+ this.editor = editor
+ this.baseExtensions = extensions
+ this.extensions = resolveExtensions(extensions)
+ this.schema = getSchemaByResolvedExtensions(this.extensions, editor)
+ this.setupExtensions()
+ }
+
+ static resolve = resolveExtensions
+
+ static sort = sortExtensions
+
+ static flatten = flattenExtensions
+
+ /**
+ * Get all commands from the extensions.
+ * @returns An object with all commands where the key is the command name and the value is the command function
+ */
+ get commands(): RawCommands {
+ return this.extensions.reduce((commands, extension) => {
+ const context = {
+ name: extension.name,
+ options: extension.options,
+ storage: this.editor.extensionStorage[extension.name as keyof Storage],
+ editor: this.editor,
+ type: getSchemaTypeByName(extension.name, this.schema),
+ }
+
+ const addCommands = getExtensionField(
+ extension,
+ 'addCommands',
+ context,
+ )
+
+ if (!addCommands) {
+ return commands
+ }
+
+ return {
+ ...commands,
+ ...addCommands(),
+ }
+ }, {} as RawCommands)
+ }
+
+ /**
+ * Get all registered Prosemirror plugins from the extensions.
+ * @returns An array of Prosemirror plugins
+ */
+ get plugins(): Plugin[] {
+ const { editor } = this
+
+ // With ProseMirror, first plugins within an array are executed first.
+ // In Tiptap, we provide the ability to override plugins,
+ // so it feels more natural to run plugins at the end of an array first.
+ // That’s why we have to reverse the `extensions` array and sort again
+ // based on the `priority` option.
+ const extensions = sortExtensions([...this.extensions].reverse())
+
+ const allPlugins = extensions.flatMap(extension => {
+ const context = {
+ name: extension.name,
+ options: extension.options,
+ storage: this.editor.extensionStorage[extension.name as keyof Storage],
+ editor,
+ type: getSchemaTypeByName(extension.name, this.schema),
+ }
+
+ const plugins: Plugin[] = []
+
+ const addKeyboardShortcuts = getExtensionField(
+ extension,
+ 'addKeyboardShortcuts',
+ context,
+ )
+
+ let defaultBindings: Record boolean> = {}
+
+ // bind exit handling
+ if (
+ extension.type === 'mark' &&
+ getExtensionField(extension, 'exitable', context)
+ ) {
+ defaultBindings.ArrowRight = () => Mark.handleExit({ editor, mark: extension as Mark })
+ }
+
+ if (addKeyboardShortcuts) {
+ const bindings = Object.fromEntries(
+ Object.entries(addKeyboardShortcuts()).map(([shortcut, method]) => {
+ return [shortcut, () => method({ editor })]
+ }),
+ )
+
+ defaultBindings = { ...defaultBindings, ...bindings }
+ }
+
+ const keyMapPlugin = keymap(defaultBindings)
+
+ plugins.push(keyMapPlugin)
+
+ const addInputRules = getExtensionField(
+ extension,
+ 'addInputRules',
+ context,
+ )
+
+ if (isExtensionRulesEnabled(extension, editor.options.enableInputRules) && addInputRules) {
+ const rules = addInputRules()
+
+ if (rules && rules.length) {
+ const inputResult = inputRulesPlugin({
+ editor,
+ rules,
+ })
+
+ const inputPlugins = Array.isArray(inputResult) ? inputResult : [inputResult]
+
+ plugins.push(...inputPlugins)
+ }
+ }
+
+ const addPasteRules = getExtensionField(
+ extension,
+ 'addPasteRules',
+ context,
+ )
+
+ if (isExtensionRulesEnabled(extension, editor.options.enablePasteRules) && addPasteRules) {
+ const rules = addPasteRules()
+
+ if (rules && rules.length) {
+ const pasteRules = pasteRulesPlugin({ editor, rules })
+
+ plugins.push(...pasteRules)
+ }
+ }
+
+ const addProseMirrorPlugins = getExtensionField(
+ extension,
+ 'addProseMirrorPlugins',
+ context,
+ )
+
+ if (addProseMirrorPlugins) {
+ const proseMirrorPlugins = addProseMirrorPlugins()
+
+ plugins.push(...proseMirrorPlugins)
+ }
+
+ return plugins
+ })
+
+ const decorationPlugin = this.createDecorationPlugin()
+
+ if (decorationPlugin) {
+ allPlugins.push(decorationPlugin)
+ }
+
+ return allPlugins
+ }
+
+ /**
+ * Aggregates decorations from extensions into a single plugin, or returns null
+ * if none exist. Destroys the previous manager to avoid orphaned listeners.
+ * @returns A ProseMirror plugin or `null`
+ * @example
+ * const plugin = editor.extensionManager.createDecorationPlugin()
+ */
+ createDecorationPlugin(): Plugin | null {
+ const { editor } = this
+
+ this.decorationManager?.destroy()
+
+ const entries: DecorationManagerEntry[] = []
+
+ this.extensions.forEach(extension => {
+ const context = {
+ name: extension.name,
+ options: extension.options,
+ storage: this.editor.extensionStorage[extension.name as keyof Storage],
+ editor,
+ type: getSchemaTypeByName(extension.name, this.schema),
+ }
+
+ const addDecorations = getExtensionField(
+ extension,
+ 'addDecorations',
+ context,
+ )
+
+ if (!addDecorations) {
+ return
+ }
+
+ entries.push({ name: extension.name, addDecorations })
+ })
+
+ this.decorationManager = new DecorationManager({ editor, entries })
+
+ return this.decorationManager.plugin
+ }
+
+ /**
+ * Get all attributes from the extensions.
+ * @returns An array of attributes
+ */
+ get attributes() {
+ return getAttributesFromExtensions(this.extensions)
+ }
+
+ /**
+ * Get all node views from the extensions.
+ * @returns An object with all node views where the key is the node name and the value is the node view function
+ */
+ get nodeViews(): Record {
+ const { editor } = this
+ const { nodeExtensions } = splitExtensions(this.extensions)
+
+ return Object.fromEntries(
+ nodeExtensions
+ .filter(extension => !!getExtensionField(extension, 'addNodeView'))
+ .map(extension => {
+ const extensionAttributes = this.attributes.filter(
+ attribute => attribute.type === extension.name,
+ )
+ const context = {
+ name: extension.name,
+ options: extension.options,
+ storage: this.editor.extensionStorage[extension.name as keyof Storage],
+ editor,
+ type: getNodeType(extension.name, this.schema),
+ }
+ const addNodeView = getExtensionField(
+ extension,
+ 'addNodeView',
+ context,
+ )
+
+ if (!addNodeView) {
+ return []
+ }
+
+ const nodeViewResult = addNodeView()
+
+ if (!nodeViewResult) {
+ return []
+ }
+
+ const nodeview: NodeViewConstructor = (
+ node,
+ view,
+ getPos,
+ decorations,
+ innerDecorations,
+ ) => {
+ const HTMLAttributes = getRenderedAttributes(node, extensionAttributes)
+
+ return nodeViewResult({
+ // pass-through
+ node,
+ view,
+ getPos: getPos as () => number,
+ decorations,
+ innerDecorations,
+ // tiptap-specific
+ editor,
+ extension,
+ HTMLAttributes,
+ })
+ }
+
+ return [extension.name, nodeview]
+ }),
+ )
+ }
+
+ /**
+ * Get the composed dispatchTransaction function from all extensions.
+ * @param baseDispatch The base dispatch function (e.g. from the editor or user props)
+ * @returns A composed dispatch function
+ */
+ dispatchTransaction(baseDispatch: (tr: Transaction) => void): (tr: Transaction) => void {
+ const { editor } = this
+ const extensions = sortExtensions([...this.extensions].reverse())
+
+ return extensions.reduceRight((next, extension) => {
+ const context = {
+ name: extension.name,
+ options: extension.options,
+ storage: this.editor.extensionStorage[extension.name as keyof Storage],
+ editor,
+ type: getSchemaTypeByName(extension.name, this.schema),
+ }
+
+ const dispatchTransaction = getExtensionField(
+ extension,
+ 'dispatchTransaction',
+ context,
+ )
+
+ if (!dispatchTransaction) {
+ return next
+ }
+
+ return (transaction: Transaction) => {
+ dispatchTransaction.call(context, { transaction, next })
+ }
+ }, baseDispatch)
+ }
+
+ /**
+ * Get the composed transformPastedHTML function from all extensions.
+ * @param baseTransform The base transform function (e.g. from the editor props)
+ * @returns A composed transform function that chains all extension transforms
+ */
+ transformPastedHTML(
+ baseTransform?: (html: string, view?: any) => string,
+ ): (html: string, view?: EditorView) => string {
+ const { editor } = this
+ const extensions = sortExtensions([...this.extensions])
+
+ return extensions.reduce(
+ (transform, extension) => {
+ const context = {
+ name: extension.name,
+ options: extension.options,
+ storage: this.editor.extensionStorage[extension.name as keyof Storage],
+ editor,
+ type: getSchemaTypeByName(extension.name, this.schema),
+ }
+
+ const extensionTransform = getExtensionField(
+ extension,
+ 'transformPastedHTML',
+ context,
+ )
+
+ if (!extensionTransform) {
+ return transform
+ }
+
+ return (html: string, view?: any) => {
+ // Chain the transforms: pass the result of the previous transform to the next
+ const transformedHtml = transform(html, view)
+ return extensionTransform.call(context, transformedHtml)
+ }
+ },
+ baseTransform || ((html: string) => html),
+ )
+ }
+
+ get markViews(): Record {
+ const { editor } = this
+ const { markExtensions } = splitExtensions(this.extensions)
+
+ return Object.fromEntries(
+ markExtensions
+ .filter(extension => !!getExtensionField(extension, 'addMarkView'))
+ .map(extension => {
+ const extensionAttributes = this.attributes.filter(
+ attribute => attribute.type === extension.name,
+ )
+ const context = {
+ name: extension.name,
+ options: extension.options,
+ storage: this.editor.extensionStorage[extension.name as keyof Storage],
+ editor,
+ type: getMarkType(extension.name, this.schema),
+ }
+ const addMarkView = getExtensionField(
+ extension,
+ 'addMarkView',
+ context,
+ )
+
+ if (!addMarkView) {
+ return []
+ }
+
+ const markView: MarkViewConstructor = (mark, view, inline) => {
+ const HTMLAttributes = getRenderedAttributes(mark, extensionAttributes)
+
+ return addMarkView()({
+ // pass-through
+ mark,
+ view,
+ inline,
+ // tiptap-specific
+ editor,
+ extension,
+ HTMLAttributes,
+ updateAttributes: (attrs: Record) => {
+ updateMarkViewAttributes(mark, editor, attrs)
+ },
+ })
+ }
+
+ return [extension.name, markView]
+ }),
+ )
+ }
+
+ /**
+ * Destroy the extension manager and clean up all extension references
+ * to prevent memory leaks through parent/child extension chains.
+ *
+ * Walks each extension's full parent chain and nulls every forward
+ * `parent.child → current` link where the parent still points to the
+ * current node. This breaks the retention path from module-scope
+ * singleton roots through deep extend() chains.
+ *
+ * Only ancestor `.child` links matching the current chain are cleared.
+ * The `.parent` pointer on ancestors is never touched — extensions
+ * may be shared across live editors, so their own backward references
+ * and non-matching forward links must remain intact.
+ */
+ destroy() {
+ this.decorationManager?.destroy()
+
+ this.extensions.forEach(extension => {
+ let current: any = extension
+
+ while (current.parent) {
+ const parent = current.parent
+
+ if (parent.child === current) {
+ parent.child = null
+ }
+
+ current = parent
+ }
+ })
+
+ this.extensions = []
+ this.baseExtensions = []
+ this.decorationManager = null
+ this.schema = null as any
+ this.editor = null as any
+ }
+
+ /**
+ * Go through all extensions, create extension storages & setup marks
+ * & bind editor event listener.
+ */
+ private setupExtensions() {
+ const extensions = this.extensions
+ // re-initialize the extension storage object instance
+ this.editor.extensionStorage = Object.fromEntries(
+ extensions.map(extension => [extension.name, extension.storage]),
+ ) as unknown as Storage
+
+ extensions.forEach(extension => {
+ const context = {
+ name: extension.name,
+ options: extension.options,
+ storage: this.editor.extensionStorage[extension.name as keyof Storage],
+ editor: this.editor,
+ type: getSchemaTypeByName(extension.name, this.schema),
+ }
+
+ if (extension.type === 'mark') {
+ const keepOnSplit =
+ callOrReturn(getExtensionField(extension, 'keepOnSplit', context)) ?? true
+
+ if (keepOnSplit) {
+ this.splittableMarks.push(extension.name)
+ }
+
+ const clearable =
+ callOrReturn(
+ getExtensionField(extension, 'clearable', context),
+ ) ?? true
+
+ if (!clearable) {
+ this.nonClearableMarks.push(extension.name)
+ }
+ }
+
+ const onBeforeCreate = getExtensionField(
+ extension,
+ 'onBeforeCreate',
+ context,
+ )
+ const onCreate = getExtensionField(extension, 'onCreate', context)
+ const onUpdate = getExtensionField(extension, 'onUpdate', context)
+ const onSelectionUpdate = getExtensionField(
+ extension,
+ 'onSelectionUpdate',
+ context,
+ )
+ const onTransaction = getExtensionField(
+ extension,
+ 'onTransaction',
+ context,
+ )
+ const onFocus = getExtensionField(extension, 'onFocus', context)
+ const onBlur = getExtensionField(extension, 'onBlur', context)
+ const onDestroy = getExtensionField(extension, 'onDestroy', context)
+
+ if (onBeforeCreate) {
+ this.editor.on('beforeCreate', onBeforeCreate)
+ }
+
+ if (onCreate) {
+ this.editor.on('create', onCreate)
+ }
+
+ if (onUpdate) {
+ this.editor.on('update', onUpdate)
+ }
+
+ if (onSelectionUpdate) {
+ this.editor.on('selectionUpdate', onSelectionUpdate)
+ }
+
+ if (onTransaction) {
+ this.editor.on('transaction', onTransaction)
+ }
+
+ if (onFocus) {
+ this.editor.on('focus', onFocus)
+ }
+
+ if (onBlur) {
+ this.editor.on('blur', onBlur)
+ }
+
+ if (onDestroy) {
+ this.editor.on('destroy', onDestroy)
+ }
+ })
+ }
+}
diff --git a/packages/core/src/InputRule.test.ts b/packages/core/src/InputRule.test.ts
new file mode 100644
index 0000000..d698b94
--- /dev/null
+++ b/packages/core/src/InputRule.test.ts
@@ -0,0 +1,113 @@
+import Code from '@tiptap/extension-code'
+import Document from '@tiptap/extension-document'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { afterEach, describe, expect, it } from 'vite-plus/test'
+
+import { Editor } from './Editor.js'
+import { Node } from './Node.js'
+
+// minimal stand-in for a mention-like node. inline atoms expand to %leaf% (6 chars)
+// in getTextContentFromNodes but only take up 1 position in the doc,
+// which used to break the input rule range math
+const InlineAtom = Node.create({
+ name: 'inlineAtom',
+ group: 'inline',
+ inline: true,
+ atom: true,
+ parseHTML: () => [{ tag: 'span[data-atom]' }],
+ renderHTML: () => ['span', { 'data-atom': '' }],
+})
+
+// same as above, but with a custom text representation instead of the %leaf% fallback
+const InlineAtomWithText = Node.create({
+ name: 'inlineAtomWithText',
+ group: 'inline',
+ inline: true,
+ atom: true,
+ parseHTML: () => [{ tag: 'span[data-atom-text]' }],
+ renderHTML: () => ['span', { 'data-atom-text': '' }],
+ renderText: () => '@mention',
+})
+
+const createEditor = (content: string) =>
+ new Editor({
+ extensions: [Document, Paragraph, Text, InlineAtom, InlineAtomWithText, Code],
+ content,
+ })
+
+const typeAtEndOfParagraph = (editor: Editor, char: string) => {
+ const pos = editor.state.doc.content.size - 1
+
+ editor.commands.setTextSelection(pos)
+
+ return editor.view.someProp('handleTextInput', f => f(editor.view, pos, pos, char))
+}
+
+describe('input rules with inline atom nodes', () => {
+ let editor: Editor
+
+ afterEach(() => {
+ editor.destroy()
+ })
+
+ it('does not crash when the match spans an inline atom (#7933)', () => {
+ editor = createEditor('`a
')
+
+ expect(() => typeAtEndOfParagraph(editor, '`')).not.toThrow()
+ })
+
+ it('does not apply the rule when the match spans an inline atom', () => {
+ editor = createEditor('`a
')
+
+ const handled = typeAtEndOfParagraph(editor, '`')
+
+ // someProp returns undefined when the handler declines the input
+ expect(handled).toBeFalsy()
+ expect(editor.getHTML()).not.toContain('')
+ })
+
+ it('does not crash with multiple atoms inside the match', () => {
+ editor = createEditor(
+ '`a
',
+ )
+
+ expect(() => typeAtEndOfParagraph(editor, '`')).not.toThrow()
+ })
+
+ it('does not corrupt preceding text when the match spans an atom (#7833)', () => {
+ // with enough text before the backtick the miscalculated range stays positive,
+ // so instead of crashing it used to silently mark/replace the wrong content
+ editor = createEditor('hello world `a
')
+
+ expect(() => typeAtEndOfParagraph(editor, '`')).not.toThrow()
+ expect(editor.getHTML()).toBe('hello world `a
')
+ })
+
+ it('does not apply the rule when the match spans an atom with a custom text representation', () => {
+ editor = createEditor('hello `a
')
+
+ const handled = typeAtEndOfParagraph(editor, '`')
+
+ expect(handled).toBeFalsy()
+ expect(editor.getHTML()).toBe('hello `a
')
+ })
+
+ it('still applies input rules to plain text', () => {
+ editor = createEditor('some `code
')
+
+ const handled = typeAtEndOfParagraph(editor, '`')
+
+ expect(handled).toBe(true)
+ expect(editor.getHTML()).toContain('code')
+ })
+
+ it('still applies input rules when an atom precedes the match', () => {
+ editor = createEditor(' x `code
')
+
+ const handled = typeAtEndOfParagraph(editor, '`')
+
+ expect(handled).toBe(true)
+ expect(editor.getHTML()).toContain('code')
+ })
+})
diff --git a/packages/core/src/InputRule.ts b/packages/core/src/InputRule.ts
new file mode 100644
index 0000000..c6b127a
--- /dev/null
+++ b/packages/core/src/InputRule.ts
@@ -0,0 +1,317 @@
+import type { Node as PMNode } from '@tiptap/pm/model'
+import { Fragment } from '@tiptap/pm/model'
+import type { EditorState, TextSelection } from '@tiptap/pm/state'
+import { Plugin } from '@tiptap/pm/state'
+
+import { CommandManager } from './CommandManager.js'
+import type { Editor } from './Editor.js'
+import { createChainableState } from './helpers/createChainableState.js'
+import { getHTMLFromFragment } from './helpers/getHTMLFromFragment.js'
+import { getTextContentFromNodes } from './helpers/getTextContentFromNodes.js'
+import type {
+ CanCommands,
+ ChainedCommands,
+ ExtendedRegExpMatchArray,
+ Range,
+ SingleCommands,
+} from './types.js'
+import { isRegExp } from './utilities/isRegExp.js'
+
+export type InputRuleMatch = {
+ index: number
+ text: string
+ replaceWith?: string
+ match?: RegExpMatchArray
+ data?: Record
+}
+
+export type InputRuleFinder = RegExp | ((text: string) => InputRuleMatch | null)
+
+export class InputRule {
+ find: InputRuleFinder
+
+ handler: (props: {
+ state: EditorState
+ range: Range
+ match: ExtendedRegExpMatchArray
+ commands: SingleCommands
+ chain: () => ChainedCommands
+ can: () => CanCommands
+ }) => void | null
+
+ undoable: boolean
+
+ constructor(config: {
+ find: InputRuleFinder
+ handler: (props: {
+ state: EditorState
+ range: Range
+ match: ExtendedRegExpMatchArray
+ commands: SingleCommands
+ chain: () => ChainedCommands
+ can: () => CanCommands
+ }) => void | null
+ undoable?: boolean
+ }) {
+ this.find = config.find
+ this.handler = config.handler
+ this.undoable = config.undoable ?? true
+ }
+}
+
+const inputRuleMatcherHandler = (
+ text: string,
+ find: InputRuleFinder,
+): ExtendedRegExpMatchArray | null => {
+ if (isRegExp(find)) {
+ return find.exec(text)
+ }
+
+ const inputRuleMatch = find(text)
+
+ if (!inputRuleMatch) {
+ return null
+ }
+
+ const result: ExtendedRegExpMatchArray = [inputRuleMatch.text]
+
+ result.index = inputRuleMatch.index
+ result.input = text
+ result.data = inputRuleMatch.data
+
+ if (inputRuleMatch.replaceWith) {
+ if (!inputRuleMatch.text.includes(inputRuleMatch.replaceWith)) {
+ console.warn(
+ '[tiptap warn]: "inputRuleMatch.replaceWith" must be part of "inputRuleMatch.text".',
+ )
+ }
+
+ result.push(inputRuleMatch.replaceWith)
+ }
+
+ return result
+}
+
+function run(config: {
+ editor: Editor
+ from: number
+ to: number
+ text: string
+ rules: InputRule[]
+ plugin: Plugin
+}): boolean {
+ const { editor, from, to, text, rules, plugin } = config
+ const { view } = editor
+
+ if (view.composing) {
+ return false
+ }
+
+ const $from = view.state.doc.resolve(from)
+
+ if (
+ // check for code node
+ $from.parent.type.spec.code ||
+ // check for code mark
+ !!($from.nodeBefore || $from.nodeAfter)?.marks.find(mark => mark.type.spec.code)
+ ) {
+ return false
+ }
+
+ let matched = false
+
+ const textBefore = getTextContentFromNodes($from) + text
+
+ rules.forEach(rule => {
+ if (matched) {
+ return
+ }
+
+ const match = inputRuleMatcherHandler(textBefore, rule.find)
+
+ if (!match) {
+ return
+ }
+
+ // Leaf nodes have a different length in text and the document.
+ // Skip matches across them to avoid wrong document positions.
+ const matchedDocLength = match[0].length - text.length
+
+ if (matchedDocLength > 0) {
+ const matchStartOffset = $from.parentOffset - matchedDocLength
+
+ if (
+ matchStartOffset < 0 ||
+ $from.parent.textBetween(matchStartOffset, $from.parentOffset) !==
+ match[0].slice(0, matchedDocLength)
+ ) {
+ return
+ }
+ }
+
+ const tr = view.state.tr
+ const state = createChainableState({
+ state: view.state,
+ transaction: tr,
+ })
+ const range = {
+ from: from - (match[0].length - text.length),
+ to,
+ }
+
+ const { commands, chain, can } = new CommandManager({
+ editor,
+ state,
+ })
+
+ const handler = rule.handler({
+ state,
+ range,
+ match,
+ commands,
+ chain,
+ can,
+ })
+
+ // stop if there are no changes
+ if (handler === null || !tr.steps.length) {
+ return
+ }
+
+ // store transform as meta data
+ // so we can undo input rules within the `undoInputRules` command
+ if (rule.undoable) {
+ tr.setMeta(plugin, {
+ transform: tr,
+ from,
+ to,
+ text,
+ })
+ }
+
+ view.dispatch(tr)
+ matched = true
+ })
+
+ return matched
+}
+
+/**
+ * Create an input rules plugin. When enabled, it will cause text
+ * input that matches any of the given rules to trigger the rule’s
+ * action.
+ */
+export function inputRulesPlugin(props: { editor: Editor; rules: InputRule[] }): Plugin {
+ const { editor, rules } = props
+ const plugin = new Plugin({
+ state: {
+ init() {
+ return null
+ },
+ apply(tr, prev, state) {
+ const stored = tr.getMeta(plugin)
+
+ if (stored) {
+ return stored
+ }
+
+ // if InputRule is triggered by insertContent()
+ const simulatedInputMeta = tr.getMeta('applyInputRules') as
+ | undefined
+ | {
+ from: number
+ text: string | PMNode | Fragment
+ }
+ const isSimulatedInput = !!simulatedInputMeta
+
+ if (isSimulatedInput) {
+ setTimeout(() => {
+ let { text } = simulatedInputMeta
+
+ if (typeof text === 'string') {
+ text = text as string
+ } else {
+ text = getHTMLFromFragment(Fragment.from(text), state.schema)
+ }
+
+ const { from } = simulatedInputMeta
+ const to = from + text.length
+
+ run({
+ editor,
+ from,
+ to,
+ text,
+ rules,
+ plugin,
+ })
+ })
+ }
+
+ return tr.selectionSet || tr.docChanged ? null : prev
+ },
+ },
+
+ props: {
+ handleTextInput(view, from, to, text) {
+ return run({
+ editor,
+ from,
+ to,
+ text,
+ rules,
+ plugin,
+ })
+ },
+
+ handleDOMEvents: {
+ compositionend: view => {
+ setTimeout(() => {
+ const { $cursor } = view.state.selection as TextSelection
+
+ if ($cursor) {
+ run({
+ editor,
+ from: $cursor.pos,
+ to: $cursor.pos,
+ text: '',
+ rules,
+ plugin,
+ })
+ }
+ })
+
+ return false
+ },
+ },
+
+ // add support for input rules to trigger on enter
+ // this is useful for example for code blocks
+ handleKeyDown(view, event) {
+ if (event.key !== 'Enter') {
+ return false
+ }
+
+ const { $cursor } = view.state.selection as TextSelection
+
+ if ($cursor) {
+ return run({
+ editor,
+ from: $cursor.pos,
+ to: $cursor.pos,
+ text: '\n',
+ rules,
+ plugin,
+ })
+ }
+
+ return false
+ },
+ },
+
+ // @ts-ignore
+ isInputRules: true,
+ }) as Plugin
+
+ return plugin
+}
diff --git a/packages/core/src/Mark.ts b/packages/core/src/Mark.ts
new file mode 100644
index 0000000..cf4a78f
--- /dev/null
+++ b/packages/core/src/Mark.ts
@@ -0,0 +1,231 @@
+import type { DOMOutputSpec, Mark as ProseMirrorMark, MarkSpec, MarkType } from '@tiptap/pm/model'
+
+import type { Editor } from './Editor.js'
+import type { ExtendableConfig } from './Extendable.js'
+import { Extendable } from './Extendable.js'
+import type { Attributes, MarkViewRenderer, ParentConfig } from './types.js'
+
+export interface MarkConfig extends ExtendableConfig<
+ Options,
+ Storage,
+ MarkConfig,
+ MarkType
+> {
+ /**
+ * Mark View
+ */
+ addMarkView?:
+ | ((this: {
+ name: string
+ options: Options
+ storage: Storage
+ editor: Editor
+ type: MarkType
+ parent: ParentConfig>['addMarkView']
+ }) => MarkViewRenderer)
+ | null
+
+ /**
+ * Keep mark after split node
+ */
+ keepOnSplit?: boolean | (() => boolean)
+
+ /**
+ * Whether this mark is removed by `unsetAllMarks`.
+ * Set to `false` for semantic marks (comments, suggestions) that should survive "clear formatting".
+ * @default true
+ */
+ clearable?: boolean | (() => boolean)
+
+ /**
+ * Inclusive
+ */
+ inclusive?:
+ | MarkSpec['inclusive']
+ | ((this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['inclusive']
+ editor?: Editor
+ }) => MarkSpec['inclusive'])
+
+ /**
+ * Excludes
+ */
+ excludes?:
+ | MarkSpec['excludes']
+ | ((this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['excludes']
+ editor?: Editor
+ }) => MarkSpec['excludes'])
+
+ /**
+ * Marks this Mark as exitable
+ */
+ exitable?: boolean | (() => boolean)
+
+ /**
+ * Group
+ */
+ group?:
+ | MarkSpec['group']
+ | ((this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['group']
+ editor?: Editor
+ }) => MarkSpec['group'])
+
+ /**
+ * Spanning
+ */
+ spanning?:
+ | MarkSpec['spanning']
+ | ((this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['spanning']
+ editor?: Editor
+ }) => MarkSpec['spanning'])
+
+ /**
+ * Code
+ */
+ code?:
+ | boolean
+ | ((this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['code']
+ editor?: Editor
+ }) => boolean)
+
+ /**
+ * Parse HTML
+ */
+ parseHTML?: (this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['parseHTML']
+ editor?: Editor
+ }) => MarkSpec['parseDOM']
+
+ /**
+ * Render HTML
+ */
+ renderHTML?:
+ | ((
+ this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['renderHTML']
+ editor?: Editor
+ },
+ props: {
+ mark: ProseMirrorMark
+ HTMLAttributes: Record
+ },
+ ) => DOMOutputSpec)
+ | null
+
+ /**
+ * Attributes
+ */
+ addAttributes?: (this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['addAttributes']
+ editor?: Editor
+ // oxlint-disable-next-lineno-empty-object-type
+ }) => Attributes | {}
+}
+
+/**
+ * The Mark class is used to create custom mark extensions.
+ * @see https://tiptap.dev/api/extensions#create-a-new-extension
+ */
+export class Mark extends Extendable<
+ Options,
+ Storage,
+ MarkConfig
+> {
+ type = 'mark'
+
+ /**
+ * Create a new Mark instance
+ * @param config - Mark configuration object or a function that returns a configuration object
+ */
+ static create(
+ config: Partial> | (() => Partial>) = {},
+ ) {
+ // If the config is a function, execute it to get the configuration object
+ const resolvedConfig = typeof config === 'function' ? config() : config
+ return new Mark(resolvedConfig)
+ }
+
+ static handleExit({ editor, mark }: { editor: Editor; mark: Mark }) {
+ const { tr } = editor.state
+ const currentPos = editor.state.selection.$from
+ const isAtEnd = currentPos.pos === currentPos.end()
+
+ if (isAtEnd) {
+ const currentMarks = currentPos.marks()
+ const isInMark = !!currentMarks.find(m => m?.type.name === mark.name)
+
+ if (!isInMark) {
+ return false
+ }
+
+ const removeMark = currentMarks.find(m => m?.type.name === mark.name)
+
+ if (removeMark) {
+ tr.removeStoredMark(removeMark)
+ }
+ tr.insertText(' ', currentPos.pos)
+
+ editor.view.dispatch(tr)
+
+ return true
+ }
+
+ return false
+ }
+
+ configure(options?: Partial) {
+ return super.configure(options) as Mark
+ }
+
+ extend<
+ ExtendedOptions = Options,
+ ExtendedStorage = Storage,
+ ExtendedConfig extends MarkConfig = MarkConfig<
+ ExtendedOptions,
+ ExtendedStorage
+ >,
+ >(
+ extendedConfig?:
+ | (() => Partial)
+ | (Partial &
+ ThisType<{
+ name: string
+ options: ExtendedOptions
+ storage: ExtendedStorage
+ editor: Editor
+ type: MarkType
+ }>),
+ ): Mark {
+ // If the extended config is a function, execute it to get the configuration object
+ const resolvedConfig = typeof extendedConfig === 'function' ? extendedConfig() : extendedConfig
+ return super.extend(resolvedConfig) as Mark
+ }
+}
diff --git a/packages/core/src/MarkView.test.ts b/packages/core/src/MarkView.test.ts
new file mode 100644
index 0000000..f452ebf
--- /dev/null
+++ b/packages/core/src/MarkView.test.ts
@@ -0,0 +1,53 @@
+import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
+
+import { createViewMutationTestContext } from '../tests/utils/createViewMutationTestContext.js'
+import { MarkView } from './MarkView.js'
+import type { MarkViewProps } from './types.js'
+
+function createMarkView() {
+ const context = createViewMutationTestContext()
+ const { wrapper, contentDOM } = context
+
+ class TestMarkView extends MarkView {
+ get dom() {
+ return wrapper
+ }
+
+ get contentDOM() {
+ return contentDOM
+ }
+ }
+
+ const markView = new TestMarkView(null, {
+ editor: { isFocused: true },
+ mark: {},
+ HTMLAttributes: {},
+ } as unknown as MarkViewProps)
+
+ return { markView, ...context }
+}
+
+describe('MarkView ignoreMutation on iOS', () => {
+ afterEach(() => {
+ document.body.replaceChildren()
+ vi.unstubAllGlobals()
+ })
+
+ it('ignores framework chrome mounted inside dom outside contentDOM', () => {
+ const { markView, chrome, createEditableChild, childListMutation } = createMarkView()
+ const mounted = createEditableChild()
+
+ chrome.append(mounted)
+
+ expect(markView.ignoreMutation(childListMutation(chrome, [mounted]))).toBe(true)
+ })
+
+ it('lets ProseMirror handle childList mutations inside contentDOM', () => {
+ const { markView, contentDOM, createEditableChild, childListMutation } = createMarkView()
+ const edited = createEditableChild()
+
+ contentDOM.append(edited)
+
+ expect(markView.ignoreMutation(childListMutation(contentDOM, [edited]))).toBe(false)
+ })
+})
diff --git a/packages/core/src/MarkView.ts b/packages/core/src/MarkView.ts
new file mode 100644
index 0000000..de7d175
--- /dev/null
+++ b/packages/core/src/MarkView.ts
@@ -0,0 +1,132 @@
+import type { Mark } from '@tiptap/pm/model'
+import type { ViewMutationRecord } from '@tiptap/pm/view'
+
+import type { Editor } from './Editor.js'
+import type { MarkViewProps, MarkViewRendererOptions } from './types.js'
+import { isAndroid, isiOS } from './utilities/index.js'
+
+export function updateMarkViewAttributes(
+ checkMark: Mark,
+ editor: Editor,
+ attrs: Record = {},
+): void {
+ const { state } = editor
+ const { doc, tr } = state
+ const thisMark = checkMark
+
+ doc.descendants((node, pos) => {
+ const from = tr.mapping.map(pos)
+ const to = tr.mapping.map(pos) + node.nodeSize
+ let foundMark: Mark | null = null
+
+ // find the mark on the current node
+ node.marks.forEach(mark => {
+ if (mark !== thisMark) {
+ return false
+ }
+
+ foundMark = mark
+ })
+
+ if (!foundMark) {
+ return
+ }
+
+ // check if we need to update given the attributes
+ let needsUpdate = false
+ Object.keys(attrs).forEach(k => {
+ if (attrs[k] !== foundMark!.attrs[k]) {
+ needsUpdate = true
+ }
+ })
+
+ if (needsUpdate) {
+ const updatedMark = checkMark.type.create({
+ ...checkMark.attrs,
+ ...attrs,
+ })
+
+ tr.removeMark(from, to, checkMark.type)
+ tr.addMark(from, to, updatedMark)
+ }
+ })
+
+ if (tr.docChanged) {
+ editor.view.dispatch(tr)
+ }
+}
+
+export class MarkView<
+ Component,
+ Options extends MarkViewRendererOptions = MarkViewRendererOptions,
+> {
+ component: Component
+ editor: Editor
+ options: Options
+ mark: MarkViewProps['mark']
+ HTMLAttributes: MarkViewProps['HTMLAttributes']
+
+ constructor(component: Component, props: MarkViewProps, options?: Partial) {
+ this.component = component
+ this.editor = props.editor
+ this.options = { ...options } as Options
+ this.mark = props.mark
+ this.HTMLAttributes = props.HTMLAttributes
+ }
+
+ get dom(): HTMLElement {
+ return this.editor.view.dom
+ }
+
+ get contentDOM(): HTMLElement | null {
+ return null
+ }
+
+ /**
+ * Update the attributes of the mark in the document.
+ * @param attrs The attributes to update.
+ */
+ updateAttributes(attrs: Record, checkMark?: Mark): void {
+ updateMarkViewAttributes(checkMark || this.mark, this.editor, attrs)
+ }
+
+ ignoreMutation(mutation: ViewMutationRecord): boolean {
+ if (!this.dom || !this.contentDOM) {
+ return true
+ }
+
+ if (typeof this.options.ignoreMutation === 'function') {
+ return this.options.ignoreMutation({ mutation })
+ }
+
+ if (mutation.type === 'selection') {
+ return false
+ }
+
+ if (
+ this.contentDOM.contains(mutation.target) &&
+ mutation.type === 'childList' &&
+ (isiOS() || isAndroid()) &&
+ this.editor.isFocused
+ ) {
+ const changedNodes = [
+ ...Array.from(mutation.addedNodes),
+ ...Array.from(mutation.removedNodes),
+ ] as HTMLElement[]
+
+ if (changedNodes.every(node => node.isContentEditable)) {
+ return false
+ }
+ }
+
+ if (this.contentDOM === mutation.target && mutation.type === 'attributes') {
+ return true
+ }
+
+ if (this.contentDOM.contains(mutation.target)) {
+ return false
+ }
+
+ return true
+ }
+}
diff --git a/packages/core/src/Node.ts b/packages/core/src/Node.ts
new file mode 100644
index 0000000..2dbb772
--- /dev/null
+++ b/packages/core/src/Node.ts
@@ -0,0 +1,390 @@
+import type { DOMOutputSpec, Node as PMNode, NodeSpec, NodeType } from '@tiptap/pm/model'
+
+import type { Editor } from './Editor.js'
+import type { ExtendableConfig } from './Extendable.js'
+import { Extendable } from './Extendable.js'
+import type { Attributes, NodeViewRenderer, ParentConfig } from './types.js'
+
+export interface NodeConfig extends ExtendableConfig<
+ Options,
+ Storage,
+ NodeConfig,
+ NodeType
+> {
+ /**
+ * Node View
+ */
+ addNodeView?:
+ | ((this: {
+ name: string
+ options: Options
+ storage: Storage
+ editor: Editor
+ type: NodeType
+ parent: ParentConfig>['addNodeView']
+ }) => NodeViewRenderer | null)
+ | null
+
+ /**
+ * Defines if this node should be a top level node (doc)
+ * @default false
+ * @example true
+ */
+ topNode?: boolean
+
+ /**
+ * The content expression for this node, as described in the [schema
+ * guide](/docs/guide/#schema.content_expressions). When not given,
+ * the node does not allow any content.
+ *
+ * You can read more about it on the Prosemirror documentation here
+ * @see https://prosemirror.net/docs/guide/#schema.content_expressions
+ * @default undefined
+ * @example content: 'block+'
+ * @example content: 'headline paragraph block*'
+ */
+ content?:
+ | NodeSpec['content']
+ | ((this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['content']
+ editor?: Editor
+ }) => NodeSpec['content'])
+
+ /**
+ * The marks that are allowed inside of this node. May be a
+ * space-separated string referring to mark names or groups, `"_"`
+ * to explicitly allow all marks, or `""` to disallow marks. When
+ * not given, nodes with inline content default to allowing all
+ * marks, other nodes default to not allowing marks.
+ *
+ * @example marks: 'strong em'
+ */
+ marks?:
+ | NodeSpec['marks']
+ | ((this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['marks']
+ editor?: Editor
+ }) => NodeSpec['marks'])
+
+ /**
+ * The group or space-separated groups to which this node belongs,
+ * which can be referred to in the content expressions for the
+ * schema.
+ *
+ * By default Tiptap uses the groups 'block' and 'inline' for nodes. You
+ * can also use custom groups if you want to group specific nodes together
+ * and handle them in your schema.
+ * @example group: 'block'
+ * @example group: 'inline'
+ * @example group: 'customBlock' // this uses a custom group
+ */
+ group?:
+ | NodeSpec['group']
+ | ((this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['group']
+ editor?: Editor
+ }) => NodeSpec['group'])
+
+ /**
+ * Should be set to true for inline nodes. (Implied for text nodes.)
+ */
+ inline?:
+ | NodeSpec['inline']
+ | ((this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['inline']
+ editor?: Editor
+ }) => NodeSpec['inline'])
+
+ /**
+ * Can be set to true to indicate that, though this isn't a [leaf
+ * node](https://prosemirror.net/docs/ref/#model.NodeType.isLeaf), it doesn't have directly editable
+ * content and should be treated as a single unit in the view.
+ *
+ * @example atom: true
+ */
+ atom?:
+ | NodeSpec['atom']
+ | ((this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['atom']
+ editor?: Editor
+ }) => NodeSpec['atom'])
+
+ /**
+ * Controls whether nodes of this type can be selected as a [node
+ * selection](https://prosemirror.net/docs/ref/#state.NodeSelection). Defaults to true for non-text
+ * nodes.
+ *
+ * @default true
+ * @example selectable: false
+ */
+ selectable?:
+ | NodeSpec['selectable']
+ | ((this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['selectable']
+ editor?: Editor
+ }) => NodeSpec['selectable'])
+
+ /**
+ * Determines whether nodes of this type can be dragged without
+ * being selected. Defaults to false.
+ *
+ * @default: false
+ * @example: draggable: true
+ */
+ draggable?:
+ | NodeSpec['draggable']
+ | ((this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['draggable']
+ editor?: Editor
+ }) => NodeSpec['draggable'])
+
+ /**
+ * Can be used to indicate that this node contains code, which
+ * causes some commands to behave differently.
+ */
+ code?:
+ | NodeSpec['code']
+ | ((this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['code']
+ editor?: Editor
+ }) => NodeSpec['code'])
+
+ /**
+ * Controls way whitespace in this a node is parsed. The default is
+ * `"normal"`, which causes the [DOM parser](https://prosemirror.net/docs/ref/#model.DOMParser) to
+ * collapse whitespace in normal mode, and normalize it (replacing
+ * newlines and such with spaces) otherwise. `"pre"` causes the
+ * parser to preserve spaces inside the node. When this option isn't
+ * given, but [`code`](https://prosemirror.net/docs/ref/#model.NodeSpec.code) is true, `whitespace`
+ * will default to `"pre"`. Note that this option doesn't influence
+ * the way the node is rendered—that should be handled by `toDOM`
+ * and/or styling.
+ */
+ whitespace?:
+ | NodeSpec['whitespace']
+ | ((this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['whitespace']
+ editor?: Editor
+ }) => NodeSpec['whitespace'])
+
+ /**
+ * Allows a **single** node to be set as linebreak equivalent (e.g. hardBreak).
+ * When converting between block types that have whitespace set to "pre"
+ * and don't support the linebreak node (e.g. codeBlock) and other block types
+ * that do support the linebreak node (e.g. paragraphs) - this node will be used
+ * as the linebreak instead of stripping the newline.
+ *
+ * See [linebreakReplacement](https://prosemirror.net/docs/ref/#model.NodeSpec.linebreakReplacement).
+ */
+ linebreakReplacement?:
+ | NodeSpec['linebreakReplacement']
+ | ((this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['linebreakReplacement']
+ editor?: Editor
+ }) => NodeSpec['linebreakReplacement'])
+
+ /**
+ * When enabled, enables both
+ * [`definingAsContext`](https://prosemirror.net/docs/ref/#model.NodeSpec.definingAsContext) and
+ * [`definingForContent`](https://prosemirror.net/docs/ref/#model.NodeSpec.definingForContent).
+ *
+ * @default false
+ * @example isolating: true
+ */
+ defining?:
+ | NodeSpec['defining']
+ | ((this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['defining']
+ editor?: Editor
+ }) => NodeSpec['defining'])
+
+ /**
+ * When enabled (default is false), the sides of nodes of this type
+ * count as boundaries that regular editing operations, like
+ * backspacing or lifting, won't cross. An example of a node that
+ * should probably have this enabled is a table cell.
+ */
+ isolating?:
+ | NodeSpec['isolating']
+ | ((this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['isolating']
+ editor?: Editor
+ }) => NodeSpec['isolating'])
+
+ /**
+ * Associates DOM parser information with this node, which can be
+ * used by [`DOMParser.fromSchema`](https://prosemirror.net/docs/ref/#model.DOMParser^fromSchema) to
+ * automatically derive a parser. The `node` field in the rules is
+ * implied (the name of this node will be filled in automatically).
+ * If you supply your own parser, you do not need to also specify
+ * parsing rules in your schema.
+ *
+ * @example parseHTML: [{ tag: 'div', attrs: { 'data-id': 'my-block' } }]
+ */
+ parseHTML?: (this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['parseHTML']
+ editor?: Editor
+ }) => NodeSpec['parseDOM']
+
+ /**
+ * A description of a DOM structure. Can be either a string, which is
+ * interpreted as a text node, a DOM node, which is interpreted as
+ * itself, a `{dom, contentDOM}` object, or an array.
+ *
+ * An array describes a DOM element. The first value in the array
+ * should be a string—the name of the DOM element, optionally prefixed
+ * by a namespace URL and a space. If the second element is plain
+ * object, it is interpreted as a set of attributes for the element.
+ * Any elements after that (including the 2nd if it's not an attribute
+ * object) are interpreted as children of the DOM elements, and must
+ * either be valid `DOMOutputSpec` values, or the number zero.
+ *
+ * The number zero (pronounced “hole”) is used to indicate the place
+ * where a node's child nodes should be inserted. If it occurs in an
+ * output spec, it should be the only child element in its parent
+ * node.
+ *
+ * @example toDOM: ['div[data-id="my-block"]', { class: 'my-block' }, 0]
+ */
+ renderHTML?:
+ | ((
+ this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['renderHTML']
+ editor?: Editor
+ },
+ props: {
+ node: PMNode
+ HTMLAttributes: Record
+ },
+ ) => DOMOutputSpec)
+ | null
+
+ /**
+ * renders the node as text
+ * @example renderText: () => 'foo
+ */
+ renderText?:
+ | ((
+ this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['renderText']
+ editor?: Editor
+ },
+ props: {
+ node: PMNode
+ pos: number
+ parent: PMNode
+ index: number
+ },
+ ) => string)
+ | null
+
+ /**
+ * Add attributes to the node
+ * @example addAttributes: () => ({ class: 'foo' })
+ */
+ addAttributes?: (this: {
+ name: string
+ options: Options
+ storage: Storage
+ parent: ParentConfig>['addAttributes']
+ editor?: Editor
+ // oxlint-disable-next-lineno-empty-object-type
+ }) => Attributes | {}
+}
+
+/**
+ * The Node class is used to create custom node extensions.
+ * @see https://tiptap.dev/api/extensions#create-a-new-extension
+ */
+export class Node extends Extendable<
+ Options,
+ Storage,
+ NodeConfig
+> {
+ type = 'node'
+
+ /**
+ * Create a new Node instance
+ * @param config - Node configuration object or a function that returns a configuration object
+ */
+ static create(
+ config: Partial> | (() => Partial>) = {},
+ ) {
+ // If the config is a function, execute it to get the configuration object
+ const resolvedConfig = typeof config === 'function' ? config() : config
+ return new Node(resolvedConfig)
+ }
+
+ configure(options?: Partial