From 8c743c72ba34a00c35987e057ff62945bd087c8f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Feb 2026 21:18:21 +0000 Subject: [PATCH 1/8] feat: add live memo refresh via Server-Sent Events (SSE) Implement real-time memo synchronization across all open browser instances using Server-Sent Events (SSE). When a memo is created, updated, or deleted on one device, all other connected clients receive the change notification and automatically refresh their data. Backend changes: - Add SSEHub (pub/sub) for broadcasting memo change events to connected clients - Add SSE HTTP endpoint at /api/v1/sse with Bearer token authentication (supports both Authorization header and query parameter for EventSource) - Broadcast memo.created, memo.updated, and memo.deleted events from the memo service after successful operations - Include SSEHub in APIV1Service and wire it into server initialization - Update test helper to include SSEHub to prevent nil pointer panics Frontend changes: - Add useLiveMemoRefresh hook that connects to SSE endpoint using fetch ReadableStream (supports custom auth headers unlike native EventSource) - Automatically invalidate React Query caches on received events: - memo.created: invalidate memo lists + user stats - memo.updated: invalidate specific memo detail + memo lists - memo.deleted: remove memo from cache + invalidate lists + user stats - Exponential backoff reconnection (1s to 30s) on connection failures - Integrate hook in AppInitializer for app-wide live refresh - Add SSE-specific Vite dev proxy config with no timeout for streaming Co-authored-by: milvasic --- server/router/api/v1/memo_service.go | 18 +++ server/router/api/v1/sse_handler.go | 101 +++++++++++++++ server/router/api/v1/sse_hub.go | 86 +++++++++++++ server/router/api/v1/test/test_helper.go | 1 + server/router/api/v1/v1.go | 2 + server/server.go | 4 + web/src/hooks/useLiveMemoRefresh.ts | 152 +++++++++++++++++++++++ web/src/main.tsx | 4 + web/vite.config.mts | 6 + 9 files changed, 374 insertions(+) create mode 100644 server/router/api/v1/sse_handler.go create mode 100644 server/router/api/v1/sse_hub.go create mode 100644 web/src/hooks/useLiveMemoRefresh.ts diff --git a/server/router/api/v1/memo_service.go b/server/router/api/v1/memo_service.go index f5d250a16..47f3a9c36 100644 --- a/server/router/api/v1/memo_service.go +++ b/server/router/api/v1/memo_service.go @@ -141,6 +141,12 @@ func (s *APIV1Service) CreateMemo(ctx context.Context, request *v1pb.CreateMemoR slog.Warn("Failed to dispatch memo created webhook", slog.Any("err", err)) } + // Broadcast live refresh event. + s.SSEHub.Broadcast(&MemoEvent{ + Type: MemoEventCreated, + Name: memoMessage.Name, + }) + return memoMessage, nil } @@ -471,6 +477,12 @@ func (s *APIV1Service) UpdateMemo(ctx context.Context, request *v1pb.UpdateMemoR slog.Warn("Failed to dispatch memo updated webhook", slog.Any("err", err)) } + // Broadcast live refresh event. + s.SSEHub.Broadcast(&MemoEvent{ + Type: MemoEventUpdated, + Name: memoMessage.Name, + }) + return memoMessage, nil } @@ -539,6 +551,12 @@ func (s *APIV1Service) DeleteMemo(ctx context.Context, request *v1pb.DeleteMemoR return nil, status.Errorf(codes.Internal, "failed to delete memo") } + // Broadcast live refresh event. + s.SSEHub.Broadcast(&MemoEvent{ + Type: MemoEventDeleted, + Name: request.Name, + }) + return &emptypb.Empty{}, nil } diff --git a/server/router/api/v1/sse_handler.go b/server/router/api/v1/sse_handler.go new file mode 100644 index 000000000..af9e2389f --- /dev/null +++ b/server/router/api/v1/sse_handler.go @@ -0,0 +1,101 @@ +package v1 + +import ( + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/labstack/echo/v4" + + "github.com/usememos/memos/server/auth" + "github.com/usememos/memos/store" +) + +const ( + // sseHeartbeatInterval is the interval between heartbeat pings to keep the connection alive. + sseHeartbeatInterval = 30 * time.Second +) + +// RegisterSSERoutes registers the SSE endpoint on the given Echo instance. +func RegisterSSERoutes(echoServer *echo.Echo, hub *SSEHub, storeInstance *store.Store, secret string) { + authenticator := auth.NewAuthenticator(storeInstance, secret) + echoServer.GET("/api/v1/sse", func(c echo.Context) error { + return handleSSE(c, hub, authenticator) + }) +} + +// handleSSE handles the SSE connection for live memo refresh. +// Authentication is done via Bearer token in the Authorization header, +// or via the "token" query parameter (for EventSource which cannot set headers). +func handleSSE(c echo.Context, hub *SSEHub, authenticator *auth.Authenticator) error { + // Authenticate the request. + authHeader := c.Request().Header.Get("Authorization") + if authHeader == "" { + // Fall back to query parameter for native EventSource support. + if token := c.QueryParam("token"); token != "" { + authHeader = "Bearer " + token + } + } + + result := authenticator.Authenticate(c.Request().Context(), authHeader) + if result == nil { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + } + + // Set SSE headers. + w := c.Response() + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering + w.WriteHeader(http.StatusOK) + + // Flush headers immediately. + if f, ok := w.Writer.(http.Flusher); ok { + f.Flush() + } + + // Subscribe to the hub. + client := hub.Subscribe() + defer hub.Unsubscribe(client) + + // Create a ticker for heartbeat pings. + heartbeat := time.NewTicker(sseHeartbeatInterval) + defer heartbeat.Stop() + + ctx := c.Request().Context() + + slog.Debug("SSE client connected") + + for { + select { + case <-ctx.Done(): + // Client disconnected. + slog.Debug("SSE client disconnected") + return nil + + case data, ok := <-client.events: + if !ok { + // Channel closed, client was unsubscribed. + return nil + } + // Write SSE event. + if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil { + return nil + } + if f, ok := w.Writer.(http.Flusher); ok { + f.Flush() + } + + case <-heartbeat.C: + // Send a heartbeat comment to keep the connection alive. + if _, err := fmt.Fprint(w, ": heartbeat\n\n"); err != nil { + return nil + } + if f, ok := w.Writer.(http.Flusher); ok { + f.Flush() + } + } + } +} diff --git a/server/router/api/v1/sse_hub.go b/server/router/api/v1/sse_hub.go new file mode 100644 index 000000000..accebe97a --- /dev/null +++ b/server/router/api/v1/sse_hub.go @@ -0,0 +1,86 @@ +package v1 + +import ( + "encoding/json" + "sync" +) + +// MemoEventType represents the type of memo change event. +type MemoEventType string + +const ( + MemoEventCreated MemoEventType = "memo.created" + MemoEventUpdated MemoEventType = "memo.updated" + MemoEventDeleted MemoEventType = "memo.deleted" +) + +// MemoEvent represents a memo change event sent to SSE clients. +type MemoEvent struct { + Type MemoEventType `json:"type"` + // Name is the memo resource name (e.g., "memos/xxxx"). + Name string `json:"name"` +} + +// JSON returns the JSON representation of the event. +func (e *MemoEvent) JSON() []byte { + data, _ := json.Marshal(e) + return data +} + +// sseClient represents a single SSE connection. +type sseClient struct { + events chan []byte +} + +// SSEHub manages SSE client connections and broadcasts events. +// It is safe for concurrent use. +type SSEHub struct { + mu sync.RWMutex + clients map[*sseClient]struct{} +} + +// NewSSEHub creates a new SSE hub. +func NewSSEHub() *SSEHub { + return &SSEHub{ + clients: make(map[*sseClient]struct{}), + } +} + +// Subscribe registers a new client and returns it. +// The caller must call Unsubscribe when done. +func (h *SSEHub) Subscribe() *sseClient { + c := &sseClient{ + // Buffer a few events so a slow client doesn't block broadcasting. + events: make(chan []byte, 32), + } + h.mu.Lock() + h.clients[c] = struct{}{} + h.mu.Unlock() + return c +} + +// Unsubscribe removes a client and closes its channel. +func (h *SSEHub) Unsubscribe(c *sseClient) { + h.mu.Lock() + if _, ok := h.clients[c]; ok { + delete(h.clients, c) + close(c.events) + } + h.mu.Unlock() +} + +// Broadcast sends an event to all connected clients. +// Slow clients that have a full buffer will have the event dropped +// to avoid blocking the broadcaster. +func (h *SSEHub) Broadcast(event *MemoEvent) { + data := event.JSON() + h.mu.RLock() + defer h.mu.RUnlock() + for c := range h.clients { + select { + case c.events <- data: + default: + // Drop event for slow client to avoid blocking. + } + } +} diff --git a/server/router/api/v1/test/test_helper.go b/server/router/api/v1/test/test_helper.go index 779ad2eea..c3afdb38b 100644 --- a/server/router/api/v1/test/test_helper.go +++ b/server/router/api/v1/test/test_helper.go @@ -46,6 +46,7 @@ func NewTestService(t *testing.T) *TestService { Profile: testProfile, Store: testStore, MarkdownService: markdownService, + SSEHub: apiv1.NewSSEHub(), } return &TestService{ diff --git a/server/router/api/v1/v1.go b/server/router/api/v1/v1.go index 694b5bbc3..b9cb6fb40 100644 --- a/server/router/api/v1/v1.go +++ b/server/router/api/v1/v1.go @@ -31,6 +31,7 @@ type APIV1Service struct { Profile *profile.Profile Store *store.Store MarkdownService markdown.Service + SSEHub *SSEHub // thumbnailSemaphore limits concurrent thumbnail generation to prevent memory exhaustion thumbnailSemaphore *semaphore.Weighted @@ -45,6 +46,7 @@ func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store Profile: profile, Store: store, MarkdownService: markdownService, + SSEHub: NewSSEHub(), thumbnailSemaphore: semaphore.NewWeighted(3), // Limit to 3 concurrent thumbnail generations } } diff --git a/server/server.go b/server/server.go index af09c4bcd..6ddbdd6fc 100644 --- a/server/server.go +++ b/server/server.go @@ -76,6 +76,10 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store // Create and register RSS routes (needs markdown service from apiV1Service). rss.NewRSSService(s.Profile, s.Store, apiV1Service.MarkdownService).RegisterRoutes(rootGroup) + + // Register SSE endpoint for live memo refresh. + apiv1.RegisterSSERoutes(echoServer, apiV1Service.SSEHub, s.Store, s.Secret) + // Register gRPC gateway as api v1. if err := apiV1Service.RegisterGateway(ctx, echoServer); err != nil { return nil, errors.Wrap(err, "failed to register gRPC gateway") diff --git a/web/src/hooks/useLiveMemoRefresh.ts b/web/src/hooks/useLiveMemoRefresh.ts new file mode 100644 index 000000000..e1a220d40 --- /dev/null +++ b/web/src/hooks/useLiveMemoRefresh.ts @@ -0,0 +1,152 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; +import { getAccessToken } from "@/auth-state"; +import { memoKeys } from "@/hooks/useMemoQueries"; +import { userKeys } from "@/hooks/useUserQueries"; + +/** + * Reconnection parameters for SSE connection. + */ +const INITIAL_RETRY_DELAY_MS = 1000; +const MAX_RETRY_DELAY_MS = 30000; +const RETRY_BACKOFF_MULTIPLIER = 2; + +/** + * useLiveMemoRefresh connects to the server's SSE endpoint and + * invalidates relevant React Query caches when memo change events + * (created, updated, deleted) are received. + * + * This enables real-time updates across all open instances of the app. + */ +export function useLiveMemoRefresh() { + const queryClient = useQueryClient(); + const retryDelayRef = useRef(INITIAL_RETRY_DELAY_MS); + const abortControllerRef = useRef(null); + + useEffect(() => { + let mounted = true; + let retryTimeout: ReturnType | null = null; + + const connect = async () => { + if (!mounted) return; + + const token = getAccessToken(); + if (!token) { + // Not logged in; retry after a delay in case the user logs in. + retryTimeout = setTimeout(connect, 5000); + return; + } + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + try { + const response = await fetch("/api/v1/sse", { + headers: { + Authorization: `Bearer ${token}`, + }, + signal: abortController.signal, + credentials: "include", + }); + + if (!response.ok || !response.body) { + throw new Error(`SSE connection failed: ${response.status}`); + } + + // Successfully connected - reset retry delay. + retryDelayRef.current = INITIAL_RETRY_DELAY_MS; + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (mounted) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Process complete SSE messages (separated by double newlines). + const messages = buffer.split("\n\n"); + // Keep the last incomplete chunk in the buffer. + buffer = messages.pop() || ""; + + for (const message of messages) { + if (!message.trim()) continue; + + // Parse SSE format: lines starting with "data: " contain JSON payload. + // Lines starting with ":" are comments (heartbeats). + for (const line of message.split("\n")) { + if (line.startsWith("data: ")) { + const jsonStr = line.slice(6); + try { + const event = JSON.parse(jsonStr) as { type: string; name: string }; + handleMemoEvent(event, queryClient); + } catch { + // Ignore malformed JSON. + } + } + } + } + } + } catch (err: unknown) { + if (err instanceof DOMException && err.name === "AbortError") { + // Intentional abort, don't reconnect. + return; + } + // Connection lost or failed - reconnect with backoff. + } + + // Reconnect with exponential backoff. + if (mounted) { + const delay = retryDelayRef.current; + retryDelayRef.current = Math.min(delay * RETRY_BACKOFF_MULTIPLIER, MAX_RETRY_DELAY_MS); + retryTimeout = setTimeout(connect, delay); + } + }; + + connect(); + + return () => { + mounted = false; + if (retryTimeout) { + clearTimeout(retryTimeout); + } + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, [queryClient]); +} + +interface MemoChangeEvent { + type: string; + name: string; +} + +function handleMemoEvent(event: MemoChangeEvent, queryClient: ReturnType) { + switch (event.type) { + case "memo.created": + // Invalidate memo lists so new memos appear. + queryClient.invalidateQueries({ queryKey: memoKeys.lists() }); + // Invalidate user stats (memo count changed). + queryClient.invalidateQueries({ queryKey: userKeys.stats() }); + break; + + case "memo.updated": + // Invalidate the specific memo detail cache. + queryClient.invalidateQueries({ queryKey: memoKeys.detail(event.name) }); + // Invalidate memo lists to reflect updated content/ordering. + queryClient.invalidateQueries({ queryKey: memoKeys.lists() }); + break; + + case "memo.deleted": + // Remove the specific memo from cache. + queryClient.removeQueries({ queryKey: memoKeys.detail(event.name) }); + // Invalidate memo lists. + queryClient.invalidateQueries({ queryKey: memoKeys.lists() }); + // Invalidate user stats (memo count changed). + queryClient.invalidateQueries({ queryKey: userKeys.stats() }); + break; + } +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 5d617b210..a87b1a14b 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -12,6 +12,7 @@ import { refreshAccessToken } from "@/connect"; import { AuthProvider, useAuth } from "@/contexts/AuthContext"; import { InstanceProvider, useInstance } from "@/contexts/InstanceContext"; import { ViewProvider } from "@/contexts/ViewContext"; +import { useLiveMemoRefresh } from "@/hooks/useLiveMemoRefresh"; import { useTokenRefreshOnFocus } from "@/hooks/useTokenRefreshOnFocus"; import { queryClient } from "@/lib/query-client"; import router from "./router"; @@ -46,6 +47,9 @@ function AppInitializer({ children }: { children: React.ReactNode }) { // Related: https://github.com/usememos/memos/issues/5589 useTokenRefreshOnFocus(refreshAccessToken, !!currentUser); + // Live refresh: listen for memo changes via SSE and invalidate caches. + useLiveMemoRefresh(); + if (!authInitialized || !instanceInitialized) { return null; } diff --git a/web/vite.config.mts b/web/vite.config.mts index 5b63cb382..1433c39f2 100644 --- a/web/vite.config.mts +++ b/web/vite.config.mts @@ -16,6 +16,12 @@ export default defineConfig({ host: "0.0.0.0", port: 3001, proxy: { + "^/api/v1/sse": { + target: devProxyServer, + xfwd: true, + // SSE requires no response buffering and longer timeout. + timeout: 0, + }, "^/api": { target: devProxyServer, xfwd: true, From bab7a53d7ae78617372584a7bf71b7ca008eaf99 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Feb 2026 21:41:44 +0000 Subject: [PATCH 2/8] feat: extend live refresh to sync reactions across instances Add SSE event broadcasting for reaction changes so that when a user adds or removes a reaction on one device, all other open instances see the update in real-time. Backend: - Rename MemoEvent/MemoEventType to SSEEvent/SSEEventType for generality - Add reaction.upserted and reaction.deleted event types - Broadcast events from UpsertMemoReaction and DeleteMemoReaction, using the reaction's ContentID (memo name) as the event name Frontend: - Handle reaction.upserted and reaction.deleted SSE events by invalidating the affected memo detail cache and memo lists - Rename internal handler to handleSSEEvent to reflect broader scope Co-authored-by: milvasic --- server/router/api/v1/memo_service.go | 12 ++++++------ server/router/api/v1/reaction_service.go | 12 ++++++++++++ server/router/api/v1/sse_hub.go | 25 +++++++++++++----------- web/src/hooks/useLiveMemoRefresh.ts | 14 ++++++++++--- 4 files changed, 43 insertions(+), 20 deletions(-) diff --git a/server/router/api/v1/memo_service.go b/server/router/api/v1/memo_service.go index 47f3a9c36..70a6a2790 100644 --- a/server/router/api/v1/memo_service.go +++ b/server/router/api/v1/memo_service.go @@ -142,8 +142,8 @@ func (s *APIV1Service) CreateMemo(ctx context.Context, request *v1pb.CreateMemoR } // Broadcast live refresh event. - s.SSEHub.Broadcast(&MemoEvent{ - Type: MemoEventCreated, + s.SSEHub.Broadcast(&SSEEvent{ + Type: SSEEventMemoCreated, Name: memoMessage.Name, }) @@ -478,8 +478,8 @@ func (s *APIV1Service) UpdateMemo(ctx context.Context, request *v1pb.UpdateMemoR } // Broadcast live refresh event. - s.SSEHub.Broadcast(&MemoEvent{ - Type: MemoEventUpdated, + s.SSEHub.Broadcast(&SSEEvent{ + Type: SSEEventMemoUpdated, Name: memoMessage.Name, }) @@ -552,8 +552,8 @@ func (s *APIV1Service) DeleteMemo(ctx context.Context, request *v1pb.DeleteMemoR } // Broadcast live refresh event. - s.SSEHub.Broadcast(&MemoEvent{ - Type: MemoEventDeleted, + s.SSEHub.Broadcast(&SSEEvent{ + Type: SSEEventMemoDeleted, Name: request.Name, }) diff --git a/server/router/api/v1/reaction_service.go b/server/router/api/v1/reaction_service.go index a7c7cc3bd..a4c521fe8 100644 --- a/server/router/api/v1/reaction_service.go +++ b/server/router/api/v1/reaction_service.go @@ -97,6 +97,12 @@ func (s *APIV1Service) UpsertMemoReaction(ctx context.Context, request *v1pb.Ups reactionMessage := convertReactionFromStore(reaction) + // Broadcast live refresh event (reaction belongs to a memo). + s.SSEHub.Broadcast(&SSEEvent{ + Type: SSEEventReactionUpserted, + Name: request.Reaction.ContentId, + }) + return reactionMessage, nil } @@ -136,6 +142,12 @@ func (s *APIV1Service) DeleteMemoReaction(ctx context.Context, request *v1pb.Del return nil, status.Errorf(codes.Internal, "failed to delete reaction") } + // Broadcast live refresh event (reaction belongs to a memo). + s.SSEHub.Broadcast(&SSEEvent{ + Type: SSEEventReactionDeleted, + Name: reaction.ContentID, + }) + return &emptypb.Empty{}, nil } diff --git a/server/router/api/v1/sse_hub.go b/server/router/api/v1/sse_hub.go index accebe97a..c75c3cc2c 100644 --- a/server/router/api/v1/sse_hub.go +++ b/server/router/api/v1/sse_hub.go @@ -5,24 +5,27 @@ import ( "sync" ) -// MemoEventType represents the type of memo change event. -type MemoEventType string +// SSEEventType represents the type of change event. +type SSEEventType string const ( - MemoEventCreated MemoEventType = "memo.created" - MemoEventUpdated MemoEventType = "memo.updated" - MemoEventDeleted MemoEventType = "memo.deleted" + SSEEventMemoCreated SSEEventType = "memo.created" + SSEEventMemoUpdated SSEEventType = "memo.updated" + SSEEventMemoDeleted SSEEventType = "memo.deleted" + SSEEventReactionUpserted SSEEventType = "reaction.upserted" + SSEEventReactionDeleted SSEEventType = "reaction.deleted" ) -// MemoEvent represents a memo change event sent to SSE clients. -type MemoEvent struct { - Type MemoEventType `json:"type"` - // Name is the memo resource name (e.g., "memos/xxxx"). +// SSEEvent represents a change event sent to SSE clients. +type SSEEvent struct { + Type SSEEventType `json:"type"` + // Name is the affected resource name (e.g., "memos/xxxx"). + // For reaction events, this is the memo resource name that the reaction belongs to. Name string `json:"name"` } // JSON returns the JSON representation of the event. -func (e *MemoEvent) JSON() []byte { +func (e *SSEEvent) JSON() []byte { data, _ := json.Marshal(e) return data } @@ -72,7 +75,7 @@ func (h *SSEHub) Unsubscribe(c *sseClient) { // Broadcast sends an event to all connected clients. // Slow clients that have a full buffer will have the event dropped // to avoid blocking the broadcaster. -func (h *SSEHub) Broadcast(event *MemoEvent) { +func (h *SSEHub) Broadcast(event *SSEEvent) { data := event.JSON() h.mu.RLock() defer h.mu.RUnlock() diff --git a/web/src/hooks/useLiveMemoRefresh.ts b/web/src/hooks/useLiveMemoRefresh.ts index e1a220d40..8dc9c40c1 100644 --- a/web/src/hooks/useLiveMemoRefresh.ts +++ b/web/src/hooks/useLiveMemoRefresh.ts @@ -81,7 +81,7 @@ export function useLiveMemoRefresh() { const jsonStr = line.slice(6); try { const event = JSON.parse(jsonStr) as { type: string; name: string }; - handleMemoEvent(event, queryClient); + handleSSEEvent(event, queryClient); } catch { // Ignore malformed JSON. } @@ -119,12 +119,12 @@ export function useLiveMemoRefresh() { }, [queryClient]); } -interface MemoChangeEvent { +interface SSEChangeEvent { type: string; name: string; } -function handleMemoEvent(event: MemoChangeEvent, queryClient: ReturnType) { +function handleSSEEvent(event: SSEChangeEvent, queryClient: ReturnType) { switch (event.type) { case "memo.created": // Invalidate memo lists so new memos appear. @@ -148,5 +148,13 @@ function handleMemoEvent(event: MemoChangeEvent, queryClient: ReturnType Date: Fri, 6 Feb 2026 22:44:15 +0000 Subject: [PATCH 3/8] feat: add table editor dialog for visual table editing Add a dialog-based table editor that makes creating and editing markdown tables much easier than manipulating raw pipe-delimited text. Features: - Visual grid of input fields for editing headers and cells - Add and remove rows and columns - Sort columns ascending/descending (supports both text and numeric) - Tab key navigation between cells (auto-creates new rows at the end) - Properly formatted/aligned markdown output on confirm - Row numbers with hover-to-delete interaction - Column sort indicators and remove buttons Integration points: 1. Toolbar: New 'Table' button in the InsertMenu (+) dropdown opens the dialog for creating new tables from the editor 2. Slash command: /table now opens the dialog instead of inserting raw markdown, via new Command.action callback support 3. Rendered tables: Edit pencil icon appears on hover over rendered tables in MemoContent, opens dialog pre-populated with parsed table data, and saves changes directly via updateMemo mutation (same pattern as TaskListItem checkbox toggling) New files: - utils/markdown-table.ts: Parse, serialize, find/replace markdown tables - components/TableEditorDialog.tsx: Reusable table editor dialog component Modified: - Extended Command interface with optional action callback for dialogs - SlashCommands handles action-based commands (skips text insertion) - Editor accepts custom commands via props - EditorContent creates commands with table editor wired in - MemoEditor manages table dialog state shared between slash cmd and toolbar - InsertMenu includes Table entry and its own dialog for toolbar flow - Table.tsx (MemoContent) adds edit button and dialog for rendered tables Co-authored-by: milvasic --- web/src/components/MemoContent/Table.tsx | 91 ++++- .../MemoEditor/Editor/SlashCommands.tsx | 12 +- .../components/MemoEditor/Editor/commands.ts | 18 + .../components/MemoEditor/Editor/index.tsx | 3 +- .../MemoEditor/Toolbar/InsertMenu.tsx | 35 +- .../MemoEditor/components/EditorContent.tsx | 9 +- .../MemoEditor/components/EditorToolbar.tsx | 3 +- web/src/components/MemoEditor/index.tsx | 25 +- .../components/MemoEditor/types/components.ts | 5 + web/src/components/TableEditorDialog.tsx | 347 ++++++++++++++++++ web/src/utils/markdown-table.ts | 203 ++++++++++ 11 files changed, 735 insertions(+), 16 deletions(-) create mode 100644 web/src/components/TableEditorDialog.tsx create mode 100644 web/src/utils/markdown-table.ts diff --git a/web/src/components/MemoContent/Table.tsx b/web/src/components/MemoContent/Table.tsx index 45d0cee93..0580fb7f6 100644 --- a/web/src/components/MemoContent/Table.tsx +++ b/web/src/components/MemoContent/Table.tsx @@ -1,20 +1,101 @@ +import { PencilIcon } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; +import TableEditorDialog from "@/components/TableEditorDialog"; +import { useUpdateMemo } from "@/hooks/useMemoQueries"; import { cn } from "@/lib/utils"; +import type { TableData } from "@/utils/markdown-table"; +import { findAllTables, parseMarkdownTable, replaceNthTable } from "@/utils/markdown-table"; +import { useMemoViewContext, useMemoViewDerived } from "../MemoView/MemoViewContext"; import type { ReactMarkdownProps } from "./markdown/types"; +// --------------------------------------------------------------------------- +// Table (root wrapper with edit button) +// --------------------------------------------------------------------------- + interface TableProps extends React.HTMLAttributes, ReactMarkdownProps { children: React.ReactNode; } export const Table = ({ children, className, node: _node, ...props }: TableProps) => { + const tableRef = useRef(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [tableData, setTableData] = useState(null); + const [tableIndex, setTableIndex] = useState(-1); + + const { memo } = useMemoViewContext(); + const { readonly } = useMemoViewDerived(); + const { mutate: updateMemo } = useUpdateMemo(); + + const handleEditClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + // Determine which table this is in the memo content by walking the DOM. + const container = tableRef.current?.closest('[class*="wrap-break-word"]'); + if (!container) return; + + const allTables = container.querySelectorAll("table"); + let idx = 0; + for (let i = 0; i < allTables.length; i++) { + if (tableRef.current?.contains(allTables[i])) { + idx = i; + break; + } + } + + // Find and parse the corresponding markdown table. + const tables = findAllTables(memo.content); + if (idx >= tables.length) return; + + const parsed = parseMarkdownTable(tables[idx].text); + if (!parsed) return; + + setTableData(parsed); + setTableIndex(idx); + setDialogOpen(true); + }, + [memo.content], + ); + + const handleConfirm = useCallback( + (markdown: string) => { + if (tableIndex < 0) return; + const newContent = replaceNthTable(memo.content, tableIndex, markdown); + updateMemo({ + update: { name: memo.name, content: newContent }, + updateMask: ["content"], + }); + }, + [memo.content, memo.name, tableIndex, updateMemo], + ); + return ( -
- - {children} -
-
+ <> +
+ + {children} +
+ {!readonly && ( + + )} +
+ + ); }; +// --------------------------------------------------------------------------- +// Sub-components (unchanged) +// --------------------------------------------------------------------------- + interface TableHeadProps extends React.HTMLAttributes, ReactMarkdownProps { children: React.ReactNode; } diff --git a/web/src/components/MemoEditor/Editor/SlashCommands.tsx b/web/src/components/MemoEditor/Editor/SlashCommands.tsx index d1ab6c12d..023ad3670 100644 --- a/web/src/components/MemoEditor/Editor/SlashCommands.tsx +++ b/web/src/components/MemoEditor/Editor/SlashCommands.tsx @@ -5,10 +5,18 @@ import { useSuggestions } from "./useSuggestions"; const SlashCommands = ({ editorRef, editorActions, commands }: SlashCommandsProps) => { const handleCommandAutocomplete = (cmd: (typeof commands)[0], word: string, index: number, actions: EditorRefActions) => { - // Remove trigger char + word, then insert command output + // Remove trigger char + word. actions.removeText(index, word.length); + + // If the command has a dialog action, invoke it instead of inserting text. + if (cmd.action) { + cmd.action(); + return; + } + + // Otherwise insert the command output text. actions.insertText(cmd.run()); - // Position cursor relative to insertion point, if specified + // Position cursor relative to insertion point, if specified. if (cmd.cursorOffset) { actions.setCursorPosition(index + cmd.cursorOffset); } diff --git a/web/src/components/MemoEditor/Editor/commands.ts b/web/src/components/MemoEditor/Editor/commands.ts index 4aa58b44a..9f8b11254 100644 --- a/web/src/components/MemoEditor/Editor/commands.ts +++ b/web/src/components/MemoEditor/Editor/commands.ts @@ -1,7 +1,10 @@ export interface Command { name: string; + /** Returns text to insert. Ignored if `action` is set. */ run: () => string; cursorOffset?: number; + /** If set, called instead of inserting run() text. Used for dialog-based commands. */ + action?: () => void; } export const editorCommands: Command[] = [ @@ -26,3 +29,18 @@ export const editorCommands: Command[] = [ cursorOffset: 1, }, ]; + +/** + * Create the full editor commands list, with the table command + * wired to open the table editor dialog instead of inserting raw markdown. + */ +export function createEditorCommands(onOpenTableEditor?: () => void): Command[] { + if (!onOpenTableEditor) return editorCommands; + + return editorCommands.map((cmd) => { + if (cmd.name === "table") { + return { ...cmd, action: onOpenTableEditor }; + } + return cmd; + }); +} diff --git a/web/src/components/MemoEditor/Editor/index.tsx b/web/src/components/MemoEditor/Editor/index.tsx index 0ef458f9e..4b7c4ed62 100644 --- a/web/src/components/MemoEditor/Editor/index.tsx +++ b/web/src/components/MemoEditor/Editor/index.tsx @@ -35,6 +35,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward isInIME = false, onCompositionStart, onCompositionEnd, + commands: customCommands, } = props; const editorRef = useRef(null); @@ -205,7 +206,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward onCompositionEnd={onCompositionEnd} > - + ); }); diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx index ec962b5e7..6a3bd3b62 100644 --- a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx +++ b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx @@ -1,9 +1,20 @@ import { LatLng } from "leaflet"; import { uniqBy } from "lodash-es"; -import { FileIcon, LinkIcon, LoaderIcon, type LucideIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react"; +import { + FileIcon, + LinkIcon, + LoaderIcon, + type LucideIcon, + MapPinIcon, + Maximize2Icon, + MoreHorizontalIcon, + PlusIcon, + TableIcon, +} from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useDebounce } from "react-use"; import { useReverseGeocoding } from "@/components/map"; +import TableEditorDialog from "@/components/TableEditorDialog"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -30,6 +41,7 @@ const InsertMenu = (props: InsertMenuProps) => { const [linkDialogOpen, setLinkDialogOpen] = useState(false); const [locationDialogOpen, setLocationDialogOpen] = useState(false); + const [tableDialogOpen, setTableDialogOpen] = useState(false); const [moreSubmenuOpen, setMoreSubmenuOpen] = useState(false); const { handleTriggerEnter, handleTriggerLeave, handleContentEnter, handleContentLeave } = useDropdownMenuSubHoverDelay( @@ -77,6 +89,17 @@ const InsertMenu = (props: InsertMenuProps) => { setLinkDialogOpen(true); }, []); + const handleOpenTableDialog = useCallback(() => { + setTableDialogOpen(true); + }, []); + + const handleTableConfirm = useCallback( + (markdown: string) => { + props.onInsertText?.(markdown); + }, + [props], + ); + const handleLocationClick = useCallback(() => { setLocationDialogOpen(true); if (!initialLocation && !location.locationInitialized) { @@ -127,6 +150,12 @@ const InsertMenu = (props: InsertMenuProps) => { icon: FileIcon, onClick: handleUploadClick, }, + { + key: "table", + label: "Table", + icon: TableIcon, + onClick: handleOpenTableDialog, + }, { key: "link", label: t("tooltip.link-memo"), @@ -140,7 +169,7 @@ const InsertMenu = (props: InsertMenuProps) => { onClick: handleLocationClick, }, ] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>, - [handleLocationClick, handleOpenLinkDialog, handleUploadClick, t], + [handleLocationClick, handleOpenLinkDialog, handleOpenTableDialog, handleUploadClick, t], ); return ( @@ -207,6 +236,8 @@ const InsertMenu = (props: InsertMenuProps) => { onCancel={handleLocationCancel} onConfirm={handleLocationConfirm} /> + + ); }; diff --git a/web/src/components/MemoEditor/components/EditorContent.tsx b/web/src/components/MemoEditor/components/EditorContent.tsx index 5cc14f784..41e8f2b39 100644 --- a/web/src/components/MemoEditor/components/EditorContent.tsx +++ b/web/src/components/MemoEditor/components/EditorContent.tsx @@ -1,11 +1,12 @@ -import { forwardRef } from "react"; +import { forwardRef, useMemo } from "react"; import Editor, { type EditorRefActions } from "../Editor"; +import { createEditorCommands } from "../Editor/commands"; import { useBlobUrls, useDragAndDrop } from "../hooks"; import { useEditorContext } from "../state"; import type { EditorContentProps } from "../types"; import type { LocalFile } from "../types/attachment"; -export const EditorContent = forwardRef(({ placeholder }, ref) => { +export const EditorContent = forwardRef(({ placeholder, onOpenTableEditor }, ref) => { const { state, actions, dispatch } = useEditorContext(); const { createBlobUrl } = useBlobUrls(); @@ -54,6 +55,9 @@ export const EditorContent = forwardRef(({ event.preventDefault(); }; + // Build commands with the table editor action wired in. + const commands = useMemo(() => createEditorCommands(onOpenTableEditor), [onOpenTableEditor]); + return (
(({ onPaste={handlePaste} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} + commands={commands} />
); diff --git a/web/src/components/MemoEditor/components/EditorToolbar.tsx b/web/src/components/MemoEditor/components/EditorToolbar.tsx index ba03378ad..9c8781b2f 100644 --- a/web/src/components/MemoEditor/components/EditorToolbar.tsx +++ b/web/src/components/MemoEditor/components/EditorToolbar.tsx @@ -7,7 +7,7 @@ import InsertMenu from "../Toolbar/InsertMenu"; import VisibilitySelector from "../Toolbar/VisibilitySelector"; import type { EditorToolbarProps } from "../types"; -export const EditorToolbar: FC = ({ onSave, onCancel, memoName }) => { +export const EditorToolbar: FC = ({ onSave, onCancel, memoName, onInsertText }) => { const t = useTranslate(); const { state, actions, dispatch } = useEditorContext(); const { valid } = validationService.canSave(state); @@ -34,6 +34,7 @@ export const EditorToolbar: FC = ({ onSave, onCancel, memoNa location={state.metadata.location} onLocationChange={handleLocationChange} onToggleFocusMode={handleToggleFocusMode} + onInsertText={onInsertText} memoName={memoName} /> diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index ec05a99d3..c0069b485 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -1,6 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; -import { useRef } from "react"; +import { useCallback, useRef, useState } from "react"; import { toast } from "react-hot-toast"; +import TableEditorDialog from "@/components/TableEditorDialog"; import { useAuth } from "@/contexts/AuthContext"; import useCurrentUser from "@/hooks/useCurrentUser"; import { memoKeys } from "@/hooks/useMemoQueries"; @@ -68,6 +69,17 @@ const MemoEditorImpl: React.FC = ({ dispatch(actions.toggleFocusMode()); }; + // Table editor dialog (shared by slash command and toolbar). + const [tableDialogOpen, setTableDialogOpen] = useState(false); + + const handleOpenTableEditor = useCallback(() => { + setTableDialogOpen(true); + }, []); + + const handleTableConfirm = useCallback((markdown: string) => { + editorRef.current?.insertText(markdown); + }, []); + useKeyboard(editorRef, { onSave: handleSave }); async function handleSave() { @@ -142,14 +154,21 @@ const MemoEditorImpl: React.FC = ({ {/* Editor content grows to fill available space in focus mode */} - + {/* Metadata and toolbar grouped together at bottom */}
- + editorRef.current?.insertText(text)} + />
+ + ); }; diff --git a/web/src/components/MemoEditor/types/components.ts b/web/src/components/MemoEditor/types/components.ts index 13f91ff48..13bcf5917 100644 --- a/web/src/components/MemoEditor/types/components.ts +++ b/web/src/components/MemoEditor/types/components.ts @@ -18,12 +18,14 @@ export interface MemoEditorProps { export interface EditorContentProps { placeholder?: string; autoFocus?: boolean; + onOpenTableEditor?: () => void; } export interface EditorToolbarProps { onSave: () => void; onCancel?: () => void; memoName?: string; + onInsertText?: (text: string) => void; } export interface EditorMetadataProps { @@ -68,6 +70,7 @@ export interface InsertMenuProps { location?: Location; onLocationChange: (location?: Location) => void; onToggleFocusMode?: () => void; + onInsertText?: (text: string) => void; memoName?: string; } @@ -92,6 +95,8 @@ export interface EditorProps { isInIME?: boolean; onCompositionStart?: () => void; onCompositionEnd?: () => void; + /** Custom commands for slash menu. If not provided, defaults are used. */ + commands?: import("../Editor/commands").Command[]; } export interface VisibilitySelectorProps { diff --git a/web/src/components/TableEditorDialog.tsx b/web/src/components/TableEditorDialog.tsx new file mode 100644 index 000000000..54a52c383 --- /dev/null +++ b/web/src/components/TableEditorDialog.tsx @@ -0,0 +1,347 @@ +import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, PlusIcon, TrashIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import type { ColumnAlignment, TableData } from "@/utils/markdown-table"; +import { createEmptyTable, serializeMarkdownTable } from "@/utils/markdown-table"; +import { Button } from "./ui/button"; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from "./ui/dialog"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; +import { VisuallyHidden } from "./ui/visually-hidden"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface TableEditorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Initial table data when editing an existing table. */ + initialData?: TableData | null; + /** Called with the formatted markdown table string on confirm. */ + onConfirm: (markdown: string) => void; +} + +type SortState = { col: number; dir: "asc" | "desc" } | null; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: TableEditorDialogProps) => { + const [headers, setHeaders] = useState([]); + const [rows, setRows] = useState([]); + const [alignments, setAlignments] = useState([]); + const [sortState, setSortState] = useState(null); + + // Ref grid for Tab navigation: inputRefs[row][col] (row -1 = headers). + const inputRefs = useRef>(new Map()); + + const setInputRef = useCallback((key: string, el: HTMLInputElement | null) => { + if (el) { + inputRefs.current.set(key, el); + } else { + inputRefs.current.delete(key); + } + }, []); + + // Initialize state when dialog opens. + useEffect(() => { + if (open) { + if (initialData) { + setHeaders([...initialData.headers]); + setRows(initialData.rows.map((r) => [...r])); + setAlignments([...initialData.alignments]); + } else { + const empty = createEmptyTable(3, 2); + setHeaders(empty.headers); + setRows(empty.rows); + setAlignments(empty.alignments); + } + setSortState(null); + } + }, [open, initialData]); + + const colCount = headers.length; + const rowCount = rows.length; + + // ---- Cell editing ---- + + const updateHeader = (col: number, value: string) => { + setHeaders((prev) => { + const next = [...prev]; + next[col] = value; + return next; + }); + }; + + const updateCell = (row: number, col: number, value: string) => { + setRows((prev) => { + const next = prev.map((r) => [...r]); + next[row][col] = value; + return next; + }); + }; + + // ---- Add / Remove ---- + + const addColumn = () => { + setHeaders((prev) => [...prev, ""]); + setRows((prev) => prev.map((r) => [...r, ""])); + setAlignments((prev) => [...prev, "none"]); + setSortState(null); + }; + + const removeColumn = (col: number) => { + if (colCount <= 1) return; + setHeaders((prev) => prev.filter((_, i) => i !== col)); + setRows((prev) => prev.map((r) => r.filter((_, i) => i !== col))); + setAlignments((prev) => prev.filter((_, i) => i !== col)); + setSortState(null); + }; + + const addRow = () => { + setRows((prev) => [...prev, Array.from({ length: colCount }, () => "")]); + }; + + const removeRow = (row: number) => { + if (rowCount <= 1) return; + setRows((prev) => prev.filter((_, i) => i !== row)); + }; + + // ---- Sorting ---- + + const sortByColumn = (col: number) => { + let newDir: "asc" | "desc" = "asc"; + if (sortState && sortState.col === col && sortState.dir === "asc") { + newDir = "desc"; + } + setSortState({ col, dir: newDir }); + + setRows((prev) => { + const sorted = [...prev].sort((a, b) => { + const va = (a[col] || "").toLowerCase(); + const vb = (b[col] || "").toLowerCase(); + // Try numeric comparison first. + const na = Number(va); + const nb = Number(vb); + if (!Number.isNaN(na) && !Number.isNaN(nb)) { + return newDir === "asc" ? na - nb : nb - na; + } + const cmp = va.localeCompare(vb); + return newDir === "asc" ? cmp : -cmp; + }); + return sorted; + }); + }; + + // ---- Tab / keyboard navigation ---- + + const handleKeyDown = (e: React.KeyboardEvent, row: number, col: number) => { + if (e.key === "Tab") { + e.preventDefault(); + const nextCol = e.shiftKey ? col - 1 : col + 1; + let nextRow = row; + + if (nextCol >= colCount) { + // Move to first cell of next row. + if (row < rowCount - 1) { + nextRow = row + 1; + focusCell(nextRow, 0); + } else { + // At last cell – add a new row and focus it. + addRow(); + // Need to wait for state update; use setTimeout. + setTimeout(() => focusCell(rowCount, 0), 0); + } + } else if (nextCol < 0) { + // Move to last cell of previous row. + if (row > 0) { + nextRow = row - 1; + focusCell(nextRow, colCount - 1); + } else { + // Move to header row. + focusCell(-1, colCount - 1); + } + } else { + focusCell(nextRow, nextCol); + } + } + }; + + const focusCell = (row: number, col: number) => { + const key = `${row}:${col}`; + const el = inputRefs.current.get(key); + el?.focus(); + }; + + // ---- Confirm ---- + + const handleConfirm = () => { + const data: TableData = { headers, rows, alignments }; + const md = serializeMarkdownTable(data); + onConfirm(md); + onOpenChange(false); + }; + + // ---- Sort indicator ---- + + const SortIndicator = ({ col }: { col: number }) => { + if (sortState?.col === col) { + return sortState.dir === "asc" ? : ; + } + return ; + }; + + return ( + + + + + + + Table Editor + + + Edit table headers, rows, columns and sort data + +
+ {/* Scrollable table area */} +
+ + {/* Header row */} + + + {/* Row number column */} + + ))} + {/* Add column button */} + + + + {/* Data rows */} + + {rows.map((row, rowIdx) => ( + + {/* Row number + remove */} + + {row.map((cell, col) => ( + + ))} + + ))} + +
+ {headers.map((header, col) => ( + +
+
+ setInputRef(`-1:${col}`, el)} + className="flex-1 min-w-0 px-2 py-1.5 font-semibold text-xs uppercase tracking-wide bg-accent/50 border border-border rounded-tl-md focus:outline-none focus:ring-1 focus:ring-primary/40" + value={header} + onChange={(e) => updateHeader(col, e.target.value)} + onKeyDown={(e) => handleKeyDown(e, -1, col)} + placeholder={`Col ${col + 1}`} + /> + + + + + Sort column + + {colCount > 1 && ( + + + + + Remove column + + )} +
+
+
+ + + + + Add column + +
+
+ {rowIdx + 1} + {rowCount > 1 && ( + + + + + Remove row + + )} +
+
+ setInputRef(`${rowIdx}:${col}`, el)} + className={cn( + "w-full px-2 py-1.5 text-sm bg-transparent border border-border focus:outline-none focus:ring-1 focus:ring-primary/40", + rowIdx === rowCount - 1 && "rounded-bl-md", + )} + value={cell} + onChange={(e) => updateCell(rowIdx, col, e.target.value)} + onKeyDown={(e) => handleKeyDown(e, rowIdx, col)} + placeholder="..." + /> + +
+ + {/* Add row button */} +
+ +
+
+ + {/* Footer */} +
+ + {colCount} {colCount === 1 ? "column" : "columns"} · {rowCount} {rowCount === 1 ? "row" : "rows"} + +
+ + +
+
+
+
+
+ ); +}; + +export default TableEditorDialog; diff --git a/web/src/utils/markdown-table.ts b/web/src/utils/markdown-table.ts new file mode 100644 index 000000000..1e6368176 --- /dev/null +++ b/web/src/utils/markdown-table.ts @@ -0,0 +1,203 @@ +/** + * Utilities for parsing, serializing, and manipulating markdown tables. + */ + +export interface TableData { + headers: string[]; + rows: string[][]; + /** Column alignments: "left" | "center" | "right" | "none". */ + alignments: ColumnAlignment[]; +} + +export type ColumnAlignment = "left" | "center" | "right" | "none"; + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +/** + * Parse a markdown table string into structured TableData. + * + * Expects a standard GFM table: + * | Header1 | Header2 | + * | ------- | ------- | + * | cell | cell | + */ +export function parseMarkdownTable(md: string): TableData | null { + const lines = md + .trim() + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0); + + if (lines.length < 2) return null; + + const parseRow = (line: string): string[] => { + // Strip leading/trailing pipes and split by pipe. + let trimmed = line; + if (trimmed.startsWith("|")) trimmed = trimmed.slice(1); + if (trimmed.endsWith("|")) trimmed = trimmed.slice(0, -1); + return trimmed.split("|").map((cell) => cell.trim()); + }; + + const headers = parseRow(lines[0]); + + // Parse the separator line for alignments. + const sepCells = parseRow(lines[1]); + const isSeparator = sepCells.every((cell) => /^:?-+:?$/.test(cell.trim())); + if (!isSeparator) return null; + + const alignments: ColumnAlignment[] = sepCells.map((cell) => { + const c = cell.trim(); + const left = c.startsWith(":"); + const right = c.endsWith(":"); + if (left && right) return "center"; + if (right) return "right"; + if (left) return "left"; + return "none"; + }); + + const rows: string[][] = []; + for (let i = 2; i < lines.length; i++) { + const cells = parseRow(lines[i]); + // Pad or trim to match header count. + while (cells.length < headers.length) cells.push(""); + if (cells.length > headers.length) cells.length = headers.length; + rows.push(cells); + } + + return { headers, rows, alignments }; +} + +// --------------------------------------------------------------------------- +// Serialization +// --------------------------------------------------------------------------- + +/** + * Serialize TableData into a properly-aligned markdown table string. + */ +export function serializeMarkdownTable(data: TableData): string { + const { headers, rows, alignments } = data; + const colCount = headers.length; + + // Calculate maximum width per column (minimum 3 for the separator). + const widths: number[] = []; + for (let c = 0; c < colCount; c++) { + let max = Math.max(3, headers[c].length); + for (const row of rows) { + max = Math.max(max, (row[c] || "").length); + } + widths.push(max); + } + + const padCell = (text: string, width: number, align: ColumnAlignment): string => { + const t = text || ""; + const padding = width - t.length; + if (padding <= 0) return t; + if (align === "right") return " ".repeat(padding) + t; + if (align === "center") { + const left = Math.floor(padding / 2); + const right = padding - left; + return " ".repeat(left) + t + " ".repeat(right); + } + return t + " ".repeat(padding); + }; + + const formatRow = (cells: string[]): string => { + const formatted = cells.map((cell, i) => { + const align = alignments[i] || "none"; + return padCell(cell, widths[i], align); + }); + return "| " + formatted.join(" | ") + " |"; + }; + + const separator = widths.map((w, i) => { + const align = alignments[i] || "none"; + const dashes = "-".repeat(w); + if (align === "center") return ":" + dashes.slice(1, -1) + ":"; + if (align === "right") return dashes.slice(0, -1) + ":"; + if (align === "left") return ":" + dashes.slice(1); + return dashes; + }); + const separatorLine = "| " + separator.join(" | ") + " |"; + + const headerLine = formatRow(headers); + const rowLines = rows.map((row) => formatRow(row)); + + return [headerLine, separatorLine, ...rowLines].join("\n"); +} + +// --------------------------------------------------------------------------- +// Find & Replace +// --------------------------------------------------------------------------- + +/** Regex that matches a full markdown table block (one or more table lines). */ +const TABLE_LINE = /^\s*\|.+\|\s*$/; + +export interface TableMatch { + /** The raw markdown of the table. */ + text: string; + /** Start index in the source string. */ + start: number; + /** End index (exclusive) in the source string. */ + end: number; +} + +/** + * Find all markdown table blocks in a content string. + */ +export function findAllTables(content: string): TableMatch[] { + const lines = content.split("\n"); + const tables: TableMatch[] = []; + let i = 0; + let offset = 0; + + while (i < lines.length) { + if (TABLE_LINE.test(lines[i])) { + const startLine = i; + const startOffset = offset; + // Consume all consecutive table lines. + while (i < lines.length && TABLE_LINE.test(lines[i])) { + offset += lines[i].length + 1; // +1 for newline + i++; + } + const endOffset = offset - 1; // exclude trailing newline + const text = lines.slice(startLine, i).join("\n"); + // Only count if it has at least a header + separator (2 lines). + if (i - startLine >= 2) { + tables.push({ text, start: startOffset, end: endOffset }); + } + } else { + offset += lines[i].length + 1; + i++; + } + } + + return tables; +} + +/** + * Replace the nth table in the content with new markdown. + */ +export function replaceNthTable(content: string, tableIndex: number, newTableMarkdown: string): string { + const tables = findAllTables(content); + if (tableIndex < 0 || tableIndex >= tables.length) return content; + + const table = tables[tableIndex]; + return content.slice(0, table.start) + newTableMarkdown + content.slice(table.end); +} + +// --------------------------------------------------------------------------- +// Default empty table +// --------------------------------------------------------------------------- + +/** + * Create a default empty table with the given dimensions. + */ +export function createEmptyTable(cols = 2, rows = 2): TableData { + return { + headers: Array.from({ length: cols }, (_, i) => `Header ${i + 1}`), + rows: Array.from({ length: rows }, () => Array.from({ length: cols }, () => "")), + alignments: Array.from({ length: cols }, () => "none" as ColumnAlignment), + }; +} From 61c78d0588d73cb1b22ef7adfd0f10658caae5f6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Feb 2026 23:13:09 +0000 Subject: [PATCH 4/8] feat: table delete button, SSE status indicator, table editor UI polish Five improvements: 1. Delete table button: A trash icon appears to the left of the edit pencil on rendered tables (on hover). Clicking it opens a confirmation dialog before removing the entire table from the memo content. 2. SSE connection status indicator: A small colored dot in the sidebar navigation (above the user menu) shows the live-refresh connection status: - Green = connected, live updates active - Yellow (pulsing) = connecting - Red = disconnected, updates not live Hover tooltip explains the current state. Uses useSyncExternalStore for efficient re-renders from a singleton status store. 3. Always-visible action buttons: Sort and delete buttons in the table editor are now always visible at 40% opacity (previously hidden until hover). They become fully opaque on hover for better discoverability. 4. Larger table editor dialog: Fixed size of 56rem x 44rem (capped to viewport) so the dialog is spacious regardless of table dimensions. The table area scrolls within the fixed frame. 5. Monospace font in table editor: All cell inputs use Fira Code with fallbacks to Fira Mono, JetBrains Mono, Cascadia Code, Consolas, and system monospace for better alignment when editing tabular data. Co-authored-by: milvasic --- web/src/components/MemoContent/Table.tsx | 112 ++++++++++++++++------ web/src/components/Navigation.tsx | 6 +- web/src/components/SSEStatusIndicator.tsx | 36 +++++++ web/src/components/TableEditorDialog.tsx | 93 +++++++++--------- web/src/hooks/useLiveMemoRefresh.ts | 73 +++++++++++--- 5 files changed, 230 insertions(+), 90 deletions(-) create mode 100644 web/src/components/SSEStatusIndicator.tsx diff --git a/web/src/components/MemoContent/Table.tsx b/web/src/components/MemoContent/Table.tsx index 0580fb7f6..4c2bbb6eb 100644 --- a/web/src/components/MemoContent/Table.tsx +++ b/web/src/components/MemoContent/Table.tsx @@ -1,6 +1,8 @@ -import { PencilIcon } from "lucide-react"; +import { PencilIcon, TrashIcon } from "lucide-react"; import { useCallback, useRef, useState } from "react"; import TableEditorDialog from "@/components/TableEditorDialog"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { useUpdateMemo } from "@/hooks/useMemoQueries"; import { cn } from "@/lib/utils"; import type { TableData } from "@/utils/markdown-table"; @@ -9,7 +11,7 @@ import { useMemoViewContext, useMemoViewDerived } from "../MemoView/MemoViewCont import type { ReactMarkdownProps } from "./markdown/types"; // --------------------------------------------------------------------------- -// Table (root wrapper with edit button) +// Table (root wrapper with edit + delete buttons) // --------------------------------------------------------------------------- interface TableProps extends React.HTMLAttributes, ReactMarkdownProps { @@ -19,6 +21,7 @@ interface TableProps extends React.HTMLAttributes, ReactMarkdo export const Table = ({ children, className, node: _node, ...props }: TableProps) => { const tableRef = useRef(null); const [dialogOpen, setDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [tableData, setTableData] = useState(null); const [tableIndex, setTableIndex] = useState(-1); @@ -26,27 +29,26 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps const { readonly } = useMemoViewDerived(); const { mutate: updateMemo } = useUpdateMemo(); + /** Resolve which markdown table index this rendered table corresponds to. */ + const resolveTableIndex = useCallback(() => { + const container = tableRef.current?.closest('[class*="wrap-break-word"]'); + if (!container) return -1; + + const allTables = container.querySelectorAll("table"); + for (let i = 0; i < allTables.length; i++) { + if (tableRef.current?.contains(allTables[i])) return i; + } + return -1; + }, []); + const handleEditClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); - // Determine which table this is in the memo content by walking the DOM. - const container = tableRef.current?.closest('[class*="wrap-break-word"]'); - if (!container) return; - - const allTables = container.querySelectorAll("table"); - let idx = 0; - for (let i = 0; i < allTables.length; i++) { - if (tableRef.current?.contains(allTables[i])) { - idx = i; - break; - } - } - - // Find and parse the corresponding markdown table. + const idx = resolveTableIndex(); const tables = findAllTables(memo.content); - if (idx >= tables.length) return; + if (idx < 0 || idx >= tables.length) return; const parsed = parseMarkdownTable(tables[idx].text); if (!parsed) return; @@ -55,10 +57,24 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps setTableIndex(idx); setDialogOpen(true); }, - [memo.content], + [memo.content, resolveTableIndex], ); - const handleConfirm = useCallback( + const handleDeleteClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + const idx = resolveTableIndex(); + if (idx < 0) return; + + setTableIndex(idx); + setDeleteDialogOpen(true); + }, + [resolveTableIndex], + ); + + const handleConfirmEdit = useCallback( (markdown: string) => { if (tableIndex < 0) return; const newContent = replaceNthTable(memo.content, tableIndex, markdown); @@ -70,6 +86,17 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps [memo.content, memo.name, tableIndex, updateMemo], ); + const handleConfirmDelete = useCallback(() => { + if (tableIndex < 0) return; + // Replace the table with an empty string to delete it. + const newContent = replaceNthTable(memo.content, tableIndex, ""); + updateMemo({ + update: { name: memo.name, content: newContent }, + updateMask: ["content"], + }); + setDeleteDialogOpen(false); + }, [memo.content, memo.name, tableIndex, updateMemo]); + return ( <>
@@ -77,17 +104,46 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps {children} {!readonly && ( - +
+ + +
)}
- + + + + {/* Delete confirmation dialog */} + + + + Delete table + Are you sure you want to delete this table? This action cannot be undone. + + + + + + + + + ); }; diff --git a/web/src/components/Navigation.tsx b/web/src/components/Navigation.tsx index 484f0837b..faf07c55b 100644 --- a/web/src/components/Navigation.tsx +++ b/web/src/components/Navigation.tsx @@ -8,6 +8,7 @@ import { Routes } from "@/router"; import { UserNotification_Status } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; import MemosLogo from "./MemosLogo"; +import SSEStatusIndicator from "./SSEStatusIndicator"; import UserMenu from "./UserMenu"; interface NavLinkItem { @@ -114,7 +115,10 @@ const Navigation = (props: Props) => { ))} {currentUser && ( -
+
+
+ +
)} diff --git a/web/src/components/SSEStatusIndicator.tsx b/web/src/components/SSEStatusIndicator.tsx new file mode 100644 index 000000000..ca4df407d --- /dev/null +++ b/web/src/components/SSEStatusIndicator.tsx @@ -0,0 +1,36 @@ +import { useSSEConnectionStatus } from "@/hooks/useLiveMemoRefresh"; +import { cn } from "@/lib/utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; + +/** + * A small colored dot that indicates the SSE live-refresh connection status. + * - Green = connected (live updates active) + * - Yellow/pulsing = connecting + * - Red = disconnected (updates not live) + */ +const SSEStatusIndicator = () => { + const status = useSSEConnectionStatus(); + + const label = + status === "connected" ? "Live updates active" : status === "connecting" ? "Connecting to live updates..." : "Live updates unavailable"; + + return ( + + + + + + + {label} + + ); +}; + +export default SSEStatusIndicator; diff --git a/web/src/components/TableEditorDialog.tsx b/web/src/components/TableEditorDialog.tsx index 54a52c383..31af74205 100644 --- a/web/src/components/TableEditorDialog.tsx +++ b/web/src/components/TableEditorDialog.tsx @@ -8,6 +8,13 @@ import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } fr import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { VisuallyHidden } from "./ui/visually-hidden"; +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Monospace font stack for the cell inputs. */ +const MONO_FONT = "'Fira Code', 'Fira Mono', 'JetBrains Mono', 'Cascadia Code', 'Consolas', ui-monospace, monospace"; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -121,7 +128,6 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table const sorted = [...prev].sort((a, b) => { const va = (a[col] || "").toLowerCase(); const vb = (b[col] || "").toLowerCase(); - // Try numeric comparison first. const na = Number(va); const nb = Number(vb); if (!Number.isNaN(na) && !Number.isNaN(nb)) { @@ -143,23 +149,18 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table let nextRow = row; if (nextCol >= colCount) { - // Move to first cell of next row. if (row < rowCount - 1) { nextRow = row + 1; focusCell(nextRow, 0); } else { - // At last cell – add a new row and focus it. addRow(); - // Need to wait for state update; use setTimeout. setTimeout(() => focusCell(rowCount, 0), 0); } } else if (nextCol < 0) { - // Move to last cell of previous row. if (row > 0) { nextRow = row - 1; focusCell(nextRow, colCount - 1); } else { - // Move to header row. focusCell(-1, colCount - 1); } } else { @@ -189,12 +190,12 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table if (sortState?.col === col) { return sortState.dir === "asc" ? : ; } - return ; + return ; }; return ( - + @@ -204,54 +205,53 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table Edit table headers, rows, columns and sort data -
- {/* Scrollable table area */} -
+
+ {/* Scrollable table area — grows to fill */} +
{/* Header row */} {/* Row number column */} - ))} @@ -277,15 +277,15 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table {rows.map((row, rowIdx) => ( {/* Row number + remove */} - with an absolutely positioned hover zone - z-index set to z-30 (above the z-20 sticky header) so column insert buttons render on top of everything - On hover, a 3px blue highlight line appears at the exact border where the new column/row will be inserted, giving clear visual feedback - The entire zone is clickable (not just the button) for easier use Sticky header improvements: - Added a solid bg-background mask row at the top of the sticky thead that hides table cells scrolling underneath the header area - All header cells including the row-number spacer and add-column button now have explicit bg-background so nothing bleeds through - Header cell background (bg-accent/50) now wraps the full cell content including sort and delete buttons (moved bg from input to the containing div), giving the header a cohesive look Row insert zones use dedicated spacer elements between data rows (instead of absolutely positioned elements inside cells), which is more reliable across different table widths and avoids clipping issues. Co-authored-by: milvasic --- web/src/components/TableEditorDialog.tsx | 252 ++++++++++++----------- 1 file changed, 130 insertions(+), 122 deletions(-) diff --git a/web/src/components/TableEditorDialog.tsx b/web/src/components/TableEditorDialog.tsx index d12394bd6..4e05f2e23 100644 --- a/web/src/components/TableEditorDialog.tsx +++ b/web/src/components/TableEditorDialog.tsx @@ -1,5 +1,5 @@ import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, PlusIcon, TrashIcon } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import type { ColumnAlignment, TableData } from "@/utils/markdown-table"; import { createEmptyTable, serializeMarkdownTable } from "@/utils/markdown-table"; @@ -40,11 +40,8 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table const inputRefs = useRef>(new Map()); const setInputRef = useCallback((key: string, el: HTMLInputElement | null) => { - if (el) { - inputRefs.current.set(key, el); - } else { - inputRefs.current.delete(key); - } + if (el) inputRefs.current.set(key, el); + else inputRefs.current.delete(key); }, []); useEffect(() => { @@ -129,16 +126,13 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table newDir = "desc"; } setSortState({ col, dir: newDir }); - setRows((prev) => { const sorted = [...prev].sort((a, b) => { const va = (a[col] || "").toLowerCase(); const vb = (b[col] || "").toLowerCase(); const na = Number(va); const nb = Number(vb); - if (!Number.isNaN(na) && !Number.isNaN(nb)) { - return newDir === "asc" ? na - nb : nb - na; - } + if (!Number.isNaN(na) && !Number.isNaN(nb)) return newDir === "asc" ? na - nb : nb - na; const cmp = va.localeCompare(vb); return newDir === "asc" ? cmp : -cmp; }); @@ -149,43 +143,38 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table // ---- Tab / keyboard navigation ---- const handleKeyDown = (e: React.KeyboardEvent, row: number, col: number) => { - if (e.key === "Tab") { - e.preventDefault(); - const nextCol = e.shiftKey ? col - 1 : col + 1; - let nextRow = row; - - if (nextCol >= colCount) { - if (row < rowCount - 1) { - nextRow = row + 1; - focusCell(nextRow, 0); - } else { - addRow(); - setTimeout(() => focusCell(rowCount, 0), 0); - } - } else if (nextCol < 0) { - if (row > 0) { - nextRow = row - 1; - focusCell(nextRow, colCount - 1); - } else { - focusCell(-1, colCount - 1); - } + if (e.key !== "Tab") return; + e.preventDefault(); + const nextCol = e.shiftKey ? col - 1 : col + 1; + let nextRow = row; + if (nextCol >= colCount) { + if (row < rowCount - 1) { + nextRow = row + 1; + focusCell(nextRow, 0); } else { - focusCell(nextRow, nextCol); + addRow(); + setTimeout(() => focusCell(rowCount, 0), 0); } + } else if (nextCol < 0) { + if (row > 0) { + nextRow = row - 1; + focusCell(nextRow, colCount - 1); + } else { + focusCell(-1, colCount - 1); + } + } else { + focusCell(nextRow, nextCol); } }; const focusCell = (row: number, col: number) => { - const key = `${row}:${col}`; - const el = inputRefs.current.get(key); - el?.focus(); + inputRefs.current.get(`${row}:${col}`)?.focus(); }; // ---- Confirm ---- const handleConfirm = () => { - const data: TableData = { headers, rows, alignments }; - const md = serializeMarkdownTable(data); + const md = serializeMarkdownTable({ headers, rows, alignments }); onConfirm(md); onOpenChange(false); }; @@ -199,6 +188,9 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table return ; }; + // Total colSpan: row-number col + data cols + action col + const totalColSpan = colCount + 2; + return ( @@ -211,52 +203,54 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table Edit table headers, rows, columns and sort data +
{/* Scrollable table area */} -
- {/* Insert-column buttons row (above the table) */} -
- {/* We position "+" buttons at each column border using the same grid layout */} -
- {/* Offset for row-number column */} -
- {headers.map((_, col) => ( -
- {/* "+" button on the left edge of each column (= between col-1 and col) */} - {col > 0 && ( -
- - - - - Insert column - -
- )} -
- ))} -
-
- +
+ {headers.map((header, col) => ( - -
-
- setInputRef(`-1:${col}`, el)} - className="flex-1 min-w-0 px-2 py-1.5 font-semibold text-xs uppercase tracking-wide bg-accent/50 border border-border rounded-tl-md focus:outline-none focus:ring-1 focus:ring-primary/40" - value={header} - onChange={(e) => updateHeader(col, e.target.value)} - onKeyDown={(e) => handleKeyDown(e, -1, col)} - placeholder={`Col ${col + 1}`} - /> +
+
+ setInputRef(`-1:${col}`, el)} + style={{ fontFamily: MONO_FONT }} + className="flex-1 min-w-0 px-2 py-1.5 font-semibold text-xs uppercase tracking-wide bg-accent/50 border border-border rounded-tl-md focus:outline-none focus:ring-1 focus:ring-primary/40" + value={header} + onChange={(e) => updateHeader(col, e.target.value)} + onKeyDown={(e) => handleKeyDown(e, -1, col)} + placeholder={`Col ${col + 1}`} + /> + + + + + Sort column + + {colCount > 1 && ( - Sort column + Remove column - {colCount > 1 && ( - - - - - Remove column - - )} -
+ )}
-
- {rowIdx + 1} +
+
+ {rowIdx + 1} {rowCount > 1 && (
setInputRef(`${rowIdx}:${col}`, el)} + style={{ fontFamily: MONO_FONT }} className={cn( "w-full px-2 py-1.5 text-sm bg-transparent border border-border focus:outline-none focus:ring-1 focus:ring-primary/40", rowIdx === rowCount - 1 && "rounded-bl-md", diff --git a/web/src/hooks/useLiveMemoRefresh.ts b/web/src/hooks/useLiveMemoRefresh.ts index 8dc9c40c1..3f0b98049 100644 --- a/web/src/hooks/useLiveMemoRefresh.ts +++ b/web/src/hooks/useLiveMemoRefresh.ts @@ -1,5 +1,5 @@ import { useQueryClient } from "@tanstack/react-query"; -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useSyncExternalStore } from "react"; import { getAccessToken } from "@/auth-state"; import { memoKeys } from "@/hooks/useMemoQueries"; import { userKeys } from "@/hooks/useUserQueries"; @@ -11,10 +11,49 @@ const INITIAL_RETRY_DELAY_MS = 1000; const MAX_RETRY_DELAY_MS = 30000; const RETRY_BACKOFF_MULTIPLIER = 2; +// --------------------------------------------------------------------------- +// Shared connection status store (singleton) +// --------------------------------------------------------------------------- + +export type SSEConnectionStatus = "connected" | "disconnected" | "connecting"; + +type Listener = () => void; + +let _status: SSEConnectionStatus = "disconnected"; +const _listeners = new Set(); + +function getSSEStatus(): SSEConnectionStatus { + return _status; +} + +function setSSEStatus(s: SSEConnectionStatus) { + if (_status !== s) { + _status = s; + _listeners.forEach((l) => l()); + } +} + +function subscribeSSEStatus(listener: Listener): () => void { + _listeners.add(listener); + return () => _listeners.delete(listener); +} + +/** + * React hook that returns the current SSE connection status. + * Re-renders the component whenever the status changes. + */ +export function useSSEConnectionStatus(): SSEConnectionStatus { + return useSyncExternalStore(subscribeSSEStatus, getSSEStatus, getSSEStatus); +} + +// --------------------------------------------------------------------------- +// Main hook +// --------------------------------------------------------------------------- + /** * useLiveMemoRefresh connects to the server's SSE endpoint and - * invalidates relevant React Query caches when memo change events - * (created, updated, deleted) are received. + * invalidates relevant React Query caches when change events + * (memos, reactions) are received. * * This enables real-time updates across all open instances of the app. */ @@ -23,6 +62,8 @@ export function useLiveMemoRefresh() { const retryDelayRef = useRef(INITIAL_RETRY_DELAY_MS); const abortControllerRef = useRef(null); + const handleEvent = useCallback((event: SSEChangeEvent) => handleSSEEvent(event, queryClient), [queryClient]); + useEffect(() => { let mounted = true; let retryTimeout: ReturnType | null = null; @@ -32,11 +73,13 @@ export function useLiveMemoRefresh() { const token = getAccessToken(); if (!token) { + setSSEStatus("disconnected"); // Not logged in; retry after a delay in case the user logs in. retryTimeout = setTimeout(connect, 5000); return; } + setSSEStatus("connecting"); const abortController = new AbortController(); abortControllerRef.current = abortController; @@ -55,6 +98,7 @@ export function useLiveMemoRefresh() { // Successfully connected - reset retry delay. retryDelayRef.current = INITIAL_RETRY_DELAY_MS; + setSSEStatus("connected"); const reader = response.body.getReader(); const decoder = new TextDecoder(); @@ -80,8 +124,8 @@ export function useLiveMemoRefresh() { if (line.startsWith("data: ")) { const jsonStr = line.slice(6); try { - const event = JSON.parse(jsonStr) as { type: string; name: string }; - handleSSEEvent(event, queryClient); + const event = JSON.parse(jsonStr) as SSEChangeEvent; + handleEvent(event); } catch { // Ignore malformed JSON. } @@ -92,11 +136,14 @@ export function useLiveMemoRefresh() { } catch (err: unknown) { if (err instanceof DOMException && err.name === "AbortError") { // Intentional abort, don't reconnect. + setSSEStatus("disconnected"); return; } // Connection lost or failed - reconnect with backoff. } + setSSEStatus("disconnected"); + // Reconnect with exponential backoff. if (mounted) { const delay = retryDelayRef.current; @@ -109,6 +156,7 @@ export function useLiveMemoRefresh() { return () => { mounted = false; + setSSEStatus("disconnected"); if (retryTimeout) { clearTimeout(retryTimeout); } @@ -116,9 +164,13 @@ export function useLiveMemoRefresh() { abortControllerRef.current.abort(); } }; - }, [queryClient]); + }, [handleEvent]); } +// --------------------------------------------------------------------------- +// Event handling +// --------------------------------------------------------------------------- + interface SSEChangeEvent { type: string; name: string; @@ -127,32 +179,23 @@ interface SSEChangeEvent { function handleSSEEvent(event: SSEChangeEvent, queryClient: ReturnType) { switch (event.type) { case "memo.created": - // Invalidate memo lists so new memos appear. queryClient.invalidateQueries({ queryKey: memoKeys.lists() }); - // Invalidate user stats (memo count changed). queryClient.invalidateQueries({ queryKey: userKeys.stats() }); break; case "memo.updated": - // Invalidate the specific memo detail cache. queryClient.invalidateQueries({ queryKey: memoKeys.detail(event.name) }); - // Invalidate memo lists to reflect updated content/ordering. queryClient.invalidateQueries({ queryKey: memoKeys.lists() }); break; case "memo.deleted": - // Remove the specific memo from cache. queryClient.removeQueries({ queryKey: memoKeys.detail(event.name) }); - // Invalidate memo lists. queryClient.invalidateQueries({ queryKey: memoKeys.lists() }); - // Invalidate user stats (memo count changed). queryClient.invalidateQueries({ queryKey: userKeys.stats() }); break; case "reaction.upserted": case "reaction.deleted": - // Reactions are embedded in the memo object, so invalidate the memo detail - // and lists to reflect the updated reaction state. queryClient.invalidateQueries({ queryKey: memoKeys.detail(event.name) }); queryClient.invalidateQueries({ queryKey: memoKeys.lists() }); break; From 8c35c75dec6168a17b2f0525dd416029c986db7d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Feb 2026 23:28:06 +0000 Subject: [PATCH 5/8] feat: table editor UX improvements - insert between, sticky header, layout Table editor improvements: 1. Insert column between columns: Hovering over the border area above two adjacent columns reveals a circular '+' button. Clicking it inserts a new empty column at that position. The buttons appear at 70% opacity on hover over the gutter zone and full opacity on direct hover. 2. Insert row between rows: Hovering over the border between two data rows reveals a circular '+' button on the first cell. Clicking it inserts a new empty row at that position. 3. Row delete button moved to end: The trash button for deleting a row is now at the right end of the row (matching the column delete button size at size-7) instead of the left side next to the row number. 4. Empty cell placeholder removed: Cell inputs no longer show '...' as placeholder text when empty. 5. Add row button moved to footer: The 'Add row' button is now in the footer bar next to the column/row count, alongside Cancel and Confirm buttons, instead of floating below the table. 6. Sticky table header: The thead is now sticky (top-0, z-20) with a background color, so column names remain visible when scrolling through large tables. Co-authored-by: milvasic --- web/src/components/TableEditorDialog.tsx | 140 ++++++++++++++++------- 1 file changed, 97 insertions(+), 43 deletions(-) diff --git a/web/src/components/TableEditorDialog.tsx b/web/src/components/TableEditorDialog.tsx index 31af74205..d12394bd6 100644 --- a/web/src/components/TableEditorDialog.tsx +++ b/web/src/components/TableEditorDialog.tsx @@ -12,7 +12,6 @@ import { VisuallyHidden } from "./ui/visually-hidden"; // Constants // --------------------------------------------------------------------------- -/** Monospace font stack for the cell inputs. */ const MONO_FONT = "'Fira Code', 'Fira Mono', 'JetBrains Mono', 'Cascadia Code', 'Consolas', ui-monospace, monospace"; // --------------------------------------------------------------------------- @@ -22,9 +21,7 @@ const MONO_FONT = "'Fira Code', 'Fira Mono', 'JetBrains Mono', 'Cascadia Code', interface TableEditorDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - /** Initial table data when editing an existing table. */ initialData?: TableData | null; - /** Called with the formatted markdown table string on confirm. */ onConfirm: (markdown: string) => void; } @@ -40,7 +37,6 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table const [alignments, setAlignments] = useState([]); const [sortState, setSortState] = useState(null); - // Ref grid for Tab navigation: inputRefs[row][col] (row -1 = headers). const inputRefs = useRef>(new Map()); const setInputRef = useCallback((key: string, el: HTMLInputElement | null) => { @@ -51,7 +47,6 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table } }, []); - // Initialize state when dialog opens. useEffect(() => { if (open) { if (initialData) { @@ -89,7 +84,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table }); }; - // ---- Add / Remove ---- + // ---- Add / Remove / Insert ---- const addColumn = () => { setHeaders((prev) => [...prev, ""]); @@ -98,6 +93,13 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table setSortState(null); }; + const insertColumnAt = (index: number) => { + setHeaders((prev) => [...prev.slice(0, index), "", ...prev.slice(index)]); + setRows((prev) => prev.map((r) => [...r.slice(0, index), "", ...r.slice(index)])); + setAlignments((prev) => [...prev.slice(0, index), "none" as ColumnAlignment, ...prev.slice(index)]); + setSortState(null); + }; + const removeColumn = (col: number) => { if (colCount <= 1) return; setHeaders((prev) => prev.filter((_, i) => i !== col)); @@ -110,6 +112,10 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table setRows((prev) => [...prev, Array.from({ length: colCount }, () => "")]); }; + const insertRowAt = (index: number) => { + setRows((prev) => [...prev.slice(0, index), Array.from({ length: colCount }, () => ""), ...prev.slice(index)]); + }; + const removeRow = (row: number) => { if (rowCount <= 1) return; setRows((prev) => prev.filter((_, i) => i !== row)); @@ -206,14 +212,44 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table Edit table headers, rows, columns and sort data
- {/* Scrollable table area — grows to fill */} + {/* Scrollable table area */}
+ {/* Insert-column buttons row (above the table) */} +
+ {/* We position "+" buttons at each column border using the same grid layout */} +
+ {/* Offset for row-number column */} +
+ {headers.map((_, col) => ( +
+ {/* "+" button on the left edge of each column (= between col-1 and col) */} + {col > 0 && ( +
+ + + + + Insert column + +
+ )} +
+ ))} +
+
+ - {/* Header row */} - + {/* Sticky header */} + {/* Row number column */} - ))} - {/* Add column button */} + {/* Add column at end */} + {/* Data rows */} {rows.map((row, rowIdx) => ( - - {/* Row number + remove */} - + {/* Row number */} + + + {/* Data cells */} {row.map((cell, col) => ( - ))} - ))}
+ {headers.map((header, col) => (
@@ -255,7 +291,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
@@ -272,32 +308,19 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
-
- {rowIdx + 1} - {rowCount > 1 && ( - - - - - Remove row - - )} -
+
+ {rowIdx + 1} + setInputRef(`${rowIdx}:${col}`, el)} style={{ fontFamily: MONO_FONT }} @@ -308,30 +331,61 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table value={cell} onChange={(e) => updateCell(rowIdx, col, e.target.value)} onKeyDown={(e) => handleKeyDown(e, rowIdx, col)} - placeholder="..." /> + {/* Insert-row button: shown on the top border between rows */} + {rowIdx > 0 && col === 0 && ( +
+ + + + + Insert row + +
+ )}
+ + {/* Row delete button (end of row) */} + + {rowCount > 1 && ( + + + + + Remove row + + )} +
+
- {/* Add row button */} -
+ {/* Footer */} +
+
+ + {colCount} {colCount === 1 ? "column" : "columns"} · {rowCount} {rowCount === 1 ? "row" : "rows"} +
-
- - {/* Footer */} -
- - {colCount} {colCount === 1 ? "column" : "columns"} · {rowCount} {rowCount === 1 ? "row" : "rows"} -
- {/* Sticky header */} - + {/* ============ STICKY HEADER ============ */} + + {/* Mask row: solid background strip that hides content scrolling behind the header */} - {/* Row number column */} - + + {/* Actual header row */} + + {/* Row-number spacer */} + ))} + {/* Add column at end */} - - {/* Data rows */} + {/* ============ DATA ROWS ============ */} {rows.map((row, rowIdx) => ( - - {/* Row number */} - - - {/* Data cells */} - {row.map((cell, col) => ( - + + + )} + + {/* ---- Actual data row ---- */} + + {/* Row number */} + + + {/* Data cells */} + {row.map((cell, col) => ( + + ))} + + {/* Row delete button (end of row) */} + - ))} - - {/* Row delete button (end of row) */} - - + + ))}
+ +
+ {headers.map((header, col) => ( - -
+
+ {/* ---- Insert-column zone (between col-1 and col) ---- */} + {col > 0 && ( +
insertColumnAt(col)} + > + {/* Blue vertical highlight line */} +
+ {/* + button */} + + + + + Insert column + +
+ )} + + {/* Header cell content — bg extends across input + action buttons */} +
setInputRef(`-1:${col}`, el)} style={{ fontFamily: MONO_FONT }} - className="flex-1 min-w-0 px-2 py-1.5 font-semibold text-xs uppercase tracking-wide bg-accent/50 border border-border rounded-tl-md focus:outline-none focus:ring-1 focus:ring-primary/40" + className="flex-1 min-w-0 px-2 py-1.5 font-semibold text-xs uppercase tracking-wide bg-transparent focus:outline-none focus:ring-1 focus:ring-primary/40" value={header} onChange={(e) => updateHeader(col, e.target.value)} onKeyDown={(e) => handleKeyDown(e, -1, col)} @@ -291,8 +285,9 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
+
- {rowIdx + 1} - - setInputRef(`${rowIdx}:${col}`, el)} - style={{ fontFamily: MONO_FONT }} - className={cn( - "w-full px-2 py-1.5 text-sm bg-transparent border border-border focus:outline-none focus:ring-1 focus:ring-primary/40", - rowIdx === rowCount - 1 && "rounded-bl-md", - )} - value={cell} - onChange={(e) => updateCell(rowIdx, col, e.target.value)} - onKeyDown={(e) => handleKeyDown(e, rowIdx, col)} - /> - {/* Insert-row button: shown on the top border between rows */} - {rowIdx > 0 && col === 0 && ( -
+ + {/* ---- Insert-row zone (between row rowIdx-1 and rowIdx) ---- */} + {rowIdx > 0 && ( +
+
insertRowAt(rowIdx)} + > + {/* Blue horizontal highlight line */} +
+ {/* + button */} @@ -348,34 +331,59 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table Insert row
+
+ {rowIdx + 1} + + setInputRef(`${rowIdx}:${col}`, el)} + style={{ fontFamily: MONO_FONT }} + className={cn( + "w-full px-2 py-1.5 text-sm bg-transparent border border-border focus:outline-none focus:ring-1 focus:ring-primary/40", + rowIdx === rowCount - 1 && "rounded-bl-md", + )} + value={cell} + onChange={(e) => updateCell(rowIdx, col, e.target.value)} + onKeyDown={(e) => handleKeyDown(e, rowIdx, col)} + /> + + {rowCount > 1 && ( + + + + + Remove row + )} - {rowCount > 1 && ( - - - - - Remove row - - )} -
- {/* Footer */} + {/* ============ FOOTER ============ */}
From ac403c60471c4d4676c657ac364429e96e8403a9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Feb 2026 23:56:15 +0000 Subject: [PATCH 7/8] feat: refine table editor insert zones, square headers, pointer cursors Insert zone improvements: - Row insert: hover zone now only covers the row-number area (left edge) instead of spanning the full row width, matching the column insert pattern where the zone is between header cells. The + button is positioned at the intersection of the horizontal row border and the vertical first-column border (translate-x-1/2 on the right edge of the row-number cell). - Column insert: blue highlight line now extends through the entire table (header + all data rows) using bottom: -200rem with pointer-events-none so it doesn't block cell interactions. - Row insert: blue highlight line extends across the full table width using width: 200rem with pointer-events-none for the same reason. - Removed the spacer approach for row inserts; zones are now directly inside the row-number with absolute positioning. Visual changes: - Headers are now square (removed rounded-tl-md from header cells and rounded-bl-md from last-row cells). - All buttons have explicit cursor-pointer class (sort, delete column, delete row, add column, add row, insert column, insert row, cancel, confirm, and the + insert buttons). Co-authored-by: milvasic --- web/src/components/TableEditorDialog.tsx | 80 +++++++++++------------- 1 file changed, 35 insertions(+), 45 deletions(-) diff --git a/web/src/components/TableEditorDialog.tsx b/web/src/components/TableEditorDialog.tsx index 4e05f2e23..630377245 100644 --- a/web/src/components/TableEditorDialog.tsx +++ b/web/src/components/TableEditorDialog.tsx @@ -1,6 +1,5 @@ import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, PlusIcon, TrashIcon } from "lucide-react"; import React, { useCallback, useEffect, useRef, useState } from "react"; -import { cn } from "@/lib/utils"; import type { ColumnAlignment, TableData } from "@/utils/markdown-table"; import { createEmptyTable, serializeMarkdownTable } from "@/utils/markdown-table"; import { Button } from "./ui/button"; @@ -122,9 +121,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table const sortByColumn = (col: number) => { let newDir: "asc" | "desc" = "asc"; - if (sortState && sortState.col === col && sortState.dir === "asc") { - newDir = "desc"; - } + if (sortState && sortState.col === col && sortState.dir === "asc") newDir = "desc"; setSortState({ col, dir: newDir }); setRows((prev) => { const sorted = [...prev].sort((a, b) => { @@ -171,16 +168,12 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table inputRefs.current.get(`${row}:${col}`)?.focus(); }; - // ---- Confirm ---- - const handleConfirm = () => { const md = serializeMarkdownTable({ headers, rows, alignments }); onConfirm(md); onOpenChange(false); }; - // ---- Sort indicator ---- - const SortIndicator = ({ col }: { col: number }) => { if (sortState?.col === col) { return sortState.dir === "asc" ? : ; @@ -188,7 +181,6 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table return ; }; - // Total colSpan: row-number col + data cols + action col const totalColSpan = colCount + 2; return ( @@ -210,12 +202,12 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table {/* ============ STICKY HEADER ============ */} - {/* Mask row: solid background strip that hides content scrolling behind the header */} + {/* Mask row: solid background that hides content scrolling behind the header */} - {/* Actual header row */} + {/* Header row */} {/* Row-number spacer */} {rows.map((row, rowIdx) => ( - {/* ---- Insert-row zone (between row rowIdx-1 and rowIdx) ---- */} - {rowIdx > 0 && ( - - + {/* Row number — with insert-row zone on top border */} + - - )} - - {/* ---- Actual data row ---- */} - - {/* Row number */} - @@ -348,10 +339,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table setInputRef(`${rowIdx}:${col}`, el)} style={{ fontFamily: MONO_FONT }} - className={cn( - "w-full px-2 py-1.5 text-sm bg-transparent border border-border focus:outline-none focus:ring-1 focus:ring-primary/40", - rowIdx === rowCount - 1 && "rounded-bl-md", - )} + className="w-full px-2 py-1.5 text-sm bg-transparent border border-border focus:outline-none focus:ring-1 focus:ring-primary/40" value={cell} onChange={(e) => updateCell(rowIdx, col, e.target.value)} onKeyDown={(e) => handleKeyDown(e, rowIdx, col)} @@ -359,14 +347,14 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table ))} - {/* Row delete button (end of row) */} + {/* Row delete button */} + + + + {/* ============ DATA ROWS ============ */} + + {rows.map((row, rowIdx) => ( + + + {/* Row number — with insert-row zone on top border */} + + + {/* Data cells */} + {row.map((cell, col) => ( + + ))} + + {/* Row delete button */} + + + + ))} + +
@@ -228,14 +220,17 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table className="group/cins absolute -left-4 top-0 bottom-0 w-8 z-30 flex items-center justify-center cursor-pointer" onClick={() => insertColumnAt(col)} > - {/* Blue vertical highlight line */} -
+ {/* Blue vertical line through the entire table */} +
{/* + button */} @@ -245,8 +240,8 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
)} - {/* Header cell content — bg extends across input + action buttons */} -
+ {/* Header cell — bg covers input + sort + delete */} +
setInputRef(`-1:${col}`, el)} style={{ fontFamily: MONO_FONT }} @@ -260,7 +255,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
+
+ {rowIdx > 0 && (
insertRowAt(rowIdx)} > - {/* Blue horizontal highlight line */} -
- {/* + button */} + {/* Blue horizontal line extending across the full table */} +
+ {/* + button at intersection of row border and first-column border */} @@ -331,14 +329,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table Insert row
-
+ )} {rowIdx + 1} {rowCount > 1 && (
- - +
From 6c6e1462bc57feb6fa56e344823f24b790d90ef3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 7 Feb 2026 00:05:41 +0000 Subject: [PATCH 8/8] fix: clip highlight lines to table bounds, fix insert button centering, footer buttons - Blue highlight lines for both column and row inserts are now clipped to the table boundaries via a relative overflow-hidden wrapper div around the table, so they no longer extend beyond the table edges. - Insert-column + button is now absolutely positioned with left-1/2 top-1/2 -translate-x/y-1/2 for pixel-perfect centering on the column border (previously used flex centering which was slightly off due to Tooltip wrapper). - Added ml-1 margin before the column delete button so it doesn't overlap with the insert-column + button hover zone. - Added a second '+ Add row' button just below the table (above the footer), in addition to the one in the footer bar. - Added '+ Add column' button in the footer bar, right next to the '+ Add row' button. Co-authored-by: milvasic --- web/src/components/TableEditorDialog.tsx | 297 ++++++++++++----------- 1 file changed, 156 insertions(+), 141 deletions(-) diff --git a/web/src/components/TableEditorDialog.tsx b/web/src/components/TableEditorDialog.tsx index 630377245..e414557c3 100644 --- a/web/src/components/TableEditorDialog.tsx +++ b/web/src/components/TableEditorDialog.tsx @@ -199,176 +199,187 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
{/* Scrollable table area */}
- - {/* ============ STICKY HEADER ============ */} - - {/* Mask row: solid background that hides content scrolling behind the header */} - - + {/* Clip wrapper: ensures blue highlight lines don't extend beyond the table */} +
+
-
+ {/* ============ STICKY HEADER ============ */} + + {/* Mask row: solid background that hides content scrolling behind the header */} + + - {/* Header row */} - - {/* Row-number spacer */} - + {/* Row-number spacer */} + - ))} - - {/* Add column at end */} - - - - - {/* ============ DATA ROWS ============ */} - - {rows.map((row, rowIdx) => ( - - - {/* Row number — with insert-row zone on top border */} - - {/* Data cells */} - {row.map((cell, col) => ( - - ))} - - {/* Row delete button */} - - - - ))} - -
+
+ {/* Header row */} +
- {headers.map((header, col) => ( - - {/* ---- Insert-column zone (between col-1 and col) ---- */} - {col > 0 && ( -
insertColumnAt(col)} - > - {/* Blue vertical line through the entire table */} + {headers.map((header, col) => ( +
+ {/* ---- Insert-column zone (between col-1 and col) ---- */} + {col > 0 && (
- {/* + button */} - - - - - Insert column - -
- )} - - {/* Header cell — bg covers input + sort + delete */} -
- setInputRef(`-1:${col}`, el)} - style={{ fontFamily: MONO_FONT }} - className="flex-1 min-w-0 px-2 py-1.5 font-semibold text-xs uppercase tracking-wide bg-transparent focus:outline-none focus:ring-1 focus:ring-primary/40" - value={header} - onChange={(e) => updateHeader(col, e.target.value)} - onKeyDown={(e) => handleKeyDown(e, -1, col)} - placeholder={`Col ${col + 1}`} - /> - - - - - Sort column - - {colCount > 1 && ( - - - - - Remove column - - )} -
-
- - - - - Add column - -
- {rowIdx > 0 && ( -
insertRowAt(rowIdx)} + className="group/cins absolute -left-4 top-0 bottom-0 w-8 z-30 cursor-pointer" + onClick={() => insertColumnAt(col)} > - {/* Blue horizontal line extending across the full table */} + {/* Blue vertical line through the entire table */}
- {/* + button at intersection of row border and first-column border */} + {/* + button — absolutely centered on the column border */} - Insert row + Insert column
)} - {rowIdx + 1} -
+ {/* Header cell — bg covers input + sort + delete */} +
setInputRef(`${rowIdx}:${col}`, el)} + ref={(el) => setInputRef(`-1:${col}`, el)} style={{ fontFamily: MONO_FONT }} - className="w-full px-2 py-1.5 text-sm bg-transparent border border-border focus:outline-none focus:ring-1 focus:ring-primary/40" - value={cell} - onChange={(e) => updateCell(rowIdx, col, e.target.value)} - onKeyDown={(e) => handleKeyDown(e, rowIdx, col)} + className="flex-1 min-w-0 px-2 py-1.5 font-semibold text-xs uppercase tracking-wide bg-transparent focus:outline-none focus:ring-1 focus:ring-primary/40" + value={header} + onChange={(e) => updateHeader(col, e.target.value)} + onKeyDown={(e) => handleKeyDown(e, -1, col)} + placeholder={`Col ${col + 1}`} /> -
- {rowCount > 1 && ( - Remove row + Sort column - )} -
+ {colCount > 1 && ( + + + + + Remove column + + )} +
+ + ))} + + {/* Add column at end */} +
+ + + + + Add column + +
+ {rowIdx > 0 && ( +
insertRowAt(rowIdx)} + > + {/* Blue horizontal line extending across the table */} +
+ {/* + button at intersection of row border and first-column border */} + + + + + Insert row + +
+ )} + {rowIdx + 1} +
+ setInputRef(`${rowIdx}:${col}`, el)} + style={{ fontFamily: MONO_FONT }} + className="w-full px-2 py-1.5 text-sm bg-transparent border border-border focus:outline-none focus:ring-1 focus:ring-primary/40" + value={cell} + onChange={(e) => updateCell(rowIdx, col, e.target.value)} + onKeyDown={(e) => handleKeyDown(e, rowIdx, col)} + /> + + {rowCount > 1 && ( + + + + + Remove row + + )} +
+
+ + {/* Add row button below the table */} +
+ +
{/* ============ FOOTER ============ */} @@ -381,6 +392,10 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table Add row +