snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

571
demos/CHANGELOG.md Normal file
View File

@@ -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))

39
demos/dedupeDeps.txt Normal file
View File

@@ -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

9
demos/index.html Normal file
View File

@@ -0,0 +1,9 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="refresh" content="0; url=/preview/" />
</head>
<body></body>
</html>

71
demos/package.json Normal file
View File

@@ -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"
}
}

8
demos/postcss.config.cjs Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': {},
tailwindcss: {},
autoprefixer: {},
},
}

233
demos/preview/Demo.vue Normal file
View File

@@ -0,0 +1,233 @@
<template>
<div class="overflow-hidden antialiased rounded-lg">
<div class="px-3 py-1 bg-black flex items-center gap-0.5">
<button
v-for="(language, index) in sortedTabs"
:key="index"
@click="setTab(language.name)"
class="px-3 py-2 text-sm text-white leading-[125%] font-semibold rounded-[0.625rem] transition-all"
:class="[
currentTab === language.name
? 'opacity-100 bg-[#1C1917]'
: 'opacity-50 bg-transparent hover:opacity-100 hover:bg-[#1C1917]',
]"
>
{{ language.name }}
</button>
</div>
<div class="overflow-hidden">
<div class="bg-white" :class="[hidePreview ? 'hidden' : '']">
<demo-frame :src="currentIframeUrl" :key="currentIframeUrl" />
</div>
<div class="text-white bg-black" v-if="!hideSource && currentFile">
<div class="flex overflow-x-auto">
<div class="flex flex-auto px-4 border-b-2 border-gray-800">
<button
class="inline-flex relative mr-4 py-2 pb-[calc(0.3rem + 2px)] mb-[-2px] border-b-2 border-transparent font-mono text-sm whitespace-nowrap"
:class="[
!showDebug && currentFile.content === file.content
? 'text-white border-white font-bold'
: 'text-gray-400',
]"
v-for="(file, index) in source"
:key="index"
@click="setFile(file.name)"
>
{{ file.name }}
</button>
<button
v-if="debugJSON"
class="inline-flex relative py-2 pb-[calc(0.3rem + 2px)] mb-[-2px] border-b-2 border-transparent font-mono text-sm ml-auto"
:class="[showDebug ? 'text-white border-white font-bold' : 'text-gray-400']"
@click="showDebug = !showDebug"
>
Inspect
</button>
</div>
</div>
<div class="overflow-dark overflow-auto max-h-[500px] relative text-white">
<shiki
class="p-4 overflow-visible"
:language="debugJSON && showDebug ? 'js' : getFileExtension(currentFile.name)"
:code="debugJSON && showDebug ? debugJSON : currentFile.content"
/>
</div>
<div class="flex justify-between px-4 py-2 text-gray-400 border-t border-gray-800 text-md">
<a
class="flex-shrink min-w-0 overflow-hidden overflow-ellipsis whitespace-nowrap"
:href="currentIframeUrl"
>
{{ name }}/{{ currentTab }}
</a>
<a class="pl-4 whitespace-nowrap" :href="githubUrl" target="_blank"> Edit on GitHub → </a>
</div>
</div>
</div>
</div>
</template>
<script>
import { getDebugJSON } from '@tiptap/core'
import DemoFrame from './DemoFrame.vue'
import Shiki from './Shiki.vue'
export default {
components: {
DemoFrame,
Shiki,
},
props: {
name: {
type: String,
required: true,
},
tabs: {
type: Object,
required: true,
},
},
data() {
return {
data: [],
sources: {},
currentTab: null,
currentFile: null,
tabOrder: ['React', 'Vue', 'Svelte', 'JS'],
debugJSON: null,
showDebug: false,
}
},
computed: {
currentIframeUrl() {
return `/src/${this.name}/${this.currentTab}/`
},
firstTabSelected() {
return this.sortedTabs[0].name === this.currentTab
},
sortedTabs() {
return [...this.tabs].sort((a, b) => {
return this.tabOrder.indexOf(a.name) - this.tabOrder.indexOf(b.name)
})
},
query() {
return Object.fromEntries(
Object.entries(this.$route.query).map(([key, value]) => [key, this.fromString(value)]),
)
},
inline() {
return this.query.inline || false
},
hideSource() {
return this.query.hideSource || false
},
hidePreview() {
return this.query.hidePreview || false
},
githubUrl() {
return `https://github.com/ueberdosis/tiptap/tree/main/demos/src/${this.name}`
},
source() {
return this.sources[this.currentTab]
},
},
methods: {
getFileExtension(name) {
return name.split('.').pop()
},
setTab(name, persist = true) {
this.currentTab = name
if (persist) {
localStorage.tab = name
}
},
setFile(name) {
this.showDebug = false
this.currentFile = this.source.find(item => item.name === name)
},
onSource(event) {
this.sources[this.currentTab] = event.detail
this.setFile(this.source[0].name)
},
onEditor(event) {
const editor = event.detail
if (!editor) {
this.debugJSON = null
return
}
this.debugJSON = JSON.stringify(getDebugJSON(editor.state.doc), null, ' ')
editor.on('update', () => {
this.debugJSON = JSON.stringify(getDebugJSON(editor.state.doc), null, ' ')
})
},
fromString(value) {
if (value === null) {
return true
}
if (value.match(/^\d*(\.\d+)?$/)) {
return Number(value)
}
if (value === 'true') {
return true
}
if (value === 'false') {
return false
}
if (value === 'null') {
return null
}
return value
},
},
mounted() {
// TODO: load language from url params
const intitialTab =
localStorage.tab && this.tabs.some(tab => tab.name === localStorage.tab)
? localStorage.tab
: this.sortedTabs[0]?.name
this.setTab(intitialTab, false)
window.document.addEventListener('editor', this.onEditor, false)
window.document.addEventListener('source', this.onSource, false)
},
beforeUnmount() {
window.document.removeEventListener('editor', this.onEditor)
window.document.removeEventListener('source', this.onSource)
},
}
</script>

View File

@@ -0,0 +1,56 @@
<template>
<div class="flex flex-col relative min-h-[5rem]">
<div
class="absolute top-0 left-0 w-full h-full flex justify-center items-center pointer-events-none"
v-if="isLoading"
>
<svg
class="animate-spin -ml-1 mr-3 h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
<iframe
class="bg-transparent max-h-[28rem]"
v-resize.quiet="{ scrolling: 'omit' }"
:src="src"
width="100%"
height="0"
frameborder="0"
@load="onLoad"
/>
</div>
</template>
<script>
export default {
props: {
src: {
required: true,
type: String,
},
},
data() {
return {
isLoading: true,
}
},
methods: {
onLoad() {
this.isLoading = false
},
},
}
</script>
<style></style>

73
demos/preview/Shiki.vue Normal file
View File

@@ -0,0 +1,73 @@
<template>
<div v-if="html" v-html="html" />
<pre v-else><code>{{ code }}</code></pre>
</template>
<script>
// this import is a bugfix
// otherwise the `onig.wasm` file is missing in the dist folder
import 'shiki/dist/onig.wasm?url'
import Worker from './shiki.worker.js?worker'
export default {
props: {
code: {
default: '',
type: String,
},
language: {
default: 'js',
type: String,
},
},
data() {
return {
worker: new Worker(),
html: null,
highlighter: window?.highlighter,
}
},
watch: {
code: {
immediate: true,
handler() {
this.render()
},
},
highlighter: {
immediate: true,
handler() {
this.render()
},
},
},
methods: {
render() {
this.worker.postMessage({
code: this.code,
language: this.language,
})
},
},
created() {
this.worker.addEventListener('message', event => {
const { html } = event.data
this.html = html
})
},
}
</script>
<style>
.shiki {
background-color: transparent !important;
}
</style>

12
demos/preview/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Preview</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/preview/index.js"></script>
</body>
</html>

61
demos/preview/index.js Normal file
View File

@@ -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')

95
demos/preview/index.vue Normal file
View File

@@ -0,0 +1,95 @@
<script setup>
import { ref, watch } from 'vue'
const searchQuery = new URLSearchParams(window.location.search)
const searchValue = ref(searchQuery.get('search') || '')
// Update the window search param when the searchValue changes
// this allows the user to bookmark / return to a specific search
// and also allows the browser to handle back/forward navigation
// without losing the search state
watch(searchValue, (newValue, oldValue) => {
if (newValue !== oldValue) {
searchQuery.set('search', newValue)
window.history.replaceState({}, '', `?${searchQuery.toString()}`)
}
})
</script>
<template>
<template v-if="$route.path === '/'">
<input
class="w-full p-3 my-3 focus:outline-none border-b"
type="search"
placeholder="Search for a demo..."
autofocus
v-model="searchValue"
/>
<ul>
<li
class="p-5 border-b-2 border-black"
v-for="route in $router.options.routes.filter(route =>
searchValue === ''
? true
: route.props.name.toLowerCase().includes(searchValue.toLowerCase()),
)"
:key="route.path"
>
<router-link class="block mb-2 font-medium" :to="route.path">
{{ route.props.name }}
</router-link>
<div class="flex">
<a
class="mr-4 text-sm text-gray-300 font-medium"
v-for="(tab, index) in route.props.tabs"
:key="index"
:href="`/src/${route.props.name}/${tab.name}/`"
>
{{ tab.name }}
</a>
</div>
</li>
</ul>
</template>
<router-view v-else />
</template>
<script>
export default {
methods: {
fromString(value) {
if (value === null) {
return true
}
if (value.match(/^\d*(\.\d+)?$/)) {
return Number(value)
}
if (value === 'true') {
return true
}
if (value === 'false') {
return false
}
if (value === 'null') {
return null
}
return value
},
},
computed: {
query() {
return Object.fromEntries(
Object.entries(this.$route.query).map(([key, value]) => [key, this.fromString(value)]),
)
},
},
}
</script>

View File

@@ -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 })
})
})

148
demos/preview/style.css Normal file
View File

@@ -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;
}

1
demos/public/_redirects Normal file
View File

@@ -0,0 +1 @@
/preview/* /preview/index.html 200

40
demos/setup/helper.ts Normal file
View File

@@ -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)
})
}

12
demos/setup/js.ts Normal file
View File

@@ -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()
}

35
demos/setup/react.ts Normal file
View File

@@ -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()
})
})
}

500
demos/setup/style.scss Normal file
View File

@@ -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,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="Gray" d="M7 10l5 5 5-5z"/></svg>');
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,<svg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='28px' height='30px' viewBox='0 0 24 30' style='enable-background:new 0 0 50 50;' xml:space='preserve'><rect x='0' y='10' width='6' height='10' fill='%236A00F5' rx='3' ry='3'><animateTransform attributeType='xml' attributeName='transform' type='translate' values='0 0; 0 5; 0 -5; 0 0' begin='0' dur='0.6s' repeatCount='indefinite'/></rect><rect x='10' y='10' width='6' height='10' fill='%236A00F5' rx='3' ry='3'><animateTransform attributeType='xml' attributeName='transform' type='translate' values='0 0; 0 5; 0 -5; 0 0' begin='0.2s' dur='0.6s' repeatCount='indefinite'/></rect><rect x='20' y='10' width='6' height='10' fill='%236A00F5' rx='3' ry='3'><animateTransform attributeType='xml' attributeName='transform' type='translate' values='0 0; 0 5; 0 -5; 0 0' begin='0.4s' dur='0.6s' repeatCount='indefinite'/></rect></svg>");
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;
}
}

21
demos/setup/svelte.ts Normal file
View File

@@ -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()
})
}

24
demos/setup/vue.ts Normal file
View File

@@ -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()
})
}

View File

View File

@@ -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 (
<div className="control-group">
<div className="button-group">
<button onClick={onCutToStart}>Cut content to start of document</button>
<button onClick={onCutToEnd}>Cut content to end of document</button>
</div>
</div>
)
}
export default () => {
const editor = useEditor({
extensions: [Color, TextStyle, StarterKit],
content: `
<h2>
Hi there,
</h2>
<p>
this is a <em>basic</em> example of <strong>Tiptap</strong>. Sure, there are all kind of basic text styles you’d probably expect from a text editor. But wait until you see the lists:
</p>
<ul>
<li>
That’s a bullet list with one …
</li>
<li>
… or two list items.
</li>
</ul>
<p>
Isn’t that great? And all of that is editable. But wait, there’s more. Let’s try a code block:
</p>
<pre><code class="language-css">body {
display: none;
}</code></pre>
<p>
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.
</p>
<blockquote>
Wow, that’s amazing. Good work, boy! 👏
<br />
— Mom
</blockquote>
`,
})
return (
<>
<MenuBar editor={editor} />
<EditorContent editor={editor} />
</>
)
}

View File

@@ -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;
}
}

View File

@@ -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 = `
<h1><a href="https://tiptap.dev/">Tiptap</a></h1>
<p><strong>Hello World</strong></p>
<p>This is a paragraph<br />with a break.</p>
<p>And this is some additional string content.</p>
`
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(`<img src="${url}" alt="Example image" />`).focus().run()
}, [editor])
if (!editor) {
return null
}
return (
<div className="control-group">
<div className="button-group">
<button
data-test-id="html-content"
onClick={() => editor.chain().insertContent(htmlContent).focus().run()}
>
Insert HTML content
</button>
<button
data-test-id="html-content-spans"
onClick={() =>
editor.chain().insertContent('<p><b>Hello</b> <i>World</i></p>').focus().run()
}
>
Insert HTML with span tags content
</button>
<button
data-test-id="text-content"
onClick={() => editor.chain().insertContent(textContent).focus().run()}
>
Insert text content
</button>
<button data-test-id="image-content" onClick={insertImage}>
Insert image
</button>
</div>
</div>
)
}
const extensions = [Image, Color, TextStyle, Link, StarterKit]
const content = ''
export default () => {
return (
<EditorProvider
slotBefore={<MenuBar />}
extensions={extensions}
content={content}
></EditorProvider>
)
}

View File

@@ -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;
}
}

View File

@@ -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 (
<div className="control-group">
<div>
<label>
<input
type="checkbox"
checked={useInputRules}
onChange={() => setUseInputRules(prev => !prev)}
/>
Apply input rules
</label>
<label>
<input
type="checkbox"
checked={usePasteRules}
onChange={() => setUsePasteRules(prev => !prev)}
/>
Apply paste rules
</label>
</div>
<div className="button-group">
<button
onClick={() => {
editor
.chain()
.insertContent('-', {
applyInputRules: useInputRules,
applyPasteRules: usePasteRules,
})
.focus()
.run()
}}
data-test-1
>
Insert "-"
</button>
<button
onClick={() => {
editor
.chain()
.insertContent(' ', {
applyInputRules: useInputRules,
applyPasteRules: usePasteRules,
})
.focus()
.run()
}}
data-test-2
>
Insert " "
</button>
<button
onClick={() => {
editor
.chain()
.insertContent('A', {
applyInputRules: useInputRules,
applyPasteRules: usePasteRules,
})
.focus()
.run()
}}
data-test-3
>
Insert "A"
</button>
<br />
<button
onClick={() => {
editor
.chain()
.insertContent('*this is', {
applyInputRules: useInputRules,
applyPasteRules: usePasteRules,
})
.focus()
.run()
}}
data-test-4
>
Insert "*this is"
</button>
<button
onClick={() => {
editor
.chain()
.insertContent(' a test*', {
applyInputRules: useInputRules,
applyPasteRules: usePasteRules,
})
.focus()
.run()
}}
data-test-5
>
Insert " a test*"
</button>
<button
onClick={() => {
editor
.chain()
.insertContent(' a test*, whooho', {
applyInputRules: useInputRules,
applyPasteRules: usePasteRules,
})
.focus()
.run()
}}
data-test-6
>
Insert " a test*, whooho."
</button>
<button
onClick={() => {
editor
.chain()
.insertContent('*this is a test*, whooho.', {
applyInputRules: useInputRules,
applyPasteRules: usePasteRules,
})
.focus()
.run()
}}
data-test-7
>
Insert "*this is a test*, whooho."
</button>
<button
onClick={() => {
editor
.chain()
.insertContent('*this is a test*', {
applyInputRules: useInputRules,
applyPasteRules: usePasteRules,
})
.focus()
.run()
}}
data-test-8
>
Insert "*this is a test*"
</button>
</div>
</div>
)
}
const extensions = [StarterKit]
const content = ''
export default () => {
return (
<EditorProvider
slotBefore={<MenuBar />}
extensions={extensions}
content={content}
></EditorProvider>
)
}

View File

@@ -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;
}
}

View File

@@ -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 <EditorProvider extensions={extensions} content={content}></EditorProvider>
}

View File

@@ -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;
}
}

View File

@@ -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 = `
<p>Hi 👋, this is a collaborative document.</p>
<p>Feel free to edit and collaborate in real-time!</p>
`
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 (
<div className="column-half">
<div className="control-group">
<div className="button-group">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={isBold ? 'is-active' : ''}
>
Bold
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={isItalic ? 'is-active' : ''}
>
Italic
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
className={isStrike ? 'is-active' : ''}
>
Strike
</button>
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={isBulletList ? 'is-active' : ''}
>
Bullet list
</button>
<button
onClick={() => editor.chain().focus().toggleCode().run()}
className={isCode ? 'is-active' : ''}
>
Code
</button>
</div>
</div>
<EditorContent editor={editor} className="main-group" />
<div
className="collab-status-group"
data-state={status === 'connected' ? 'online' : 'offline'}
>
<label>
{status === 'connected'
? `${userCount} user${userCount === 1 ? '' : 's'} online in ${room}`
: 'offline'}
</label>
<button style={{ '--color': currentUser.color }} onClick={setName}>
✎ {currentUser.name}
</button>
</div>
</div>
)
}
export default Editor

View File

@@ -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 (
<div className="col-group">
<Editor provider={providerA} ydoc={ydocA} room={room} />
<Editor provider={providerB} ydoc={ydocB} room={room} />
</div>
)
}
export default App

View File

@@ -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);
}
}
}
}

View File

@@ -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)
})
})
})
})

View File

@@ -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 (
<div className="editor">
<div className="editor__header">
<div className="dots">
<span className="dot"></span>
<span className="dot"></span>
<span className="dot"></span>
</div>
<div className="editor__users">
<div className={`editor__status editor__status--${status}`}>
{status === 'connected'
? `${userCount} user${userCount === 1 ? '' : 's'} online in ${room}`
: 'offline'}
</div>
<div className="editor__name">
<button onClick={setName}>{currentUser.name}</button>
</div>
</div>
</div>
<EditorContent className="editor__content" editor={editor} />
</div>
)
}

View File

@@ -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;
}

View File

@@ -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)
})
})
})
})

0
demos/src/Dev/.gitkeep Normal file
View File

View File

@@ -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<HTMLDivElement>(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 (
<FloatingMenu
editor={editor}
shouldShow={null}
aria-orientation="horizontal"
role="menubar"
aria-label="Insert Element menu"
className="floating-menu"
// Types are broken here, since we import jsx from vue-2
ref={containerRef as any}
onFocus={e => {
// 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}
>
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={activeNodeType === 'bulletList' ? 'is-active' : ''}
aria-pressed={activeNodeType === 'bulletList'}
aria-label="Bullet List"
tabIndex={-1}
>
Bullet list
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={activeNodeType === 'orderedList' ? 'is-active' : ''}
aria-pressed={activeNodeType === 'orderedList'}
aria-label="Ordered List"
tabIndex={-1}
>
Ordered List
</button>
<button
onClick={() => editor.chain().focus().setHorizontalRule().run()}
aria-label="Horizontal rule"
tabIndex={-1}
>
Horizontal rule
</button>
<button
onClick={() => editor.chain().focus().setHardBreak().run()}
aria-label="Hard break"
tabIndex={-1}
>
Hard break
</button>
</FloatingMenu>
)
}

View File

@@ -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<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(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 (
<div
className="node-type-dropdown__container"
ref={menuRef}
onKeyDown={e => {
// 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()
}
}
}}
>
<button
onClick={e => {
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)}
</button>
{isOpen && (
<div
ref={dropdownRef}
id="node-type-dropdown"
role="menu"
aria-orientation="vertical"
className="node-type-dropdown__items"
>
<button
onClick={() => {
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
</button>
<button
onClick={() => {
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
</button>
<button
onClick={() => {
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
</button>
<button
onClick={() => {
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
</button>
<button
onClick={() => {
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
</button>
<button
onClick={() => {
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
</button>
<button
onClick={() => {
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
</button>
<button
onClick={() => {
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
</button>
<button
onClick={() => {
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
</button>
<button
onClick={() => {
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
</button>
<button
onClick={() => {
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
</button>
</div>
)}
</div>
)
}
/**
* An accessible static top menu bar for the editor
*/
export function MenuBar({ editor }: { editor: Editor }) {
const containerRef = useRef<HTMLDivElement>(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 (
<div
className="control-group"
role="toolbar"
aria-orientation="horizontal"
aria-keyshortcuts="Alt+F10"
ref={containerRef}
>
<div className="button-group">
<NodeTypeDropdown editor={editor} />
<button
onClick={() => editor.chain().focus().undo().run()}
disabled={!editorState.canUndo}
tabIndex={-1}
aria-label="Undo"
>
Undo
</button>
<button
onClick={() => editor.chain().focus().redo().run()}
disabled={!editorState.canRedo}
tabIndex={-1}
aria-label="Redo"
>
Redo
</button>
</div>
</div>
)
}

View File

@@ -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<HTMLDivElement>(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 (
<BubbleMenu
editor={editor}
shouldShow={null}
aria-label="Text formatting menu"
aria-orientation="horizontal"
role="menubar"
className="bubble-menu"
// Types are broken here, since we import jsx from vue-2
ref={containerRef as any}
onFocus={e => {
// 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}
>
<button
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editorState.canBold}
className={editorState.isBold ? 'is-active' : ''}
aria-pressed={editorState.isBold}
aria-label="Bold"
tabIndex={-1}
>
Bold
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editorState.canItalic}
className={editorState.isItalic ? 'is-active' : ''}
aria-pressed={editorState.isItalic}
aria-label="Italic"
tabIndex={-1}
>
Italic
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
disabled={!editorState.canStrike}
className={editorState.isStrike ? 'is-active' : ''}
aria-pressed={editorState.isStrike}
tabIndex={-1}
>
Strike
</button>
<button
onClick={() => editor.chain().focus().toggleCode().run()}
disabled={!editorState.canCode}
className={editorState.isCode ? 'is-active' : ''}
aria-pressed={editorState.isCode}
aria-label="Code"
tabIndex={-1}
>
Code
</button>
<button
disabled={!editorState.canClearMarks}
onClick={() => editor.chain().focus().unsetAllMarks().run()}
aria-label="Clear Marks"
tabIndex={-1}
>
Clear marks
</button>
</BubbleMenu>
)
}

View File

@@ -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: `
<h2>Accessibility Demo</h2>
<p>Tab into the demo & navigate around with only your keyboard</p>
<p>Use <code>Alt + F10</code> to focus the menu bar</p>
`,
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 (
<div role="application" className="editor-application">
{/* The static menu bar */}
<MenuBar editor={editor} />
{/* The editor content */}
<EditorContent editor={editor} />
{/* Handles marks: bold, italic, etc. using a bubble menu */}
<TextMenu editor={editor} />
{/* Handles nodes: headings, lists, etc. using a floating menu */}
<InsertMenu editor={editor} />
</div>
)
}

View File

@@ -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:
*
* <h1>Heading</h1>
* <p>Paragraph</p>
*
* 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;
}

View File

@@ -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<HTMLElement>
/**
* The editor instance
*/
editor: Editor
/**
* Callback when the user presses the escape key
*/
onEscape?: (
event: KeyboardEvent,
ctx: { editor: Editor; ref: React.RefObject<HTMLElement> },
) => 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<HTMLElement> },
) => 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<HTMLElement>('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 }
}

View File

@@ -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: `
<p>Hey! Try to type in url with and without a http/s protocol. - Links without a protocol should not get auto linked</p>
`,
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 (
<>
<div className="control-group">
<div className="button-group">
<button
onClick={setLink}
className={editorState.isLink ? 'is-active' : ''}
data-testid="setLink"
>
Set link
</button>
<button
onClick={() => editor.chain().focus().unsetLink().run()}
disabled={!editorState.isLink}
data-testid="unsetLink"
>
Unset link
</button>
</div>
</div>
<EditorContent editor={editor} />
</>
)
}

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,101 @@
<template>
<editor-content :editor="editor" />
</template>
<script>
import Link from '@tiptap/extension-link'
import StarterKit from '@tiptap/starter-kit'
import { Editor, EditorContent } from '@tiptap/vue'
export default {
components: {
EditorContent,
},
data() {
return {
editor: null,
}
},
mounted() {
this.editor = new Editor({
extensions: [
StarterKit,
Link.configure({
validate: link => /^https?:\/\//.test(link),
}),
],
content: `
<p>Hey! Try to type in url with and without a http/s protocol. - Links without a protocol should not get auto linked</p>
`,
editorProps: {
attributes: {
spellcheck: 'false',
},
},
})
},
beforeUnmount() {
this.editor.destroy()
},
}
</script>
<style lang="scss">
/* 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;
}
hr {
margin: 1rem 0;
}
blockquote {
padding-left: 1rem;
border-left: 2px solid rgba(#0d0d0d, 0.1);
}
}
</style>

View File

@@ -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<Promise<void>>(
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',
)
})
}
})
})
})

View File

View File

@@ -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 (
<div className="control-group">
<div className="button-group">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={editorState.isBold ? 'is-active' : ''}
>
Bold
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editorState.isItalic ? 'is-active' : ''}
>
Italic
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
className={editorState.isStrike ? 'is-active' : ''}
>
Strike
</button>
<button
onClick={() => editor.chain().focus().toggleCode().run()}
className={editorState.isCode ? 'is-active' : ''}
>
Code
</button>
<button onClick={() => editor.chain().focus().unsetAllMarks().run()}>Clear marks</button>
<button onClick={() => editor.chain().focus().clearNodes().run()}>Clear nodes</button>
<button
onClick={() => editor.chain().focus().setParagraph().run()}
className={editorState.isParagraph ? 'is-active' : ''}
>
Paragraph
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editorState.isHeading1 ? 'is-active' : ''}
>
H1
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editorState.isHeading2 ? 'is-active' : ''}
>
H2
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={editorState.isHeading3 ? 'is-active' : ''}
>
H3
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 4 }).run()}
className={editorState.isHeading4 ? 'is-active' : ''}
>
H4
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 5 }).run()}
className={editorState.isHeading5 ? 'is-active' : ''}
>
H5
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 6 }).run()}
className={editorState.isHeading6 ? 'is-active' : ''}
>
H6
</button>
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editorState.isBulletList ? 'is-active' : ''}
>
Bullet list
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editorState.isOrderedList ? 'is-active' : ''}
>
Ordered list
</button>
<button
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
className={editorState.isCodeBlock ? 'is-active' : ''}
>
Code block
</button>
<button
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editorState.isBlockquote ? 'is-active' : ''}
>
Blockquote
</button>
<button onClick={() => editor.chain().focus().setHorizontalRule().run()}>
Horizontal rule
</button>
<button onClick={() => editor.chain().focus().setHardBreak().run()}>Hard break</button>
<button onClick={() => editor.chain().focus().undo().run()}>Undo</button>
<button onClick={() => editor.chain().focus().redo().run()}>Redo</button>
</div>
</div>
)
}
export default () => {
const editor = useEditor({
extensions: [StarterKit],
content,
editorProps: {
attributes: {
spellcheck: 'false',
},
},
})
return (
<>
<MenuBar editor={editor} />
<EditorContent editor={editor} />
</>
)
}

View File

@@ -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;
}
}

View File

View File

@@ -0,0 +1,234 @@
<template>
<div v-if="editor" class="container">
<div class="control-group">
<div class="button-group">
<button
@click="editor.chain().focus().toggleBold().run()"
:class="{ 'is-active': editor.isActive('bold') }"
>
Bold
</button>
<button
@click="editor.chain().focus().toggleItalic().run()"
:class="{ 'is-active': editor.isActive('italic') }"
>
Italic
</button>
<button
@click="editor.chain().focus().toggleStrike().run()"
:class="{ 'is-active': editor.isActive('strike') }"
>
Strike
</button>
<button
@click="editor.chain().focus().toggleCode().run()"
:class="{ 'is-active': editor.isActive('code') }"
>
Code
</button>
<button @click="editor.chain().focus().unsetAllMarks().run()">Clear marks</button>
<button @click="editor.chain().focus().clearNodes().run()">Clear nodes</button>
<button
@click="editor.chain().focus().setParagraph().run()"
:class="{ 'is-active': editor.isActive('paragraph') }"
>
Paragraph
</button>
<button
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
>
H1
</button>
<button
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
>
H2
</button>
<button
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
>
H3
</button>
<button
@click="editor.chain().focus().toggleHeading({ level: 4 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 4 }) }"
>
H4
</button>
<button
@click="editor.chain().focus().toggleHeading({ level: 5 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 5 }) }"
>
H5
</button>
<button
@click="editor.chain().focus().toggleHeading({ level: 6 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 6 }) }"
>
H6
</button>
<button
@click="editor.chain().focus().toggleBulletList().run()"
:class="{ 'is-active': editor.isActive('bulletList') }"
>
Bullet list
</button>
<button
@click="editor.chain().focus().toggleOrderedList().run()"
:class="{ 'is-active': editor.isActive('orderedList') }"
>
Ordered list
</button>
<button
@click="editor.chain().focus().toggleCodeBlock().run()"
:class="{ 'is-active': editor.isActive('codeBlock') }"
>
Code block
</button>
<button
@click="editor.chain().focus().toggleBlockquote().run()"
:class="{ 'is-active': editor.isActive('blockquote') }"
>
Blockquote
</button>
<button @click="editor.chain().focus().setHorizontalRule().run()">Horizontal rule</button>
<button @click="editor.chain().focus().setHardBreak().run()">Hard break</button>
<button @click="editor.chain().focus().undo().run()">Undo</button>
<button @click="editor.chain().focus().redo().run()">Redo</button>
</div>
</div>
<editor-content :editor="editor" />
</div>
</template>
<script>
import StarterKit from '@tiptap/starter-kit'
import { Editor, EditorContent } from '@tiptap/vue'
import { content } from '../content.js'
export default {
components: {
EditorContent,
},
data() {
return {
editor: null,
}
},
mounted() {
this.editor = new Editor({
extensions: [StarterKit],
content,
editorProps: {
attributes: {
spellcheck: 'false',
},
},
})
},
beforeUnmount() {
this.editor.destroy()
},
}
</script>
<style lang="scss">
/* 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;
}
}
</style>

View File

@@ -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 (
<div className={`toolbar ${styles.toolbar}`}>
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={editorState.isBold ? 'is-active' : ''}
>
Bold
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editorState.isItalic ? 'is-active' : ''}
>
Italic
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
className={editorState.isStrike ? 'is-active' : ''}
>
Strike
</button>
<button
onClick={() => editor.chain().focus().toggleCode().run()}
className={editorState.isCode ? 'is-active' : ''}
>
Code
</button>
<button onClick={() => editor.chain().focus().unsetAllMarks().run()}>Clear marks</button>
<button onClick={() => editor.chain().focus().clearNodes().run()}>Clear nodes</button>
<button
onClick={() => editor.chain().focus().setParagraph().run()}
className={editorState.isParagraph ? 'is-active' : ''}
>
Paragraph
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editorState.isHeading1 ? 'is-active' : ''}
>
H1
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editorState.isHeading2 ? 'is-active' : ''}
>
H2
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={editorState.isHeading3 ? 'is-active' : ''}
>
H3
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 4 }).run()}
className={editorState.isHeading4 ? 'is-active' : ''}
>
H4
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 5 }).run()}
className={editorState.isHeading5 ? 'is-active' : ''}
>
H5
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 6 }).run()}
className={editorState.isHeading6 ? 'is-active' : ''}
>
H6
</button>
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editorState.isBulletList ? 'is-active' : ''}
>
Bullet list
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editorState.isOrderedList ? 'is-active' : ''}
>
Ordered list
</button>
<button
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
className={editorState.isCodeBlock ? 'is-active' : ''}
>
Code block
</button>
<button
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editorState.isBlockquote ? 'is-active' : ''}
>
Blockquote
</button>
<button onClick={() => editor.chain().focus().setHorizontalRule().run()}>
Horizontal rule
</button>
<button onClick={() => editor.chain().focus().setHardBreak().run()}>Hard break</button>
<button onClick={() => editor.chain().focus().undo().run()}>Undo</button>
<button onClick={() => editor.chain().focus().redo().run()}>Redo</button>
</div>
)
}
export default () => {
const editor = useEditor({
extensions: [StarterKit],
content: `
<h2>
Hi there,
</h2>
<p>
this is a <em>basic</em> example of <strong>Tiptap</strong>. Sure, there are all kind of basic text styles you’d probably expect from a text editor. But wait until you see the lists:
</p>
<ul>
<li>
That’s a bullet list with one …
</li>
<li>
… or two list items.
</li>
</ul>
<p>
Isn’t that great? And all of that is editable. But wait, there’s more. Let’s try a code block:
</p>
<pre><code class="language-css">body {
display: none;
}</code></pre>
<p>
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.
</p>
<blockquote>
Wow, that’s amazing. Good work, boy! 👏
<br />
— Mom
</blockquote>
`,
})
return (
<>
<MenuBar editor={editor} />
<EditorContent editor={editor} />
</>
)
}

View File

@@ -0,0 +1,4 @@
.toolbar {
background-color: red;
padding: 16px;
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,4 @@
.toolbar {
background-color: red;
padding: 16px;
}

View File

@@ -0,0 +1,255 @@
<template>
<div v-if="editor" class="toolbar" :class="styles.toolbar">
<button
@click="editor.chain().focus().toggleBold().run()"
:class="{ 'is-active': editor.isActive('bold') }"
>
Bold
</button>
<button
@click="editor.chain().focus().toggleItalic().run()"
:class="{ 'is-active': editor.isActive('italic') }"
>
Italic
</button>
<button
@click="editor.chain().focus().toggleStrike().run()"
:class="{ 'is-active': editor.isActive('strike') }"
>
Strike
</button>
<button
@click="editor.chain().focus().toggleCode().run()"
:class="{ 'is-active': editor.isActive('code') }"
>
Code
</button>
<button @click="editor.chain().focus().unsetAllMarks().run()">Clear marks</button>
<button @click="editor.chain().focus().clearNodes().run()">Clear nodes</button>
<button
@click="editor.chain().focus().setParagraph().run()"
:class="{ 'is-active': editor.isActive('paragraph') }"
>
Paragraph
</button>
<button
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
>
H1
</button>
<button
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
>
H2
</button>
<button
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
>
H3
</button>
<button
@click="editor.chain().focus().toggleHeading({ level: 4 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 4 }) }"
>
H4
</button>
<button
@click="editor.chain().focus().toggleHeading({ level: 5 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 5 }) }"
>
H5
</button>
<button
@click="editor.chain().focus().toggleHeading({ level: 6 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 6 }) }"
>
H6
</button>
<button
@click="editor.chain().focus().toggleBulletList().run()"
:class="{ 'is-active': editor.isActive('bulletList') }"
>
Bullet list
</button>
<button
@click="editor.chain().focus().toggleOrderedList().run()"
:class="{ 'is-active': editor.isActive('orderedList') }"
>
Ordered list
</button>
<button
@click="editor.chain().focus().toggleCodeBlock().run()"
:class="{ 'is-active': editor.isActive('codeBlock') }"
>
Code block
</button>
<button
@click="editor.chain().focus().toggleBlockquote().run()"
:class="{ 'is-active': editor.isActive('blockquote') }"
>
Blockquote
</button>
<button @click="editor.chain().focus().setHorizontalRule().run()">Horizontal rule</button>
<button @click="editor.chain().focus().setHardBreak().run()">Hard break</button>
<button @click="editor.chain().focus().undo().run()">Undo</button>
<button @click="editor.chain().focus().redo().run()">Redo</button>
</div>
<editor-content :editor="editor" />
</template>
<script>
import StarterKit from '@tiptap/starter-kit'
import { Editor, EditorContent } from '@tiptap/vue'
import styles from './index.module.css'
export default {
components: {
EditorContent,
},
data() {
return {
editor: null,
styles,
}
},
mounted() {
this.editor = new Editor({
extensions: [StarterKit],
content: `
<h1 class="test">
This is a red headline
</h1>
<p>
this is a <em>basic</em> example of <strong>Tiptap</strong>. Sure, there are all kind of basic text styles you’d probably expect from a text editor. But wait until you see the lists:
</p>
<ul>
<li>
That’s a bullet list with one …
</li>
<li>
… or two list items.
</li>
</ul>
<p>
Isn’t that great? And all of that is editable. But wait, there’s more. Let’s try a code block:
</p>
<pre><code class="language-css">body {
display: none;
}</code></pre>
<p>
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.
</p>
<blockquote>
Wow, that’s amazing. Good work, boy! 👏
<br />
— Mom
</blockquote>
`,
})
},
beforeUnmount() {
this.editor.destroy()
},
}
</script>
<style lang="scss">
/* 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;
}
}
</style>

View File

@@ -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')
})
})
})
})

View File

@@ -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,
}) => (
<NodeViewWrapper className="code-block">
<select
contentEditable={false}
defaultValue={defaultLanguage}
onChange={event => updateAttributes({ language: event.target.value })}
>
<option value="null">auto</option>
<option disabled>—</option>
{extension.options.lowlight.listLanguages().map((lang, index) => (
<option key={index} value={lang}>
{lang}
</option>
))}
</select>
<pre>
<NodeViewContent as="code" />
</pre>
</NodeViewWrapper>
)

View File

@@ -0,0 +1,13 @@
.tiptap {
.code-block {
position: relative;
select {
position: absolute;
background-color: var(--white);
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="Black" d="M7 10l5 5 5-5z"/></svg>');
right: 0.5rem;
top: 0.5rem;
}
}
}

View File

@@ -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 (
<div className="control-group">
<div className="button-group">
<button
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
className={isCodeBlock ? 'is-active' : ''}
>
Toggle code block
</button>
</div>
</div>
)
}
export default () => {
const editor = useEditor({
extensions: [
Document,
Paragraph,
Text,
CodeBlockLowlight.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockComponent)
},
}).configure({ lowlight }),
],
content: `
<p>
That's a boring paragraph followed by a fenced code block:
</p>
<pre><code class="language-javascript">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);
}</code></pre>
<p>
Press Command/Ctrl + Enter to leave the fenced code block and continue typing in boring paragraphs.
</p>
`,
})
return (
<>
<MenuBar editor={editor} />
<EditorContent editor={editor} />
</>
)
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,58 @@
<template>
<node-view-wrapper class="code-block">
<select contenteditable="false" v-model="selectedLanguage">
<option :value="null">auto</option>
<option disabled>—</option>
<option v-for="(language, index) in languages" :value="language" :key="index">
{{ language }}
</option>
</select>
<pre><code><node-view-content /></code></pre>
</node-view-wrapper>
</template>
<script>
import { NodeViewContent, nodeViewProps, NodeViewWrapper } from '@tiptap/vue'
export default {
components: {
NodeViewWrapper,
NodeViewContent,
},
props: nodeViewProps,
data() {
return {
languages: this.extension.options.lowlight.listLanguages(),
}
},
computed: {
selectedLanguage: {
get() {
return this.node.attrs.language
},
set(language) {
this.updateAttributes({ language })
},
},
},
}
</script>
<style lang="scss">
.tiptap {
.code-block {
position: relative;
select {
position: absolute;
background-color: var(--white);
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="Black" d="M7 10l5 5 5-5z"/></svg>');
right: 0.5rem;
top: 0.5rem;
}
}
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<div v-if="editor" class="container">
<div class="control-group">
<div class="button-">
<button
@click="editor.chain().focus().toggleCodeBlock().run()"
:class="{ 'is-active': editor.isActive('codeBlock') }"
>
Toggle code block
</button>
</div>
</div>
</div>
<editor-content :editor="editor" />
</template>
<script>
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 { Editor, EditorContent, VueNodeViewRenderer } from '@tiptap/vue'
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 CodeBlockComponent from './CodeBlockComponent.vue'
// create a lowlight instance
const lowlight = createLowlight(all)
// you can also register languages
lowlight.register('html', html)
lowlight.register('css', css)
lowlight.register('js', js)
lowlight.register('ts', ts)
export default {
components: {
EditorContent,
},
data() {
return {
editor: null,
}
},
mounted() {
this.editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
CodeBlockLowlight.extend({
addNodeView() {
return VueNodeViewRenderer(CodeBlockComponent)
},
}).configure({ lowlight }),
],
content: `
<p>
That's a boring paragraph followed by a fenced code block:
</p>
<pre><code class="language-javascript">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);
}</code></pre>
<p>
Press Command/Ctrl + Enter to leave the fenced code block and continue typing in boring paragraphs.
</p>
`,
})
},
beforeUnmount() {
this.editor.destroy()
},
}
</script>
<style lang="scss">
/* 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;
}
}
}
</style>

View File

@@ -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 (
<div className="editor__header">
{items.map((item, index) => (
<Fragment key={index}>
{item.type === 'divider' ? <div className="divider" /> : <MenuItem {...item} />}
</Fragment>
))}
</div>
)
}

View File

@@ -0,0 +1,7 @@
.divider {
background-color: rgba(#fff, 0.25);
height: 1.25rem;
margin-left: 0.5rem;
margin-right: 0.75rem;
width: 1px;
}

View File

@@ -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 }) => (
<button
className={`menu-item${isActive && isActive() ? ' is-active' : ''}`}
onClick={action}
title={title}
>
<svg className="remix">
<use xlinkHref={`${remixiconUrl}#ri-${icon}`} />
</svg>
</button>
)

View File

@@ -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;
}
}

View File

@@ -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 (
<div className="editor">
{editor && <MenuBar editor={editor} />}
<EditorContent className="editor__content" editor={editor} />
<div className="editor__footer">
<div className={`editor__status editor__status--${status}`}>
{status === 'connected'
? `${userCount} user${userCount === 1 ? '' : 's'} online in ${room}`
: 'offline'}
</div>
<div className="editor__name">
<button onClick={setName}>{currentUser.name}</button>
</div>
</div>
</div>
)
}

View File

@@ -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;
}

View File

@@ -0,0 +1,157 @@
<template>
<div>
<template v-for="(item, index) in items">
<div class="divider" v-if="item.type === 'divider'" :key="`divider${index}`" />
<menu-item v-else :key="index" v-bind="item" />
</template>
</div>
</template>
<script>
import MenuItem from './MenuItem.vue'
export default {
components: {
MenuItem,
},
props: {
editor: {
type: Object,
required: true,
},
},
data() {
return {
items: [
{
icon: 'bold',
title: 'Bold',
action: () => this.editor.chain().focus().toggleBold().run(),
isActive: () => this.editor.isActive('bold'),
},
{
icon: 'italic',
title: 'Italic',
action: () => this.editor.chain().focus().toggleItalic().run(),
isActive: () => this.editor.isActive('italic'),
},
{
icon: 'strikethrough',
title: 'Strike',
action: () => this.editor.chain().focus().toggleStrike().run(),
isActive: () => this.editor.isActive('strike'),
},
{
icon: 'code-view',
title: 'Code',
action: () => this.editor.chain().focus().toggleCode().run(),
isActive: () => this.editor.isActive('code'),
},
{
icon: 'mark-pen-line',
title: 'Highlight',
action: () => this.editor.chain().focus().toggleHighlight().run(),
isActive: () => this.editor.isActive('highlight'),
},
{
type: 'divider',
},
{
icon: 'h-1',
title: 'Heading 1',
action: () => this.editor.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => this.editor.isActive('heading', { level: 1 }),
},
{
icon: 'h-2',
title: 'Heading 2',
action: () => this.editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => this.editor.isActive('heading', { level: 2 }),
},
{
icon: 'paragraph',
title: 'Paragraph',
action: () => this.editor.chain().focus().setParagraph().run(),
isActive: () => this.editor.isActive('paragraph'),
},
{
icon: 'list-unordered',
title: 'Bullet list',
action: () => this.editor.chain().focus().toggleBulletList().run(),
isActive: () => this.editor.isActive('bulletList'),
},
{
icon: 'list-ordered',
title: 'Ordered list',
action: () => this.editor.chain().focus().toggleOrderedList().run(),
isActive: () => this.editor.isActive('orderedList'),
},
{
icon: 'list-check-2',
title: 'Task list',
action: () => this.editor.chain().focus().toggleTaskList().run(),
isActive: () => this.editor.isActive('taskList'),
},
{
icon: 'code-box-line',
title: 'Code block',
action: () => this.editor.chain().focus().toggleCodeBlock().run(),
isActive: () => this.editor.isActive('codeBlock'),
},
{
type: 'divider',
},
{
icon: 'double-quotes-l',
title: 'Blockquote',
action: () => this.editor.chain().focus().toggleBlockquote().run(),
isActive: () => this.editor.isActive('blockquote'),
},
{
icon: 'separator',
title: 'Horizontal rule',
action: () => this.editor.chain().focus().setHorizontalRule().run(),
},
{
type: 'divider',
},
{
icon: 'text-wrap',
title: 'Hard break',
action: () => this.editor.chain().focus().setHardBreak().run(),
},
{
icon: 'format-clear',
title: 'Clear format',
action: () => this.editor.chain().focus().clearNodes().unsetAllMarks().run(),
},
{
type: 'divider',
},
{
icon: 'arrow-go-back-line',
title: 'Undo',
action: () => this.editor.chain().focus().undo().run(),
},
{
icon: 'arrow-go-forward-line',
title: 'Redo',
action: () => this.editor.chain().focus().redo().run(),
},
],
}
},
}
</script>
<style lang="scss">
.divider {
background-color: rgba(#fff, 0.25);
height: 1.25rem;
margin-left: 0.5rem;
margin-right: 0.75rem;
width: 1px;
}
</style>

View File

@@ -0,0 +1,71 @@
<template>
<button
class="menu-item"
:class="{ 'is-active': isActive ? isActive() : null }"
@click="action"
:title="title"
>
<svg class="remix">
<use :xlink:href="`${remixiconUrl}#ri-${icon}`" />
</svg>
</button>
</template>
<script>
import remixiconUrl from 'remixicon/fonts/remixicon.symbol.svg'
export default {
props: {
icon: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
action: {
type: Function,
required: true,
},
isActive: {
type: Function,
default: null,
},
},
data() {
return {
remixiconUrl,
}
},
}
</script>
<style lang="scss">
.menu-item {
background: transparent;
border: none;
border-radius: 0.4rem;
color: #fff;
cursor: pointer;
height: 1.75rem;
padding: 0.25rem;
margin-right: 0.25rem;
width: 1.75rem;
svg {
fill: currentColor;
height: 100%;
width: 100%;
}
&.is-active,
&:hover {
background-color: #303030;
}
}
</style>

View File

@@ -0,0 +1,372 @@
<template>
<div class="editor" v-if="editor">
<menu-bar class="editor__header" :editor="editor" />
<editor-content class="editor__content" :editor="editor" />
<div class="editor__footer">
<div :class="`editor__status editor__status--${status}`">
<template v-if="status === 'connected'">
{{ editor.storage.collaborationCaret.users.length }} user{{
editor.storage.collaborationCaret.users.length === 1 ? '' : 's'
}}
online in {{ room }}
</template>
<template v-else> offline </template>
</div>
<div class="editor__name">
<button @click="setName">
{{ currentUser.name }}
</button>
</div>
</div>
</div>
</template>
<script>
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 StarterKit from '@tiptap/starter-kit'
import { Editor, EditorContent } from '@tiptap/vue'
import * as Y from 'yjs'
import { variables } from '../../../variables.js'
import MenuBar from './MenuBar.vue'
const getRandomElement = list => {
return 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}`))
}
export default {
components: {
EditorContent,
MenuBar,
},
data() {
return {
currentUser: JSON.parse(localStorage.getItem('currentUser')) || {
name: this.getRandomName(),
color: this.getRandomColor(),
},
provider: null,
editor: null,
status: 'connecting',
room: getRandomRoom(),
}
},
mounted() {
const ydoc = new Y.Doc()
this.provider = new TiptapCollabProvider({
appId: '7j9y6m10',
name: this.room,
document: ydoc,
})
this.provider.on('status', event => {
this.status = event.status
})
this.editor = new Editor({
extensions: [
StarterKit.configure({
history: false,
}),
Highlight,
TaskList,
TaskItem,
Collaboration.configure({
document: ydoc,
}),
CollaborationCaret.configure({
provider: this.provider,
user: this.currentUser,
}),
CharacterCount.configure({
limit: 10000,
}),
],
})
localStorage.setItem('currentUser', JSON.stringify(this.currentUser))
},
methods: {
setName() {
const name = (window.prompt('Name') || '').trim().substring(0, 32)
if (name) {
return this.updateCurrentUser({
name,
})
}
},
updateCurrentUser(attributes) {
this.currentUser = { ...this.currentUser, ...attributes }
this.editor.chain().focus().updateUser(this.currentUser).run()
localStorage.setItem('currentUser', JSON.stringify(this.currentUser))
},
getRandomColor() {
return getRandomElement([
'#958DF1',
'#F98181',
'#FBBC88',
'#FAF594',
'#70CFF8',
'#94FADB',
'#B9F18D',
])
},
getRandomName() {
return getRandomElement([
'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',
])
},
},
beforeUnmount() {
this.editor.destroy()
this.provider.destroy()
},
}
</script>
<style lang="scss">
.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;
flex-wrap: wrap;
font-size: 12px;
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;
}
/* 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;
}
}
}
}
</style>

View File

@@ -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 (
<div className="dropdown-menu">
{props.items.length ? (
props.items.map((item, index) => (
<button
className={index === selectedIndex ? 'is-selected' : ''}
key={index}
onClick={() => selectItem(index)}
>
{item}
</button>
))
) : (
<div className="item">No result</div>
)}
</div>
)
})

View File

@@ -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);
}
}
}

View File

@@ -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: `
<p>
What do you all think about the new <span data-type="mention" data-id="Winona Ryder"></span> movie?
</p>
`,
})
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 (
<>
<EditorContent editor={editor} />
{editor && (
<div className={`character-count ${isAtLimit ? 'character-count--warning' : ''}`}>
<svg height="20" width="20" viewBox="0 0 20 20">
<circle r="10" cx="10" cy="10" fill="#e9ecef" />
<circle
r="5"
cx="10"
cy="10"
fill="transparent"
stroke="currentColor"
strokeWidth="10"
strokeDasharray={`calc(${percentage} * 31.4 / 100) 31.4`}
transform="rotate(-90) translate(-20)"
/>
<circle r="6" cx="10" cy="10" fill="white" />
</svg>
{characterCount} / {limit} characters
</div>
)}
</>
)
}

View File

@@ -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);
}
}

View File

@@ -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()
},
}
},
}

View File

@@ -0,0 +1,118 @@
<template>
<div class="dropdown-menu">
<template v-if="items.length">
<button
:class="{ 'is-selected': index === selectedIndex }"
v-for="(item, index) in items"
:key="index"
@click="selectItem(index)"
>
{{ item }}
</button>
</template>
<div class="item" v-else>No result</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
required: true,
},
command: {
type: Function,
required: true,
},
},
data() {
return {
selectedIndex: 0,
}
},
watch: {
items() {
this.selectedIndex = 0
},
},
methods: {
onKeyDown({ event }) {
if (event.key === 'ArrowUp') {
this.upHandler()
return true
}
if (event.key === 'ArrowDown') {
this.downHandler()
return true
}
if (event.key === 'Enter') {
this.enterHandler()
return true
}
return false
},
upHandler() {
this.selectedIndex = (this.selectedIndex + this.items.length - 1) % this.items.length
},
downHandler() {
this.selectedIndex = (this.selectedIndex + 1) % this.items.length
},
enterHandler() {
this.selectItem(this.selectedIndex)
},
selectItem(index) {
const item = this.items[index]
if (item) {
this.command({ id: item })
}
},
},
}
</script>
<style lang="scss">
/* 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);
}
}
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<editor-content :editor="editor" />
<div
v-if="editor"
:class="{
'character-count': true,
'character-count--warning': editor.storage.characterCount.characters() === limit,
}"
>
<svg height="20" width="20" viewBox="0 0 20 20">
<circle r="10" cx="10" cy="10" fill="#e9ecef" />
<circle
r="5"
cx="10"
cy="10"
fill="transparent"
stroke="currentColor"
stroke-width="10"
:stroke-dasharray="`calc(${percentage} * 31.4 / 100) 31.4`"
transform="rotate(-90) translate(-20)"
/>
<circle r="6" cx="10" cy="10" fill="white" />
</svg>
{{ editor.storage.characterCount.characters() }} / {{ limit }} characters
</div>
</template>
<script>
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 { Editor, EditorContent } from '@tiptap/vue'
import suggestion from './suggestion.js'
export default {
components: {
EditorContent,
},
data() {
return {
editor: null,
limit: 280,
}
},
mounted() {
this.editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
CharacterCount.configure({
limit: this.limit,
}),
Mention.configure({
HTMLAttributes: {
class: 'mention',
},
suggestion,
}),
],
content: `
<p>
What do you all think about the new <span data-type="mention" data-id="Winona Ryder"></span> movie?
</p>
`,
})
},
computed: {
percentage() {
return Math.round((100 / this.limit) * this.editor.storage.characterCount.characters())
},
},
beforeUnmount() {
this.editor.destroy()
},
}
</script>
<style lang="scss">
/* 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);
}
}
</style>

View File

@@ -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()
},
}
},
}

View File

@@ -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')
})
})
})
})

View File

@@ -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: `
<h1>
It’ll always have a heading …
</h1>
<p>
… if you pass a custom document. That’s the beauty of having full control over the schema.
</p>
`,
})
return <EditorContent editor={editor} />
}

Some files were not shown because too many files have changed in this diff Show More