| 1 | "use client"; |
| 2 | |
| 3 | import { BlockSelectionPlugin } from "@platejs/selection/react"; |
| 4 | import { HighlighterIcon } from "lucide-react"; |
| 5 | import { KEYS } from "platejs"; |
| 6 | import { useEditorRef, useEditorSelector } from "platejs/react"; |
| 7 | import * as React from "react"; |
| 8 | |
| 9 | import { |
| 10 | DropdownMenu, |
| 11 | DropdownMenuContent, |
| 12 | DropdownMenuTrigger, |
| 13 | } from "@/components/plate/ui/dropdown-menu"; |
| 14 | import { |
| 15 | Tooltip, |
| 16 | TooltipContent, |
| 17 | TooltipProvider, |
| 18 | TooltipTrigger, |
| 19 | } from "@/components/plate/ui/tooltip"; |
| 20 | import { cn } from "@/lib/utils"; |
| 21 | import { |
| 22 | ToolbarSplitButton, |
| 23 | ToolbarSplitButtonPrimary, |
| 24 | ToolbarSplitButtonSecondary, |
| 25 | } from "./toolbar"; |
| 26 | |
| 27 | // Default yellow highlight color |
| 28 | const DEFAULT_HIGHLIGHT_COLOR = "#FEFF00"; |
| 29 | |
| 30 | // Highlight color options - lighter/pastel colors suitable for highlighting |
| 31 | const HIGHLIGHT_COLORS = [ |
| 32 | { |
| 33 | isBrightColor: true, |
| 34 | name: "yellow", |
| 35 | value: "#FEFF00", |
| 36 | }, |
| 37 | { |
| 38 | isBrightColor: true, |
| 39 | name: "light yellow", |
| 40 | value: "#FFF2CC", |
| 41 | }, |
| 42 | { |
| 43 | isBrightColor: true, |
| 44 | name: "light orange", |
| 45 | value: "#FCE4CD", |
| 46 | }, |
| 47 | { |
| 48 | isBrightColor: true, |
| 49 | name: "light green", |
| 50 | value: "#D9EAD3", |
| 51 | }, |
| 52 | { |
| 53 | isBrightColor: true, |
| 54 | name: "light cyan", |
| 55 | value: "#D0DFE3", |
| 56 | }, |
| 57 | { |
| 58 | isBrightColor: true, |
| 59 | name: "light blue", |
| 60 | value: "#CFE1F3", |
| 61 | }, |
| 62 | { |
| 63 | isBrightColor: true, |
| 64 | name: "light purple", |
| 65 | value: "#D9D2E9", |
| 66 | }, |
| 67 | { |
| 68 | isBrightColor: true, |
| 69 | name: "light pink", |
| 70 | value: "#EAD1DB", |
| 71 | }, |
| 72 | { |
| 73 | isBrightColor: false, |
| 74 | name: "orange", |
| 75 | value: "#F9CB9C", |
| 76 | }, |
| 77 | { |
| 78 | isBrightColor: false, |
| 79 | name: "green", |
| 80 | value: "#B7D6A8", |
| 81 | }, |
| 82 | { |
| 83 | isBrightColor: false, |
| 84 | name: "cyan", |
| 85 | value: "#A1C4C9", |
| 86 | }, |
| 87 | { |
| 88 | isBrightColor: false, |
| 89 | name: "blue", |
| 90 | value: "#A4C2F4", |
| 91 | }, |
| 92 | { |
| 93 | isBrightColor: false, |
| 94 | name: "purple", |
| 95 | value: "#B5A7D5", |
| 96 | }, |
| 97 | { |
| 98 | isBrightColor: false, |
| 99 | name: "pink", |
| 100 | value: "#D5A6BD", |
| 101 | }, |
| 102 | { |
| 103 | isBrightColor: false, |
| 104 | name: "red", |
| 105 | value: "#EA9999", |
| 106 | }, |
| 107 | { |
| 108 | isBrightColor: false, |
| 109 | name: "light red", |
| 110 | value: "#F4CCCC", |
| 111 | }, |
| 112 | ]; |
| 113 | |
| 114 | export function HighlightToolbarButton() { |
| 115 | const editor = useEditorRef(); |
| 116 | const [open, setOpen] = React.useState(false); |
| 117 | |
| 118 | const currentColor = useEditorSelector( |
| 119 | (editor) => editor.api.mark(KEYS.backgroundColor) as string | undefined, |
| 120 | [], |
| 121 | ); |
| 122 | |
| 123 | const isHighlighted = !!currentColor; |
| 124 | |
| 125 | const applyHighlight = React.useCallback( |
| 126 | (color: string) => { |
| 127 | const blockSelectionApi = |
| 128 | editor.getApi(BlockSelectionPlugin).blockSelection; |
| 129 | const selectedBlocks = blockSelectionApi |
| 130 | ? blockSelectionApi.getNodes() |
| 131 | : []; |
| 132 | |
| 133 | if (selectedBlocks.length > 0) { |
| 134 | selectedBlocks.forEach(([_, path]) => { |
| 135 | editor.tf.setNodes( |
| 136 | { [KEYS.backgroundColor]: color }, |
| 137 | { at: path, match: (n) => editor.api.isText(n), mode: "all" }, |
| 138 | ); |
| 139 | }); |
| 140 | return; |
| 141 | } |
| 142 | |
| 143 | if (editor.selection) { |
| 144 | editor.tf.select(editor.selection); |
| 145 | editor.tf.focus(); |
| 146 | editor.tf.addMarks({ [KEYS.backgroundColor]: color }); |
| 147 | } |
| 148 | }, |
| 149 | [editor], |
| 150 | ); |
| 151 | |
| 152 | const removeHighlight = React.useCallback(() => { |
| 153 | const blockSelectionApi = |
| 154 | editor.getApi(BlockSelectionPlugin).blockSelection; |
| 155 | const selectedBlocks = blockSelectionApi |
| 156 | ? blockSelectionApi.getNodes() |
| 157 | : []; |
| 158 | |
| 159 | if (selectedBlocks.length > 0) { |
| 160 | selectedBlocks.forEach(([_, path]) => { |
| 161 | editor.tf.unsetNodes(KEYS.backgroundColor, { |
| 162 | at: path, |
| 163 | match: (n) => editor.api.isText(n), |
| 164 | mode: "all", |
| 165 | }); |
| 166 | }); |
| 167 | return; |
| 168 | } |
| 169 | |
| 170 | if (editor.selection) { |
| 171 | editor.tf.select(editor.selection); |
| 172 | editor.tf.focus(); |
| 173 | editor.tf.removeMarks(KEYS.backgroundColor); |
| 174 | } |
| 175 | }, [editor]); |
| 176 | |
| 177 | const handlePrimaryClick = React.useCallback(() => { |
| 178 | if (isHighlighted) { |
| 179 | removeHighlight(); |
| 180 | } else { |
| 181 | applyHighlight(DEFAULT_HIGHLIGHT_COLOR); |
| 182 | } |
| 183 | }, [isHighlighted, applyHighlight, removeHighlight]); |
| 184 | |
| 185 | const handleColorSelect = React.useCallback( |
| 186 | (color: string) => { |
| 187 | applyHighlight(color); |
| 188 | setOpen(false); |
| 189 | }, |
| 190 | [applyHighlight], |
| 191 | ); |
| 192 | |
| 193 | return ( |
| 194 | <ToolbarSplitButton pressed={open}> |
| 195 | <Tooltip> |
| 196 | <TooltipTrigger asChild> |
| 197 | <ToolbarSplitButtonPrimary |
| 198 | className="data-[state=on]:bg-accent data-[state=on]:text-accent-foreground" |
| 199 | onClick={handlePrimaryClick} |
| 200 | data-state={isHighlighted ? "on" : "off"} |
| 201 | > |
| 202 | <HighlighterIcon className="size-4" /> |
| 203 | </ToolbarSplitButtonPrimary> |
| 204 | </TooltipTrigger> |
| 205 | <TooltipContent>Highlight</TooltipContent> |
| 206 | </Tooltip> |
| 207 | |
| 208 | <DropdownMenu open={open} onOpenChange={setOpen} modal={false}> |
| 209 | <DropdownMenuTrigger asChild> |
| 210 | <ToolbarSplitButtonSecondary /> |
| 211 | </DropdownMenuTrigger> |
| 212 | |
| 213 | <DropdownMenuContent |
| 214 | className="ignore-click-outside/toolbar p-2" |
| 215 | align="start" |
| 216 | alignOffset={-32} |
| 217 | > |
| 218 | <TooltipProvider> |
| 219 | <div className="grid grid-cols-4 gap-1"> |
| 220 | {HIGHLIGHT_COLORS.map((color) => ( |
| 221 | <Tooltip key={color.value}> |
| 222 | <TooltipTrigger asChild> |
| 223 | <button |
| 224 | type="button" |
| 225 | className={cn( |
| 226 | "size-6 rounded border transition-transform hover:scale-110", |
| 227 | color.isBrightColor |
| 228 | ? "border-muted" |
| 229 | : "border-transparent", |
| 230 | currentColor === color.value && |
| 231 | "ring-2 ring-primary ring-offset-1", |
| 232 | )} |
| 233 | style={{ backgroundColor: color.value }} |
| 234 | onClick={() => handleColorSelect(color.value)} |
| 235 | > |
| 236 | <span className="sr-only">{color.name}</span> |
| 237 | </button> |
| 238 | </TooltipTrigger> |
| 239 | <TooltipContent className="capitalize"> |
| 240 | {color.name} |
| 241 | </TooltipContent> |
| 242 | </Tooltip> |
| 243 | ))} |
| 244 | </div> |
| 245 | {currentColor && ( |
| 246 | <button |
| 247 | type="button" |
| 248 | className="mt-2 flex w-full items-center justify-center gap-1 rounded border border-muted px-2 py-1 text-xs hover:bg-muted" |
| 249 | onClick={() => { |
| 250 | removeHighlight(); |
| 251 | setOpen(false); |
| 252 | }} |
| 253 | > |
| 254 | Remove highlight |
| 255 | </button> |
| 256 | )} |
| 257 | </TooltipProvider> |
| 258 | </DropdownMenuContent> |
| 259 | </DropdownMenu> |
| 260 | </ToolbarSplitButton> |
| 261 | ); |
| 262 | } |
| 263 |