Files
TasGrid/src/components/TextBubbleMenu.tsx
T
tcardoza d33a838aec
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m5s
multi-checklist ready for use
2026-03-24 16:18:05 -05:00

209 lines
11 KiB
TypeScript

import { type Component, createSignal, createEffect, For } from "solid-js";
import {
Bold,
Italic,
Underline,
Highlighter,
AlignLeft,
AlignCenter,
AlignRight,
Heading1,
Heading2,
Heading3,
Heading,
Eraser,
ListTodo,
Grid3X3,
AlignJustify
} from "lucide-solid";
import { cn } from "@/lib/utils";
import type { Editor } from "@tiptap/core";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface GenericAction {
label: string;
icon: Component;
isActive?: () => boolean;
command: () => void;
iconClass?: string;
}
interface FormatGroup {
name: string;
actions: GenericAction[];
collapseOnMobile?: boolean;
mobileIcon?: Component;
}
export const TextBubbleMenu: Component<{ editor: Editor }> = (props) => {
// Track state of active styles so we can highlight the buttons
const [activeFormats, setActiveFormats] = createSignal<Record<string, boolean>>({});
createEffect(() => {
const updateState = () => {
const editor = props.editor;
if (!editor) return;
setActiveFormats({
bold: editor.isActive('bold'),
italic: editor.isActive('italic'),
underline: editor.isActive('underline'),
highlight: editor.isActive('highlight'),
alignLeft: editor.isActive({ textAlign: 'left' }),
alignCenter: editor.isActive({ textAlign: 'center' }),
alignRight: editor.isActive({ textAlign: 'right' }),
h1: editor.isActive('heading', { level: 1 }),
h2: editor.isActive('heading', { level: 2 }),
h3: editor.isActive('heading', { level: 3 }),
checklist: editor.isActive('taskList'),
multiChecklist: editor.isActive('multiTaskList'),
table: editor.isActive('table'),
});
};
props.editor.on("selectionUpdate", updateState);
props.editor.on("transaction", updateState);
return () => {
props.editor.off("selectionUpdate", updateState);
props.editor.off("transaction", updateState);
};
});
const groups: FormatGroup[] = [
{
name: "Style",
actions: [
{ label: "Bold", icon: () => <Bold size={16} />, isActive: () => activeFormats().bold, command: () => props.editor.chain().focus().toggleBold().run() },
{ label: "Italic", icon: () => <Italic size={16} />, isActive: () => activeFormats().italic, command: () => props.editor.chain().focus().toggleItalic().run() },
{ label: "Underline", icon: () => <Underline size={16} />, isActive: () => activeFormats().underline, command: () => props.editor.chain().focus().toggleUnderline().run() },
{ label: "Highlight", icon: () => <Highlighter size={16} />, isActive: () => activeFormats().highlight, command: () => props.editor.chain().focus().toggleHighlight().run() },
]
},
{
name: "Headings",
collapseOnMobile: true,
mobileIcon: () => <Heading size={16} />,
actions: [
{ label: "Heading 1", icon: () => <Heading1 size={16} />, isActive: () => activeFormats().h1, command: () => props.editor.chain().focus().toggleHeading({ level: 1 }).run() },
{ label: "Heading 2", icon: () => <Heading2 size={16} />, isActive: () => activeFormats().h2, command: () => props.editor.chain().focus().toggleHeading({ level: 2 }).run() },
{ label: "Heading 3", icon: () => <Heading3 size={16} />, isActive: () => activeFormats().h3, command: () => props.editor.chain().focus().toggleHeading({ level: 3 }).run() },
]
},
{
name: "Alignment",
collapseOnMobile: true,
mobileIcon: () => <AlignJustify size={16} />,
actions: [
{ label: "Align Left", icon: () => <AlignLeft size={16} />, isActive: () => activeFormats().alignLeft, command: () => props.editor.chain().focus().setTextAlign('left').run() },
{ label: "Align Center", icon: () => <AlignCenter size={16} />, isActive: () => activeFormats().alignCenter, command: () => props.editor.chain().focus().setTextAlign('center').run() },
{ label: "Align Right", icon: () => <AlignRight size={16} />, isActive: () => activeFormats().alignRight, command: () => props.editor.chain().focus().setTextAlign('right').run() },
]
},
{
name: "Blocks",
actions: [
{ label: "Checklist", icon: () => <ListTodo size={16} />, isActive: () => activeFormats().checklist, command: () => props.editor.chain().focus().toggleTaskList().run() },
{ label: "Multi Checklist", icon: () => <ListTodo size={16} />, isActive: () => activeFormats().multiChecklist, command: () => props.editor.chain().focus().toggleMultiTaskList().run() },
{ label: "Table", icon: () => <Grid3X3 size={16} />, isActive: () => activeFormats().table, command: () => props.editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
]
},
{
name: "Clear",
actions: [
{ label: "Clear Formatting", icon: () => <Eraser size={16} />, command: () => props.editor.chain().focus().clearNodes().unsetAllMarks().run() },
]
}
];
return (
<div
class={cn(
"relative z-[140] w-full bg-background/80 backdrop-blur-md border-b border-border transition-all duration-300 ease-in-out overflow-hidden flex flex-col h-9 opacity-100"
)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<div class="flex items-center justify-center h-full px-2 max-w-4xl mx-auto w-full">
<div class="flex items-center gap-1">
<For each={groups}>
{(group, groupIdx) => (
<>
<div class={cn("flex items-center gap-0 px-0.5", group.collapseOnMobile ? "hidden sm:flex" : "")}>
<For each={group.actions}>
{(action) => (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
action.command();
}}
class={cn(
"p-1.5 rounded-md transition-colors group relative shrink-0",
action.isActive?.()
? "bg-primary/20 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
action.iconClass
)}
title={action.label}
>
<action.icon />
<span class="absolute top-full left-1/2 -translate-x-1/2 mt-2 px-2 py-1 text-[10px] font-bold bg-foreground text-background rounded opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-[200] shadow-lg">
{action.label}
</span>
</button>
)}
</For>
</div>
{group.collapseOnMobile && group.mobileIcon && (
<div class="flex sm:hidden items-center gap-0 px-0.5">
<Popover>
<PopoverTrigger class={cn(
"p-1.5 rounded-md transition-colors group relative shrink-0 text-muted-foreground hover:bg-muted hover:text-foreground",
group.actions.some(a => a.isActive?.()) && "bg-primary/20 text-primary"
)}>
<group.mobileIcon />
</PopoverTrigger>
<PopoverContent class="w-auto p-1 flex items-center gap-1 shadow-xl" align="center">
<For each={group.actions}>
{(action) => (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
action.command();
}}
class={cn(
"p-1.5 rounded-md transition-colors relative shrink-0",
action.isActive?.()
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
action.iconClass
)}
title={action.label}
>
<action.icon />
</button>
)}
</For>
</PopoverContent>
</Popover>
</div>
)}
{groupIdx() < groups.length - 1 && (
<div class="w-[1px] h-4 bg-border/50 mx-1" />
)}
</>
)}
</For>
</div>
</div>
</div>
);
};