create-starlight-cms 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +18 -0
- package/THIRD_PARTY_NOTICES.md +4 -0
- package/bin/index.js +147 -0
- package/package.json +46 -0
- package/template/.nvmrc +1 -0
- package/template/LICENSE +21 -0
- package/template/README.md +68 -0
- package/template/THIRD_PARTY_NOTICES.md +12 -0
- package/template/_gitignore +14 -0
- package/template/astro.config.mjs +26 -0
- package/template/migrations/0001_schema.sql +73 -0
- package/template/migrations/0002_publish_delivery.sql +16 -0
- package/template/package-lock.json.template +9123 -0
- package/template/package.json +89 -0
- package/template/scripts/build-admin.mjs +38 -0
- package/template/scripts/check-linux-bindings.mjs +18 -0
- package/template/scripts/dev.mjs +94 -0
- package/template/src/admin/admin.scss +182 -0
- package/template/src/admin/app.ts +130 -0
- package/template/src/admin/client.tsx +684 -0
- package/template/src/admin/components/tiptap-extension/node-background-extension.ts +150 -0
- package/template/src/admin/components/tiptap-icons/align-center-icon.tsx +38 -0
- package/template/src/admin/components/tiptap-icons/align-justify-icon.tsx +38 -0
- package/template/src/admin/components/tiptap-icons/align-left-icon.tsx +38 -0
- package/template/src/admin/components/tiptap-icons/align-right-icon.tsx +38 -0
- package/template/src/admin/components/tiptap-icons/arrow-left-icon.tsx +24 -0
- package/template/src/admin/components/tiptap-icons/arrow-right-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/ban-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/blockquote-icon.tsx +44 -0
- package/template/src/admin/components/tiptap-icons/bold-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/case-sensitive-icon.tsx +32 -0
- package/template/src/admin/components/tiptap-icons/check-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/chevron-down-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/chevron-up-icon.tsx +22 -0
- package/template/src/admin/components/tiptap-icons/close-icon.tsx +24 -0
- package/template/src/admin/components/tiptap-icons/code-block-icon.tsx +38 -0
- package/template/src/admin/components/tiptap-icons/code2-icon.tsx +32 -0
- package/template/src/admin/components/tiptap-icons/corner-down-left-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/external-link-icon.tsx +28 -0
- package/template/src/admin/components/tiptap-icons/heading-five-icon.tsx +28 -0
- package/template/src/admin/components/tiptap-icons/heading-four-icon.tsx +28 -0
- package/template/src/admin/components/tiptap-icons/heading-icon.tsx +24 -0
- package/template/src/admin/components/tiptap-icons/heading-one-icon.tsx +28 -0
- package/template/src/admin/components/tiptap-icons/heading-six-icon.tsx +30 -0
- package/template/src/admin/components/tiptap-icons/heading-three-icon.tsx +36 -0
- package/template/src/admin/components/tiptap-icons/heading-two-icon.tsx +28 -0
- package/template/src/admin/components/tiptap-icons/highlighter-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/image-plus-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/italic-icon.tsx +24 -0
- package/template/src/admin/components/tiptap-icons/link-icon.tsx +28 -0
- package/template/src/admin/components/tiptap-icons/list-icon.tsx +56 -0
- package/template/src/admin/components/tiptap-icons/list-ordered-icon.tsx +56 -0
- package/template/src/admin/components/tiptap-icons/list-todo-icon.tsx +50 -0
- package/template/src/admin/components/tiptap-icons/moon-star-icon.tsx +30 -0
- package/template/src/admin/components/tiptap-icons/redo2-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/search-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/strike-icon.tsx +28 -0
- package/template/src/admin/components/tiptap-icons/subscript-icon.tsx +38 -0
- package/template/src/admin/components/tiptap-icons/sun-icon.tsx +58 -0
- package/template/src/admin/components/tiptap-icons/superscript-icon.tsx +38 -0
- package/template/src/admin/components/tiptap-icons/trash-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/underline-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/undo2-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/whole-word-icon.tsx +36 -0
- package/template/src/admin/components/tiptap-node/blockquote-node/blockquote-node.scss +37 -0
- package/template/src/admin/components/tiptap-node/code-block-node/code-block-node.scss +54 -0
- package/template/src/admin/components/tiptap-node/heading-node/heading-node.scss +45 -0
- package/template/src/admin/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts +14 -0
- package/template/src/admin/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss +25 -0
- package/template/src/admin/components/tiptap-node/image-node/image-node.scss +35 -0
- package/template/src/admin/components/tiptap-node/image-upload-node/image-upload-node-extension.ts +161 -0
- package/template/src/admin/components/tiptap-node/image-upload-node/image-upload-node.scss +249 -0
- package/template/src/admin/components/tiptap-node/image-upload-node/image-upload-node.tsx +554 -0
- package/template/src/admin/components/tiptap-node/image-upload-node/index.tsx +1 -0
- package/template/src/admin/components/tiptap-node/list-node/list-node.scss +208 -0
- package/template/src/admin/components/tiptap-node/paragraph-node/paragraph-node.scss +217 -0
- package/template/src/admin/components/tiptap-templates/simple/NOTICE.md +14 -0
- package/template/src/admin/components/tiptap-templates/simple/data/content.json +477 -0
- package/template/src/admin/components/tiptap-templates/simple/simple-editor.scss +97 -0
- package/template/src/admin/components/tiptap-templates/simple/simple-editor.tsx +348 -0
- package/template/src/admin/components/tiptap-templates/simple/theme-toggle.tsx +44 -0
- package/template/src/admin/components/tiptap-ui/blockquote-button/blockquote-button.tsx +122 -0
- package/template/src/admin/components/tiptap-ui/blockquote-button/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/blockquote-button/use-blockquote.ts +273 -0
- package/template/src/admin/components/tiptap-ui/code-block-button/code-block-button.tsx +122 -0
- package/template/src/admin/components/tiptap-ui/code-block-button/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/code-block-button/use-code-block.ts +283 -0
- package/template/src/admin/components/tiptap-ui/color-highlight-button/color-highlight-button.scss +49 -0
- package/template/src/admin/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx +170 -0
- package/template/src/admin/components/tiptap-ui/color-highlight-button/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/color-highlight-button/use-color-highlight.ts +380 -0
- package/template/src/admin/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx +231 -0
- package/template/src/admin/components/tiptap-ui/color-highlight-popover/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui/heading-button/heading-button.tsx +124 -0
- package/template/src/admin/components/tiptap-ui/heading-button/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/heading-button/use-heading.ts +348 -0
- package/template/src/admin/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx +128 -0
- package/template/src/admin/components/tiptap-ui/heading-dropdown-menu/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts +132 -0
- package/template/src/admin/components/tiptap-ui/image-upload-button/image-upload-button.tsx +130 -0
- package/template/src/admin/components/tiptap-ui/image-upload-button/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/image-upload-button/use-image-upload.ts +197 -0
- package/template/src/admin/components/tiptap-ui/link-popover/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/link-popover/link-popover.scss +16 -0
- package/template/src/admin/components/tiptap-ui/link-popover/link-popover.tsx +315 -0
- package/template/src/admin/components/tiptap-ui/link-popover/use-link-popover.ts +297 -0
- package/template/src/admin/components/tiptap-ui/list-button/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/list-button/list-button.tsx +120 -0
- package/template/src/admin/components/tiptap-ui/list-button/use-list.ts +353 -0
- package/template/src/admin/components/tiptap-ui/list-dropdown-menu/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx +126 -0
- package/template/src/admin/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts +220 -0
- package/template/src/admin/components/tiptap-ui/mark-button/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/mark-button/mark-button.tsx +122 -0
- package/template/src/admin/components/tiptap-ui/mark-button/use-mark.ts +219 -0
- package/template/src/admin/components/tiptap-ui/search-and-replace/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/search-and-replace/search-and-replace.scss +300 -0
- package/template/src/admin/components/tiptap-ui/search-and-replace/search-and-replace.tsx +718 -0
- package/template/src/admin/components/tiptap-ui/search-and-replace/use-search-and-replace.ts +528 -0
- package/template/src/admin/components/tiptap-ui/text-align-button/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/text-align-button/text-align-button.tsx +144 -0
- package/template/src/admin/components/tiptap-ui/text-align-button/use-text-align.ts +237 -0
- package/template/src/admin/components/tiptap-ui/undo-redo-button/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx +125 -0
- package/template/src/admin/components/tiptap-ui/undo-redo-button/use-undo-redo.ts +200 -0
- package/template/src/admin/components/tiptap-ui-primitive/badge/badge-colors.scss +395 -0
- package/template/src/admin/components/tiptap-ui-primitive/badge/badge-group.scss +16 -0
- package/template/src/admin/components/tiptap-ui-primitive/badge/badge.scss +99 -0
- package/template/src/admin/components/tiptap-ui-primitive/badge/badge.tsx +44 -0
- package/template/src/admin/components/tiptap-ui-primitive/badge/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/button/button-colors.scss +613 -0
- package/template/src/admin/components/tiptap-ui-primitive/button/button.scss +390 -0
- package/template/src/admin/components/tiptap-ui-primitive/button/button.tsx +158 -0
- package/template/src/admin/components/tiptap-ui-primitive/button/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/button-group/button-group.scss +89 -0
- package/template/src/admin/components/tiptap-ui-primitive/button-group/button-group.tsx +72 -0
- package/template/src/admin/components/tiptap-ui-primitive/button-group/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/card/card.scss +74 -0
- package/template/src/admin/components/tiptap-ui-primitive/card/card.tsx +79 -0
- package/template/src/admin/components/tiptap-ui-primitive/card/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss +152 -0
- package/template/src/admin/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx +262 -0
- package/template/src/admin/components/tiptap-ui-primitive/dropdown-menu/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/input/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/input/input.scss +41 -0
- package/template/src/admin/components/tiptap-ui-primitive/input/input.tsx +22 -0
- package/template/src/admin/components/tiptap-ui-primitive/input-group/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/input-group/input-group.scss +225 -0
- package/template/src/admin/components/tiptap-ui-primitive/input-group/input-group.tsx +135 -0
- package/template/src/admin/components/tiptap-ui-primitive/popover/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/popover/popover.scss +64 -0
- package/template/src/admin/components/tiptap-ui-primitive/popover/popover.tsx +35 -0
- package/template/src/admin/components/tiptap-ui-primitive/separator/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/separator/separator.scss +23 -0
- package/template/src/admin/components/tiptap-ui-primitive/separator/separator.tsx +28 -0
- package/template/src/admin/components/tiptap-ui-primitive/spacer/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/spacer/spacer.tsx +24 -0
- package/template/src/admin/components/tiptap-ui-primitive/switch/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/switch/switch.scss +86 -0
- package/template/src/admin/components/tiptap-ui-primitive/switch/switch.tsx +29 -0
- package/template/src/admin/components/tiptap-ui-primitive/textarea/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/textarea/textarea.scss +42 -0
- package/template/src/admin/components/tiptap-ui-primitive/textarea/textarea.tsx +16 -0
- package/template/src/admin/components/tiptap-ui-primitive/toolbar/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/toolbar/toolbar.scss +98 -0
- package/template/src/admin/components/tiptap-ui-primitive/toolbar/toolbar.tsx +203 -0
- package/template/src/admin/components/tiptap-ui-primitive/tooltip/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/tooltip/tooltip.scss +43 -0
- package/template/src/admin/components/tiptap-ui-primitive/tooltip/tooltip.tsx +241 -0
- package/template/src/admin/hooks/use-composed-ref.ts +46 -0
- package/template/src/admin/hooks/use-cursor-visibility.ts +69 -0
- package/template/src/admin/hooks/use-element-rect.ts +166 -0
- package/template/src/admin/hooks/use-is-breakpoint.ts +35 -0
- package/template/src/admin/hooks/use-menu-navigation.ts +204 -0
- package/template/src/admin/hooks/use-scrolling.ts +75 -0
- package/template/src/admin/hooks/use-throttled-callback.ts +48 -0
- package/template/src/admin/hooks/use-tiptap-editor.ts +69 -0
- package/template/src/admin/hooks/use-unmount.ts +21 -0
- package/template/src/admin/hooks/use-window-size.ts +93 -0
- package/template/src/admin/html.ts +17 -0
- package/template/src/admin/lib/tiptap-utils.ts +652 -0
- package/template/src/admin/navigation-tree.tsx +82 -0
- package/template/src/admin/preview.ts +73 -0
- package/template/src/admin/styles/_cms-theme.scss +58 -0
- package/template/src/admin/styles/_keyframe-animations.scss +91 -0
- package/template/src/admin/styles/_variables.scss +298 -0
- package/template/src/assets/favicon.svg +1 -0
- package/template/src/assets/logo.svg +1 -0
- package/template/src/astro-env.d.ts +1 -0
- package/template/src/content.config.ts +12 -0
- package/template/src/db/bootstrap.ts +36 -0
- package/template/src/db/client.ts +7 -0
- package/template/src/db/schema.ts +45 -0
- package/template/src/documents/service.ts +178 -0
- package/template/src/documents/validation.ts +9 -0
- package/template/src/env.ts +9 -0
- package/template/src/index.ts +10 -0
- package/template/src/locales.ts +10 -0
- package/template/src/media/service.ts +120 -0
- package/template/src/navigation/service.ts +171 -0
- package/template/src/pages/[locale]/cms-preview-shell.astro +12 -0
- package/template/src/pages/cms-preview-shell.astro +5 -0
- package/template/src/publish/deploy-hook.ts +42 -0
- package/template/src/publish/record.ts +20 -0
- package/template/src/publish/service.ts +135 -0
- package/template/src/scss.d.ts +5 -0
- package/template/src/site.config.ts +7 -0
- package/template/src/sql.d.ts +4 -0
- package/template/src/starlight/cms-origin.ts +4 -0
- package/template/src/starlight/loader.ts +21 -0
- package/template/src/starlight/preview-render.ts +162 -0
- package/template/src/starlight/preview-shell.astro +14 -0
- package/template/src/starlight/render.ts +59 -0
- package/template/src/starlight/schema.ts +34 -0
- package/template/src/starlight/sidebar.ts +35 -0
- package/template/src/starlight/snapshot.ts +47 -0
- package/template/src/starlight/source.ts +26 -0
- package/template/src/starlight/tiptap.ts +29 -0
- package/template/src/styles/starlight.css +12 -0
- package/template/test-files/content.test.template.ts +82 -0
- package/template/test-files/deploy-hook.test.template.ts +32 -0
- package/template/test-files/media.test.template.ts +39 -0
- package/template/test-files/publishing.integration.test.template.ts +156 -0
- package/template/tsconfig.admin.json +18 -0
- package/template/tsconfig.json +23 -0
- package/template/worker-configuration.d.ts +15389 -0
- package/template/wrangler.jsonc +26 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react"
|
|
2
|
+
import type { Editor } from "@tiptap/react"
|
|
3
|
+
import { useCurrentEditor } from "@tiptap/react"
|
|
4
|
+
import type { FindAndReplaceStorage } from "@tiptap/extension-find-and-replace"
|
|
5
|
+
|
|
6
|
+
// --- Hooks ---
|
|
7
|
+
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
|
8
|
+
|
|
9
|
+
// --- Icons ---
|
|
10
|
+
import { SearchIcon } from "@/components/tiptap-icons/search-icon"
|
|
11
|
+
|
|
12
|
+
// --- Lib ---
|
|
13
|
+
import { isExtensionAvailable } from "@/lib/tiptap-utils"
|
|
14
|
+
|
|
15
|
+
export const SEARCH_AND_REPLACE_SHORTCUT_KEY = "mod+f"
|
|
16
|
+
export const NEXT_RESULT_SHORTCUT_KEY = "mod+shift+f"
|
|
17
|
+
export const PREVIOUS_RESULT_SHORTCUT_KEY = "mod+shift+d"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* CSS classes rendered by the Find and Replace extension for result
|
|
21
|
+
* decorations inside the editor.
|
|
22
|
+
*/
|
|
23
|
+
export const SEARCH_RESULT_CLASS = "find-and-replace-result"
|
|
24
|
+
export const SEARCH_RESULT_CURRENT_CLASS = "find-and-replace-result-current"
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_SCROLL_INTO_VIEW_OPTIONS: ScrollIntoViewOptions = {
|
|
27
|
+
block: "nearest",
|
|
28
|
+
inline: "nearest",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Configuration for the search and replace functionality
|
|
33
|
+
*/
|
|
34
|
+
export interface UseSearchAndReplaceConfig {
|
|
35
|
+
/**
|
|
36
|
+
* The Tiptap editor instance.
|
|
37
|
+
*/
|
|
38
|
+
editor?: Editor | null
|
|
39
|
+
/**
|
|
40
|
+
* Whether the trigger should hide when the Find and Replace extension
|
|
41
|
+
* is not registered on the editor.
|
|
42
|
+
* @default false
|
|
43
|
+
*/
|
|
44
|
+
hideWhenUnavailable?: boolean
|
|
45
|
+
/**
|
|
46
|
+
* How the current result is scrolled into view. `behavior` is forced to
|
|
47
|
+
* `auto` when the user prefers reduced motion.
|
|
48
|
+
* @default DEFAULT_SCROLL_INTO_VIEW_OPTIONS
|
|
49
|
+
*/
|
|
50
|
+
scrollIntoViewOptions?: ScrollIntoViewOptions
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Reactive snapshot of the Find and Replace extension storage.
|
|
55
|
+
*/
|
|
56
|
+
export interface SearchAndReplaceEditorState {
|
|
57
|
+
/**
|
|
58
|
+
* Number of results for the applied search term.
|
|
59
|
+
*/
|
|
60
|
+
total: number
|
|
61
|
+
/**
|
|
62
|
+
* Zero-based index of the current result, or `null` when none is selected.
|
|
63
|
+
*/
|
|
64
|
+
currentIndex: number | null
|
|
65
|
+
/**
|
|
66
|
+
* The search term the extension has applied (post-debounce).
|
|
67
|
+
*/
|
|
68
|
+
appliedSearchTerm: string
|
|
69
|
+
/**
|
|
70
|
+
* Whether the search is case sensitive.
|
|
71
|
+
*/
|
|
72
|
+
caseSensitive: boolean
|
|
73
|
+
/**
|
|
74
|
+
* Whether only whole words are matched.
|
|
75
|
+
*/
|
|
76
|
+
wholeWord: boolean
|
|
77
|
+
/**
|
|
78
|
+
* Whether the search term is treated as a regular expression.
|
|
79
|
+
*/
|
|
80
|
+
useRegex: boolean
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const EMPTY_EDITOR_STATE: SearchAndReplaceEditorState = {
|
|
84
|
+
total: 0,
|
|
85
|
+
currentIndex: null,
|
|
86
|
+
appliedSearchTerm: "",
|
|
87
|
+
caseSensitive: false,
|
|
88
|
+
wholeWord: false,
|
|
89
|
+
useRegex: false,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Checks if the Find and Replace extension is registered on the editor
|
|
94
|
+
*/
|
|
95
|
+
export function isFindAndReplaceAvailable(editor: Editor | null): boolean {
|
|
96
|
+
return isExtensionAvailable(editor, "findAndReplace")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Returns the Find and Replace extension storage, or `null` when unavailable
|
|
101
|
+
*/
|
|
102
|
+
export function getFindAndReplaceStorage(
|
|
103
|
+
editor: Editor | null
|
|
104
|
+
): FindAndReplaceStorage | null {
|
|
105
|
+
if (!isFindAndReplaceAvailable(editor)) return null
|
|
106
|
+
return editor?.storage.findAndReplace ?? null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Determines if the search and replace trigger should be shown
|
|
111
|
+
*/
|
|
112
|
+
export function shouldShowSearchAndReplace(props: {
|
|
113
|
+
editor: Editor | null
|
|
114
|
+
hideWhenUnavailable: boolean
|
|
115
|
+
}): boolean {
|
|
116
|
+
const { editor, hideWhenUnavailable } = props
|
|
117
|
+
|
|
118
|
+
if (!editor) return false
|
|
119
|
+
if (hideWhenUnavailable) return isFindAndReplaceAvailable(editor)
|
|
120
|
+
|
|
121
|
+
return true
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Formats the result counter as `current / total` for display.
|
|
126
|
+
* The extension index is zero-based and can be `null`, so the counter
|
|
127
|
+
* renders `0 / 0` without results and one-based positions otherwise.
|
|
128
|
+
*/
|
|
129
|
+
export function formatResultCount(
|
|
130
|
+
currentIndex: number | null,
|
|
131
|
+
total: number
|
|
132
|
+
): string {
|
|
133
|
+
if (total === 0) return "0 / 0"
|
|
134
|
+
if (currentIndex === null) return `0 / ${total}`
|
|
135
|
+
|
|
136
|
+
return `${currentIndex + 1} / ${total}`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface NavigateSearchResultOptions {
|
|
140
|
+
editor: Editor
|
|
141
|
+
direction: "next" | "previous"
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function navigateSearchResult({
|
|
145
|
+
editor,
|
|
146
|
+
direction,
|
|
147
|
+
}: NavigateSearchResultOptions): boolean {
|
|
148
|
+
const selection = editor.state.selection
|
|
149
|
+
const chain = editor.chain()
|
|
150
|
+
const navigation =
|
|
151
|
+
direction === "next" ? chain.goToNextResult() : chain.goToPreviousResult()
|
|
152
|
+
|
|
153
|
+
// Search navigation should update its highlight without changing the
|
|
154
|
+
// editor selection that drives toolbar state.
|
|
155
|
+
return navigation
|
|
156
|
+
.command(({ tr }) => {
|
|
157
|
+
tr.setSelection(selection.map(tr.doc, tr.mapping))
|
|
158
|
+
return true
|
|
159
|
+
})
|
|
160
|
+
.run()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Scrolls the current result into view without stealing focus. The element
|
|
165
|
+
* is resolved from the result's document position rather than the highlight
|
|
166
|
+
* decoration, so it works regardless of how decorations are rendered.
|
|
167
|
+
* Skipped while the editor itself has focus so typing inside the document
|
|
168
|
+
* never causes scroll jumps, and honors `prefers-reduced-motion`.
|
|
169
|
+
*/
|
|
170
|
+
export function scrollCurrentResultIntoView(
|
|
171
|
+
editor: Editor | null,
|
|
172
|
+
scrollIntoViewOptions: ScrollIntoViewOptions = DEFAULT_SCROLL_INTO_VIEW_OPTIONS
|
|
173
|
+
): void {
|
|
174
|
+
if (!editor || editor.isDestroyed) return
|
|
175
|
+
|
|
176
|
+
const storage = getFindAndReplaceStorage(editor)
|
|
177
|
+
const currentIndex = storage?.currentIndex
|
|
178
|
+
if (!storage || currentIndex === null || currentIndex === undefined) return
|
|
179
|
+
|
|
180
|
+
const result = storage.results[currentIndex]
|
|
181
|
+
if (!result) return
|
|
182
|
+
|
|
183
|
+
const editorDom = editor.view.dom
|
|
184
|
+
if (editorDom.contains(document.activeElement)) return
|
|
185
|
+
|
|
186
|
+
let target: HTMLElement | null = null
|
|
187
|
+
try {
|
|
188
|
+
const { node } = editor.view.domAtPos(result.from)
|
|
189
|
+
target = node instanceof HTMLElement ? node : node.parentElement
|
|
190
|
+
} catch {
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!target || typeof target.scrollIntoView !== "function") return
|
|
195
|
+
|
|
196
|
+
const prefersReducedMotion =
|
|
197
|
+
typeof window.matchMedia === "function" &&
|
|
198
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
199
|
+
|
|
200
|
+
target.scrollIntoView({
|
|
201
|
+
behavior: "smooth",
|
|
202
|
+
...scrollIntoViewOptions,
|
|
203
|
+
...(prefersReducedMotion ? { behavior: "auto" } : {}),
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Main hook that provides search and replace functionality for a Tiptap editor.
|
|
209
|
+
*
|
|
210
|
+
* The `searchTerm`/`replaceTerm` values mirror the inputs immediately, while
|
|
211
|
+
* search execution is forwarded to the extension's `setSearchTerm`, which
|
|
212
|
+
* applies its own debounce (`searchDebounceMs`) — no additional UI debounce is
|
|
213
|
+
* added. An empty search cancels pending work and clears highlights
|
|
214
|
+
* immediately.
|
|
215
|
+
*
|
|
216
|
+
* @example
|
|
217
|
+
* ```tsx
|
|
218
|
+
* function MySearchPanel() {
|
|
219
|
+
* const {
|
|
220
|
+
* isVisible,
|
|
221
|
+
* resultCountLabel,
|
|
222
|
+
* searchTerm,
|
|
223
|
+
* setSearchTerm,
|
|
224
|
+
* goToNext,
|
|
225
|
+
* } = useSearchAndReplace()
|
|
226
|
+
*
|
|
227
|
+
* if (!isVisible) return null
|
|
228
|
+
*
|
|
229
|
+
* return (
|
|
230
|
+
* <div>
|
|
231
|
+
* <input
|
|
232
|
+
* value={searchTerm}
|
|
233
|
+
* onChange={(event) => setSearchTerm(event.target.value)}
|
|
234
|
+
* />
|
|
235
|
+
* <span>{resultCountLabel}</span>
|
|
236
|
+
* <button onClick={goToNext}>Next</button>
|
|
237
|
+
* </div>
|
|
238
|
+
* )
|
|
239
|
+
* }
|
|
240
|
+
* ```
|
|
241
|
+
*/
|
|
242
|
+
export function useSearchAndReplace(config?: UseSearchAndReplaceConfig) {
|
|
243
|
+
const {
|
|
244
|
+
editor: providedEditor,
|
|
245
|
+
hideWhenUnavailable = false,
|
|
246
|
+
scrollIntoViewOptions = DEFAULT_SCROLL_INTO_VIEW_OPTIONS,
|
|
247
|
+
} = config || {}
|
|
248
|
+
|
|
249
|
+
// `useTiptapEditor` resolves through `useEditorState`, whose snapshot stays
|
|
250
|
+
// `null` until the editor's first transaction (with `immediatelyRender:
|
|
251
|
+
// false` there may be none on mount). Fall back to the always-fresh context
|
|
252
|
+
// editor so the trigger and commands work before any interaction.
|
|
253
|
+
const { editor: resolvedEditor } = useTiptapEditor(providedEditor)
|
|
254
|
+
const { editor: contextEditor } = useCurrentEditor()
|
|
255
|
+
const editor = resolvedEditor ?? providedEditor ?? contextEditor ?? null
|
|
256
|
+
const canSearch = isFindAndReplaceAvailable(editor)
|
|
257
|
+
|
|
258
|
+
// Visibility defaults to true and is refined once the editor reports
|
|
259
|
+
// activity. The editor resolved above stays `null` until its first
|
|
260
|
+
// transaction (with `immediatelyRender: false` there may be none on
|
|
261
|
+
// mount), so deriving visibility directly would hide the trigger on load.
|
|
262
|
+
const [isVisible, setIsVisible] = useState(true)
|
|
263
|
+
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
if (!editor) return
|
|
266
|
+
|
|
267
|
+
const handleUpdate = () => {
|
|
268
|
+
setIsVisible(shouldShowSearchAndReplace({ editor, hideWhenUnavailable }))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
handleUpdate()
|
|
272
|
+
|
|
273
|
+
editor.on("transaction", handleUpdate)
|
|
274
|
+
|
|
275
|
+
return () => {
|
|
276
|
+
editor.off("transaction", handleUpdate)
|
|
277
|
+
}
|
|
278
|
+
}, [editor, hideWhenUnavailable])
|
|
279
|
+
|
|
280
|
+
// Immediate input values. Extension storage lags behind the search input by
|
|
281
|
+
// the extension's debounce, so the inputs keep their own state while the
|
|
282
|
+
// extension owns matching, navigation and replacement.
|
|
283
|
+
const [searchTerm, setSearchTermState] = useState("")
|
|
284
|
+
const [replaceTerm, setReplaceTermState] = useState("")
|
|
285
|
+
|
|
286
|
+
// Adopt terms configured on the extension (e.g. an initial `searchTerm`
|
|
287
|
+
// option) once the editor is available.
|
|
288
|
+
useEffect(() => {
|
|
289
|
+
const storage = getFindAndReplaceStorage(editor)
|
|
290
|
+
if (!storage) return
|
|
291
|
+
|
|
292
|
+
if (storage.searchTerm) {
|
|
293
|
+
setSearchTermState((previous) => previous || storage.searchTerm)
|
|
294
|
+
}
|
|
295
|
+
if (storage.replaceTerm) {
|
|
296
|
+
setReplaceTermState((previous) => previous || storage.replaceTerm)
|
|
297
|
+
}
|
|
298
|
+
}, [editor])
|
|
299
|
+
|
|
300
|
+
// Subscribe to extension transactions so count, current index and disabled
|
|
301
|
+
// states update immediately. Synced eagerly on subscribe: the extension may
|
|
302
|
+
// already hold results from a configured `searchTerm` before any
|
|
303
|
+
// transaction happens (e.g. on a freshly loaded page).
|
|
304
|
+
const [editorState, setEditorState] =
|
|
305
|
+
useState<SearchAndReplaceEditorState>(EMPTY_EDITOR_STATE)
|
|
306
|
+
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
if (!editor || !isFindAndReplaceAvailable(editor)) {
|
|
309
|
+
setEditorState(EMPTY_EDITOR_STATE)
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const sync = () => {
|
|
314
|
+
const storage = getFindAndReplaceStorage(editor)
|
|
315
|
+
if (!storage) return
|
|
316
|
+
|
|
317
|
+
const next: SearchAndReplaceEditorState = {
|
|
318
|
+
total: storage.results.length,
|
|
319
|
+
currentIndex: storage.currentIndex,
|
|
320
|
+
appliedSearchTerm: storage.searchTerm,
|
|
321
|
+
caseSensitive: storage.caseSensitive,
|
|
322
|
+
wholeWord: storage.wholeWord,
|
|
323
|
+
useRegex: storage.useRegex,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
setEditorState((previous) =>
|
|
327
|
+
previous.total === next.total &&
|
|
328
|
+
previous.currentIndex === next.currentIndex &&
|
|
329
|
+
previous.appliedSearchTerm === next.appliedSearchTerm &&
|
|
330
|
+
previous.caseSensitive === next.caseSensitive &&
|
|
331
|
+
previous.wholeWord === next.wholeWord &&
|
|
332
|
+
previous.useRegex === next.useRegex
|
|
333
|
+
? previous
|
|
334
|
+
: next
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
sync()
|
|
339
|
+
|
|
340
|
+
editor.on("transaction", sync)
|
|
341
|
+
|
|
342
|
+
return () => {
|
|
343
|
+
editor.off("transaction", sync)
|
|
344
|
+
}
|
|
345
|
+
}, [editor])
|
|
346
|
+
|
|
347
|
+
const {
|
|
348
|
+
total,
|
|
349
|
+
currentIndex,
|
|
350
|
+
appliedSearchTerm,
|
|
351
|
+
caseSensitive,
|
|
352
|
+
wholeWord,
|
|
353
|
+
useRegex,
|
|
354
|
+
} = editorState
|
|
355
|
+
|
|
356
|
+
const setSearchTerm = useCallback(
|
|
357
|
+
(value: string) => {
|
|
358
|
+
setSearchTermState(value)
|
|
359
|
+
|
|
360
|
+
if (!editor || !isFindAndReplaceAvailable(editor)) return
|
|
361
|
+
|
|
362
|
+
if (value.length === 0) {
|
|
363
|
+
// Cancels the pending debounced search and clears highlights and
|
|
364
|
+
// the result count immediately.
|
|
365
|
+
editor.commands.clearSearch()
|
|
366
|
+
} else {
|
|
367
|
+
editor.commands.setSearchTerm(value)
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
[editor]
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
const setReplaceTerm = useCallback(
|
|
374
|
+
(value: string) => {
|
|
375
|
+
setReplaceTermState(value)
|
|
376
|
+
|
|
377
|
+
if (!editor || !isFindAndReplaceAvailable(editor)) return
|
|
378
|
+
editor.commands.setReplaceTerm(value)
|
|
379
|
+
},
|
|
380
|
+
[editor]
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
const toggleCaseSensitive = useCallback(() => {
|
|
384
|
+
if (!editor || !isFindAndReplaceAvailable(editor)) return
|
|
385
|
+
editor.commands.setCaseSensitive(
|
|
386
|
+
!getFindAndReplaceStorage(editor)?.caseSensitive
|
|
387
|
+
)
|
|
388
|
+
}, [editor])
|
|
389
|
+
|
|
390
|
+
const toggleWholeWord = useCallback(() => {
|
|
391
|
+
if (!editor || !isFindAndReplaceAvailable(editor)) return
|
|
392
|
+
editor.commands.setWholeWord(!getFindAndReplaceStorage(editor)?.wholeWord)
|
|
393
|
+
}, [editor])
|
|
394
|
+
|
|
395
|
+
const toggleUseRegex = useCallback(() => {
|
|
396
|
+
if (!editor || !isFindAndReplaceAvailable(editor)) return
|
|
397
|
+
editor.commands.setUseRegex(!getFindAndReplaceStorage(editor)?.useRegex)
|
|
398
|
+
}, [editor])
|
|
399
|
+
|
|
400
|
+
const goToNext = useCallback(() => {
|
|
401
|
+
if (!editor) return false
|
|
402
|
+
return navigateSearchResult({ editor, direction: "next" })
|
|
403
|
+
}, [editor])
|
|
404
|
+
|
|
405
|
+
const goToPrevious = useCallback(() => {
|
|
406
|
+
if (!editor) return false
|
|
407
|
+
return navigateSearchResult({ editor, direction: "previous" })
|
|
408
|
+
}, [editor])
|
|
409
|
+
|
|
410
|
+
const replaceCurrent = useCallback(() => {
|
|
411
|
+
if (!editor || !editor.isEditable) return false
|
|
412
|
+
return editor.commands.replace()
|
|
413
|
+
}, [editor])
|
|
414
|
+
|
|
415
|
+
const replaceAll = useCallback(() => {
|
|
416
|
+
if (!editor || !editor.isEditable) return false
|
|
417
|
+
return editor.commands.replaceAll()
|
|
418
|
+
}, [editor])
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Re-applies the stored terms to the editor. Used when the search UI
|
|
422
|
+
* reopens while the hook instance stayed mounted (e.g. popover reopen).
|
|
423
|
+
*/
|
|
424
|
+
const applySearch = useCallback(() => {
|
|
425
|
+
if (!editor || !isFindAndReplaceAvailable(editor)) return
|
|
426
|
+
|
|
427
|
+
editor.commands.setReplaceTerm(replaceTerm)
|
|
428
|
+
if (searchTerm) {
|
|
429
|
+
editor.commands.setSearchTerm(searchTerm)
|
|
430
|
+
}
|
|
431
|
+
}, [editor, searchTerm, replaceTerm])
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Clears highlights and cancels pending work while keeping the entered
|
|
435
|
+
* terms, so closing the search UI does not erase non-empty field values.
|
|
436
|
+
*/
|
|
437
|
+
const suspendSearch = useCallback(() => {
|
|
438
|
+
if (!editor || !isFindAndReplaceAvailable(editor) || editor.isDestroyed) {
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
editor.commands.clearSearch()
|
|
442
|
+
}, [editor])
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Clears the search entirely: highlights, pending work and field values.
|
|
446
|
+
*/
|
|
447
|
+
const clearSearch = useCallback(() => {
|
|
448
|
+
setSearchTermState("")
|
|
449
|
+
setReplaceTermState("")
|
|
450
|
+
|
|
451
|
+
if (!editor || !isFindAndReplaceAvailable(editor) || editor.isDestroyed) {
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
editor.commands.clearSearch()
|
|
455
|
+
}, [editor])
|
|
456
|
+
|
|
457
|
+
// Clear highlights and cancel the pending debounced search when the search
|
|
458
|
+
// UI unmounts, so no stale decorations remain without a way to clear them.
|
|
459
|
+
// Unconditional on purpose: a debounced search may still be pending without
|
|
460
|
+
// being reflected in the extension storage yet.
|
|
461
|
+
useEffect(() => {
|
|
462
|
+
if (!editor || !canSearch) return
|
|
463
|
+
|
|
464
|
+
return () => {
|
|
465
|
+
if (editor.isDestroyed) return
|
|
466
|
+
editor.commands.clearSearch()
|
|
467
|
+
}
|
|
468
|
+
}, [editor, canSearch])
|
|
469
|
+
|
|
470
|
+
// Keep the current result visible whenever results become available or the
|
|
471
|
+
// active result changes. Deferred a frame so layout-heavy integrations
|
|
472
|
+
// (e.g. paginated documents) have settled before measuring.
|
|
473
|
+
useEffect(() => {
|
|
474
|
+
if (total === 0 || currentIndex === null) return
|
|
475
|
+
|
|
476
|
+
const frame = requestAnimationFrame(() => {
|
|
477
|
+
scrollCurrentResultIntoView(editor, scrollIntoViewOptions)
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
return () => cancelAnimationFrame(frame)
|
|
481
|
+
}, [editor, total, currentIndex, appliedSearchTerm, scrollIntoViewOptions])
|
|
482
|
+
|
|
483
|
+
const isEditable = !!editor?.isEditable
|
|
484
|
+
const canNavigate = canSearch && total > 0
|
|
485
|
+
const hasReplaceTerm = replaceTerm.length > 0
|
|
486
|
+
const canReplace =
|
|
487
|
+
canSearch &&
|
|
488
|
+
isEditable &&
|
|
489
|
+
hasReplaceTerm &&
|
|
490
|
+
total > 0 &&
|
|
491
|
+
currentIndex !== null
|
|
492
|
+
const canReplaceAll = canSearch && isEditable && hasReplaceTerm && total > 0
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
editor,
|
|
496
|
+
isVisible,
|
|
497
|
+
canSearch,
|
|
498
|
+
searchTerm,
|
|
499
|
+
replaceTerm,
|
|
500
|
+
appliedSearchTerm,
|
|
501
|
+
total,
|
|
502
|
+
currentIndex,
|
|
503
|
+
resultCountLabel: formatResultCount(currentIndex, total),
|
|
504
|
+
caseSensitive,
|
|
505
|
+
wholeWord,
|
|
506
|
+
useRegex,
|
|
507
|
+
canNavigate,
|
|
508
|
+
canReplace,
|
|
509
|
+
canReplaceAll,
|
|
510
|
+
setSearchTerm,
|
|
511
|
+
setReplaceTerm,
|
|
512
|
+
toggleCaseSensitive,
|
|
513
|
+
toggleWholeWord,
|
|
514
|
+
toggleUseRegex,
|
|
515
|
+
goToNext,
|
|
516
|
+
goToPrevious,
|
|
517
|
+
replaceCurrent,
|
|
518
|
+
replaceAll,
|
|
519
|
+
applySearch,
|
|
520
|
+
suspendSearch,
|
|
521
|
+
clearSearch,
|
|
522
|
+
label: "Search and replace",
|
|
523
|
+
shortcutKeys: SEARCH_AND_REPLACE_SHORTCUT_KEY,
|
|
524
|
+
Icon: SearchIcon,
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export type UseSearchAndReplaceReturn = ReturnType<typeof useSearchAndReplace>
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { forwardRef, useCallback } from "react"
|
|
4
|
+
|
|
5
|
+
// --- Lib ---
|
|
6
|
+
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
|
7
|
+
|
|
8
|
+
// --- Hooks ---
|
|
9
|
+
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
|
10
|
+
|
|
11
|
+
// --- Tiptap UI ---
|
|
12
|
+
import type {
|
|
13
|
+
TextAlign,
|
|
14
|
+
UseTextAlignConfig,
|
|
15
|
+
} from "@/components/tiptap-ui/text-align-button"
|
|
16
|
+
import {
|
|
17
|
+
TEXT_ALIGN_SHORTCUT_KEYS,
|
|
18
|
+
useTextAlign,
|
|
19
|
+
} from "@/components/tiptap-ui/text-align-button"
|
|
20
|
+
|
|
21
|
+
// --- UI Primitives ---
|
|
22
|
+
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
|
23
|
+
import { Button } from "@/components/tiptap-ui-primitive/button"
|
|
24
|
+
import { Badge } from "@/components/tiptap-ui-primitive/badge"
|
|
25
|
+
|
|
26
|
+
type IconProps = React.SVGProps<SVGSVGElement>
|
|
27
|
+
type IconComponent = ({ className, ...props }: IconProps) => React.ReactElement
|
|
28
|
+
|
|
29
|
+
export interface TextAlignButtonProps
|
|
30
|
+
extends Omit<ButtonProps, "type">, UseTextAlignConfig {
|
|
31
|
+
/**
|
|
32
|
+
* Optional text to display alongside the icon.
|
|
33
|
+
*/
|
|
34
|
+
text?: string
|
|
35
|
+
/**
|
|
36
|
+
* Optional show shortcut keys in the button.
|
|
37
|
+
* @default false
|
|
38
|
+
*/
|
|
39
|
+
showShortcut?: boolean
|
|
40
|
+
/**
|
|
41
|
+
* Optional custom icon component to render instead of the default.
|
|
42
|
+
*/
|
|
43
|
+
icon?: React.MemoExoticComponent<IconComponent> | React.FC<IconProps>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function TextAlignShortcutBadge({
|
|
47
|
+
align,
|
|
48
|
+
shortcutKeys = TEXT_ALIGN_SHORTCUT_KEYS[align],
|
|
49
|
+
}: {
|
|
50
|
+
align: TextAlign
|
|
51
|
+
shortcutKeys?: string
|
|
52
|
+
}) {
|
|
53
|
+
return <Badge>{parseShortcutKeys({ shortcutKeys })}</Badge>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Button component for setting text alignment in a Tiptap editor.
|
|
58
|
+
*
|
|
59
|
+
* For custom button implementations, use the `useTextAlign` hook instead.
|
|
60
|
+
*/
|
|
61
|
+
export const TextAlignButton = forwardRef<
|
|
62
|
+
HTMLButtonElement,
|
|
63
|
+
TextAlignButtonProps
|
|
64
|
+
>(
|
|
65
|
+
(
|
|
66
|
+
{
|
|
67
|
+
editor: providedEditor,
|
|
68
|
+
align,
|
|
69
|
+
text,
|
|
70
|
+
hideWhenUnavailable = false,
|
|
71
|
+
onAligned,
|
|
72
|
+
showShortcut = false,
|
|
73
|
+
onClick,
|
|
74
|
+
icon: CustomIcon,
|
|
75
|
+
children,
|
|
76
|
+
...buttonProps
|
|
77
|
+
},
|
|
78
|
+
ref
|
|
79
|
+
) => {
|
|
80
|
+
const { editor } = useTiptapEditor(providedEditor)
|
|
81
|
+
const {
|
|
82
|
+
isVisible,
|
|
83
|
+
handleTextAlign,
|
|
84
|
+
label,
|
|
85
|
+
canAlign,
|
|
86
|
+
isActive,
|
|
87
|
+
Icon,
|
|
88
|
+
shortcutKeys,
|
|
89
|
+
} = useTextAlign({
|
|
90
|
+
editor,
|
|
91
|
+
align,
|
|
92
|
+
hideWhenUnavailable,
|
|
93
|
+
onAligned,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const handleClick = useCallback(
|
|
97
|
+
(event: React.MouseEvent<HTMLButtonElement>) => {
|
|
98
|
+
onClick?.(event)
|
|
99
|
+
if (event.defaultPrevented) return
|
|
100
|
+
handleTextAlign()
|
|
101
|
+
},
|
|
102
|
+
[handleTextAlign, onClick]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if (!isVisible) {
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const RenderIcon = CustomIcon ?? Icon
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<Button
|
|
113
|
+
type="button"
|
|
114
|
+
disabled={!canAlign}
|
|
115
|
+
variant="ghost"
|
|
116
|
+
data-active-state={isActive ? "on" : "off"}
|
|
117
|
+
data-disabled={!canAlign}
|
|
118
|
+
role="button"
|
|
119
|
+
tabIndex={-1}
|
|
120
|
+
aria-label={label}
|
|
121
|
+
aria-pressed={isActive}
|
|
122
|
+
tooltip={label}
|
|
123
|
+
onClick={handleClick}
|
|
124
|
+
{...buttonProps}
|
|
125
|
+
ref={ref}
|
|
126
|
+
>
|
|
127
|
+
{children ?? (
|
|
128
|
+
<>
|
|
129
|
+
<RenderIcon className="tiptap-button-icon" />
|
|
130
|
+
{text && <span className="tiptap-button-text">{text}</span>}
|
|
131
|
+
{showShortcut && (
|
|
132
|
+
<TextAlignShortcutBadge
|
|
133
|
+
align={align}
|
|
134
|
+
shortcutKeys={shortcutKeys}
|
|
135
|
+
/>
|
|
136
|
+
)}
|
|
137
|
+
</>
|
|
138
|
+
)}
|
|
139
|
+
</Button>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
TextAlignButton.displayName = "TextAlignButton"
|