@jikjo/ui-kit 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1119 @@
1
+ import { Editor, historyExtension, richTextExtension, useBlockHoverPlugin, useSelectionPlugin, useSlashCommandPlugin } from "@jikjo/core";
2
+ import { $createHeadingNode, $isHeadingNode } from "@lexical/rich-text";
3
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
4
+ import { $createParagraphNode, $getNearestNodeFromDOMNode, $getNodeByKey, $getSelection, $isElementNode, $isRangeSelection, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, DRAGOVER_COMMAND, DROP_COMMAND } from "lexical";
5
+ import { Bold, Code, GripVertical, Heading1, Heading2, Heading3, Italic, List, ListOrdered, ListTodo, Plus, Quote, Strikethrough, Text, Underline } from "lucide-react";
6
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
+ import { AnimatePresence, motion } from "motion/react";
8
+ import { createPortal } from "react-dom";
9
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
10
+
11
+ //#region src/components/bubble-menu.tsx
12
+ /**
13
+ * selection rect를 컨테이너 기준 absolute 좌표로 변환.
14
+ * 컨테이너가 스크롤되어도 absolute 요소는 함께 움직인다.
15
+ */
16
+ function getSelectionAbsPos(container) {
17
+ const sel = window.getSelection();
18
+ if (!sel || sel.rangeCount === 0) return null;
19
+ const range = sel.getRangeAt(0);
20
+ if (range.collapsed) return null;
21
+ const selRect = range.getBoundingClientRect();
22
+ if (selRect.width === 0) return null;
23
+ const cr = container.getBoundingClientRect();
24
+ return {
25
+ top: selRect.top - cr.top + container.scrollTop - 36 - 6,
26
+ left: selRect.left - cr.left + container.scrollLeft + selRect.width / 2
27
+ };
28
+ }
29
+ function Btn({ active, label, onMouseDown, children }) {
30
+ return /* @__PURE__ */ jsx("button", {
31
+ type: "button",
32
+ "aria-label": label,
33
+ onMouseDown,
34
+ className: [
35
+ "flex items-center justify-center w-8 h-8 rounded-md",
36
+ "transition-colors duration-75",
37
+ active ? "bg-white/15 text-white" : "text-zinc-400 hover:bg-white/10 hover:text-zinc-200"
38
+ ].join(" "),
39
+ children
40
+ });
41
+ }
42
+ function BubbleMenu({ isVisible, format, onToggleFormat, editor }) {
43
+ const [container, setContainer] = useState(null);
44
+ const [pos, setPos] = useState(null);
45
+ useEffect(() => {
46
+ function attachContainer() {
47
+ const rootEl = editor.getRootElement();
48
+ if (!rootEl) return;
49
+ const parent = rootEl.parentElement;
50
+ if (parent) setContainer(parent);
51
+ }
52
+ attachContainer();
53
+ return editor.registerRootListener(attachContainer);
54
+ }, [editor]);
55
+ useEffect(() => {
56
+ if (!isVisible || !container) {
57
+ setPos(null);
58
+ return;
59
+ }
60
+ const newPos = getSelectionAbsPos(container);
61
+ if (newPos !== null) setPos(newPos);
62
+ }, [isVisible, container]);
63
+ if (!container) return null;
64
+ const menuStyle = pos ? {
65
+ position: "absolute",
66
+ top: pos.top,
67
+ left: pos.left,
68
+ x: "-50%",
69
+ zIndex: 50
70
+ } : {
71
+ position: "absolute",
72
+ top: -9999,
73
+ left: -9999,
74
+ zIndex: 50
75
+ };
76
+ return createPortal(/* @__PURE__ */ jsx(AnimatePresence, { children: isVisible && /* @__PURE__ */ jsxs(motion.div, {
77
+ style: menuStyle,
78
+ initial: {
79
+ opacity: 0,
80
+ y: 6
81
+ },
82
+ animate: {
83
+ opacity: 1,
84
+ y: 0
85
+ },
86
+ exit: {
87
+ opacity: 0,
88
+ y: 6
89
+ },
90
+ transition: {
91
+ duration: .1,
92
+ ease: "easeOut"
93
+ },
94
+ className: "flex items-center gap-0.5 px-1.5 py-1.5 rounded-lg bg-zinc-800 shadow-xl shadow-black/50",
95
+ children: [
96
+ /* @__PURE__ */ jsx(Btn, {
97
+ active: format.bold,
98
+ label: "Bold",
99
+ onMouseDown: (e) => {
100
+ e.preventDefault();
101
+ onToggleFormat("bold");
102
+ },
103
+ children: /* @__PURE__ */ jsx(Bold, {
104
+ size: 13,
105
+ strokeWidth: 2.5
106
+ })
107
+ }),
108
+ /* @__PURE__ */ jsx(Btn, {
109
+ active: format.italic,
110
+ label: "Italic",
111
+ onMouseDown: (e) => {
112
+ e.preventDefault();
113
+ onToggleFormat("italic");
114
+ },
115
+ children: /* @__PURE__ */ jsx(Italic, {
116
+ size: 13,
117
+ strokeWidth: 2
118
+ })
119
+ }),
120
+ /* @__PURE__ */ jsx(Btn, {
121
+ active: format.underline,
122
+ label: "Underline",
123
+ onMouseDown: (e) => {
124
+ e.preventDefault();
125
+ onToggleFormat("underline");
126
+ },
127
+ children: /* @__PURE__ */ jsx(Underline, {
128
+ size: 13,
129
+ strokeWidth: 2
130
+ })
131
+ }),
132
+ /* @__PURE__ */ jsx("div", { className: "w-px h-4 bg-zinc-600/50 mx-0.5 shrink-0" }),
133
+ /* @__PURE__ */ jsx(Btn, {
134
+ active: format.strikethrough,
135
+ label: "Strikethrough",
136
+ onMouseDown: (e) => {
137
+ e.preventDefault();
138
+ onToggleFormat("strikethrough");
139
+ },
140
+ children: /* @__PURE__ */ jsx(Strikethrough, {
141
+ size: 13,
142
+ strokeWidth: 2
143
+ })
144
+ }),
145
+ /* @__PURE__ */ jsx(Btn, {
146
+ active: format.code,
147
+ label: "Code",
148
+ onMouseDown: (e) => {
149
+ e.preventDefault();
150
+ onToggleFormat("code");
151
+ },
152
+ children: /* @__PURE__ */ jsx(Code, {
153
+ size: 13,
154
+ strokeWidth: 2
155
+ })
156
+ })
157
+ ]
158
+ }) }), container);
159
+ }
160
+
161
+ //#endregion
162
+ //#region src/components/block-toolbar.tsx
163
+ const DRAG_DATA_FORMAT = "application/x-lexical-drag-block";
164
+ const BTN = 24;
165
+ const GAP = 4;
166
+ const GUTTER_LEFT = 6;
167
+ const btnBase = {
168
+ display: "flex",
169
+ alignItems: "center",
170
+ justifyContent: "center",
171
+ borderRadius: 4,
172
+ border: "none",
173
+ background: "transparent",
174
+ color: "#a1a1aa",
175
+ cursor: "pointer",
176
+ padding: 0,
177
+ transition: "background 80ms, color 80ms"
178
+ };
179
+ const btnHover = {
180
+ background: "rgba(63,63,70,0.7)",
181
+ color: "#e4e4e7"
182
+ };
183
+ const ICON_MAP$1 = {
184
+ paragraph: /* @__PURE__ */ jsx(Text, {
185
+ size: 13,
186
+ strokeWidth: 1.75
187
+ }),
188
+ heading1: /* @__PURE__ */ jsx(Heading1, {
189
+ size: 13,
190
+ strokeWidth: 1.75
191
+ }),
192
+ heading2: /* @__PURE__ */ jsx(Heading2, {
193
+ size: 13,
194
+ strokeWidth: 1.75
195
+ }),
196
+ heading3: /* @__PURE__ */ jsx(Heading3, {
197
+ size: 13,
198
+ strokeWidth: 1.75
199
+ }),
200
+ bulletList: /* @__PURE__ */ jsx(List, {
201
+ size: 13,
202
+ strokeWidth: 1.75
203
+ }),
204
+ orderedList: /* @__PURE__ */ jsx(ListOrdered, {
205
+ size: 13,
206
+ strokeWidth: 1.75
207
+ }),
208
+ taskList: /* @__PURE__ */ jsx(ListTodo, {
209
+ size: 13,
210
+ strokeWidth: 1.75
211
+ }),
212
+ quote: /* @__PURE__ */ jsx(Quote, {
213
+ size: 13,
214
+ strokeWidth: 1.75
215
+ })
216
+ };
217
+ /** blockEl의 top을 container 기준 절대 좌표로 계산 (getBoundingClientRect 기반) */
218
+ function getOffsetTopToContainer(blockEl, container) {
219
+ const blockRect = blockEl.getBoundingClientRect();
220
+ const containerRect = container.getBoundingClientRect();
221
+ return blockRect.top - containerRect.top + container.scrollTop;
222
+ }
223
+ function BlockToolbar({ isVisible, nodeKey, focusedNodeKey, items, editor, showDragHandle = true, showAddButton = true, onDragStarted, onDropped }) {
224
+ const [panelOpen, setPanelOpen] = useState(false);
225
+ const [activeIndex, setActiveIndex] = useState(0);
226
+ const [container, setContainer] = useState(null);
227
+ const [top, setTop] = useState(null);
228
+ const [dragHovered, setDragHovered] = useState(false);
229
+ const [addHovered, setAddHovered] = useState(false);
230
+ const [dropLine, setDropLine] = useState(null);
231
+ const [focusedTop, setFocusedTop] = useState(null);
232
+ const [focusedHeight, setFocusedHeight] = useState(0);
233
+ const panelRef = useRef(null);
234
+ const addBtnRef = useRef(null);
235
+ const numBtns = (showDragHandle ? 1 : 0) + (showAddButton ? 1 : 0);
236
+ useEffect(() => {
237
+ return editor.registerRootListener((rootElement) => {
238
+ setContainer(rootElement?.parentElement ?? null);
239
+ });
240
+ }, [editor]);
241
+ useEffect(() => {
242
+ function calc() {
243
+ if (!isVisible || !nodeKey || !container) {
244
+ setTop(null);
245
+ return;
246
+ }
247
+ const blockEl = editor.getElementByKey(nodeKey);
248
+ if (!blockEl) {
249
+ setTop(null);
250
+ return;
251
+ }
252
+ setTop(getOffsetTopToContainer(blockEl, container) + (blockEl.offsetHeight - BTN) / 2);
253
+ }
254
+ calc();
255
+ return editor.registerUpdateListener(() => calc());
256
+ }, [
257
+ isVisible,
258
+ nodeKey,
259
+ editor,
260
+ container
261
+ ]);
262
+ useEffect(() => {
263
+ function calc() {
264
+ if (!focusedNodeKey || !container) {
265
+ setFocusedTop(null);
266
+ return;
267
+ }
268
+ const blockEl = editor.getElementByKey(focusedNodeKey);
269
+ if (!blockEl) {
270
+ setFocusedTop(null);
271
+ return;
272
+ }
273
+ setFocusedTop(getOffsetTopToContainer(blockEl, container));
274
+ setFocusedHeight(blockEl.offsetHeight);
275
+ }
276
+ calc();
277
+ return editor.registerUpdateListener(() => calc());
278
+ }, [
279
+ focusedNodeKey,
280
+ editor,
281
+ container
282
+ ]);
283
+ useEffect(() => {
284
+ if (!panelOpen) setActiveIndex(0);
285
+ }, [panelOpen]);
286
+ useEffect(() => {
287
+ if (!isVisible) setPanelOpen(false);
288
+ }, [isVisible]);
289
+ useEffect(() => {
290
+ if (!panelOpen) return;
291
+ function onScroll() {
292
+ setPanelOpen(false);
293
+ }
294
+ window.addEventListener("scroll", onScroll, {
295
+ passive: true,
296
+ capture: true
297
+ });
298
+ return () => window.removeEventListener("scroll", onScroll, { capture: true });
299
+ }, [panelOpen]);
300
+ useEffect(() => {
301
+ if (!panelOpen) return;
302
+ function onPointerDown(e) {
303
+ if (panelRef.current && !panelRef.current.contains(e.target)) setPanelOpen(false);
304
+ }
305
+ window.addEventListener("pointerdown", onPointerDown);
306
+ return () => window.removeEventListener("pointerdown", onPointerDown);
307
+ }, [panelOpen]);
308
+ useEffect(() => {
309
+ if (!panelOpen) return;
310
+ function onKeyDown(e) {
311
+ if (e.key === "ArrowDown") {
312
+ e.preventDefault();
313
+ setActiveIndex((p) => Math.min(p + 1, items.length - 1));
314
+ } else if (e.key === "ArrowUp") {
315
+ e.preventDefault();
316
+ setActiveIndex((p) => Math.max(p - 1, 0));
317
+ } else if (e.key === "Enter") {
318
+ e.preventDefault();
319
+ const item = items[activeIndex];
320
+ if (item) editor.focus(() => {
321
+ item.onSelect(editor);
322
+ setPanelOpen(false);
323
+ });
324
+ } else if (e.key === "Escape") {
325
+ e.preventDefault();
326
+ setPanelOpen(false);
327
+ }
328
+ }
329
+ window.addEventListener("keydown", onKeyDown, { capture: true });
330
+ return () => window.removeEventListener("keydown", onKeyDown, { capture: true });
331
+ }, [
332
+ panelOpen,
333
+ items,
334
+ activeIndex,
335
+ editor
336
+ ]);
337
+ useEffect(() => {
338
+ if (!showDragHandle || !container) return;
339
+ const unregDragover = editor.registerCommand(DRAGOVER_COMMAND, (event) => {
340
+ if (!event.dataTransfer?.types.includes(DRAG_DATA_FORMAT)) return false;
341
+ event.preventDefault();
342
+ const draggedKey = event.dataTransfer.getData(DRAG_DATA_FORMAT);
343
+ const target = event.target;
344
+ if (!target) return true;
345
+ if (draggedKey) {
346
+ if (editor.getElementByKey(draggedKey)?.contains(target)) {
347
+ setDropLine(null);
348
+ return true;
349
+ }
350
+ }
351
+ let targetKey = null;
352
+ editor.read(() => {
353
+ const node = $getNearestNodeFromDOMNode(target);
354
+ if (!node) return;
355
+ const top$1 = node.isInline() ? node.getTopLevelElement() : node;
356
+ if (top$1) targetKey = top$1.getKey();
357
+ });
358
+ if (!targetKey || targetKey === draggedKey) {
359
+ setDropLine(null);
360
+ return true;
361
+ }
362
+ const targetDOM = editor.getElementByKey(targetKey);
363
+ if (!targetDOM) {
364
+ setDropLine(null);
365
+ return true;
366
+ }
367
+ const { top: domTop, height } = targetDOM.getBoundingClientRect();
368
+ const isBefore = event.clientY < domTop + height / 2;
369
+ const offsetTop = getOffsetTopToContainer(targetDOM, container);
370
+ setDropLine({
371
+ top: isBefore ? offsetTop : offsetTop + targetDOM.offsetHeight,
372
+ position: isBefore ? "before" : "after"
373
+ });
374
+ return true;
375
+ }, COMMAND_PRIORITY_LOW);
376
+ const unregDrop = editor.registerCommand(DROP_COMMAND, (event) => {
377
+ const draggedKey = event.dataTransfer?.getData(DRAG_DATA_FORMAT);
378
+ if (!draggedKey) return false;
379
+ event.preventDefault();
380
+ setDropLine(null);
381
+ const target = event.target;
382
+ if (!target) return true;
383
+ let willMove = false;
384
+ editor.read(() => {
385
+ const draggedNode = $getNodeByKey(draggedKey);
386
+ if (!draggedNode) return;
387
+ const nearestNode = $getNearestNodeFromDOMNode(target);
388
+ if (!nearestNode) return;
389
+ const targetNode = nearestNode.isInline() ? nearestNode.getTopLevelElement() : nearestNode;
390
+ if (!targetNode) return;
391
+ const targetKey = targetNode.getKey();
392
+ if (targetKey === draggedKey) return;
393
+ if ($isElementNode(draggedNode) && draggedNode.getChildren().some((child) => child.getKey() === targetKey)) return;
394
+ willMove = true;
395
+ });
396
+ editor.update(() => {
397
+ const draggedNode = $getNodeByKey(draggedKey);
398
+ if (!draggedNode) return;
399
+ const nearestNode = $getNearestNodeFromDOMNode(target);
400
+ if (!nearestNode) return;
401
+ const targetNode = nearestNode.isInline() ? nearestNode.getTopLevelElement() : nearestNode;
402
+ if (!targetNode) return;
403
+ const targetKey = targetNode.getKey();
404
+ if (targetKey === draggedKey) return;
405
+ if ($isElementNode(draggedNode) && draggedNode.getChildren().some((child) => child.getKey() === targetKey)) return;
406
+ const targetDOM = editor.getElementByKey(targetKey);
407
+ if (!targetDOM) return;
408
+ const { top: targetTop, height } = targetDOM.getBoundingClientRect();
409
+ if (event.clientY < targetTop + height / 2) targetNode.insertBefore(draggedNode);
410
+ else targetNode.insertAfter(draggedNode);
411
+ });
412
+ if (willMove && onDropped) requestAnimationFrame(() => onDropped(draggedKey));
413
+ return true;
414
+ }, COMMAND_PRIORITY_HIGH);
415
+ function onDragEnd() {
416
+ setDropLine(null);
417
+ }
418
+ document.addEventListener("dragend", onDragEnd);
419
+ return () => {
420
+ unregDragover();
421
+ unregDrop();
422
+ document.removeEventListener("dragend", onDragEnd);
423
+ };
424
+ }, [
425
+ editor,
426
+ showDragHandle,
427
+ container,
428
+ onDropped
429
+ ]);
430
+ const handleDragStart = useCallback((e) => {
431
+ if (!nodeKey) return;
432
+ e.dataTransfer.setData(DRAG_DATA_FORMAT, nodeKey);
433
+ e.dataTransfer.effectAllowed = "move";
434
+ const blockEl = editor.getElementByKey(nodeKey);
435
+ if (blockEl) e.dataTransfer.setDragImage(blockEl, 0, 0);
436
+ onDragStarted?.(nodeKey);
437
+ }, [
438
+ nodeKey,
439
+ editor,
440
+ onDragStarted
441
+ ]);
442
+ const handleAddClick = useCallback((e) => {
443
+ e.preventDefault();
444
+ setPanelOpen((p) => !p);
445
+ }, []);
446
+ if (!container) return null;
447
+ const dragBtnStyle = {
448
+ ...btnBase,
449
+ width: BTN,
450
+ height: BTN,
451
+ cursor: "grab",
452
+ ...dragHovered ? btnHover : {}
453
+ };
454
+ const addBtnStyle = {
455
+ ...btnBase,
456
+ width: BTN,
457
+ height: BTN,
458
+ ...panelOpen || addHovered ? btnHover : {}
459
+ };
460
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
461
+ createPortal(/* @__PURE__ */ jsx(AnimatePresence, { children: focusedTop !== null && /* @__PURE__ */ jsx(motion.div, {
462
+ style: {
463
+ position: "absolute",
464
+ top: focusedTop,
465
+ left: 0,
466
+ width: 2,
467
+ height: focusedHeight,
468
+ background: "#818cf8",
469
+ borderRadius: 1,
470
+ zIndex: 2,
471
+ pointerEvents: "none"
472
+ },
473
+ initial: { opacity: 0 },
474
+ animate: { opacity: 1 },
475
+ exit: { opacity: 0 },
476
+ transition: { duration: .1 }
477
+ }, focusedNodeKey ?? "cursor-indicator") }), container),
478
+ createPortal(/* @__PURE__ */ jsx(AnimatePresence, { children: isVisible && top !== null && /* @__PURE__ */ jsxs(motion.div, {
479
+ "data-block-toolbar": true,
480
+ style: {
481
+ position: "absolute",
482
+ top,
483
+ left: GUTTER_LEFT,
484
+ display: "flex",
485
+ alignItems: "center",
486
+ gap: GAP,
487
+ width: numBtns * BTN + Math.max(0, numBtns - 1) * GAP,
488
+ zIndex: 2,
489
+ userSelect: "none"
490
+ },
491
+ initial: { opacity: 0 },
492
+ animate: { opacity: 1 },
493
+ exit: { opacity: 0 },
494
+ transition: {
495
+ duration: .08,
496
+ ease: "easeOut"
497
+ },
498
+ children: [showDragHandle && /* @__PURE__ */ jsx("div", {
499
+ draggable: true,
500
+ "data-drag-handle": true,
501
+ onDragStart: handleDragStart,
502
+ "aria-label": "Drag to reorder",
503
+ style: dragBtnStyle,
504
+ onMouseEnter: () => setDragHovered(true),
505
+ onMouseLeave: () => setDragHovered(false),
506
+ children: /* @__PURE__ */ jsx(GripVertical, {
507
+ size: 14,
508
+ strokeWidth: 2
509
+ })
510
+ }), showAddButton && /* @__PURE__ */ jsx("button", {
511
+ ref: addBtnRef,
512
+ type: "button",
513
+ "aria-label": "Add block",
514
+ "aria-expanded": panelOpen,
515
+ style: addBtnStyle,
516
+ onMouseDown: handleAddClick,
517
+ onMouseEnter: () => setAddHovered(true),
518
+ onMouseLeave: () => setAddHovered(false),
519
+ children: /* @__PURE__ */ jsx(Plus, {
520
+ size: 13,
521
+ strokeWidth: 2.5
522
+ })
523
+ })]
524
+ }, nodeKey ?? "toolbar") }), container),
525
+ createPortal(/* @__PURE__ */ jsx(AnimatePresence, { children: dropLine !== null && /* @__PURE__ */ jsxs(motion.div, {
526
+ style: {
527
+ position: "absolute",
528
+ top: dropLine.top,
529
+ left: 0,
530
+ right: 0,
531
+ height: 2,
532
+ zIndex: 3,
533
+ pointerEvents: "none",
534
+ display: "flex",
535
+ alignItems: "center",
536
+ transform: "translateY(-1px)"
537
+ },
538
+ initial: { opacity: 0 },
539
+ animate: { opacity: 1 },
540
+ exit: { opacity: 0 },
541
+ transition: { duration: .06 },
542
+ children: [/* @__PURE__ */ jsx("div", { style: {
543
+ width: 6,
544
+ height: 6,
545
+ borderRadius: "50%",
546
+ background: "#818cf8",
547
+ flexShrink: 0,
548
+ marginLeft: 4
549
+ } }), /* @__PURE__ */ jsx("div", { style: {
550
+ flex: 1,
551
+ height: 2,
552
+ background: "#818cf8",
553
+ marginRight: 8
554
+ } })]
555
+ }, "drop-line") }), container),
556
+ createPortal(/* @__PURE__ */ jsx(AnimatePresence, { children: isVisible && panelOpen && top !== null && /* @__PURE__ */ jsx(motion.div, {
557
+ ref: panelRef,
558
+ "data-block-toolbar": true,
559
+ style: {
560
+ position: "absolute",
561
+ top: top + BTN + GAP,
562
+ left: GUTTER_LEFT,
563
+ zIndex: 50,
564
+ width: 240
565
+ },
566
+ initial: {
567
+ opacity: 0,
568
+ y: -4
569
+ },
570
+ animate: {
571
+ opacity: 1,
572
+ y: 0
573
+ },
574
+ exit: {
575
+ opacity: 0,
576
+ y: -4
577
+ },
578
+ transition: {
579
+ duration: .1,
580
+ ease: "easeOut"
581
+ },
582
+ role: "dialog",
583
+ "aria-label": "Insert block",
584
+ className: "rounded-lg bg-zinc-800 shadow-xl shadow-black/50 py-1.5",
585
+ children: /* @__PURE__ */ jsx("div", {
586
+ role: "listbox",
587
+ "aria-label": "Block type",
588
+ className: "w-full px-1.5 flex flex-col",
589
+ children: items.map((item, index) => {
590
+ const icon = ICON_MAP$1[item.id] ?? item.icon;
591
+ const isActive = index === activeIndex;
592
+ return /* @__PURE__ */ jsxs("button", {
593
+ type: "button",
594
+ role: "option",
595
+ "aria-selected": isActive,
596
+ onMouseDown: (e) => {
597
+ e.preventDefault();
598
+ editor.focus(() => {
599
+ item.onSelect(editor);
600
+ setPanelOpen(false);
601
+ });
602
+ },
603
+ onMouseEnter: () => setActiveIndex(index),
604
+ className: [
605
+ "w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors duration-75",
606
+ "outline-none",
607
+ isActive ? "bg-zinc-700/60 text-zinc-100" : "text-zinc-400 hover:bg-zinc-700/40 hover:text-zinc-200"
608
+ ].join(" "),
609
+ children: [/* @__PURE__ */ jsx("span", {
610
+ className: "flex items-center justify-center shrink-0 w-4 text-current",
611
+ children: icon
612
+ }), /* @__PURE__ */ jsx("span", {
613
+ className: "text-sm font-normal leading-none",
614
+ children: item.label
615
+ })]
616
+ }, item.id);
617
+ })
618
+ })
619
+ }) }), container)
620
+ ] });
621
+ }
622
+
623
+ //#endregion
624
+ //#region src/components/slash-menu.tsx
625
+ const ICON_MAP = {
626
+ paragraph: /* @__PURE__ */ jsx(Text, {
627
+ size: 14,
628
+ strokeWidth: 1.75
629
+ }),
630
+ heading1: /* @__PURE__ */ jsx(Heading1, {
631
+ size: 14,
632
+ strokeWidth: 1.75
633
+ }),
634
+ heading2: /* @__PURE__ */ jsx(Heading2, {
635
+ size: 14,
636
+ strokeWidth: 1.75
637
+ }),
638
+ heading3: /* @__PURE__ */ jsx(Heading3, {
639
+ size: 14,
640
+ strokeWidth: 1.75
641
+ }),
642
+ quote: /* @__PURE__ */ jsx(Quote, {
643
+ size: 14,
644
+ strokeWidth: 1.75
645
+ })
646
+ };
647
+ /**
648
+ * 캐럿(collapsed range)의 bottom 위치를 컨테이너 기준 absolute 좌표로 변환.
649
+ */
650
+ function getCaretAbsPos(container) {
651
+ const sel = window.getSelection();
652
+ if (!sel || sel.rangeCount === 0) return null;
653
+ const range = sel.getRangeAt(0).cloneRange();
654
+ range.collapse(true);
655
+ const caretRect = range.getBoundingClientRect();
656
+ if (caretRect.width === 0 && caretRect.height === 0 && caretRect.top === 0) return null;
657
+ const cr = container.getBoundingClientRect();
658
+ return {
659
+ top: caretRect.bottom - cr.top + container.scrollTop + 6,
660
+ left: caretRect.left - cr.left + container.scrollLeft
661
+ };
662
+ }
663
+ function SlashMenu({ isVisible, query, items, editor, onClose }) {
664
+ const [activeIndex, setActiveIndex] = useState(0);
665
+ const containerRef = useRef(null);
666
+ const [portalContainer, setPortalContainer] = useState(null);
667
+ const [pos, setPos] = useState(null);
668
+ useEffect(() => {
669
+ function attachContainer() {
670
+ const rootEl = editor.getRootElement();
671
+ if (!rootEl) return;
672
+ const parent = rootEl.parentElement;
673
+ if (parent) setPortalContainer(parent);
674
+ }
675
+ attachContainer();
676
+ return editor.registerRootListener(attachContainer);
677
+ }, [editor]);
678
+ const filtered = items.filter((item) => !query || item.label.toLowerCase().includes(query.toLowerCase()) || item.description.toLowerCase().includes(query.toLowerCase()));
679
+ useEffect(() => {
680
+ if (!isVisible || !portalContainer) {
681
+ setPos(null);
682
+ return;
683
+ }
684
+ setPos(getCaretAbsPos(portalContainer));
685
+ }, [
686
+ isVisible,
687
+ query,
688
+ portalContainer
689
+ ]);
690
+ useEffect(() => {
691
+ setActiveIndex(0);
692
+ }, [query]);
693
+ useEffect(() => {
694
+ if (!isVisible) return;
695
+ function handleKeyDown(event) {
696
+ if (event.key === "ArrowDown") {
697
+ event.preventDefault();
698
+ setActiveIndex((prev) => Math.min(prev + 1, filtered.length - 1));
699
+ } else if (event.key === "ArrowUp") {
700
+ event.preventDefault();
701
+ setActiveIndex((prev) => Math.max(prev - 1, 0));
702
+ } else if (event.key === "Enter") {
703
+ event.preventDefault();
704
+ const item = filtered[activeIndex];
705
+ if (!item) return;
706
+ editor.focus(() => {
707
+ item.onSelect(editor);
708
+ onClose();
709
+ });
710
+ } else if (event.key === "Escape") onClose();
711
+ }
712
+ window.addEventListener("keydown", handleKeyDown);
713
+ return () => window.removeEventListener("keydown", handleKeyDown);
714
+ }, [
715
+ isVisible,
716
+ filtered,
717
+ activeIndex,
718
+ editor,
719
+ onClose
720
+ ]);
721
+ useEffect(() => {
722
+ (containerRef.current?.querySelector("[data-active=\"true\"]"))?.scrollIntoView({ block: "nearest" });
723
+ }, [activeIndex]);
724
+ if (!portalContainer) return null;
725
+ const menuStyle = pos ? {
726
+ position: "absolute",
727
+ top: pos.top,
728
+ left: pos.left,
729
+ zIndex: 50,
730
+ width: 240
731
+ } : {
732
+ position: "absolute",
733
+ top: -9999,
734
+ left: -9999,
735
+ zIndex: 50,
736
+ width: 240
737
+ };
738
+ return createPortal(/* @__PURE__ */ jsx(AnimatePresence, { children: isVisible && /* @__PURE__ */ jsx(motion.div, {
739
+ style: menuStyle,
740
+ initial: {
741
+ opacity: 0,
742
+ y: -4
743
+ },
744
+ animate: {
745
+ opacity: 1,
746
+ y: 0
747
+ },
748
+ exit: {
749
+ opacity: 0,
750
+ y: -4
751
+ },
752
+ transition: {
753
+ duration: .1,
754
+ ease: "easeOut"
755
+ },
756
+ role: "dialog",
757
+ "aria-label": "Insert block",
758
+ className: "rounded-lg bg-zinc-800 shadow-xl shadow-black/50 py-1.5 overflow-hidden",
759
+ children: filtered.length === 0 ? /* @__PURE__ */ jsxs("p", {
760
+ className: "px-4 py-2 text-xs text-zinc-500",
761
+ children: [
762
+ "No results for “",
763
+ query,
764
+ "”"
765
+ ]
766
+ }) : /* @__PURE__ */ jsx("div", {
767
+ ref: containerRef,
768
+ role: "listbox",
769
+ "aria-label": "Block type",
770
+ className: "w-full max-h-72 overflow-y-auto px-1.5 flex flex-col",
771
+ children: filtered.map((item, index) => {
772
+ const icon = ICON_MAP[item.id] ?? item.icon;
773
+ const isActive = index === activeIndex;
774
+ return /* @__PURE__ */ jsxs("button", {
775
+ type: "button",
776
+ role: "option",
777
+ "aria-selected": isActive,
778
+ "data-active": isActive,
779
+ onMouseDown: (e) => {
780
+ e.preventDefault();
781
+ editor.focus(() => {
782
+ item.onSelect(editor);
783
+ onClose();
784
+ });
785
+ },
786
+ onMouseEnter: () => setActiveIndex(index),
787
+ className: [
788
+ "w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors duration-75",
789
+ "outline-none focus-visible:outline-none",
790
+ isActive ? "bg-zinc-700/80 text-zinc-100" : "text-zinc-400 hover:bg-zinc-700/60 hover:text-zinc-200 focus-visible:bg-zinc-700/80 focus-visible:text-zinc-100"
791
+ ].join(" "),
792
+ children: [/* @__PURE__ */ jsx("span", {
793
+ className: "flex items-center justify-center shrink-0 w-4 text-current",
794
+ children: icon
795
+ }), /* @__PURE__ */ jsx("span", {
796
+ className: "text-sm font-normal leading-none",
797
+ children: item.label
798
+ })]
799
+ }, item.id);
800
+ })
801
+ })
802
+ }) }), portalContainer);
803
+ }
804
+
805
+ //#endregion
806
+ //#region src/editor-ui.tsx
807
+ const tbtnBase = {
808
+ display: "flex",
809
+ alignItems: "center",
810
+ justifyContent: "center",
811
+ width: 32,
812
+ height: 32,
813
+ borderRadius: 6,
814
+ border: "none",
815
+ background: "transparent",
816
+ color: "#71717a",
817
+ cursor: "pointer",
818
+ padding: 0,
819
+ transition: "background 100ms, color 100ms",
820
+ flexShrink: 0
821
+ };
822
+ const tbtnHover = {
823
+ background: "rgba(39,39,42,0.9)",
824
+ color: "#e4e4e7"
825
+ };
826
+ const tbtnActive = {
827
+ background: "rgba(63,63,70,0.8)",
828
+ color: "#f4f4f5"
829
+ };
830
+ function ToolbarButton({ label, isActive, onMouseDown, children }) {
831
+ const [hovered, setHovered] = useState(false);
832
+ const style = {
833
+ ...tbtnBase,
834
+ ...hovered ? tbtnHover : {},
835
+ ...isActive ? tbtnActive : {}
836
+ };
837
+ return /* @__PURE__ */ jsx("button", {
838
+ type: "button",
839
+ "aria-label": label,
840
+ onMouseDown,
841
+ onMouseEnter: () => setHovered(true),
842
+ onMouseLeave: () => setHovered(false),
843
+ style,
844
+ children
845
+ });
846
+ }
847
+ const defaultExtensions = [richTextExtension, historyExtension];
848
+ const ALL_FEATURES = [
849
+ "blockHandle",
850
+ "inlineAdd",
851
+ "slashCommand",
852
+ "bubbleMenu"
853
+ ];
854
+ function EditorInner({ extensions, toolbarContent, features }) {
855
+ const [editor] = useLexicalComposerContext();
856
+ const selection = useSelectionPlugin();
857
+ const slashCommand = useSlashCommandPlugin();
858
+ const blockHover = useBlockHoverPlugin();
859
+ const [currentHeadingTag, setCurrentHeadingTag] = useState(null);
860
+ const [focusedNodeKey, setFocusedNodeKey] = useState(null);
861
+ const dropSucceededRef = useRef(false);
862
+ useEffect(() => {
863
+ return editor.registerUpdateListener(({ editorState }) => {
864
+ editorState.read(() => {
865
+ const sel = $getSelection();
866
+ if (!$isRangeSelection(sel)) {
867
+ setCurrentHeadingTag(null);
868
+ return;
869
+ }
870
+ const topNode = sel.anchor.getNode().getTopLevelElement();
871
+ setCurrentHeadingTag($isHeadingNode(topNode) ? topNode.getTag() : null);
872
+ });
873
+ });
874
+ }, [editor]);
875
+ useEffect(() => {
876
+ return editor.registerRootListener((rootElement) => {
877
+ if (!rootElement) return;
878
+ const root = rootElement;
879
+ function onClick(e) {
880
+ const target = e.target;
881
+ if (!target) return;
882
+ let key = null;
883
+ editor.read(() => {
884
+ const node = $getNearestNodeFromDOMNode(target);
885
+ if (!node) return;
886
+ const top = node.isInline() ? node.getTopLevelElement() : node;
887
+ if (top) key = top.getKey();
888
+ });
889
+ if (key) setFocusedNodeKey(key);
890
+ }
891
+ function onDocClick(e) {
892
+ const target = e.target;
893
+ if (root.contains(target)) return;
894
+ if (target?.closest("[data-block-toolbar]") || target?.closest("[data-drag-handle]")) return;
895
+ setFocusedNodeKey(null);
896
+ }
897
+ root.addEventListener("click", onClick);
898
+ document.addEventListener("click", onDocClick, true);
899
+ return () => {
900
+ root.removeEventListener("click", onClick);
901
+ document.removeEventListener("click", onDocClick, true);
902
+ };
903
+ });
904
+ }, [editor]);
905
+ const handleDragStarted = useCallback((key) => {
906
+ dropSucceededRef.current = false;
907
+ setFocusedNodeKey(key);
908
+ editor.focus();
909
+ }, [editor]);
910
+ const handleDropped = useCallback((key) => {
911
+ dropSucceededRef.current = true;
912
+ setFocusedNodeKey(key);
913
+ editor.focus();
914
+ }, [editor]);
915
+ useEffect(() => {
916
+ function onDragEnd() {
917
+ if (!dropSucceededRef.current) setFocusedNodeKey(null);
918
+ dropSucceededRef.current = false;
919
+ }
920
+ document.addEventListener("dragend", onDragEnd);
921
+ return () => document.removeEventListener("dragend", onDragEnd);
922
+ }, []);
923
+ const slashMenuItems = useMemo(() => extensions.flatMap((ext) => ext.slashMenuItems ?? []), [extensions]);
924
+ const toggleHeading = useCallback((tag) => {
925
+ editor.update(() => {
926
+ const sel = $getSelection();
927
+ if (!$isRangeSelection(sel)) return;
928
+ const topNode = sel.anchor.getNode().getTopLevelElementOrThrow();
929
+ const newNode = $isHeadingNode(topNode) && topNode.getTag() === tag ? $createParagraphNode() : $createHeadingNode(tag);
930
+ topNode.replace(newNode);
931
+ newNode.select();
932
+ });
933
+ }, [editor]);
934
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
935
+ toolbarContent === false ? null : toolbarContent !== void 0 ? /* @__PURE__ */ jsx("div", {
936
+ style: {
937
+ display: "flex",
938
+ alignItems: "center",
939
+ gap: 2,
940
+ padding: "8px 16px",
941
+ borderBottom: "1px solid rgba(39,39,42,0.5)"
942
+ },
943
+ children: toolbarContent
944
+ }) : /* @__PURE__ */ jsxs("div", {
945
+ style: {
946
+ display: "flex",
947
+ alignItems: "center",
948
+ gap: 2,
949
+ padding: "8px 16px",
950
+ borderBottom: "1px solid rgba(39,39,42,0.5)"
951
+ },
952
+ children: [
953
+ /* @__PURE__ */ jsxs("div", {
954
+ style: {
955
+ display: "flex",
956
+ alignItems: "center",
957
+ gap: 2
958
+ },
959
+ children: [
960
+ /* @__PURE__ */ jsx(ToolbarButton, {
961
+ label: "Bold",
962
+ isActive: selection.format.bold,
963
+ onMouseDown: (e) => {
964
+ e.preventDefault();
965
+ selection.toggleFormat("bold");
966
+ },
967
+ children: /* @__PURE__ */ jsx(Bold, {
968
+ size: 14,
969
+ strokeWidth: 2.5
970
+ })
971
+ }),
972
+ /* @__PURE__ */ jsx(ToolbarButton, {
973
+ label: "Italic",
974
+ isActive: selection.format.italic,
975
+ onMouseDown: (e) => {
976
+ e.preventDefault();
977
+ selection.toggleFormat("italic");
978
+ },
979
+ children: /* @__PURE__ */ jsx(Italic, {
980
+ size: 14,
981
+ strokeWidth: 2.5
982
+ })
983
+ }),
984
+ /* @__PURE__ */ jsx(ToolbarButton, {
985
+ label: "Underline",
986
+ isActive: selection.format.underline,
987
+ onMouseDown: (e) => {
988
+ e.preventDefault();
989
+ selection.toggleFormat("underline");
990
+ },
991
+ children: /* @__PURE__ */ jsx(Underline, {
992
+ size: 14,
993
+ strokeWidth: 2.5
994
+ })
995
+ }),
996
+ /* @__PURE__ */ jsx(ToolbarButton, {
997
+ label: "Strikethrough",
998
+ isActive: selection.format.strikethrough,
999
+ onMouseDown: (e) => {
1000
+ e.preventDefault();
1001
+ selection.toggleFormat("strikethrough");
1002
+ },
1003
+ children: /* @__PURE__ */ jsx(Strikethrough, {
1004
+ size: 14,
1005
+ strokeWidth: 2.5
1006
+ })
1007
+ }),
1008
+ /* @__PURE__ */ jsx(ToolbarButton, {
1009
+ label: "Code",
1010
+ isActive: selection.format.code,
1011
+ onMouseDown: (e) => {
1012
+ e.preventDefault();
1013
+ selection.toggleFormat("code");
1014
+ },
1015
+ children: /* @__PURE__ */ jsx(Code, {
1016
+ size: 14,
1017
+ strokeWidth: 2.5
1018
+ })
1019
+ })
1020
+ ]
1021
+ }),
1022
+ /* @__PURE__ */ jsx("div", { style: {
1023
+ width: 1,
1024
+ height: 16,
1025
+ background: "#27272a",
1026
+ margin: "0 4px"
1027
+ } }),
1028
+ /* @__PURE__ */ jsxs("div", {
1029
+ style: {
1030
+ display: "flex",
1031
+ alignItems: "center",
1032
+ gap: 2
1033
+ },
1034
+ children: [
1035
+ /* @__PURE__ */ jsx(ToolbarButton, {
1036
+ label: "Heading 1",
1037
+ isActive: currentHeadingTag === "h1",
1038
+ onMouseDown: (e) => {
1039
+ e.preventDefault();
1040
+ toggleHeading("h1");
1041
+ },
1042
+ children: /* @__PURE__ */ jsx(Heading1, {
1043
+ size: 15,
1044
+ strokeWidth: 2
1045
+ })
1046
+ }),
1047
+ /* @__PURE__ */ jsx(ToolbarButton, {
1048
+ label: "Heading 2",
1049
+ isActive: currentHeadingTag === "h2",
1050
+ onMouseDown: (e) => {
1051
+ e.preventDefault();
1052
+ toggleHeading("h2");
1053
+ },
1054
+ children: /* @__PURE__ */ jsx(Heading2, {
1055
+ size: 15,
1056
+ strokeWidth: 2
1057
+ })
1058
+ }),
1059
+ /* @__PURE__ */ jsx(ToolbarButton, {
1060
+ label: "Heading 3",
1061
+ isActive: currentHeadingTag === "h3",
1062
+ onMouseDown: (e) => {
1063
+ e.preventDefault();
1064
+ toggleHeading("h3");
1065
+ },
1066
+ children: /* @__PURE__ */ jsx(Heading3, {
1067
+ size: 15,
1068
+ strokeWidth: 2
1069
+ })
1070
+ })
1071
+ ]
1072
+ })
1073
+ ]
1074
+ }),
1075
+ features.includes("bubbleMenu") && /* @__PURE__ */ jsx(BubbleMenu, {
1076
+ isVisible: selection.isActive,
1077
+ format: selection.format,
1078
+ onToggleFormat: selection.toggleFormat,
1079
+ editor
1080
+ }),
1081
+ features.includes("slashCommand") && /* @__PURE__ */ jsx(SlashMenu, {
1082
+ isVisible: slashCommand.isActive,
1083
+ query: slashCommand.query,
1084
+ items: slashMenuItems,
1085
+ editor,
1086
+ onClose: slashCommand.close
1087
+ }),
1088
+ /* @__PURE__ */ jsx(BlockToolbar, {
1089
+ isVisible: blockHover.isActive && (features.includes("blockHandle") || features.includes("inlineAdd")),
1090
+ nodeKey: blockHover.nodeKey,
1091
+ focusedNodeKey,
1092
+ items: slashMenuItems,
1093
+ editor,
1094
+ showDragHandle: features.includes("blockHandle"),
1095
+ showAddButton: features.includes("inlineAdd"),
1096
+ onDragStarted: handleDragStarted,
1097
+ onDropped: handleDropped
1098
+ })
1099
+ ] });
1100
+ }
1101
+ function EditorUI({ extensions = defaultExtensions, namespace = "jikjo", toolbarContent, className, features = [...ALL_FEATURES] }) {
1102
+ return /* @__PURE__ */ jsx("div", {
1103
+ className,
1104
+ ...features.includes("blockHandle") || features.includes("inlineAdd") ? { "data-has-block-toolbar": "" } : {},
1105
+ children: /* @__PURE__ */ jsx(Editor, {
1106
+ extensions,
1107
+ namespace,
1108
+ children: /* @__PURE__ */ jsx(EditorInner, {
1109
+ extensions,
1110
+ toolbarContent,
1111
+ features
1112
+ })
1113
+ })
1114
+ });
1115
+ }
1116
+
1117
+ //#endregion
1118
+ export { BlockToolbar, BubbleMenu, EditorUI, SlashMenu };
1119
+ //# sourceMappingURL=index.js.map