diff --git a/apps/web/src/hooks/useCollaboration.ts b/apps/web/src/hooks/useCollaboration.ts index ec68de9..8317b53 100644 --- a/apps/web/src/hooks/useCollaboration.ts +++ b/apps/web/src/hooks/useCollaboration.ts @@ -169,7 +169,7 @@ export const useCollaboration = ({ }); } }; - // eslint-disable-next-line react-hooks/exhaustive-deps + // Note: onRemoteUpdate is intentionally not in deps to prevent re-subscription on callback changes }, [documentId, documentType, userId, userEmail, userName, enabled]); diff --git a/apps/web/src/pages/CommsPlannerEditor.tsx b/apps/web/src/pages/CommsPlannerEditor.tsx index 1015ff9..34208db 100644 --- a/apps/web/src/pages/CommsPlannerEditor.tsx +++ b/apps/web/src/pages/CommsPlannerEditor.tsx @@ -82,6 +82,11 @@ const CommsPlannerEditor = () => { const effectiveUserId = user?.id || (isSharedEdit ? anonymousUserId : ""); const effectiveUserEmail = user?.email || (isSharedEdit ? `${anonymousUserId}@shared` : ""); const effectiveUserName = user?.user_metadata?.name || (isSharedEdit ? "Anonymous User" : ""); + + // Local state for plan name input to prevent reversion during typing + const [localPlanName, setLocalPlanName] = useState(""); + const [localPlanNameInitialized, setLocalPlanNameInitialized] = useState(false); + const { planName, elements, @@ -211,10 +216,22 @@ const CommsPlannerEditor = () => { userName: effectiveUserName, enabled: collaborationEnabled, onRemoteUpdate: (payload) => { + // GUARD: Don't process remote updates if collaboration is disabled + // This prevents input reversion on NEW documents where collaboration is off + if (!collaborationEnabled) { + console.log("[CommsPlannerEditor] Ignoring remote update - collaboration disabled"); + return; + } + if (payload.type === "field_update" && payload.field) { // Handle field updates from remote users console.log("[CommsPlannerEditor] Remote field update:", payload.field, payload.value); - // Update will be handled by database subscription + if (payload.field === "name") { + setPlanName(payload.value); + // Update local plan name state when remote changes arrive + setLocalPlanName(payload.value); + } + // Other field updates will be handled by database subscription } }, }); @@ -225,6 +242,43 @@ const CommsPlannerEditor = () => { userId: effectiveUserId, }); + // Initialize local plan name from planName when document loads + useEffect(() => { + if (planName && !localPlanNameInitialized) { + setLocalPlanName(planName); + setLocalPlanNameInitialized(true); + } + }, [planName, localPlanNameInitialized]); + + // Debounced sync: Update planName after user stops typing (500ms delay) + useEffect(() => { + if (!localPlanNameInitialized) return; + + const handler = setTimeout(() => { + if (localPlanName !== planName) { + setPlanName(localPlanName); + + // Broadcast plan name change to other collaborators + if (collaborationEnabled && broadcast) { + broadcast({ + type: "field_update", + field: "name", + value: localPlanName, + }); + } + } + }, 500); + + return () => clearTimeout(handler); + }, [ + localPlanName, + localPlanNameInitialized, + planName, + setPlanName, + collaborationEnabled, + broadcast, + ]); + // Real-time database subscription for syncing changes across users useEffect(() => { if (!collaborationEnabled || !documentId) { @@ -245,41 +299,10 @@ const CommsPlannerEditor = () => { }, (payload) => { console.log("[CommsPlannerEditor] Received database UPDATE event:", payload); - // Update local state with the new data - // IMPORTANT: Exclude metadata fields (version, last_edited, metadata) to prevent - // triggering auto-save, which would create an infinite loop - if (payload.new) { - const { version, last_edited, metadata, ...userEditableFields } = payload.new as any; - - // Update store with new data - if (userEditableFields.name) { - setPlanName(userEditableFields.name); - } - if (userEditableFields.venue_geometry) { - setVenueWidth(userEditableFields.venue_geometry.width); - setVenueHeight(userEditableFields.venue_geometry.height); - } - if (userEditableFields.zones) { - setZones(userEditableFields.zones); - } - if (userEditableFields.elements) { - const loadedElements = userEditableFields.elements.map((el: any) => ({ - ...el, - systemType: el.system_type, - channels: el.channel_set, - })); - setElements(loadedElements); - } - if (userEditableFields.beltpacks) { - setBeltpacks(userEditableFields.beltpacks); - } - if (typeof userEditableFields.dfs_enabled !== "undefined") { - setDfsEnabled(userEditableFields.dfs_enabled); - } - if (typeof userEditableFields.poe_budget_total !== "undefined") { - setPoeBudget(userEditableFields.poe_budget_total); - } - } + // NOTE: We intentionally DO NOT update local state from database UPDATE events + // because they include our own saves echoing back, which would overwrite user typing + // Real-time collaboration updates happen via the broadcast channel in useCollaboration + console.log("[CommsPlannerEditor] Ignoring database UPDATE to prevent input reversion"); }, ) .subscribe(); @@ -1488,8 +1511,8 @@ const CommsPlannerEditor = () => { setPlanName(e.target.value)} + value={localPlanName || planName} + onChange={(e) => setLocalPlanName(e.target.value)} className="text-2xl font-bold text-white bg-transparent border-none focus:outline-none focus:ring-0" /> diff --git a/apps/web/src/pages/CorporateMicPlotEditor.tsx b/apps/web/src/pages/CorporateMicPlotEditor.tsx index 6527f3f..fc41027 100644 --- a/apps/web/src/pages/CorporateMicPlotEditor.tsx +++ b/apps/web/src/pages/CorporateMicPlotEditor.tsx @@ -59,6 +59,10 @@ const CorporateMicPlotEditor: React.FC = () => { const effectiveUserEmail = user?.email || (isSharedEdit ? `${anonymousUserId}@shared` : ""); const effectiveUserName = user?.user_metadata?.name || (isSharedEdit ? "Anonymous User" : ""); + // Local state for name input to prevent reversion during typing + const [localName, setLocalName] = useState(""); + const [localNameInitialized, setLocalNameInitialized] = useState(false); + // Enable collaboration for existing documents (including edit-mode shared links) // For shared links, routeId will be undefined, so we check micPlot?.id instead // For edit-mode shared links, allow collaboration even without authentication @@ -189,11 +193,27 @@ const CorporateMicPlotEditor: React.FC = () => { userName: effectiveUserName, enabled: collaborationEnabled, onRemoteUpdate: (payload) => { + // GUARD: Don't process remote updates if collaboration is disabled + // This prevents input reversion on NEW documents where collaboration is off + if (!collaborationEnabled) { + console.log("[CorporateMicPlotEditor] Ignoring remote update - collaboration disabled"); + return; + } + if (payload.type === "field_update" && payload.field) { - setMicPlot((prev: any) => ({ - ...prev, - [payload.field!]: payload.value, - })); + if (payload.field === "name") { + setMicPlot((prev: any) => ({ + ...prev, + name: payload.value, + })); + // Update local name state when remote changes arrive + setLocalName(payload.value); + } else { + setMicPlot((prev: any) => ({ + ...prev, + [payload.field!]: payload.value, + })); + } } }, }); @@ -204,6 +224,47 @@ const CorporateMicPlotEditor: React.FC = () => { userId: effectiveUserId, }); + // Initialize local name from micPlot.name when document loads + useEffect(() => { + if (micPlot?.name && !localNameInitialized) { + setLocalName(micPlot.name); + setLocalNameInitialized(true); + } + }, [micPlot?.name, localNameInitialized]); + + // Debounced sync: Update micPlot.name after user stops typing (500ms delay) + useEffect(() => { + if (!localNameInitialized) return; + + const handler = setTimeout(() => { + if (localName !== micPlot?.name) { + setMicPlot((prev: any) => (prev ? { ...prev, name: localName } : prev)); + + // Broadcast name change to other collaborators + if (collaborationEnabled && broadcast) { + broadcast({ + type: "field_update", + field: "name", + value: localName, + userId: effectiveUserId, + }).catch((err) => + console.error("[CorporateMicPlotEditor] Failed to broadcast name change:", err), + ); + } + } + }, 500); + + return () => clearTimeout(handler); + }, [ + localName, + localNameInitialized, + micPlot?.name, + setMicPlot, + collaborationEnabled, + broadcast, + effectiveUserId, + ]); + // Keyboard shortcut for manual save (Cmd/Ctrl+S) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -239,20 +300,12 @@ const CorporateMicPlotEditor: React.FC = () => { }, (payload) => { console.log("[CorporateMicPlotEditor] Received database UPDATE event:", payload); - // Update local state with the new data - // IMPORTANT: Exclude metadata fields (version, last_edited, metadata) to prevent - // triggering auto-save, which would create an infinite loop - if (payload.new) { - const { version, last_edited, metadata, ...userEditableFields } = payload.new as any; - setMicPlot((prev: any) => ({ - ...prev, - ...userEditableFields, - // Keep existing metadata to avoid triggering auto-save - version: prev?.version, - last_edited: prev?.last_edited, - metadata: prev?.metadata, - })); - } + // NOTE: We intentionally DO NOT update local state from database UPDATE events + // because they include our own saves echoing back, which would overwrite user typing + // Real-time collaboration updates happen via the broadcast channel in useCollaboration + console.log( + "[CorporateMicPlotEditor] Ignoring database UPDATE to prevent input reversion", + ); }, ) .subscribe(); @@ -264,22 +317,7 @@ const CorporateMicPlotEditor: React.FC = () => { }, [collaborationEnabled, micPlot?.id]); const handleNameChange = (e: React.ChangeEvent) => { - if (micPlot) { - const newName = e.target.value; - setMicPlot({ ...micPlot, name: newName }); - - // Broadcast name change to other collaborators - if (collaborationEnabled && broadcast) { - broadcast({ - type: "field_update", - field: "name", - value: newName, - userId: effectiveUserId, - }).catch((err) => - console.error("[CorporateMicPlotEditor] Failed to broadcast name change:", err), - ); - } - } + setLocalName(e.target.value); }; const handleAddPresenter = () => { @@ -633,7 +671,7 @@ const CorporateMicPlotEditor: React.FC = () => {
{ const effectiveUserEmail = user?.email || (isSharedEdit ? `${anonymousUserId}@shared` : ""); const effectiveUserName = user?.user_metadata?.name || (isSharedEdit ? "Anonymous User" : ""); + // Local state for project name input to prevent reversion during typing + const [localProjectName, setLocalProjectName] = useState(""); + const [localProjectNameInitialized, setLocalProjectNameInitialized] = useState(false); + const [mapData, setMapData] = useState({ projectName: "My Awesome Show", screenName: "Main LED Wall", @@ -138,14 +142,64 @@ const LedPixelMapEditor = () => { userName: effectiveUserName, enabled: collaborationEnabled, onRemoteUpdate: (payload) => { + // GUARD: Don't process remote updates if collaboration is disabled + // This prevents input reversion on NEW documents where collaboration is off + if (!collaborationEnabled) { + console.log("[LedPixelMapEditor] Ignoring remote update - collaboration disabled"); + return; + } + if (payload.type === "field_update" && payload.field) { - setMapData((prev) => (prev ? { ...prev, [payload.field!]: payload.value } : prev)); + if (payload.field === "projectName") { + setMapData((prev) => (prev ? { ...prev, projectName: payload.value } : prev)); + // Update local project name state when remote changes arrive + setLocalProjectName(payload.value); + } else { + setMapData((prev) => (prev ? { ...prev, [payload.field!]: payload.value } : prev)); + } } }, }); usePresence({ channel: null, userId: effectiveUserId }); + // Initialize local project name from mapData.projectName when document loads + useEffect(() => { + if (mapData.projectName && !localProjectNameInitialized) { + setLocalProjectName(mapData.projectName); + setLocalProjectNameInitialized(true); + } + }, [mapData.projectName, localProjectNameInitialized]); + + // Debounced sync: Update mapData.projectName after user stops typing (500ms delay) + useEffect(() => { + if (!localProjectNameInitialized) return; + + const handler = setTimeout(() => { + if (localProjectName !== mapData.projectName) { + setMapData((prev) => ({ ...prev, projectName: localProjectName })); + + // Broadcast project name change to other collaborators + if (collaborationEnabled && broadcast) { + broadcast({ + type: "field_update", + field: "projectName", + value: localProjectName, + }); + } + } + }, 500); + + return () => clearTimeout(handler); + }, [ + localProjectName, + localProjectNameInitialized, + mapData.projectName, + setMapData, + collaborationEnabled, + broadcast, + ]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "s") { @@ -177,16 +231,10 @@ const LedPixelMapEditor = () => { }, (payload) => { console.log("[LedPixelMapEditor] Received database UPDATE event:", payload); - // Update local state with the new data - // IMPORTANT: Exclude metadata fields to prevent triggering auto-save - if (payload.new) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { last_edited, ...userEditableFields } = payload.new as Record; - setMapData((prev) => ({ - ...prev, - ...userEditableFields, - })); - } + // NOTE: We intentionally DO NOT update local state from database UPDATE events + // because they include our own saves echoing back, which would overwrite user typing + // Real-time collaboration updates happen via the broadcast channel in useCollaboration + console.log("[LedPixelMapEditor] Ignoring database UPDATE to prevent input reversion"); }, ) .subscribe(); @@ -610,8 +658,26 @@ const LedPixelMapEditor = () => {
{ + if (typeof update === "function") { + const newData = update(mapData); + if (newData.projectName !== mapData.projectName) { + setLocalProjectName(newData.projectName); + } else { + setMapData(newData); + } + } else { + if (update.projectName !== mapData.projectName) { + setLocalProjectName(update.projectName); + } else { + setMapData(update); + } + } + }} previewOptions={previewOptions} setPreviewOptions={setPreviewOptions} /> diff --git a/apps/web/src/pages/PatchSheetEditor.tsx b/apps/web/src/pages/PatchSheetEditor.tsx index 03662e9..93139b1 100644 --- a/apps/web/src/pages/PatchSheetEditor.tsx +++ b/apps/web/src/pages/PatchSheetEditor.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { useParams, useNavigate, useLocation } from "react-router-dom"; import { supabase } from "../lib/supabase"; import { useAuth } from "../lib/AuthContext"; @@ -79,6 +79,10 @@ const PatchSheetEditor = () => { const [shareLink, setShareLink] = useState(null); const [currentShareLink, setCurrentShareLink] = useState(null); + // Local state for title input to prevent auto-save interference during typing + const [localName, setLocalName] = useState(""); + const localNameInitialized = useRef(false); + // Collaboration state const [showHistory, setShowHistory] = useState(false); const [showConflict, setShowConflict] = useState(false); @@ -184,6 +188,13 @@ const PatchSheetEditor = () => { userName: effectiveUserName, enabled: collaborationEnabled, onRemoteUpdate: (payload) => { + // GUARD: Don't process remote updates if collaboration is disabled + // This prevents title reversion on NEW patch sheets where collaboration is off + if (!collaborationEnabled) { + console.log("[PatchSheetEditor] Ignoring remote update - collaboration disabled"); + return; + } + if (payload.type === "field_update" && payload.field) { console.log(`[PatchSheetEditor] Applying remote update for field: ${payload.field}`); @@ -213,6 +224,13 @@ const PatchSheetEditor = () => { }); // Create a new array reference to ensure React detects the change setOutputs([...payload.value]); + } else if (payload.field === "name" && typeof payload.value === "string") { + console.log( + "[PatchSheetEditor] Updating localName from remote broadcast:", + payload.value, + ); + // Update local name to reflect remote change + setLocalName(payload.value); } } }, @@ -236,6 +254,35 @@ const PatchSheetEditor = () => { return () => window.removeEventListener("keydown", handleKeyDown); }, [forceSave]); + // Sync local name with patchSheet when it loads + useEffect(() => { + if (patchSheet?.name && !localNameInitialized.current) { + console.log("[PatchSheetEditor] Initializing localName from patchSheet:", patchSheet.name); + setLocalName(patchSheet.name); + localNameInitialized.current = true; + } + }, [patchSheet?.name]); + + // Debounced sync from local name to patchSheet state + useEffect(() => { + if (!localNameInitialized.current) return; + + const timer = setTimeout(() => { + if (localName !== patchSheet?.name) { + console.log("[PatchSheetEditor] Syncing localName to patchSheet:", { + localName, + previousName: patchSheet?.name, + }); + setPatchSheet((prev: any) => ({ + ...prev, + name: localName, + })); + } + }, 500); // 500ms debounce + + return () => clearTimeout(timer); + }, [localName, patchSheet?.name]); + // Real-time database subscription for syncing changes across users useEffect(() => { if (!collaborationEnabled || !patchSheet?.id) { @@ -558,8 +605,8 @@ const PatchSheetEditor = () => {
setPatchSheet({ ...patchSheet, name: e.target.value })} + value={localName || "Untitled Patch Sheet"} + onChange={(e) => setLocalName(e.target.value)} className="text-xl md:text-2xl font-bold text-white bg-transparent border-none focus:outline-none focus:ring-0 w-full max-w-[220px] sm:max-w-none" placeholder="Enter patch sheet name" /> diff --git a/apps/web/src/pages/ProductionScheduleEditor.tsx b/apps/web/src/pages/ProductionScheduleEditor.tsx index 65e6a47..5e8137c 100644 --- a/apps/web/src/pages/ProductionScheduleEditor.tsx +++ b/apps/web/src/pages/ProductionScheduleEditor.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useMemo, useRef } from "react"; import { useParams, useNavigate, useLocation } from "react-router-dom"; import { supabase } from "../lib/supabase"; import { useAuth } from "../lib/AuthContext"; @@ -154,6 +154,10 @@ const ProductionScheduleEditor = () => { const effectiveUserEmail = user?.email || (isSharedEdit ? `${anonymousUserId}@shared` : ""); const effectiveUserName = user?.user_metadata?.name || (isSharedEdit ? "Anonymous User" : ""); + // Local state for title input to prevent auto-save interference during typing + const [localName, setLocalName] = useState(""); + const localNameInitialized = useRef(false); + // Enable collaboration for existing documents (including edit-mode shared links) // For shared links, id will be undefined, so we check schedule?.id instead // For edit-mode shared links, allow collaboration even without authentication @@ -217,11 +221,27 @@ const ProductionScheduleEditor = () => { userName: effectiveUserName, enabled: collaborationEnabled, onRemoteUpdate: (payload) => { + // GUARD: Don't process remote updates if collaboration is disabled + // This prevents input reversion on NEW documents where collaboration is off + if (!collaborationEnabled) { + console.log("[ProductionScheduleEditor] Ignoring remote update - collaboration disabled"); + return; + } + if (payload.type === "field_update" && payload.field) { - setSchedule((prev: any) => ({ - ...prev, - [payload.field!]: payload.value, - })); + // Handle name field separately to prevent input reversion + if (payload.field === "name" && typeof payload.value === "string") { + console.log( + "[ProductionScheduleEditor] Updating localName from remote broadcast:", + payload.value, + ); + setLocalName(payload.value); + } else { + setSchedule((prev: any) => ({ + ...prev, + [payload.field!]: payload.value, + })); + } } }, }); @@ -255,20 +275,12 @@ const ProductionScheduleEditor = () => { }, (payload) => { console.log("[ProductionScheduleEditor] Received database UPDATE event:", payload); - // Update local state with the new data - // IMPORTANT: Exclude metadata fields (version, last_edited, metadata) to prevent - // triggering auto-save, which would create an infinite loop - if (payload.new) { - const { version, last_edited, metadata, ...userEditableFields } = payload.new as any; - setSchedule((prev: any) => ({ - ...prev, - ...userEditableFields, - // Keep existing metadata to avoid triggering auto-save - version: prev?.version, - last_edited: prev?.last_edited, - metadata: prev?.metadata, - })); - } + // NOTE: We intentionally DO NOT update local state from database UPDATE events + // because they include our own saves echoing back, which would overwrite user typing + // Real-time collaboration updates happen via the broadcast channel in useCollaboration + console.log( + "[ProductionScheduleEditor] Ignoring database UPDATE to prevent input reversion", + ); }, ) .subscribe(); @@ -279,6 +291,38 @@ const ProductionScheduleEditor = () => { }; }, [collaborationEnabled, schedule?.id]); + // Sync local name with schedule when it loads + useEffect(() => { + if (schedule?.name && !localNameInitialized.current) { + console.log( + "[ProductionScheduleEditor] Initializing localName from schedule:", + schedule.name, + ); + setLocalName(schedule.name); + localNameInitialized.current = true; + } + }, [schedule?.name]); + + // Debounced sync from local name to schedule state + useEffect(() => { + if (!localNameInitialized.current) return; + + const timer = setTimeout(() => { + if (localName !== schedule?.name) { + console.log("[ProductionScheduleEditor] Syncing localName to schedule:", { + localName, + previousName: schedule?.name, + }); + setSchedule((prev: any) => ({ + ...prev, + name: localName, + })); + } + }, 500); // 500ms debounce + + return () => clearTimeout(timer); + }, [localName, schedule?.name]); + // Keyboard shortcut for manual save (Cmd/Ctrl+S) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -906,8 +950,8 @@ const ProductionScheduleEditor = () => {
setLocalName(e.target.value)} className="text-xl md:text-2xl font-bold text-white bg-transparent border-none focus:outline-none focus:ring-0 w-full" placeholder="Enter schedule name" /> diff --git a/apps/web/src/pages/RiderEditor.tsx b/apps/web/src/pages/RiderEditor.tsx index 51b29a8..84b68a1 100644 --- a/apps/web/src/pages/RiderEditor.tsx +++ b/apps/web/src/pages/RiderEditor.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { useParams, useNavigate, useLocation } from "react-router-dom"; import { supabase } from "../lib/supabase"; import { useAuth } from "../lib/AuthContext"; @@ -76,6 +76,10 @@ const RiderEditor = () => { const [historyLoading, setHistoryLoading] = useState(false); const [historyError, setHistoryError] = useState(null); + // Local state for name input to prevent collaboration reversion + const [localName, setLocalName] = useState(""); + const localNameInitialized = useRef(false); + // For unauthenticated shared edit users, generate a temporary ID const [anonymousUserId] = useState(() => `anonymous-${uuidv4()}`); const effectiveUserId = user?.id || (isSharedEdit ? anonymousUserId : ""); @@ -146,11 +150,23 @@ const RiderEditor = () => { userName: effectiveUserName, enabled: collaborationEnabled, onRemoteUpdate: (payload) => { + // GUARD: Don't process remote updates if collaboration is disabled + // This prevents input reversion on NEW documents where collaboration is off + if (!collaborationEnabled) { + console.log("[RiderEditor] Ignoring remote update - collaboration disabled"); + return; + } + if (payload.type === "field_update" && payload.field) { - setRider((prev: any) => ({ - ...prev, - [payload.field!]: payload.value, - })); + if (payload.field === "name" && typeof payload.value === "string") { + console.log("[RiderEditor] Updating localName from remote broadcast:", payload.value); + setLocalName(payload.value); + } else { + setRider((prev: any) => ({ + ...prev, + [payload.field!]: payload.value, + })); + } } }, }); @@ -181,20 +197,10 @@ const RiderEditor = () => { }, (payload) => { console.log("[RiderEditor] Received database UPDATE event:", payload); - // Update local state with the new data - // IMPORTANT: Exclude metadata fields (version, last_edited, metadata) to prevent - // triggering auto-save, which would create an infinite loop - if (payload.new) { - const { version, last_edited, metadata, ...userEditableFields } = payload.new as any; - setRider((prev: any) => ({ - ...prev, - ...userEditableFields, - // Keep existing metadata to avoid triggering auto-save - version: prev?.version, - last_edited: prev?.last_edited, - metadata: prev?.metadata, - })); - } + // NOTE: We intentionally DO NOT update local state from database UPDATE events + // because they include our own saves echoing back, which would overwrite user typing + // Real-time collaboration updates happen via the broadcast channel in useCollaboration + console.log("[RiderEditor] Ignoring database UPDATE to prevent input reversion"); }, ) .subscribe(); @@ -217,6 +223,32 @@ const RiderEditor = () => { return () => window.removeEventListener("keydown", handleKeyDown); }, [forceSave]); + // Initialize localName from rider on first load + useEffect(() => { + if (rider?.name && !localNameInitialized.current) { + console.log("[RiderEditor] Initializing localName from rider:", rider.name); + setLocalName(rider.name); + localNameInitialized.current = true; + } + }, [rider?.name]); + + // Debounced sync: Update rider when localName changes (after user stops typing) + useEffect(() => { + if (!localNameInitialized.current) return; + + const timer = setTimeout(() => { + if (localName !== rider?.name) { + console.log("[RiderEditor] Syncing localName to rider:", { + localName, + previousName: rider?.name, + }); + setRider((prev) => (prev ? { ...prev, name: localName } : prev)); + } + }, 500); + + return () => clearTimeout(timer); + }, [localName, rider?.name]); + useEffect(() => { const fetchUserAndRider = async () => { setLoading(true); @@ -674,8 +706,8 @@ const RiderEditor = () => {
setRider({ ...rider, name: e.target.value })} + value={localName} + onChange={(e) => setLocalName(e.target.value)} className="text-3xl md:text-4xl font-bold text-white bg-transparent border-b-2 border-gray-700 focus:border-indigo-500 focus:outline-none w-full" placeholder="Rider Name" /> diff --git a/apps/web/src/pages/RunOfShowEditor.tsx b/apps/web/src/pages/RunOfShowEditor.tsx index 5c4ebde..41a0c9c 100644 --- a/apps/web/src/pages/RunOfShowEditor.tsx +++ b/apps/web/src/pages/RunOfShowEditor.tsx @@ -184,6 +184,10 @@ const RunOfShowEditor: React.FC = () => { // State for import modal const [showImportModal, setShowImportModal] = useState(false); + // Local state for name input to prevent collaboration reversion + const [localName, setLocalName] = useState(""); + const localNameInitialized = useRef(false); + // Enable collaboration for existing documents (including edit-mode shared links) // For shared links, id will be undefined, so we check runOfShow?.id instead // For edit-mode shared links, allow collaboration even without authentication @@ -249,8 +253,20 @@ const RunOfShowEditor: React.FC = () => { userName: effectiveUserName, enabled: collaborationEnabled, onRemoteUpdate: (payload) => { + // GUARD: Don't process remote updates if collaboration is disabled + // This prevents input reversion on NEW documents where collaboration is off + if (!collaborationEnabled) { + console.log("[RunOfShowEditor] Ignoring remote update - collaboration disabled"); + return; + } + if (payload.type === "field_update" && payload.field) { - setRunOfShow((prev) => (prev ? { ...prev, [payload.field!]: payload.value } : prev)); + if (payload.field === "name" && typeof payload.value === "string") { + console.log("[RunOfShowEditor] Updating localName from remote broadcast:", payload.value); + setLocalName(payload.value); + } else { + setRunOfShow((prev) => (prev ? { ...prev, [payload.field!]: payload.value } : prev)); + } } }, }); @@ -269,6 +285,32 @@ const RunOfShowEditor: React.FC = () => { return () => window.removeEventListener("keydown", handleKeyDown); }, [forceSave]); + // Initialize localName from runOfShow on first load + useEffect(() => { + if (runOfShow?.name && !localNameInitialized.current) { + console.log("[RunOfShowEditor] Initializing localName from runOfShow:", runOfShow.name); + setLocalName(runOfShow.name); + localNameInitialized.current = true; + } + }, [runOfShow?.name]); + + // Debounced sync: Update runOfShow when localName changes (after user stops typing) + useEffect(() => { + if (!localNameInitialized.current) return; + + const timer = setTimeout(() => { + if (localName !== runOfShow?.name) { + console.log("[RunOfShowEditor] Syncing localName to runOfShow:", { + localName, + previousName: runOfShow?.name, + }); + setRunOfShow((prev) => (prev ? { ...prev, name: localName } : prev)); + } + }, 500); + + return () => clearTimeout(timer); + }, [localName, runOfShow?.name]); + // Real-time database subscription for syncing changes across users useEffect(() => { if (!collaborationEnabled || !runOfShow?.id) { @@ -292,24 +334,10 @@ const RunOfShowEditor: React.FC = () => { }, (payload) => { console.log("[RunOfShowEditor] Received database UPDATE event:", payload); - // Update local state with the new data - // IMPORTANT: Exclude metadata fields (version, last_edited, metadata) to prevent - // triggering auto-save, which would create an infinite loop - if (payload.new) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { version, last_edited, metadata, ...userEditableFields } = payload.new as Record< - string, - unknown - >; - setRunOfShow((prev) => ({ - ...prev, - ...userEditableFields, - // Keep existing metadata to avoid triggering auto-save - version: prev?.version, - last_edited: prev?.last_edited, - metadata: prev?.metadata, - })); - } + // NOTE: We intentionally DO NOT update local state from database UPDATE events + // because they include our own saves echoing back, which would overwrite user typing + // Real-time collaboration updates happen via the broadcast channel in useCollaboration + console.log("[RunOfShowEditor] Ignoring database UPDATE to prevent input reversion"); }, ) .subscribe(); @@ -1284,8 +1312,8 @@ const RunOfShowEditor: React.FC = () => {
setLocalName(e.target.value)} className="text-xl md:text-2xl font-bold text-white bg-transparent border-none focus:outline-none focus:ring-0 w-full" placeholder="Enter Run of Show Name" /> diff --git a/apps/web/src/pages/StagePlotEditor.tsx b/apps/web/src/pages/StagePlotEditor.tsx index 1d372bf..f29df21 100644 --- a/apps/web/src/pages/StagePlotEditor.tsx +++ b/apps/web/src/pages/StagePlotEditor.tsx @@ -142,6 +142,10 @@ const StagePlotEditor = () => { const effectiveUserEmail = user?.email || (isSharedEdit ? `${anonymousUserId}@shared` : ""); const effectiveUserName = user?.user_metadata?.name || (isSharedEdit ? "Anonymous User" : ""); + // Local state for name input to prevent reversion during typing + const [localName, setLocalName] = useState(""); + const [localNameInitialized, setLocalNameInitialized] = useState(false); + const lastDimsRef = React.useRef(stageSizeToPx(stageSize)); const canvasRef = useRef(null); const stagePlotRef = useRef(null); @@ -154,6 +158,43 @@ const StagePlotEditor = () => { // Screen size no longer forces view mode. }, [location.pathname]); + // Initialize local name from stagePlot.name when document loads + useEffect(() => { + if (stagePlot?.name && !localNameInitialized) { + setLocalName(stagePlot.name); + setLocalNameInitialized(true); + } + }, [stagePlot?.name, localNameInitialized]); + + // Debounced sync: Update stagePlot.name after user stops typing (500ms delay) + useEffect(() => { + if (!localNameInitialized) return; + + const handler = setTimeout(() => { + if (localName !== stagePlot?.name) { + setStagePlot((prev: any) => ({ ...prev, name: localName })); + + // Broadcast name change to other collaborators + if (collaborationEnabled && broadcast) { + broadcast({ + type: "field_update", + field: "name", + value: localName, + }); + } + } + }, 500); + + return () => clearTimeout(handler); + }, [ + localName, + localNameInitialized, + stagePlot?.name, + setStagePlot, + collaborationEnabled, + broadcast, + ]); + // Enable collaboration for existing documents (including edit-mode shared links) // For shared links, id will be undefined, so we check stagePlot?.id instead // For edit-mode shared links, allow collaboration even without authentication @@ -236,10 +277,19 @@ const StagePlotEditor = () => { userName: effectiveUserName, enabled: collaborationEnabled, onRemoteUpdate: (payload) => { + // GUARD: Don't process remote updates if collaboration is disabled + // This prevents input reversion on NEW documents where collaboration is off + if (!collaborationEnabled) { + console.log("[StagePlotEditor] Ignoring remote update - collaboration disabled"); + return; + } + if (payload.type === "field_update" && payload.field) { // Handle different field updates if (payload.field === "name") { setStagePlot((prev: any) => ({ ...prev, name: payload.value })); + // Update local name state when remote changes arrive + setLocalName(payload.value); } else if (payload.field === "elements") { setElements(payload.value); } else if (payload.field === "stage_size") { @@ -289,37 +339,10 @@ const StagePlotEditor = () => { }, (payload) => { console.log("[StagePlotEditor] Received database UPDATE event:", payload); - // Update local state with the new data - // IMPORTANT: Exclude metadata fields (version, last_edited, metadata) to prevent - // triggering auto-save, which would create an infinite loop - if (payload.new) { - const { version, last_edited, metadata, ...userEditableFields } = payload.new as any; - - // Update stagePlot data - setStagePlot((prev: any) => ({ - ...prev, - ...userEditableFields, - // Keep existing metadata to avoid triggering auto-save - version: prev?.version, - last_edited: prev?.last_edited, - metadata: prev?.metadata, - })); - - // Update individual state fields from the database payload - if (userEditableFields.elements) { - setElements(userEditableFields.elements); - } - if (userEditableFields.stage_size) { - setStageSize(parseStageSize(userEditableFields.stage_size)); - } - if (userEditableFields.backgroundImage !== undefined) { - setBackgroundImage(userEditableFields.backgroundImage); - setImageUrl(userEditableFields.backgroundImage); - } - if (userEditableFields.backgroundOpacity !== undefined) { - setBackgroundOpacity(userEditableFields.backgroundOpacity); - } - } + // NOTE: We intentionally DO NOT update local state from database UPDATE events + // because they include our own saves echoing back, which would overwrite user typing + // Real-time collaboration updates happen via the broadcast channel in useCollaboration + console.log("[StagePlotEditor] Ignoring database UPDATE to prevent input reversion"); }, ) .subscribe(); @@ -965,20 +988,10 @@ const StagePlotEditor = () => {
{ if (!isViewMode) { - const newName = e.target.value; - setStagePlot({ ...stagePlot, name: newName }); - - // Broadcast name change to other collaborators - if (collaborationEnabled && broadcast) { - broadcast({ - type: "field_update", - field: "name", - value: newName, - }); - } + setLocalName(e.target.value); } }} className={`text-xl md:text-2xl font-bold text-white bg-transparent border-none focus:outline-none focus:ring-0 w-full max-w-[220px] sm:max-w-none ${isViewMode ? "cursor-default" : ""}`} diff --git a/apps/web/src/pages/StandardPixelMapEditor.tsx b/apps/web/src/pages/StandardPixelMapEditor.tsx index ec608f4..9f9f31c 100644 --- a/apps/web/src/pages/StandardPixelMapEditor.tsx +++ b/apps/web/src/pages/StandardPixelMapEditor.tsx @@ -79,6 +79,10 @@ const StandardPixelMapEditor = () => { const effectiveUserEmail = user?.email || (isSharedEdit ? `${anonymousUserId}@shared` : ""); const effectiveUserName = user?.user_metadata?.name || (isSharedEdit ? "Anonymous User" : ""); + // Local state for project name input to prevent reversion during typing + const [localProjectName, setLocalProjectName] = useState(""); + const [localProjectNameInitialized, setLocalProjectNameInitialized] = useState(false); + // Enable collaboration for existing documents (including edit-mode shared links) // For shared links, id will be undefined, so we check document?.id instead // For edit-mode shared links, allow collaboration even without authentication @@ -137,8 +141,21 @@ const StandardPixelMapEditor = () => { userName: effectiveUserName, enabled: collaborationEnabled, onRemoteUpdate: (payload) => { + // GUARD: Don't process remote updates if collaboration is disabled + // This prevents input reversion on NEW documents where collaboration is off + if (!collaborationEnabled) { + console.log("[StandardPixelMapEditor] Ignoring remote update - collaboration disabled"); + return; + } + if (payload.type === "field_update" && payload.field) { - setMapData((prev: any) => (prev ? { ...prev, [payload.field!]: payload.value } : prev)); + if (payload.field === "project_name") { + setMapData((prev: any) => (prev ? { ...prev, project_name: payload.value } : prev)); + // Update local project name state when remote changes arrive + setLocalProjectName(payload.value); + } else { + setMapData((prev: any) => (prev ? { ...prev, [payload.field!]: payload.value } : prev)); + } } }, }); @@ -148,6 +165,43 @@ const StandardPixelMapEditor = () => { userId: effectiveUserId, }); + // Initialize local project name from mapData.project_name when document loads + useEffect(() => { + if (mapData.project_name && !localProjectNameInitialized) { + setLocalProjectName(mapData.project_name); + setLocalProjectNameInitialized(true); + } + }, [mapData.project_name, localProjectNameInitialized]); + + // Debounced sync: Update mapData.project_name after user stops typing (500ms delay) + useEffect(() => { + if (!localProjectNameInitialized) return; + + const handler = setTimeout(() => { + if (localProjectName !== mapData.project_name) { + setMapData((prev: any) => ({ ...prev, project_name: localProjectName })); + + // Broadcast project name change to other collaborators + if (collaborationEnabled && broadcast) { + broadcast({ + type: "field_update", + field: "project_name", + value: localProjectName, + }); + } + } + }, 500); + + return () => clearTimeout(handler); + }, [ + localProjectName, + localProjectNameInitialized, + mapData.project_name, + setMapData, + collaborationEnabled, + broadcast, + ]); + // Real-time database subscription for syncing changes across users useEffect(() => { if (!collaborationEnabled || !document?.id) { @@ -171,32 +225,12 @@ const StandardPixelMapEditor = () => { }, (payload) => { console.log("[StandardPixelMapEditor] Received database UPDATE event:", payload); - // Update local state with the new data - // IMPORTANT: Exclude metadata fields (version, last_edited, metadata) to prevent - // triggering auto-save, which would create an infinite loop - if (payload.new) { - const { version, last_edited, metadata, ...userEditableFields } = payload.new as any; - setMapData((prev: any) => ({ - ...prev, - project_name: userEditableFields.project_name, - screen_name: userEditableFields.screen_name, - aspect_ratio_w: userEditableFields.aspect_ratio_w, - aspect_ratio_h: userEditableFields.aspect_ratio_h, - aspect_ratio_preset: `${userEditableFields.aspect_ratio_w}:${userEditableFields.aspect_ratio_h}`, - resolution_w: userEditableFields.resolution_w, - resolution_h: userEditableFields.resolution_h, - resolution_preset: `${userEditableFields.resolution_w}x${userEditableFields.resolution_h}`, - })); - // Update document state with metadata preserved - setDocument((prev: any) => ({ - ...prev, - ...userEditableFields, - // Keep existing metadata to avoid triggering auto-save - version: prev?.version, - last_edited: prev?.last_edited, - metadata: prev?.metadata, - })); - } + // NOTE: We intentionally DO NOT update local state from database UPDATE events + // because they include our own saves echoing back, which would overwrite user typing + // Real-time collaboration updates happen via the broadcast channel in useCollaboration + console.log( + "[StandardPixelMapEditor] Ignoring database UPDATE to prevent input reversion", + ); }, ) .subscribe(); @@ -634,8 +668,26 @@ const StandardPixelMapEditor = () => {
{ + if (typeof update === "function") { + const newData = update(mapData); + if (newData.project_name !== mapData.project_name) { + setLocalProjectName(newData.project_name); + } else { + setMapData(newData); + } + } else { + if (update.project_name !== mapData.project_name) { + setLocalProjectName(update.project_name); + } else { + setMapData(update); + } + } + }} showColorSwatches={showColorSwatches} setShowColorSwatches={setShowColorSwatches} showGrid={showGrid} diff --git a/apps/web/src/pages/TheaterMicPlotEditor.tsx b/apps/web/src/pages/TheaterMicPlotEditor.tsx index b18644f..504e451 100644 --- a/apps/web/src/pages/TheaterMicPlotEditor.tsx +++ b/apps/web/src/pages/TheaterMicPlotEditor.tsx @@ -61,6 +61,10 @@ const TheaterMicPlotEditor: React.FC = () => { const effectiveUserEmail = user?.email || (isSharedEdit ? `${anonymousUserId}@shared` : ""); const effectiveUserName = user?.user_metadata?.name || (isSharedEdit ? "Anonymous User" : ""); + // Local state for name input to prevent reversion during typing + const [localName, setLocalName] = useState(""); + const [localNameInitialized, setLocalNameInitialized] = useState(false); + // Enable collaboration for existing documents (including edit-mode shared links) // For shared links, routeId will be undefined, so we check micPlot?.id instead // For edit-mode shared links, allow collaboration even without authentication @@ -120,14 +124,68 @@ const TheaterMicPlotEditor: React.FC = () => { userName: effectiveUserName, enabled: collaborationEnabled, onRemoteUpdate: (payload) => { + // GUARD: Don't process remote updates if collaboration is disabled + // This prevents input reversion on NEW documents where collaboration is off + if (!collaborationEnabled) { + console.log("[TheaterMicPlotEditor] Ignoring remote update - collaboration disabled"); + return; + } + if (payload.type === "field_update" && payload.field) { - setMicPlot((prev: any) => (prev ? { ...prev, [payload.field!]: payload.value } : prev)); + if (payload.field === "name") { + setMicPlot((prev: any) => (prev ? { ...prev, name: payload.value } : prev)); + // Update local name state when remote changes arrive + setLocalName(payload.value); + } else { + setMicPlot((prev: any) => (prev ? { ...prev, [payload.field!]: payload.value } : prev)); + } } }, }); const { setEditingField } = usePresence({ channel: null, userId: effectiveUserId }); + // Initialize local name from micPlot.name when document loads + useEffect(() => { + if (micPlot?.name && !localNameInitialized) { + setLocalName(micPlot.name); + setLocalNameInitialized(true); + } + }, [micPlot?.name, localNameInitialized]); + + // Debounced sync: Update micPlot.name after user stops typing (500ms delay) + useEffect(() => { + if (!localNameInitialized) return; + + const handler = setTimeout(() => { + if (localName !== micPlot?.name) { + setMicPlot((prev: any) => (prev ? { ...prev, name: localName } : prev)); + + // Broadcast name change to other collaborators + if (collaborationEnabled && broadcast) { + broadcast({ + type: "field_update", + field: "name", + value: localName, + userId: effectiveUserId, + }).catch((err) => + console.error("[TheaterMicPlotEditor] Failed to broadcast name change:", err), + ); + } + } + }, 500); + + return () => clearTimeout(handler); + }, [ + localName, + localNameInitialized, + micPlot?.name, + setMicPlot, + collaborationEnabled, + broadcast, + effectiveUserId, + ]); + // Real-time database subscription for syncing changes across users useEffect(() => { if (!collaborationEnabled || !micPlot?.id) { @@ -151,20 +209,10 @@ const TheaterMicPlotEditor: React.FC = () => { }, (payload) => { console.log("[TheaterMicPlotEditor] Received database UPDATE event:", payload); - // Update local state with the new data - // IMPORTANT: Exclude metadata fields (version, last_edited, metadata) to prevent - // triggering auto-save, which would create an infinite loop - if (payload.new) { - const { version, last_edited, metadata, ...userEditableFields } = payload.new as any; - setMicPlot((prev: any) => ({ - ...prev, - ...userEditableFields, - // Keep existing metadata to avoid triggering auto-save - version: prev?.version, - last_edited: prev?.last_edited, - metadata: prev?.metadata, - })); - } + // NOTE: We intentionally DO NOT update local state from database UPDATE events + // because they include our own saves echoing back, which would overwrite user typing + // Real-time collaboration updates happen via the broadcast channel in useCollaboration + console.log("[TheaterMicPlotEditor] Ignoring database UPDATE to prevent input reversion"); }, ) .subscribe(); @@ -262,22 +310,7 @@ const TheaterMicPlotEditor: React.FC = () => { }, [routeId, shareCode, navigate, location.pathname, user]); const handleNameChange = (e: React.ChangeEvent) => { - if (micPlot) { - const newName = e.target.value; - setMicPlot({ ...micPlot, name: newName }); - - // Broadcast name change to other collaborators - if (collaborationEnabled && broadcast) { - broadcast({ - type: "field_update", - field: "name", - value: newName, - userId: effectiveUserId, - }).catch((err) => - console.error("[TheaterMicPlotEditor] Failed to broadcast name change:", err), - ); - } - } + setLocalName(e.target.value); }; const handleAddActor = () => { @@ -505,7 +538,7 @@ const TheaterMicPlotEditor: React.FC = () => {