| 1 | "use client"; |
| 2 | |
| 3 | import { Copy, Shapes, StepBack, StepForward, Trash2 } from "lucide-react"; |
| 4 | import { KEYS, nanoid, NodeApi, PathApi, type TElement } from "platejs"; |
| 5 | |
| 6 | import { updateSiblingsForcefully } from "@/components/notebook/presentation/editor/dnd/utils/updateSiblingsForcefully"; |
| 7 | import { |
| 8 | ARROW_LIST_ITEM, |
| 9 | BOX_ITEM, |
| 10 | BULLET_ITEM, |
| 11 | CYCLE_ITEM, |
| 12 | ICON_ELEMENT, |
| 13 | ICON_LIST_ITEM, |
| 14 | PYRAMID_ITEM, |
| 15 | STAIR_ITEM, |
| 16 | TIMELINE_ITEM, |
| 17 | } from "@/components/notebook/presentation/editor/lib"; |
| 18 | import { ToolbarButton, ToolbarGroup } from "@/components/plate/ui/toolbar"; |
| 19 | import { IconPicker } from "@/components/ui/icon-picker"; |
| 20 | import { cn } from "@/lib/utils"; |
| 21 | import { useToolbarContext } from "./ToolbarContext"; |
| 22 | |
| 23 | const ICON_EDITABLE_ITEM_TYPES = new Set<string>([ |
| 24 | ARROW_LIST_ITEM, |
| 25 | BOX_ITEM, |
| 26 | BULLET_ITEM, |
| 27 | CYCLE_ITEM, |
| 28 | ICON_ELEMENT, |
| 29 | ICON_LIST_ITEM, |
| 30 | PYRAMID_ITEM, |
| 31 | STAIR_ITEM, |
| 32 | TIMELINE_ITEM, |
| 33 | ]); |
| 34 | |
| 35 | function isElementNode(node: unknown): node is TElement { |
| 36 | return ( |
| 37 | typeof node === "object" && |
| 38 | node !== null && |
| 39 | "type" in node && |
| 40 | "children" in node |
| 41 | ); |
| 42 | } |
| 43 | |
| 44 | function createBlankItemFrom(item: TElement): TElement { |
| 45 | const itemRecord = item as TElement & Record<string, unknown>; |
| 46 | const alignment = |
| 47 | typeof itemRecord.alignment === "string" |
| 48 | ? { alignment: itemRecord.alignment } |
| 49 | : {}; |
| 50 | |
| 51 | return { |
| 52 | ...alignment, |
| 53 | id: nanoid(), |
| 54 | type: item.type, |
| 55 | children: [ |
| 56 | { |
| 57 | id: nanoid(), |
| 58 | type: KEYS.p, |
| 59 | children: [{ text: "" }], |
| 60 | }, |
| 61 | ], |
| 62 | }; |
| 63 | } |
| 64 | |
| 65 | function cloneWithNewIds(node: unknown): unknown { |
| 66 | if (Array.isArray(node)) { |
| 67 | return node.map(cloneWithNewIds); |
| 68 | } |
| 69 | if (node !== null && typeof node === "object") { |
| 70 | const clone = { ...node } as Record<string, unknown>; |
| 71 | if ("id" in clone) { |
| 72 | clone.id = nanoid(); |
| 73 | } |
| 74 | for (const key in clone) { |
| 75 | clone[key] = cloneWithNewIds(clone[key]); |
| 76 | } |
| 77 | return clone; |
| 78 | } |
| 79 | return node; |
| 80 | } |
| 81 | |
| 82 | export function CustomItemControls() { |
| 83 | const { |
| 84 | editor, |
| 85 | element, |
| 86 | elementType, |
| 87 | handleNodePropertyUpdate, |
| 88 | isLayoutChildElement, |
| 89 | } = useToolbarContext(); |
| 90 | |
| 91 | if (!isLayoutChildElement || !element || !isElementNode(element)) { |
| 92 | return null; |
| 93 | } |
| 94 | |
| 95 | const itemPath = editor.api.findPath(element); |
| 96 | const parentPath = itemPath ? PathApi.parent(itemPath) : undefined; |
| 97 | const currentIcon = |
| 98 | typeof (element as { icon?: unknown }).icon === "string" |
| 99 | ? (element as TElement & { icon: string }).icon |
| 100 | : undefined; |
| 101 | const supportsIcon = ICON_EDITABLE_ITEM_TYPES.has(elementType); |
| 102 | |
| 103 | const forceUpdateSiblings = () => { |
| 104 | if (!parentPath) return; |
| 105 | const currentParent = NodeApi.get(editor, parentPath) as |
| 106 | | TElement |
| 107 | | undefined; |
| 108 | if (currentParent && Array.isArray(currentParent.children)) { |
| 109 | updateSiblingsForcefully(editor, currentParent, parentPath); |
| 110 | } |
| 111 | }; |
| 112 | |
| 113 | const insertItem = (placement: "before" | "after") => { |
| 114 | if (!itemPath) return; |
| 115 | |
| 116 | editor.tf.withoutNormalizing(() => { |
| 117 | const insertionPath = |
| 118 | placement === "before" ? itemPath : PathApi.next(itemPath); |
| 119 | |
| 120 | editor.tf.insertNodes(createBlankItemFrom(element), { |
| 121 | at: insertionPath, |
| 122 | select: true, |
| 123 | }); |
| 124 | forceUpdateSiblings(); |
| 125 | }); |
| 126 | }; |
| 127 | |
| 128 | const duplicateItem = () => { |
| 129 | if (!itemPath) return; |
| 130 | |
| 131 | editor.tf.withoutNormalizing(() => { |
| 132 | const clonedNode = cloneWithNewIds(element) as TElement; |
| 133 | editor.tf.insertNodes(clonedNode, { |
| 134 | at: PathApi.next(itemPath), |
| 135 | select: true, |
| 136 | }); |
| 137 | forceUpdateSiblings(); |
| 138 | }); |
| 139 | }; |
| 140 | |
| 141 | return ( |
| 142 | <> |
| 143 | <ToolbarGroup> |
| 144 | <ToolbarButton |
| 145 | onClick={() => insertItem("before")} |
| 146 | size="sm" |
| 147 | tooltip="Add Previous Item" |
| 148 | > |
| 149 | <StepBack className="size-4" /> |
| 150 | </ToolbarButton> |
| 151 | <ToolbarButton |
| 152 | onClick={() => insertItem("after")} |
| 153 | size="sm" |
| 154 | tooltip="Add Next Item" |
| 155 | > |
| 156 | <StepForward className="size-4" /> |
| 157 | </ToolbarButton> |
| 158 | <ToolbarButton |
| 159 | onClick={duplicateItem} |
| 160 | size="sm" |
| 161 | tooltip="Duplicate Item" |
| 162 | > |
| 163 | <Copy className="size-4" /> |
| 164 | </ToolbarButton> |
| 165 | </ToolbarGroup> |
| 166 | |
| 167 | {supportsIcon && ( |
| 168 | <ToolbarGroup> |
| 169 | <IconPicker |
| 170 | defaultIcon={currentIcon} |
| 171 | placeholder={<Shapes className="size-4" />} |
| 172 | size="sm" |
| 173 | variant="ghost" |
| 174 | className={cn("border-0 shadow-none")} |
| 175 | title="Change icon" |
| 176 | onIconSelect={(iconName) => |
| 177 | handleNodePropertyUpdate("icon", iconName) |
| 178 | } |
| 179 | onIconRemove={() => handleNodePropertyUpdate("icon", undefined)} |
| 180 | /> |
| 181 | </ToolbarGroup> |
| 182 | )} |
| 183 | </> |
| 184 | ); |
| 185 | } |
| 186 | |
| 187 | export function CustomItemDeleteButton() { |
| 188 | const { editor, element, isLayoutChildElement } = useToolbarContext(); |
| 189 | |
| 190 | if (!isLayoutChildElement || !element || !isElementNode(element)) { |
| 191 | return null; |
| 192 | } |
| 193 | |
| 194 | const itemPath = editor.api.findPath(element); |
| 195 | const parentPath = itemPath ? PathApi.parent(itemPath) : undefined; |
| 196 | const parent = parentPath ? NodeApi.get(editor, parentPath) : undefined; |
| 197 | const canDelete = |
| 198 | isElementNode(parent) && Array.isArray(parent.children) |
| 199 | ? parent.children.length > 1 |
| 200 | : true; |
| 201 | |
| 202 | const forceUpdateSiblings = () => { |
| 203 | if (!parentPath) return; |
| 204 | const currentParent = NodeApi.get(editor, parentPath) as |
| 205 | | TElement |
| 206 | | undefined; |
| 207 | if (currentParent && Array.isArray(currentParent.children)) { |
| 208 | updateSiblingsForcefully(editor, currentParent, parentPath); |
| 209 | } |
| 210 | }; |
| 211 | |
| 212 | const deleteItem = () => { |
| 213 | if (!itemPath || !canDelete) return; |
| 214 | |
| 215 | editor.tf.withoutNormalizing(() => { |
| 216 | editor.tf.removeNodes({ at: itemPath }); |
| 217 | forceUpdateSiblings(); |
| 218 | }); |
| 219 | }; |
| 220 | |
| 221 | return ( |
| 222 | <ToolbarButton |
| 223 | disabled={!canDelete} |
| 224 | onClick={deleteItem} |
| 225 | size="sm" |
| 226 | tooltip={canDelete ? "Delete Item" : "Keep at least one item"} |
| 227 | className="text-destructive hover:bg-destructive/10 hover:text-destructive" |
| 228 | > |
| 229 | <Trash2 className="size-4" /> |
| 230 | </ToolbarButton> |
| 231 | ); |
| 232 | } |
| 233 |