Compare commits

...

4 Commits

Author SHA1 Message Date
tcardoza 163003d857 tables great
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m1s
2026-03-13 16:34:31 -05:00
tcardoza abbdb993d5 Tabe controls stable 2026-03-13 16:11:57 -05:00
tcardoza cb8c81c299 updated table controls 2026-03-13 14:49:01 -05:00
tcardoza 3bfb4cd40a Table control improvements 2026-03-13 14:27:29 -05:00
5 changed files with 236 additions and 30 deletions
+183
View File
@@ -0,0 +1,183 @@
import { type Component, createSignal, createEffect, For } from "solid-js";
import {
Trash2,
Type
} from "lucide-solid";
import { cn } from "@/lib/utils";
import type { Editor } from "@tiptap/core";
// Custom Icons
const AddRowAbove = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="8" width="12" height="7" rx="1.5" stroke="currentColor" stroke-width="1.5" />
<path d="M9 2V6M7 4H11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
const AddRowBelow = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="12" height="7" rx="1.5" stroke="currentColor" stroke-width="1.5" />
<path d="M9 12V16M7 14H11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
const AddColLeft = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="8" y="3" width="7" height="12" rx="1.5" stroke="currentColor" stroke-width="1.5" />
<path d="M2 9H6M4 7V11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
const AddColRight = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="7" height="12" rx="1.5" stroke="currentColor" stroke-width="1.5" />
<path d="M12 9H16M14 7V11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
const DeleteRow = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="7" width="14" height="4" rx="0.5" stroke="currentColor" stroke-width="1.5" />
<path d="M4 3L14 15M14 3L4 15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
const DeleteCol = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="7" y="2" width="4" height="14" rx="0.5" stroke="currentColor" stroke-width="1.5" />
<path d="M3 4L15 14M15 4L3 14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
const MergeCells = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="4" width="14" height="10" rx="1" stroke="currentColor" stroke-width="1.5" />
<path d="M6 9H12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
const SplitCell = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="4" width="14" height="10" rx="1" stroke="currentColor" stroke-width="1.5" />
<path d="M9 7V11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
interface TableAction {
label: string;
icon: Component;
command: () => void;
iconClass?: string;
}
interface TableGroup {
name: string;
actions: TableAction[];
}
interface TableBubbleMenuProps {
editor: Editor;
}
export const TableBubbleMenu: Component<TableBubbleMenuProps> = (props) => {
const [isVisible, setIsVisible] = createSignal(false);
createEffect(() => {
const updateVisibility = () => {
const editor = props.editor;
if (!editor) return;
setIsVisible(editor.isActive("table"));
};
props.editor.on("selectionUpdate", updateVisibility);
props.editor.on("transaction", updateVisibility);
return () => {
props.editor.off("selectionUpdate", updateVisibility);
props.editor.off("transaction", updateVisibility);
};
});
const groups: TableGroup[] = [
{
name: "Rows",
actions: [
{ label: "Insert Row Above", icon: AddRowAbove, command: () => props.editor.chain().focus().addRowBefore().run() },
{ label: "Insert Row Below", icon: AddRowBelow, command: () => props.editor.chain().focus().addRowAfter().run() },
{ label: "Delete Row", icon: DeleteRow, iconClass: "text-destructive", command: () => props.editor.chain().focus().deleteRow().run() },
]
},
{
name: "Cols",
actions: [
{ label: "Insert Column Left", icon: AddColLeft, command: () => props.editor.chain().focus().addColumnBefore().run() },
{ label: "Insert Column Right", icon: AddColRight, command: () => props.editor.chain().focus().addColumnAfter().run() },
{ label: "Delete Column", icon: DeleteCol, iconClass: "text-destructive", command: () => props.editor.chain().focus().deleteColumn().run() },
]
},
{
name: "Cells",
actions: [
{ label: "Merge Cells", icon: MergeCells, command: () => props.editor.chain().focus().mergeCells().run() },
{ label: "Split Cell", icon: SplitCell, command: () => props.editor.chain().focus().splitCell().run() },
{ label: "Header Row", icon: () => <Type size={18} />, command: () => props.editor.chain().focus().toggleHeaderRow().run() },
]
},
{
name: "Table",
actions: [
{ label: "Delete Table", icon: () => <Trash2 size={18} />, iconClass: "text-destructive", command: () => props.editor.chain().focus().deleteTable().run() },
]
}
];
return (
<div
class={cn(
"relative z-[150] w-full bg-background/80 backdrop-blur-md border-b border-border transition-all duration-300 ease-in-out overflow-hidden flex flex-col",
isVisible() ? "h-9 opacity-100" : "h-0 opacity-0 pointer-events-none"
)}
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="flex items-center gap-0 px-0.5">
<For each={group.actions}>
{(action) => (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
action.command();
}}
class={cn(
"p-0.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground shrink-0 group relative",
action.iconClass
)}
>
<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>
{groupIdx() < groups.length - 1 && (
<div class="w-[1px] h-4 bg-border/50 mx-1" />
)}
</>
)}
</For>
</div>
</div>
</div>
);
};
+3 -3
View File
@@ -233,10 +233,10 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
e.preventDefault();
}
}}
class="w-full sm:max-w-2xl px-0 sm:px-0 flex flex-col h-full bg-background border-l border-border shadow-2xl"
class="w-full sm:max-w-2xl p-0 gap-0 flex flex-col h-full bg-background border-l border-border shadow-2xl"
>
{/* Header Area */}
<div class="px-6 pt-6 pb-2 shrink-0">
<div class="px-6 pt-6 pb-0 shrink-0">
<div class="flex items-start gap-4">
<div class="shrink-0 mt-0.5">
<Select<any>
@@ -496,7 +496,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
</div>
{/* Content Area */}
<div class="flex-1 overflow-y-auto px-6 py-4 pb-20 flex flex-col gap-6">
<div class="flex-1 overflow-y-auto px-6 pb-20 flex flex-col">
<Suspense fallback={<div class="h-32 animate-pulse bg-muted/20 rounded-lg" />}>
<TaskEditor
content={props.task.content}
+32 -26
View File
@@ -35,6 +35,7 @@ import { Video } from "@/lib/extensions/video";
import { VideoUpload } from "@/lib/extensions/video-upload";
import { FileAttachment } from "@/lib/extensions/file-attachment";
import { FileUpload } from "@/lib/extensions/file-upload";
import { TableBubbleMenu } from "./TableBubbleMenu";
interface TaskEditorProps {
content?: string;
@@ -321,33 +322,38 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
// Handle full screen images as before
return (
<div class="relative w-full flex flex-col flex-1">
<div
class="absolute -top-6 -left-4 -right-4 h-8 cursor-pointer group flex items-center justify-center z-10"
onClick={() => {
const e = editor();
if (!e) return;
e.chain().insertContentAt(0, '<p></p>').focus('start').run();
}}
title="Add empty line at the top"
>
<span class="opacity-0 group-hover:opacity-100 text-[10px] uppercase font-bold text-muted-foreground tracking-widest transition-opacity mt-1">
Click to insert newline above
</span>
<div class="w-full h-full flex flex-col">
<Show when={editor()}>
{(e) => <TableBubbleMenu editor={e()} />}
</Show>
<div class="w-full flex-1 flex flex-col">
<div
class="w-full h-8 cursor-pointer group flex items-center justify-center shrink-0 transition-all"
onClick={() => {
const e = editor();
if (!e) return;
e.chain().insertContentAt(0, '<p></p>').focus('start').run();
}}
title="Add empty line at the top"
>
<span class="opacity-0 group-hover:opacity-100 text-[10px] uppercase font-bold text-muted-foreground tracking-widest transition-opacity">
Click to insert newline above
</span>
</div>
<div
ref={editorRef}
class="w-full flex-1 cursor-text"
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.tagName.toLowerCase() === 'img') {
setFullscreenImage((target as HTMLImageElement).src);
} else if (e.target === editorRef) {
// Focus end if clicking on padding below content
editor()?.chain().focus().run();
}
}}
/>
</div>
<div
ref={editorRef}
class="w-full flex-1 cursor-text"
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.tagName.toLowerCase() === 'img') {
setFullscreenImage((target as HTMLImageElement).src);
} else if (e.target === editorRef) {
// Focus end if clicking on padding below content
editor()?.chain().focus().run();
}
}}
/>
{/* Note Preview Popover */}
<Show when={previewNote() && store.notes.find(n => n.id === previewNote()!.noteId)}>
+17
View File
@@ -172,4 +172,21 @@
.fade-enter,
.fade-exit-to {
opacity: 0;
}
/* --- Tiptap Table Selection --- */
.ProseMirror td.selectedCell,
.ProseMirror th.selectedCell {
background: color-mix(in srgb, var(--primary), transparent 90%);
position: relative;
}
.ProseMirror td.selectedCell::after,
.ProseMirror th.selectedCell::after {
content: "";
position: absolute;
inset: -1px;
border: 2px solid var(--primary);
pointer-events: none;
z-index: 10;
}
+1 -1
View File
@@ -567,7 +567,7 @@ export const NotepadView: Component<{
{/* Editor Instance */}
<div class={cn(
"grow shrink-0 flex flex-col",
hasTopViewer() ? "border-none rounded-none p-0 shadow-none mb-0 min-h-full" : "min-h-[300px] mb-4 p-4 border border-border/50 rounded-xl bg-background shadow-sm"
hasTopViewer() ? "border-none rounded-none p-0 shadow-none mb-0 min-h-full" : "min-h-[300px] mb-4 px-4 pt-0 pb-4 border border-border/50 rounded-xl bg-background shadow-sm"
)}>
<TaskEditor
content={note().content}