@nikala-ui/core 0.11.0 → 0.12.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.
Files changed (107) hide show
  1. package/package.json +43 -2
  2. package/registry/api-table.json +21 -0
  3. package/registry/banner.json +1 -1
  4. package/registry/button.json +1 -1
  5. package/registry/callout.json +19 -0
  6. package/registry/checkbox.json +4 -1
  7. package/registry/code-block.json +26 -0
  8. package/registry/code-group.json +20 -0
  9. package/registry/combobox.json +1 -1
  10. package/registry/command.json +3 -2
  11. package/registry/component-viewer.json +25 -0
  12. package/registry/container.json +18 -0
  13. package/registry/context-menu.json +1 -1
  14. package/registry/create-app-updater.json +13 -0
  15. package/registry/create-document-tabs.json +13 -0
  16. package/registry/create-global-shortcut.json +13 -0
  17. package/registry/create-tauri-window.json +13 -0
  18. package/registry/create-tiptap-editor.json +34 -0
  19. package/registry/dialog.json +1 -1
  20. package/registry/dropdown-menu.json +3 -2
  21. package/registry/field.json +1 -1
  22. package/registry/file-tree.json +21 -0
  23. package/registry/footer.json +1 -1
  24. package/registry/form-message.json +3 -2
  25. package/registry/icon-button.json +1 -1
  26. package/registry/index.json +336 -5
  27. package/registry/navbar.json +1 -1
  28. package/registry/navigation-menu.json +1 -1
  29. package/registry/number-input.json +1 -1
  30. package/registry/package-manager-tabs.json +24 -0
  31. package/registry/pager.json +21 -0
  32. package/registry/popover.json +4 -1
  33. package/registry/resizable.json +1 -1
  34. package/registry/rich-text-editor.json +95 -0
  35. package/registry/scroll-area.json +1 -1
  36. package/registry/section-heading.json +21 -0
  37. package/registry/select.json +3 -2
  38. package/registry/sheet.json +1 -1
  39. package/registry/sidebar.json +1 -1
  40. package/registry/status.json +1 -1
  41. package/registry/steps.json +20 -0
  42. package/registry/switch.json +4 -1
  43. package/registry/table-of-contents.json +23 -0
  44. package/registry/tabs.json +4 -1
  45. package/registry/theme-manager.json +6 -5
  46. package/registry/titlebar-tabs.json +24 -0
  47. package/registry/titlebar.json +26 -0
  48. package/registry/toggle-group.json +1 -1
  49. package/registry/updater-modal.json +26 -0
  50. package/src/index.ts +48 -0
  51. package/src/registry/components/ui/api-table.tsx +117 -0
  52. package/src/registry/components/ui/banner.tsx +0 -2
  53. package/src/registry/components/ui/button.tsx +1 -1
  54. package/src/registry/components/ui/callout.tsx +137 -0
  55. package/src/registry/components/ui/checkbox.tsx +1 -1
  56. package/src/registry/components/ui/code-block.tsx +326 -0
  57. package/src/registry/components/ui/code-group.tsx +105 -0
  58. package/src/registry/components/ui/combobox.tsx +83 -18
  59. package/src/registry/components/ui/command.tsx +110 -106
  60. package/src/registry/components/ui/component-viewer.tsx +363 -0
  61. package/src/registry/components/ui/container.tsx +45 -0
  62. package/src/registry/components/ui/context-menu.tsx +67 -22
  63. package/src/registry/components/ui/dialog.tsx +42 -32
  64. package/src/registry/components/ui/dropdown-menu.tsx +69 -31
  65. package/src/registry/components/ui/field.tsx +7 -3
  66. package/src/registry/components/ui/file-tree.tsx +181 -0
  67. package/src/registry/components/ui/footer.tsx +1 -1
  68. package/src/registry/components/ui/form-message.tsx +2 -2
  69. package/src/registry/components/ui/icon-button.tsx +1 -1
  70. package/src/registry/components/ui/navbar.tsx +18 -11
  71. package/src/registry/components/ui/navigation-menu.tsx +2 -2
  72. package/src/registry/components/ui/number-input.tsx +3 -3
  73. package/src/registry/components/ui/package-manager-tabs.tsx +226 -0
  74. package/src/registry/components/ui/pager.tsx +102 -0
  75. package/src/registry/components/ui/popover.tsx +1 -1
  76. package/src/registry/components/ui/resizable.tsx +3 -1
  77. package/src/registry/components/ui/rich-text-editor/bubble-menu.tsx +236 -0
  78. package/src/registry/components/ui/rich-text-editor/editor.tsx +308 -0
  79. package/src/registry/components/ui/rich-text-editor/extensions.ts +116 -0
  80. package/src/registry/components/ui/rich-text-editor/footer.tsx +36 -0
  81. package/src/registry/components/ui/rich-text-editor/image-dialog.tsx +75 -0
  82. package/src/registry/components/ui/rich-text-editor/index.ts +11 -0
  83. package/src/registry/components/ui/rich-text-editor/link-dialog.tsx +48 -0
  84. package/src/registry/components/ui/rich-text-editor/markdown.ts +170 -0
  85. package/src/registry/components/ui/rich-text-editor/slash-command.tsx +301 -0
  86. package/src/registry/components/ui/rich-text-editor/table-controls.tsx +68 -0
  87. package/src/registry/components/ui/rich-text-editor/toolbar.tsx +465 -0
  88. package/src/registry/components/ui/rich-text-editor/use-editor.ts +231 -0
  89. package/src/registry/components/ui/scroll-area.tsx +12 -2
  90. package/src/registry/components/ui/section-heading.tsx +108 -0
  91. package/src/registry/components/ui/select.tsx +16 -19
  92. package/src/registry/components/ui/sheet.tsx +58 -53
  93. package/src/registry/components/ui/sidebar.tsx +4 -4
  94. package/src/registry/components/ui/status.tsx +7 -5
  95. package/src/registry/components/ui/steps.tsx +198 -0
  96. package/src/registry/components/ui/switch.tsx +1 -1
  97. package/src/registry/components/ui/table-of-contents.tsx +131 -0
  98. package/src/registry/components/ui/tabs.tsx +1 -1
  99. package/src/registry/components/ui/theme-toggle.tsx +175 -139
  100. package/src/registry/components/ui/titlebar-tabs.tsx +773 -0
  101. package/src/registry/components/ui/titlebar.tsx +468 -0
  102. package/src/registry/components/ui/toggle-group.tsx +1 -1
  103. package/src/registry/components/ui/updater-modal.tsx +254 -0
  104. package/src/registry/metadata.ts +167 -6
  105. package/src/registry/providers/theme-provider.tsx +16 -8
  106. package/src/registry/providers/theme-script.tsx +29 -9
  107. package/src/registry/providers/theme-transitions.ts +81 -50
@@ -11,7 +11,7 @@ import {
11
11
  Show,
12
12
  } from "solid-js";
13
13
  import { cva, type VariantProps } from "class-variance-authority";
14
- import { createClickOutside } from "@nikala-ui/hooks";
14
+ import { createClickOutside } from "@/hooks/create-click-outside";
15
15
  import { cn } from "@/lib/cn";
16
16
  import { ChevronDown } from "lucide-solid";
17
17
 
@@ -26,7 +26,7 @@ interface NavigationMenuContextValue {
26
26
 
27
27
  const NavigationMenuContext = createContext<NavigationMenuContextValue>();
28
28
 
29
- export function useNavigationMenu() {
29
+ function useNavigationMenu() {
30
30
  const context = useContext(NavigationMenuContext);
31
31
  if (!context) {
32
32
  throw new Error("useNavigationMenu must be used within a <NavigationMenu />");
@@ -1,9 +1,9 @@
1
1
  import { createSignal, onCleanup, splitProps, type Component, type JSX } from "solid-js";
2
2
  import { NumberField as KobalteNumberField } from "@kobalte/core/number-field";
3
3
  import { Plus, Minus } from "lucide-solid";
4
- import { Input } from "./input";
5
- import { Button } from "./button";
6
- import { createLongPress } from "@nikala-ui/hooks";
4
+ import { Input } from "@/components/ui/input";
5
+ import { Button } from "@/components/ui/button";
6
+ import { createLongPress } from "@/hooks/create-long-press";
7
7
  import { cn } from "@/lib/cn";
8
8
 
9
9
  export interface NumberInputProps {
@@ -0,0 +1,226 @@
1
+ import {
2
+ createSignal,
3
+ createMemo,
4
+ splitProps,
5
+ For,
6
+ Show,
7
+ type JSX,
8
+ type ParentComponent,
9
+ } from "solid-js";
10
+ import { Check, Copy } from "lucide-solid";
11
+ import { Button } from "@/components/ui/button";
12
+ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
13
+ import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
14
+ import { createClipboard } from "@/hooks/create-clipboard";
15
+ import { cn } from "@/lib/cn";
16
+
17
+ export type DefaultPackageManager = "bun" | "pnpm" | "npm" | "yarn" | "bunx" | "npx" | "deno";
18
+
19
+ export interface PackageManagerTabsProps
20
+ extends Omit<JSX.HTMLAttributes<HTMLDivElement>, "onChange"> {
21
+ /** List of package managers to show in the switcher tabs */
22
+ managers?: string[];
23
+ /** Single CLI command template (e.g. "add @nikala-ui/core", "create @nikala-ui/docs my-app") */
24
+ command?: string;
25
+ /** Explicit map of custom commands per manager (e.g. { bun: "bun add foo", npm: "npm i foo" }) */
26
+ commands?: Record<string, string>;
27
+ /** Selected manager (controlled) */
28
+ value?: string;
29
+ /** Initial selected manager (uncontrolled) */
30
+ defaultValue?: string;
31
+ /** Callback fired when the active package manager changes */
32
+ onChange?: (manager: string) => void;
33
+ /** Whether to show the copy button in the header */
34
+ copyable?: boolean;
35
+ class?: string;
36
+ }
37
+
38
+ /**
39
+ * Automatically formats a command string for a specific package manager.
40
+ */
41
+ function resolvePmCommand(manager: string, command: string): string {
42
+ const trimmed = command.trim();
43
+ const pm = manager.toLowerCase();
44
+
45
+ // 1. "create" command
46
+ const createMatch = trimmed.match(/^(?:bun|npm|npx|pnpm|yarn|deno)?\s*create\s+(.*)$/);
47
+ if (createMatch) {
48
+ const args = createMatch[1];
49
+ if (pm === "bun" || pm === "bunx") return `bun create ${args}`;
50
+ if (pm === "pnpm") return `pnpm create ${args}`;
51
+ if (pm === "yarn") return `yarn create ${args}`;
52
+ if (pm === "deno") return `deno run -A npm:create-${args}`;
53
+ return `npm create ${args}`;
54
+ }
55
+
56
+ // 2. "add" / "install" command
57
+ const addMatch = trimmed.match(/^(?:bun\s+add|npm\s+i|npm\s+install|pnpm\s+add|yarn\s+add|deno\s+add|add|install|i)\s+(.*)$/);
58
+ if (addMatch) {
59
+ const args = addMatch[1];
60
+ if (pm === "npm") return `npm i ${args}`;
61
+ if (pm === "pnpm") return `pnpm add ${args}`;
62
+ if (pm === "yarn") return `yarn add ${args}`;
63
+ if (pm === "deno") return `deno add ${args}`;
64
+ return `bun add ${args}`;
65
+ }
66
+
67
+ // 3. CLI runner (e.g. "nikala add button" or "@nikala-ui/cli add button")
68
+ const runnerMatch = trimmed.match(/^(?:bunx|npx|pnpm\s+dlx|yarn\s+dlx)?\s*(.*)$/);
69
+ if (runnerMatch) {
70
+ const rest = runnerMatch[1];
71
+ if (pm === "npx") return `npx ${rest}`;
72
+ if (pm === "pnpm") return `pnpm dlx ${rest}`;
73
+ if (pm === "yarn") return `yarn dlx ${rest}`;
74
+ if (pm === "bun" || pm === "bunx") return `bunx ${rest}`;
75
+ return `${pm} ${rest}`;
76
+ }
77
+
78
+ return trimmed;
79
+ }
80
+
81
+ function highlightCliCommandText(code: string): string {
82
+ const trimmed = code.trim();
83
+ if (!trimmed) return "";
84
+ const parts = trimmed.split(/\s+/);
85
+
86
+ let pkgIndex = 1;
87
+ if ((parts[0] === "pnpm" || parts[0] === "yarn") && parts[1] === "dlx") {
88
+ pkgIndex = 2;
89
+ } else if (parts[0] === "bun" && (parts[1] === "create" || parts[1] === "add" || parts[1] === "run")) {
90
+ pkgIndex = 2;
91
+ } else if (parts[0] === "npm" && (parts[1] === "create" || parts[1] === "run")) {
92
+ pkgIndex = 2;
93
+ }
94
+
95
+ const mainPart = parts.slice(0, pkgIndex).join(" ");
96
+ const restTokens = parts.slice(pkgIndex);
97
+
98
+ const highlightedRest = restTokens
99
+ .map((token) => {
100
+ if (token.startsWith("-")) {
101
+ return `<span class="text-amber-500 dark:text-amber-400 font-medium">${token}</span>`;
102
+ }
103
+ if (["add", "create", "init", "run", "dev", "build", "set", "theme", "diff", "list"].includes(token)) {
104
+ return `<span class="text-sky-500 dark:text-sky-400 font-semibold">${token}</span>`;
105
+ }
106
+ return `<span class="text-violet-500 dark:text-violet-400 font-medium">${token}</span>`;
107
+ })
108
+ .join(" ");
109
+
110
+ return `<span class="text-emerald-500 dark:text-emerald-400 font-semibold">${mainPart}</span>${
111
+ highlightedRest ? " " + highlightedRest : ""
112
+ }`;
113
+ }
114
+
115
+ export const PackageManagerTabs: ParentComponent<PackageManagerTabsProps> = (props) => {
116
+ const [local, rest] = splitProps(props, [
117
+ "managers",
118
+ "command",
119
+ "commands",
120
+ "value",
121
+ "defaultValue",
122
+ "onChange",
123
+ "copyable",
124
+ "class",
125
+ ]);
126
+
127
+ const defaultManagers = ["bun", "pnpm", "npm", "yarn"];
128
+ const managersList = () => local.managers || defaultManagers;
129
+
130
+ const [internalActive, setInternalActive] = createSignal(
131
+ local.defaultValue || managersList()[0] || "bun"
132
+ );
133
+
134
+ const activeManager = () => local.value ?? internalActive();
135
+
136
+ const handleSelect = (pm: string) => {
137
+ if (local.value === undefined) {
138
+ setInternalActive(pm);
139
+ }
140
+ local.onChange?.(pm);
141
+ };
142
+
143
+ const currentCommand = createMemo(() => {
144
+ const pm = activeManager();
145
+ if (local.commands && local.commands[pm]) {
146
+ return local.commands[pm];
147
+ }
148
+ if (local.command) {
149
+ return resolvePmCommand(pm, local.command);
150
+ }
151
+ return "";
152
+ });
153
+
154
+ const clipboard = createClipboard();
155
+ const copyable = () => local.copyable ?? true;
156
+
157
+ const handleCopy = async () => {
158
+ const text = currentCommand();
159
+ if (!text) return;
160
+ await clipboard.copy(text);
161
+ };
162
+
163
+ return (
164
+ <div
165
+ class={cn(
166
+ "group relative my-4 w-full overflow-hidden rounded-lg border border-border bg-muted/40 font-mono text-sm shadow-2xs",
167
+ local.class
168
+ )}
169
+ {...rest}
170
+ >
171
+ {/* Header with Manager Switcher Tabs and Copy Button */}
172
+ <div class="flex h-9 items-center justify-between border-b border-border/70 bg-muted/70 px-3.5 select-none">
173
+ {/* Switcher Tabs */}
174
+ <Tabs
175
+ value={activeManager()}
176
+ onChange={handleSelect}
177
+ class="w-auto flex-none"
178
+ >
179
+ <TabsList class="w-auto inline-flex h-auto bg-background/60 p-0.5 border border-border/50 gap-0.5 rounded-md font-mono">
180
+ <For each={managersList()}>
181
+ {(pm) => (
182
+ <TabsTrigger
183
+ value={pm}
184
+ class="rounded-xs px-2 py-0.5 text-[11px] font-mono transition-colors cursor-pointer data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:font-semibold data-[state=active]:shadow-2xs"
185
+ >
186
+ {pm}
187
+ </TabsTrigger>
188
+ )}
189
+ </For>
190
+ </TabsList>
191
+ </Tabs>
192
+
193
+ {/* Copy Button */}
194
+ <Show when={copyable() && currentCommand()}>
195
+ <Tooltip>
196
+ <TooltipTrigger
197
+ as={Button}
198
+ variant="ghost"
199
+ size="icon"
200
+ onClick={handleCopy}
201
+ aria-label={clipboard.copied() ? "Copied command" : "Copy command"}
202
+ class="size-6 p-0 rounded-xs text-muted-foreground hover:bg-background hover:text-foreground cursor-pointer"
203
+ >
204
+ <Show when={clipboard.copied()} fallback={<Copy class="size-3.5" />}>
205
+ <Check class="size-3.5 text-emerald-500 dark:text-emerald-400" />
206
+ </Show>
207
+ </TooltipTrigger>
208
+ <TooltipContent class="text-[10px] py-1 px-2">
209
+ {clipboard.copied() ? "Copied!" : "Copy command"}
210
+ </TooltipContent>
211
+ </Tooltip>
212
+ </Show>
213
+ </div>
214
+
215
+ {/* Command Box Content */}
216
+ <div class="overflow-x-auto p-4 text-[13px] leading-relaxed [scrollbar-width:thin]">
217
+ <pre class="font-mono">
218
+ <code>
219
+ <span class="text-sky-500 dark:text-sky-400 font-semibold select-none">$ </span>
220
+ <span innerHTML={highlightCliCommandText(currentCommand())} />
221
+ </code>
222
+ </pre>
223
+ </div>
224
+ </div>
225
+ );
226
+ };
@@ -0,0 +1,102 @@
1
+ import { splitProps, Show, children, type Component, type JSX } from "solid-js";
2
+ import { ChevronLeft, ChevronRight } from "lucide-solid";
3
+ import {
4
+ Card,
5
+ CardHeader,
6
+ CardTitle,
7
+ CardDescription,
8
+ } from "@/components/ui/card";
9
+ import { cn } from "@/lib/cn";
10
+
11
+ export interface PagerItem {
12
+ title: string;
13
+ href: string;
14
+ }
15
+
16
+ export interface PagerLinkProps extends JSX.AnchorHTMLAttributes<HTMLAnchorElement> {
17
+ title: string;
18
+ href: string;
19
+ type: "prev" | "next";
20
+ class?: string;
21
+ }
22
+
23
+ /**
24
+ * Individual Next or Previous page navigation card.
25
+ */
26
+ export const PagerLink: Component<PagerLinkProps> = (props) => {
27
+ const [local, rest] = splitProps(props, ["title", "href", "type", "class"]);
28
+
29
+ const isNext = () => local.type === "next";
30
+
31
+ return (
32
+ <a
33
+ href={local.href}
34
+ class={cn(
35
+ "group block rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
36
+ isNext() && "sm:col-start-2",
37
+ local.class
38
+ )}
39
+ {...rest}
40
+ >
41
+ <Card class="h-full transition-all group-hover:bg-accent/40 group-hover:shadow-2xs">
42
+ <CardHeader class={cn("p-4 flex flex-col space-y-1.5", isNext() && "items-end text-right")}>
43
+ <CardDescription class="flex items-center gap-1 text-xs font-medium text-muted-foreground">
44
+ <Show when={!isNext()}>
45
+ <ChevronLeft class="size-3.5 transition-transform group-hover:-translate-x-0.5" />
46
+ <span>Previous</span>
47
+ </Show>
48
+ <Show when={isNext()}>
49
+ <span>Next</span>
50
+ <ChevronRight class="size-3.5 transition-transform group-hover:translate-x-0.5" />
51
+ </Show>
52
+ </CardDescription>
53
+ <CardTitle class="text-sm font-semibold tracking-tight group-hover:text-primary transition-colors line-clamp-1">
54
+ {local.title}
55
+ </CardTitle>
56
+ </CardHeader>
57
+ </Card>
58
+ </a>
59
+ );
60
+ };
61
+
62
+ export interface PagerProps extends JSX.HTMLAttributes<HTMLDivElement> {
63
+ /** Previous documentation page item */
64
+ prev?: PagerItem | null;
65
+ /** Next documentation page item */
66
+ next?: PagerItem | null;
67
+ class?: string;
68
+ }
69
+
70
+ /**
71
+ * Documentation Next / Previous article navigation block composed of Nikala UI Card primitives.
72
+ */
73
+ export const Pager: Component<PagerProps> = (props) => {
74
+ const [local, rest] = splitProps(props, ["prev", "next", "class", "children"]);
75
+ const resolved = children(() => local.children);
76
+
77
+ return (
78
+ <div
79
+ class={cn("grid grid-cols-1 gap-4 sm:grid-cols-2 mt-10 pt-6 border-t border-border", local.class)}
80
+ {...rest}
81
+ >
82
+ <Show
83
+ when={resolved()}
84
+ fallback={
85
+ <>
86
+ <Show
87
+ when={local.prev}
88
+ fallback={<span class="hidden sm:block" />}
89
+ >
90
+ <PagerLink type="prev" title={local.prev!.title} href={local.prev!.href} />
91
+ </Show>
92
+ <Show when={local.next}>
93
+ <PagerLink type="next" title={local.next!.title} href={local.next!.href} />
94
+ </Show>
95
+ </>
96
+ }
97
+ >
98
+ {resolved()}
99
+ </Show>
100
+ </div>
101
+ );
102
+ };
@@ -1,6 +1,6 @@
1
1
  import { splitProps, type Component, type ComponentProps } from "solid-js";
2
2
  import { Popover as KobaltePopover } from "@kobalte/core/popover";
3
- import { createClickOutside } from "@nikala-ui/hooks";
3
+ import { createClickOutside } from "@/hooks/create-click-outside";
4
4
  import { X } from "lucide-solid";
5
5
  import { cn } from "@/lib/cn";
6
6
 
@@ -7,7 +7,7 @@ import {
7
7
  type JSX,
8
8
  type Accessor,
9
9
  } from "solid-js";
10
- import { createElementSize } from "@nikala-ui/hooks";
10
+ import { createElementSize } from "@/hooks/create-resize-observer";
11
11
  import { GripVertical, GripHorizontal } from "lucide-solid";
12
12
  import { cn } from "@/lib/cn";
13
13
 
@@ -66,6 +66,7 @@ export const ResizableGroup: Component<ResizableGroupProps> = (props) => {
66
66
  let newLeft = startLeftPct + deltaPct;
67
67
  let newRight = startRightPct - deltaPct;
68
68
 
69
+ // Min size limits (10% minimum)
69
70
  if (newLeft < 10) {
70
71
  newLeft = 10;
71
72
  newRight = startLeftPct + startRightPct - 10;
@@ -134,6 +135,7 @@ export const ResizablePanel: Component<ResizablePanelProps> = (props) => {
134
135
 
135
136
  const currentPct = () => ctx.sizes()[local.id] ?? local.initialSize ?? 50;
136
137
 
138
+ // Utilize Nikala UI createElementSize hook for reactive size inspection
137
139
  const containerSize = createElementSize(() => ctx.containerRef());
138
140
 
139
141
  return (
@@ -0,0 +1,236 @@
1
+ import {
2
+ createSignal,
3
+ createEffect,
4
+ onCleanup,
5
+ Show,
6
+ For,
7
+ type Component,
8
+ } from "solid-js";
9
+ import { Portal } from "solid-js/web";
10
+ import { useRichTextEditor } from "./editor.js";
11
+ import { ToolbarButton, HIGHLIGHT_PALETTE } from "./toolbar.js";
12
+ import {
13
+ Bold,
14
+ Italic,
15
+ Underline as UnderlineIcon,
16
+ Strikethrough,
17
+ Code,
18
+ Link as LinkIcon,
19
+ Highlighter,
20
+ Heading1,
21
+ Heading2,
22
+ List,
23
+ ListTodo,
24
+ } from "lucide-solid";
25
+
26
+ export const EditorBubbleMenu: Component = () => {
27
+ const { actions, container, isFullscreen, setShowLinkDialog } = useRichTextEditor();
28
+ const [isVisible, setIsVisible] = createSignal(false);
29
+ const [position, setPosition] = createSignal({ top: 0, left: 0 });
30
+ const [showHighlightPalette, setShowHighlightPalette] = createSignal(false);
31
+
32
+ createEffect(() => {
33
+ if (typeof window === "undefined") return;
34
+
35
+ const handleSelectionChange = () => {
36
+ const ed = actions.editor();
37
+ const containerEl = container();
38
+ if (!ed || !ed.isFocused || !ed.isEditable) {
39
+ setIsVisible(false);
40
+ setShowHighlightPalette(false);
41
+ return;
42
+ }
43
+
44
+ const { from, to } = ed.state.selection;
45
+ if (from === to || ed.state.selection.empty) {
46
+ setIsVisible(false);
47
+ setShowHighlightPalette(false);
48
+ return;
49
+ }
50
+
51
+ const selection = window.getSelection();
52
+ if (!selection || selection.rangeCount === 0) {
53
+ setIsVisible(false);
54
+ setShowHighlightPalette(false);
55
+ return;
56
+ }
57
+
58
+ const range = selection.getRangeAt(0);
59
+ const rect = range.getBoundingClientRect();
60
+ if (rect.width === 0 || rect.height === 0) {
61
+ setIsVisible(false);
62
+ setShowHighlightPalette(false);
63
+ return;
64
+ }
65
+
66
+ // Check that selection is inside the editor view element
67
+ const editorDom = ed.view.dom;
68
+ if (!editorDom.contains(range.commonAncestorContainer)) {
69
+ setIsVisible(false);
70
+ setShowHighlightPalette(false);
71
+ return;
72
+ }
73
+
74
+ if (isFullscreen() && containerEl) {
75
+ const containerRect = containerEl.getBoundingClientRect();
76
+ const top = rect.top - containerRect.top - 46;
77
+ const left = rect.left - containerRect.left + rect.width / 2;
78
+
79
+ setPosition({
80
+ top: Math.max(8, top),
81
+ left: Math.max(140, Math.min(containerRect.width - 140, left)),
82
+ });
83
+ } else {
84
+ // Normal mode: viewport coordinates
85
+ setPosition({
86
+ top: Math.max(12, rect.top - 46),
87
+ left: Math.max(140, Math.min(window.innerWidth - 140, rect.left + rect.width / 2)),
88
+ });
89
+ }
90
+
91
+ setIsVisible(true);
92
+ };
93
+
94
+ const handleMouseDown = (e: MouseEvent) => {
95
+ const target = e.target as HTMLElement;
96
+ // If clicking toolbar or any open popovers, hide bubble menu
97
+ if (
98
+ target.closest(".editor-popover-trigger") ||
99
+ target.closest(".editor-popover-content") ||
100
+ target.closest(".editor-toolbar")
101
+ ) {
102
+ setIsVisible(false);
103
+ setShowHighlightPalette(false);
104
+ }
105
+ };
106
+
107
+ document.addEventListener("selectionchange", handleSelectionChange);
108
+ document.addEventListener("mousedown", handleMouseDown);
109
+
110
+ onCleanup(() => {
111
+ document.removeEventListener("selectionchange", handleSelectionChange);
112
+ document.removeEventListener("mousedown", handleMouseDown);
113
+ });
114
+ });
115
+
116
+ const portalTarget = () => {
117
+ if (typeof document === "undefined") return undefined;
118
+ return isFullscreen() ? container() || document.body : document.body;
119
+ };
120
+
121
+ return (
122
+ <Show when={isVisible()}>
123
+ <Portal mount={portalTarget()}>
124
+ <div
125
+ class={`${
126
+ isFullscreen() ? "absolute" : "fixed"
127
+ } z-[999999] flex items-center gap-0.5 p-1 rounded-md border border-border bg-popover text-popover-foreground shadow-xl backdrop-blur-md -translate-x-1/2 transition-all duration-75 animate-in fade-in zoom-in-95 pointer-events-auto select-none`}
128
+ style={{
129
+ top: `${position().top}px`,
130
+ left: `${position().left}px`,
131
+ }}
132
+ >
133
+ <ToolbarButton onClick={actions.toggleBold} isActive={actions.isActive("bold")} tooltip="Bold">
134
+ <Bold class="h-3.5 w-3.5" />
135
+ </ToolbarButton>
136
+ <ToolbarButton onClick={actions.toggleItalic} isActive={actions.isActive("italic")} tooltip="Italic">
137
+ <Italic class="h-3.5 w-3.5" />
138
+ </ToolbarButton>
139
+ <ToolbarButton onClick={actions.toggleUnderline} isActive={actions.isActive("underline")} tooltip="Underline">
140
+ <UnderlineIcon class="h-3.5 w-3.5" />
141
+ </ToolbarButton>
142
+ <ToolbarButton onClick={actions.toggleStrike} isActive={actions.isActive("strike")} tooltip="Strikethrough">
143
+ <Strikethrough class="h-3.5 w-3.5" />
144
+ </ToolbarButton>
145
+ <ToolbarButton onClick={actions.toggleCode} isActive={actions.isActive("code")} tooltip="Code">
146
+ <Code class="h-3.5 w-3.5" />
147
+ </ToolbarButton>
148
+
149
+ {/* Marker Highlight with Tailwind Palette */}
150
+ <div class="relative">
151
+ <ToolbarButton
152
+ onClick={() => setShowHighlightPalette((p) => !p)}
153
+ isActive={actions.isActive("highlight")}
154
+ tooltip="Highlight Color"
155
+ >
156
+ <Highlighter class="h-3.5 w-3.5" />
157
+ </ToolbarButton>
158
+
159
+ <Show when={showHighlightPalette()}>
160
+ <div class="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 z-30 flex flex-col gap-1.5 p-2 bg-popover text-popover-foreground border border-border rounded-lg shadow-2xl min-w-[200px]">
161
+ <div class="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
162
+ Marker Palette
163
+ </div>
164
+ <div class="grid grid-cols-5 gap-1.5">
165
+ <For each={HIGHLIGHT_PALETTE}>
166
+ {(color) => (
167
+ <button
168
+ type="button"
169
+ title={color.name}
170
+ class="h-6 w-6 rounded-md border border-border/60 hover:scale-110 transition-transform cursor-pointer shadow-2xs"
171
+ style={{ "background-color": color.value }}
172
+ onClick={() => {
173
+ actions.toggleHighlight(color.value);
174
+ setShowHighlightPalette(false);
175
+ }}
176
+ />
177
+ )}
178
+ </For>
179
+ </div>
180
+ <button
181
+ type="button"
182
+ class="w-full text-center py-1 mt-1 text-xs rounded-md bg-muted hover:bg-muted/80 text-foreground transition-colors cursor-pointer font-medium"
183
+ onClick={() => {
184
+ actions.toggleHighlight();
185
+ setShowHighlightPalette(false);
186
+ }}
187
+ >
188
+ Clear Highlight
189
+ </button>
190
+ </div>
191
+ </Show>
192
+ </div>
193
+
194
+ <ToolbarButton
195
+ onClick={() => setShowLinkDialog(true)}
196
+ isActive={actions.isActive("link")}
197
+ tooltip="Insert Link"
198
+ >
199
+ <LinkIcon class="h-3.5 w-3.5" />
200
+ </ToolbarButton>
201
+
202
+ <div class="h-4 w-px bg-border mx-0.5" />
203
+
204
+ <ToolbarButton
205
+ onClick={() => actions.setHeading(1)}
206
+ isActive={actions.isActive("heading", { level: 1 })}
207
+ tooltip="H1"
208
+ >
209
+ <Heading1 class="h-3.5 w-3.5" />
210
+ </ToolbarButton>
211
+ <ToolbarButton
212
+ onClick={() => actions.setHeading(2)}
213
+ isActive={actions.isActive("heading", { level: 2 })}
214
+ tooltip="H2"
215
+ >
216
+ <Heading2 class="h-3.5 w-3.5" />
217
+ </ToolbarButton>
218
+ <ToolbarButton
219
+ onClick={actions.toggleBulletList}
220
+ isActive={actions.isActive("bulletList")}
221
+ tooltip="List"
222
+ >
223
+ <List class="h-3.5 w-3.5" />
224
+ </ToolbarButton>
225
+ <ToolbarButton
226
+ onClick={actions.toggleTaskList}
227
+ isActive={actions.isActive("taskList")}
228
+ tooltip="Tasks"
229
+ >
230
+ <ListTodo class="h-3.5 w-3.5" />
231
+ </ToolbarButton>
232
+ </div>
233
+ </Portal>
234
+ </Show>
235
+ );
236
+ };