diff --git a/src/components/empty-state/empty-state.tsx b/src/components/empty-state/empty-state.tsx index 1f44db03..3d7186cd 100644 --- a/src/components/empty-state/empty-state.tsx +++ b/src/components/empty-state/empty-state.tsx @@ -1,9 +1,16 @@ import React, { forwardRef } from 'react'; import EmptyStateImage from '@/assets/empty_state.png'; import EmptyStateImageDark from '@/assets/empty_state_dark.png'; -import { Label } from '@/components/label/label'; import { cn } from '@/lib/utils'; import { useTheme } from '@/hooks/use-theme'; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '../empty/empty'; export interface EmptyStateProps { title: string; @@ -38,26 +45,29 @@ export const EmptyState = forwardRef< className )} > - Empty state - - + + + + {/* */} + Empty state + + + {title} + + + {description} + + + + ); } diff --git a/src/components/empty/empty.tsx b/src/components/empty/empty.tsx new file mode 100644 index 00000000..6a058322 --- /dev/null +++ b/src/components/empty/empty.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils/index'; + +function Empty({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +const emptyMediaVariants = cva( + 'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0', + { + variants: { + variant: { + default: 'bg-transparent', + icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6", + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +function EmptyMedia({ + className, + variant = 'default', + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ); +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) { + return ( +
a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4', + className + )} + {...props} + /> + ); +} + +function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +export { + Empty, + EmptyHeader, + EmptyTitle, + EmptyDescription, + EmptyContent, + EmptyMedia, +}; diff --git a/src/context/canvas-context/canvas-context.tsx b/src/context/canvas-context/canvas-context.tsx index f9b76ed0..e23bbedf 100644 --- a/src/context/canvas-context/canvas-context.tsx +++ b/src/context/canvas-context/canvas-context.tsx @@ -2,6 +2,24 @@ import { createContext } from 'react'; import { emptyFn } from '@/lib/utils'; import type { Graph } from '@/lib/graph'; import { createGraph } from '@/lib/graph'; +import { EventEmitter } from 'ahooks/lib/useEventEmitter'; + +export type CanvasEventType = 'pan_click'; + +export type CanvasEventBase = { + action: T; + data: D; +}; + +export type PanClickEvent = CanvasEventBase< + 'pan_click', + { + x: number; + y: number; + } +>; + +export type CanvasEvent = PanClickEvent; export interface CanvasContext { reorderTables: (options?: { updateHistory?: boolean }) => void; @@ -49,6 +67,7 @@ export interface CanvasContext { y: number; }) => void; hideCreateRelationshipNode: () => void; + events: EventEmitter; } export const canvasContext = createContext({ @@ -68,4 +87,5 @@ export const canvasContext = createContext({ setHoveringTableId: emptyFn, showCreateRelationshipNode: emptyFn, hideCreateRelationshipNode: emptyFn, + events: new EventEmitter(), }); diff --git a/src/context/canvas-context/canvas-provider.tsx b/src/context/canvas-context/canvas-provider.tsx index c0ed8c03..857def06 100644 --- a/src/context/canvas-context/canvas-provider.tsx +++ b/src/context/canvas-context/canvas-provider.tsx @@ -5,7 +5,7 @@ import React, { useEffect, useRef, } from 'react'; -import type { CanvasContext } from './canvas-context'; +import type { CanvasContext, CanvasEvent } from './canvas-context'; import { canvasContext } from './canvas-context'; import { useChartDB } from '@/hooks/use-chartdb'; import { adjustTablePositions } from '@/lib/domain/db-table'; @@ -20,6 +20,7 @@ import { CREATE_RELATIONSHIP_NODE_ID, type CreateRelationshipNodeType, } from '@/pages/editor-page/canvas/create-relationship-node/create-relationship-node'; +import { useEventEmitter } from 'ahooks'; interface CanvasProviderProps { children: ReactNode; @@ -43,6 +44,8 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => { fieldId?: string; } | null>(null); + const events = useEventEmitter(); + const [showFilter, setShowFilter] = useState(false); const [tempFloatingEdge, setTempFloatingEdge] = @@ -212,6 +215,7 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => { setHoveringTableId, showCreateRelationshipNode, hideCreateRelationshipNode, + events, }} > {children} diff --git a/src/context/chartdb-context/chartdb-context.tsx b/src/context/chartdb-context/chartdb-context.tsx index 31cc68b1..c8450528 100644 --- a/src/context/chartdb-context/chartdb-context.tsx +++ b/src/context/chartdb-context/chartdb-context.tsx @@ -12,6 +12,7 @@ import type { DBDependency } from '@/lib/domain/db-dependency'; import { EventEmitter } from 'ahooks/lib/useEventEmitter'; import type { Area } from '@/lib/domain/area'; import type { DBCustomType } from '@/lib/domain/db-custom-type'; +import type { Note } from '@/lib/domain/note'; export type ChartDBEventType = | 'add_tables' @@ -74,6 +75,7 @@ export interface ChartDBContext { dependencies: DBDependency[]; areas: Area[]; customTypes: DBCustomType[]; + notes: Note[]; currentDiagram: Diagram; events: EventEmitter; readonly?: boolean; @@ -255,6 +257,31 @@ export interface ChartDBContext { options?: { updateHistory: boolean } ) => Promise; + // Note operations + createNote: (attributes?: Partial>) => Promise; + addNote: ( + note: Note, + options?: { updateHistory: boolean } + ) => Promise; + addNotes: ( + notes: Note[], + options?: { updateHistory: boolean } + ) => Promise; + getNote: (id: string) => Note | null; + removeNote: ( + id: string, + options?: { updateHistory: boolean } + ) => Promise; + removeNotes: ( + ids: string[], + options?: { updateHistory: boolean } + ) => Promise; + updateNote: ( + id: string, + note: Partial, + options?: { updateHistory: boolean } + ) => Promise; + // Custom type operations createCustomType: ( attributes?: Partial> @@ -292,6 +319,7 @@ export const chartDBContext = createContext({ dependencies: [], areas: [], customTypes: [], + notes: [], schemas: [], highlightCustomTypeId: emptyFn, currentDiagram: { @@ -368,6 +396,15 @@ export const chartDBContext = createContext({ removeAreas: emptyFn, updateArea: emptyFn, + // Note operations + createNote: emptyFn, + addNote: emptyFn, + addNotes: emptyFn, + getNote: emptyFn, + removeNote: emptyFn, + removeNotes: emptyFn, + updateNote: emptyFn, + // Custom type operations createCustomType: emptyFn, addCustomType: emptyFn, diff --git a/src/context/chartdb-context/chartdb-provider.tsx b/src/context/chartdb-context/chartdb-provider.tsx index 8559539c..c748740e 100644 --- a/src/context/chartdb-context/chartdb-provider.tsx +++ b/src/context/chartdb-context/chartdb-provider.tsx @@ -24,6 +24,7 @@ import { defaultSchemas } from '@/lib/data/default-schemas'; import { useEventEmitter } from 'ahooks'; import type { DBDependency } from '@/lib/domain/db-dependency'; import type { Area } from '@/lib/domain/area'; +import type { Note } from '@/lib/domain/note'; import { storageInitialValue } from '../storage-context/storage-context'; import { useDiff } from '../diff-context/use-diff'; import type { DiffCalculatedEvent } from '../diff-context/diff-context'; @@ -67,6 +68,7 @@ export const ChartDBProvider: React.FC< const [customTypes, setCustomTypes] = useState( diagram?.customTypes ?? [] ); + const [notes, setNotes] = useState(diagram?.notes ?? []); const { events: diffEvents } = useDiff(); @@ -147,6 +149,7 @@ export const ChartDBProvider: React.FC< dependencies, areas, customTypes, + notes, }), [ diagramId, @@ -158,6 +161,7 @@ export const ChartDBProvider: React.FC< dependencies, areas, customTypes, + notes, diagramCreatedAt, diagramUpdatedAt, ] @@ -171,6 +175,7 @@ export const ChartDBProvider: React.FC< setDependencies([]); setAreas([]); setCustomTypes([]); + setNotes([]); setDiagramUpdatedAt(updatedAt); resetRedoStack(); @@ -183,6 +188,7 @@ export const ChartDBProvider: React.FC< db.deleteDiagramDependencies(diagramId), db.deleteDiagramAreas(diagramId), db.deleteDiagramCustomTypes(diagramId), + db.deleteDiagramNotes(diagramId), ]); }, [db, diagramId, resetRedoStack, resetUndoStack]); @@ -197,6 +203,7 @@ export const ChartDBProvider: React.FC< setDependencies([]); setAreas([]); setCustomTypes([]); + setNotes([]); resetRedoStack(); resetUndoStack(); @@ -207,6 +214,7 @@ export const ChartDBProvider: React.FC< db.deleteDiagramDependencies(diagramId), db.deleteDiagramAreas(diagramId), db.deleteDiagramCustomTypes(diagramId), + db.deleteDiagramNotes(diagramId), ]); }, [db, diagramId, resetRedoStack, resetUndoStack]); @@ -1528,6 +1536,130 @@ export const ChartDBProvider: React.FC< [db, diagramId, setAreas, getArea, addUndoAction, resetRedoStack] ); + // Note operations + const addNotes: ChartDBContext['addNotes'] = useCallback( + async (notes: Note[], options = { updateHistory: true }) => { + setNotes((currentNotes) => [...currentNotes, ...notes]); + + const updatedAt = new Date(); + setDiagramUpdatedAt(updatedAt); + + await Promise.all([ + ...notes.map((note) => db.addNote({ diagramId, note })), + db.updateDiagram({ id: diagramId, attributes: { updatedAt } }), + ]); + + if (options.updateHistory) { + addUndoAction({ + action: 'addNotes', + redoData: { notes }, + undoData: { noteIds: notes.map((n) => n.id) }, + }); + resetRedoStack(); + } + }, + [db, diagramId, setNotes, addUndoAction, resetRedoStack] + ); + + const addNote: ChartDBContext['addNote'] = useCallback( + async (note: Note, options = { updateHistory: true }) => { + return addNotes([note], options); + }, + [addNotes] + ); + + const createNote: ChartDBContext['createNote'] = useCallback( + async (attributes) => { + const note: Note = { + id: generateId(), + content: '', + x: 0, + y: 0, + width: 200, + height: 150, + color: '#ffe374', // Default warm yellow + ...attributes, + }; + + await addNote(note); + + return note; + }, + [addNote] + ); + + const getNote: ChartDBContext['getNote'] = useCallback( + (id: string) => notes.find((note) => note.id === id) ?? null, + [notes] + ); + + const removeNotes: ChartDBContext['removeNotes'] = useCallback( + async (ids: string[], options = { updateHistory: true }) => { + const prevNotes = [ + ...notes.filter((note) => ids.includes(note.id)), + ]; + + setNotes((notes) => notes.filter((note) => !ids.includes(note.id))); + + const updatedAt = new Date(); + setDiagramUpdatedAt(updatedAt); + + await Promise.all([ + ...ids.map((id) => db.deleteNote({ diagramId, id })), + db.updateDiagram({ id: diagramId, attributes: { updatedAt } }), + ]); + + if (prevNotes.length > 0 && options.updateHistory) { + addUndoAction({ + action: 'removeNotes', + redoData: { noteIds: ids }, + undoData: { notes: prevNotes }, + }); + resetRedoStack(); + } + }, + [db, diagramId, setNotes, notes, addUndoAction, resetRedoStack] + ); + + const removeNote: ChartDBContext['removeNote'] = useCallback( + async (id: string, options = { updateHistory: true }) => { + return removeNotes([id], options); + }, + [removeNotes] + ); + + const updateNote: ChartDBContext['updateNote'] = useCallback( + async ( + id: string, + note: Partial, + options = { updateHistory: true } + ) => { + const prevNote = getNote(id); + + setNotes((notes) => + notes.map((n) => (n.id === id ? { ...n, ...note } : n)) + ); + + const updatedAt = new Date(); + setDiagramUpdatedAt(updatedAt); + + await Promise.all([ + db.updateDiagram({ id: diagramId, attributes: { updatedAt } }), + db.updateNote({ id, attributes: note }), + ]); + + if (!!prevNote && options.updateHistory) { + addUndoAction({ + action: 'updateNote', + redoData: { noteId: id, note }, + undoData: { noteId: id, note: prevNote }, + }); + resetRedoStack(); + } + }, + [db, diagramId, setNotes, getNote, addUndoAction, resetRedoStack] + ); + const highlightCustomTypeId = useCallback( (id?: string) => setHighlightedCustomTypeId(id), [setHighlightedCustomTypeId] @@ -1554,6 +1686,7 @@ export const ChartDBProvider: React.FC< setDiagramCreatedAt(diagram.createdAt); setDiagramUpdatedAt(diagram.updatedAt); setHighlightedCustomTypeId(undefined); + setNotes(diagram.notes ?? []); events.emit({ action: 'load_diagram', data: { diagram } }); @@ -1574,6 +1707,7 @@ export const ChartDBProvider: React.FC< setDiagramUpdatedAt, setHighlightedCustomTypeId, events, + setNotes, resetRedoStack, resetUndoStack, ] @@ -1597,6 +1731,7 @@ export const ChartDBProvider: React.FC< includeDependencies: true, includeAreas: true, includeCustomTypes: true, + includeNotes: true, }); if (diagram) { @@ -1762,6 +1897,7 @@ export const ChartDBProvider: React.FC< relationships, dependencies, areas, + notes, currentDiagram, schemas, events, @@ -1825,6 +1961,13 @@ export const ChartDBProvider: React.FC< updateCustomType, highlightCustomTypeId, highlightedCustomType, + createNote, + addNote, + addNotes, + getNote, + removeNote, + removeNotes, + updateNote, }} > {children} diff --git a/src/context/history-context/history-provider.tsx b/src/context/history-context/history-provider.tsx index f1563304..8c814daa 100644 --- a/src/context/history-context/history-provider.tsx +++ b/src/context/history-context/history-provider.tsx @@ -39,6 +39,9 @@ export const HistoryProvider: React.FC = ({ addCustomTypes, removeCustomTypes, updateCustomType, + addNotes, + removeNotes, + updateNote, } = useChartDB(); const redoActionHandlers = useMemo( @@ -135,6 +138,15 @@ export const HistoryProvider: React.FC = ({ updateHistory: false, }); }, + addNotes: ({ redoData: { notes } }) => { + return addNotes(notes, { updateHistory: false }); + }, + removeNotes: ({ redoData: { noteIds } }) => { + return removeNotes(noteIds, { updateHistory: false }); + }, + updateNote: ({ redoData: { noteId, note } }) => { + return updateNote(noteId, note, { updateHistory: false }); + }, }), [ addTables, @@ -160,6 +172,9 @@ export const HistoryProvider: React.FC = ({ addCustomTypes, removeCustomTypes, updateCustomType, + addNotes, + removeNotes, + updateNote, ] ); @@ -271,6 +286,15 @@ export const HistoryProvider: React.FC = ({ updateHistory: false, }); }, + addNotes: ({ undoData: { noteIds } }) => { + return removeNotes(noteIds, { updateHistory: false }); + }, + removeNotes: ({ undoData: { notes } }) => { + return addNotes(notes, { updateHistory: false }); + }, + updateNote: ({ undoData: { noteId, note } }) => { + return updateNote(noteId, note, { updateHistory: false }); + }, }), [ addTables, @@ -296,6 +320,9 @@ export const HistoryProvider: React.FC = ({ addCustomTypes, removeCustomTypes, updateCustomType, + addNotes, + removeNotes, + updateNote, ] ); diff --git a/src/context/history-context/redo-undo-action.ts b/src/context/history-context/redo-undo-action.ts index ec73da8a..ef32f6b0 100644 --- a/src/context/history-context/redo-undo-action.ts +++ b/src/context/history-context/redo-undo-action.ts @@ -6,6 +6,7 @@ import type { DBRelationship } from '@/lib/domain/db-relationship'; import type { DBDependency } from '@/lib/domain/db-dependency'; import type { Area } from '@/lib/domain/area'; import type { DBCustomType } from '@/lib/domain/db-custom-type'; +import type { Note } from '@/lib/domain/note'; type Action = keyof ChartDBContext; @@ -161,6 +162,24 @@ type RedoUndoActionRemoveCustomTypes = RedoUndoActionBase< { customTypes: DBCustomType[] } >; +type RedoUndoActionAddNotes = RedoUndoActionBase< + 'addNotes', + { notes: Note[] }, + { noteIds: string[] } +>; + +type RedoUndoActionUpdateNote = RedoUndoActionBase< + 'updateNote', + { noteId: string; note: Partial }, + { noteId: string; note: Partial } +>; + +type RedoUndoActionRemoveNotes = RedoUndoActionBase< + 'removeNotes', + { noteIds: string[] }, + { notes: Note[] } +>; + export type RedoUndoAction = | RedoUndoActionAddTables | RedoUndoActionRemoveTables @@ -184,7 +203,10 @@ export type RedoUndoAction = | RedoUndoActionRemoveAreas | RedoUndoActionAddCustomTypes | RedoUndoActionUpdateCustomType - | RedoUndoActionRemoveCustomTypes; + | RedoUndoActionRemoveCustomTypes + | RedoUndoActionAddNotes + | RedoUndoActionUpdateNote + | RedoUndoActionRemoveNotes; export type RedoActionData = Extract< RedoUndoAction, diff --git a/src/context/layout-context/layout-context.tsx b/src/context/layout-context/layout-context.tsx index cfd39e88..0487fa17 100644 --- a/src/context/layout-context/layout-context.tsx +++ b/src/context/layout-context/layout-context.tsx @@ -5,8 +5,10 @@ export type SidebarSection = | 'dbml' | 'tables' | 'refs' - | 'areas' - | 'customTypes'; + | 'customTypes' + | 'visuals'; + +export type VisualsTab = 'areas' | 'notes'; export interface LayoutContext { openedTableInSidebar: string | undefined; @@ -27,6 +29,10 @@ export interface LayoutContext { openAreaFromSidebar: (areaId: string) => void; closeAllAreasInSidebar: () => void; + openedNoteInSidebar: string | undefined; + openNoteFromSidebar: (noteId: string) => void; + closeAllNotesInSidebar: () => void; + openedCustomTypeInSidebar: string | undefined; openCustomTypeFromSidebar: (customTypeId: string) => void; closeAllCustomTypesInSidebar: () => void; @@ -34,6 +40,9 @@ export interface LayoutContext { selectedSidebarSection: SidebarSection; selectSidebarSection: (section: SidebarSection) => void; + selectedVisualsTab: VisualsTab; + selectVisualsTab: (tab: VisualsTab) => void; + isSidePanelShowed: boolean; hideSidePanel: () => void; showSidePanel: () => void; @@ -58,6 +67,10 @@ export const layoutContext = createContext({ openAreaFromSidebar: emptyFn, closeAllAreasInSidebar: emptyFn, + openedNoteInSidebar: undefined, + openNoteFromSidebar: emptyFn, + closeAllNotesInSidebar: emptyFn, + openedCustomTypeInSidebar: undefined, openCustomTypeFromSidebar: emptyFn, closeAllCustomTypesInSidebar: emptyFn, @@ -66,6 +79,9 @@ export const layoutContext = createContext({ openTableFromSidebar: emptyFn, closeAllTablesInSidebar: emptyFn, + selectedVisualsTab: 'areas', + selectVisualsTab: emptyFn, + isSidePanelShowed: false, hideSidePanel: emptyFn, showSidePanel: emptyFn, diff --git a/src/context/layout-context/layout-provider.tsx b/src/context/layout-context/layout-provider.tsx index 5849387f..e7241838 100644 --- a/src/context/layout-context/layout-provider.tsx +++ b/src/context/layout-context/layout-provider.tsx @@ -1,5 +1,9 @@ import React from 'react'; -import type { LayoutContext, SidebarSection } from './layout-context'; +import type { + LayoutContext, + SidebarSection, + VisualsTab, +} from './layout-context'; import { layoutContext } from './layout-context'; import { useBreakpoint } from '@/hooks/use-breakpoint'; @@ -16,10 +20,15 @@ export const LayoutProvider: React.FC = ({ const [openedAreaInSidebar, setOpenedAreaInSidebar] = React.useState< string | undefined >(); + const [openedNoteInSidebar, setOpenedNoteInSidebar] = React.useState< + string | undefined + >(); const [openedCustomTypeInSidebar, setOpenedCustomTypeInSidebar] = React.useState(); const [selectedSidebarSection, setSelectedSidebarSection] = React.useState('tables'); + const [selectedVisualsTab, setSelectedVisualsTab] = + React.useState('areas'); const [isSidePanelShowed, setIsSidePanelShowed] = React.useState(isDesktop); @@ -38,6 +47,9 @@ export const LayoutProvider: React.FC = ({ const closeAllAreasInSidebar: LayoutContext['closeAllAreasInSidebar'] = () => setOpenedAreaInSidebar(''); + const closeAllNotesInSidebar: LayoutContext['closeAllNotesInSidebar'] = + () => setOpenedNoteInSidebar(''); + const closeAllCustomTypesInSidebar: LayoutContext['closeAllCustomTypesInSidebar'] = () => setOpenedCustomTypeInSidebar(''); @@ -83,10 +95,20 @@ export const LayoutProvider: React.FC = ({ areaId ) => { showSidePanel(); - setSelectedSidebarSection('areas'); + setSelectedSidebarSection('visuals'); + setSelectedVisualsTab('areas'); setOpenedAreaInSidebar(areaId); }; + const openNoteFromSidebar: LayoutContext['openNoteFromSidebar'] = ( + noteId + ) => { + showSidePanel(); + setSelectedSidebarSection('visuals'); + setSelectedVisualsTab('notes'); + setOpenedNoteInSidebar(noteId); + }; + const openCustomTypeFromSidebar: LayoutContext['openCustomTypeFromSidebar'] = (customTypeId) => { showSidePanel(); @@ -116,9 +138,14 @@ export const LayoutProvider: React.FC = ({ openedAreaInSidebar, openAreaFromSidebar, closeAllAreasInSidebar, + openedNoteInSidebar, + openNoteFromSidebar, + closeAllNotesInSidebar, openedCustomTypeInSidebar, openCustomTypeFromSidebar, closeAllCustomTypesInSidebar, + selectedVisualsTab, + selectVisualsTab: setSelectedVisualsTab, }} > {children} diff --git a/src/context/storage-context/storage-context.tsx b/src/context/storage-context/storage-context.tsx index 565f4997..a56ee06b 100644 --- a/src/context/storage-context/storage-context.tsx +++ b/src/context/storage-context/storage-context.tsx @@ -8,6 +8,7 @@ import type { DBDependency } from '@/lib/domain/db-dependency'; import type { Area } from '@/lib/domain/area'; import type { DBCustomType } from '@/lib/domain/db-custom-type'; import type { DiagramFilter } from '@/lib/domain/diagram-filter/diagram-filter'; +import type { Note } from '@/lib/domain/note'; export interface StorageContext { // Config operations @@ -30,6 +31,7 @@ export interface StorageContext { includeDependencies?: boolean; includeAreas?: boolean; includeCustomTypes?: boolean; + includeNotes?: boolean; }) => Promise; getDiagram: ( id: string, @@ -39,6 +41,7 @@ export interface StorageContext { includeDependencies?: boolean; includeAreas?: boolean; includeCustomTypes?: boolean; + includeNotes?: boolean; } ) => Promise; updateDiagram: (params: { @@ -135,6 +138,20 @@ export interface StorageContext { }) => Promise; listCustomTypes: (diagramId: string) => Promise; deleteDiagramCustomTypes: (diagramId: string) => Promise; + + // Note operations + addNote: (params: { diagramId: string; note: Note }) => Promise; + getNote: (params: { + diagramId: string; + id: string; + }) => Promise; + updateNote: (params: { + id: string; + attributes: Partial; + }) => Promise; + deleteNote: (params: { diagramId: string; id: string }) => Promise; + listNotes: (diagramId: string) => Promise; + deleteDiagramNotes: (diagramId: string) => Promise; } export const storageInitialValue: StorageContext = { @@ -187,6 +204,14 @@ export const storageInitialValue: StorageContext = { deleteCustomType: emptyFn, listCustomTypes: emptyFn, deleteDiagramCustomTypes: emptyFn, + + // Note operations + addNote: emptyFn, + getNote: emptyFn, + updateNote: emptyFn, + deleteNote: emptyFn, + listNotes: emptyFn, + deleteDiagramNotes: emptyFn, }; export const storageContext = diff --git a/src/context/storage-context/storage-provider.tsx b/src/context/storage-context/storage-provider.tsx index b5ff2622..37cff29b 100644 --- a/src/context/storage-context/storage-provider.tsx +++ b/src/context/storage-context/storage-provider.tsx @@ -11,6 +11,7 @@ import type { DBDependency } from '@/lib/domain/db-dependency'; import type { Area } from '@/lib/domain/area'; import type { DBCustomType } from '@/lib/domain/db-custom-type'; import type { DiagramFilter } from '@/lib/domain/diagram-filter/diagram-filter'; +import type { Note } from '@/lib/domain/note'; export const StorageProvider: React.FC = ({ children, @@ -41,6 +42,10 @@ export const StorageProvider: React.FC = ({ DBCustomType & { diagramId: string }, 'id' // primary key "id" (for the typings only) >; + notes: EntityTable< + Note & { diagramId: string }, + 'id' // primary key "id" (for the typings only) + >; config: EntityTable< ChartDBConfig & { id: number }, 'id' // primary key "id" (for the typings only) @@ -216,6 +221,23 @@ export const StorageProvider: React.FC = ({ tx.table('config').clear(); }); + dexieDB.version(13).stores({ + diagrams: + '++id, name, databaseType, databaseEdition, createdAt, updatedAt', + db_tables: + '++id, diagramId, name, schema, x, y, fields, indexes, color, createdAt, width, comment, isView, isMaterializedView, order', + db_relationships: + '++id, diagramId, name, sourceSchema, sourceTableId, targetSchema, targetTableId, sourceFieldId, targetFieldId, type, createdAt', + db_dependencies: + '++id, diagramId, schema, tableId, dependentSchema, dependentTableId, createdAt', + areas: '++id, diagramId, name, x, y, width, height, color', + db_custom_types: + '++id, diagramId, schema, type, kind, values, fields', + config: '++id, defaultDiagramId', + diagram_filters: 'diagramId, tableIds, schemasIds', + notes: '++id, diagramId, content, x, y, width, height, color', + }); + dexieDB.on('ready', async () => { const config = await dexieDB.config.get(1); @@ -550,6 +572,56 @@ export const StorageProvider: React.FC = ({ [db] ); + // Note operations + const addNote: StorageContext['addNote'] = useCallback( + async ({ note, diagramId }) => { + await db.notes.add({ + ...note, + diagramId, + }); + }, + [db] + ); + + const getNote: StorageContext['getNote'] = useCallback( + async ({ diagramId, id }) => { + return await db.notes.get({ id, diagramId }); + }, + [db] + ); + + const updateNote: StorageContext['updateNote'] = useCallback( + async ({ id, attributes }) => { + await db.notes.update(id, attributes); + }, + [db] + ); + + const deleteNote: StorageContext['deleteNote'] = useCallback( + async ({ diagramId, id }) => { + await db.notes.where({ id, diagramId }).delete(); + }, + [db] + ); + + const listNotes: StorageContext['listNotes'] = useCallback( + async (diagramId) => { + return await db.notes + .where('diagramId') + .equals(diagramId) + .toArray(); + }, + [db] + ); + + const deleteDiagramNotes: StorageContext['deleteDiagramNotes'] = + useCallback( + async (diagramId) => { + await db.notes.where('diagramId').equals(diagramId).delete(); + }, + [db] + ); + const addDiagram: StorageContext['addDiagram'] = useCallback( async ({ diagram }) => { const promises = []; @@ -597,9 +669,22 @@ export const StorageProvider: React.FC = ({ ) ); + const notes = diagram.notes ?? []; + promises.push( + ...notes.map((note) => addNote({ diagramId: diagram.id, note })) + ); + await Promise.all(promises); }, - [db, addArea, addCustomType, addDependency, addRelationship, addTable] + [ + db, + addArea, + addCustomType, + addDependency, + addRelationship, + addTable, + addNote, + ] ); const listDiagrams: StorageContext['listDiagrams'] = useCallback( @@ -610,6 +695,7 @@ export const StorageProvider: React.FC = ({ includeDependencies: false, includeAreas: false, includeCustomTypes: false, + includeNotes: false, } ): Promise => { let diagrams = await db.diagrams.toArray(); @@ -663,6 +749,15 @@ export const StorageProvider: React.FC = ({ ); } + if (options.includeNotes) { + diagrams = await Promise.all( + diagrams.map(async (diagram) => { + diagram.notes = await listNotes(diagram.id); + return diagram; + }) + ); + } + return diagrams; }, [ @@ -672,6 +767,7 @@ export const StorageProvider: React.FC = ({ listDependencies, listRelationships, listTables, + listNotes, ] ); @@ -684,6 +780,7 @@ export const StorageProvider: React.FC = ({ includeDependencies: false, includeAreas: false, includeCustomTypes: false, + includeNotes: false, } ): Promise => { const diagram = await db.diagrams.get(id); @@ -712,6 +809,10 @@ export const StorageProvider: React.FC = ({ diagram.customTypes = await listCustomTypes(id); } + if (options.includeNotes) { + diagram.notes = await listNotes(id); + } + return diagram; }, [ @@ -721,6 +822,7 @@ export const StorageProvider: React.FC = ({ listDependencies, listRelationships, listTables, + listNotes, ] ); @@ -749,6 +851,9 @@ export const StorageProvider: React.FC = ({ .where('diagramId') .equals(id) .modify({ diagramId: attributes.id }), + db.notes.where('diagramId').equals(id).modify({ + diagramId: attributes.id, + }), ]); } }, @@ -764,6 +869,7 @@ export const StorageProvider: React.FC = ({ db.db_dependencies.where('diagramId').equals(id).delete(), db.areas.where('diagramId').equals(id).delete(), db.db_custom_types.where('diagramId').equals(id).delete(), + db.notes.where('diagramId').equals(id).delete(), ]); }, [db] @@ -810,6 +916,12 @@ export const StorageProvider: React.FC = ({ deleteCustomType, listCustomTypes, deleteDiagramCustomTypes, + addNote, + getNote, + updateNote, + deleteNote, + listNotes, + deleteDiagramNotes, getDiagramFilter, updateDiagramFilter, deleteDiagramFilter, diff --git a/src/hooks/use-focus-on.ts b/src/hooks/use-focus-on.ts index 17ca76c7..a00f061d 100644 --- a/src/hooks/use-focus-on.ts +++ b/src/hooks/use-focus-on.ts @@ -88,6 +88,44 @@ export const useFocusOn = () => { [fitView, setNodes, hideSidePanel, isDesktop] ); + const focusOnNote = useCallback( + (noteId: string, options: FocusOptions = {}) => { + const { select = true } = options; + + if (select) { + setNodes((nodes) => + nodes.map((node) => + node.id === noteId + ? { + ...node, + selected: true, + } + : { + ...node, + selected: false, + } + ) + ); + } + + fitView({ + duration: 500, + maxZoom: 1, + minZoom: 1, + nodes: [ + { + id: noteId, + }, + ], + }); + + if (!isDesktop) { + hideSidePanel(); + } + }, + [fitView, setNodes, hideSidePanel, isDesktop] + ); + const focusOnRelationship = useCallback( ( relationshipId: string, @@ -137,6 +175,7 @@ export const useFocusOn = () => { return { focusOnArea, focusOnTable, + focusOnNote, focusOnRelationship, }; }; diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index e7c1f8dc..328a8dea 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -7,9 +7,9 @@ export const ar: LanguageTranslation = { browse: 'تصفح', tables: 'الجداول', refs: 'المراجع', - areas: 'المناطق', dependencies: 'التبعيات', custom_types: 'الأنواع المخصصة', + visuals: 'مرئيات', }, menu: { actions: { @@ -232,6 +232,33 @@ export const ar: LanguageTranslation = { }, }, + visuals_section: { + visuals: 'مرئيات', + tabs: { + areas: 'Areas', + notes: 'ملاحظات', + }, + }, + + notes_section: { + filter: 'تصفية', + add_note: 'إضافة ملاحظة', + no_results: 'لم يتم العثور على ملاحظات', + clear: 'مسح التصفية', + empty_state: { + title: 'لا توجد ملاحظات', + description: 'أنشئ ملاحظة لإضافة تعليقات نصية على اللوحة', + }, + note: { + empty_note: 'ملاحظة فارغة', + note_actions: { + title: 'إجراءات الملاحظة', + edit_content: 'تحرير المحتوى', + delete_note: 'حذف الملاحظة', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -479,6 +506,7 @@ export const ar: LanguageTranslation = { new_relationship: 'علاقة جديدة', // TODO: Translate new_area: 'New Area', + new_note: 'ملاحظة جديدة', }, table_node_context_menu: { diff --git a/src/i18n/locales/bn.ts b/src/i18n/locales/bn.ts index 75a4166b..e082a0ce 100644 --- a/src/i18n/locales/bn.ts +++ b/src/i18n/locales/bn.ts @@ -7,9 +7,9 @@ export const bn: LanguageTranslation = { browse: 'ব্রাউজ', tables: 'টেবিল', refs: 'রেফস', - areas: 'এলাকা', dependencies: 'নির্ভরতা', custom_types: 'কাস্টম টাইপ', + visuals: 'ভিজ্যুয়াল', }, menu: { actions: { @@ -233,6 +233,35 @@ export const bn: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'ভিজ্যুয়াল', + tabs: { + areas: 'Areas', + notes: 'নোট', + }, + }, + + notes_section: { + filter: 'ফিল্টার', + add_note: 'নোট যোগ করুন', + no_results: 'কোনো নোট পাওয়া যায়নি', + clear: 'ফিল্টার সাফ করুন', + empty_state: { + title: 'কোনো নোট নেই', + description: + 'ক্যানভাসে টেক্সট টীকা যোগ করতে একটি নোট তৈরি করুন', + }, + note: { + empty_note: 'খালি নোট', + note_actions: { + title: 'নোট ক্রিয়া', + edit_content: 'বিষয়বস্তু সম্পাদনা', + delete_note: 'নোট মুছুন', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -484,6 +513,7 @@ export const bn: LanguageTranslation = { new_relationship: 'নতুন সম্পর্ক', // TODO: Translate new_area: 'New Area', + new_note: 'নতুন নোট', }, table_node_context_menu: { diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 097ba181..647fbb2c 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -7,9 +7,9 @@ export const de: LanguageTranslation = { browse: 'Durchsuchen', tables: 'Tabellen', refs: 'Refs', - areas: 'Bereiche', dependencies: 'Abhängigkeiten', custom_types: 'Benutzerdefinierte Typen', + visuals: 'Darstellungen', }, menu: { actions: { @@ -234,6 +234,35 @@ export const de: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Darstellungen', + tabs: { + areas: 'Areas', + notes: 'Notizen', + }, + }, + + notes_section: { + filter: 'Filter', + add_note: 'Notiz hinzufügen', + no_results: 'Keine Notizen gefunden', + clear: 'Filter löschen', + empty_state: { + title: 'Keine Notizen', + description: + 'Erstellen Sie eine Notiz, um Textanmerkungen auf der Leinwand hinzuzufügen', + }, + note: { + empty_note: 'Leere Notiz', + note_actions: { + title: 'Notiz-Aktionen', + edit_content: 'Inhalt bearbeiten', + delete_note: 'Notiz löschen', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -487,6 +516,7 @@ export const de: LanguageTranslation = { new_relationship: 'Neue Beziehung', // TODO: Translate new_area: 'New Area', + new_note: 'Neue Notiz', }, table_node_context_menu: { diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 1af7ca09..6e4c87bb 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -7,9 +7,9 @@ export const en = { browse: 'Browse', tables: 'Tables', refs: 'Refs', - areas: 'Areas', dependencies: 'Dependencies', custom_types: 'Custom Types', + visuals: 'Visuals', }, menu: { actions: { @@ -227,6 +227,34 @@ export const en = { }, }, + visuals_section: { + visuals: 'Visuals', + tabs: { + areas: 'Areas', + notes: 'Notes', + }, + }, + + notes_section: { + filter: 'Filter', + add_note: 'Add Note', + no_results: 'No notes found', + clear: 'Clear Filter', + empty_state: { + title: 'No Notes', + description: + 'Create a note to add text annotations on the canvas', + }, + note: { + empty_note: 'Empty note', + note_actions: { + title: 'Note Actions', + edit_content: 'Edit Content', + delete_note: 'Delete Note', + }, + }, + }, + custom_types_section: { custom_types: 'Custom Types', filter: 'Filter', @@ -473,6 +501,7 @@ export const en = { new_view: 'New View', new_relationship: 'New Relationship', new_area: 'New Area', + new_note: 'New Note', }, table_node_context_menu: { diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 399a74e8..971f5cd8 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -7,9 +7,9 @@ export const es: LanguageTranslation = { browse: 'Examinar', tables: 'Tablas', refs: 'Refs', - areas: 'Áreas', dependencies: 'Dependencias', custom_types: 'Tipos Personalizados', + visuals: 'Visuales', }, menu: { actions: { @@ -232,6 +232,35 @@ export const es: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Visuales', + tabs: { + areas: 'Areas', + notes: 'Notas', + }, + }, + + notes_section: { + filter: 'Filtrar', + add_note: 'Agregar Nota', + no_results: 'No se encontraron notas', + clear: 'Limpiar Filtro', + empty_state: { + title: 'Sin Notas', + description: + 'Crea una nota para agregar anotaciones de texto en el lienzo', + }, + note: { + empty_note: 'Nota vacía', + note_actions: { + title: 'Acciones de Nota', + edit_content: 'Editar Contenido', + delete_note: 'Eliminar Nota', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -486,6 +515,7 @@ export const es: LanguageTranslation = { new_relationship: 'Nueva Relación', // TODO: Translate new_area: 'New Area', + new_note: 'Nueva Nota', }, table_node_context_menu: { diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index c95746d0..293f9860 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -7,9 +7,9 @@ export const fr: LanguageTranslation = { browse: 'Parcourir', tables: 'Tables', refs: 'Refs', - areas: 'Zones', dependencies: 'Dépendances', custom_types: 'Types Personnalisés', + visuals: 'Visuels', }, menu: { actions: { @@ -230,6 +230,35 @@ export const fr: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Visuels', + tabs: { + areas: 'Areas', + notes: 'Notes', + }, + }, + + notes_section: { + filter: 'Filtrer', + add_note: 'Ajouter une Note', + no_results: 'Aucune note trouvée', + clear: 'Effacer le Filtre', + empty_state: { + title: 'Pas de Notes', + description: + 'Créez une note pour ajouter des annotations de texte sur le canevas', + }, + note: { + empty_note: 'Note vide', + note_actions: { + title: 'Actions de Note', + edit_content: 'Modifier le Contenu', + delete_note: 'Supprimer la Note', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -482,6 +511,7 @@ export const fr: LanguageTranslation = { new_relationship: 'Nouvelle Relation', // TODO: Translate new_area: 'New Area', + new_note: 'Nouvelle Note', }, table_node_context_menu: { diff --git a/src/i18n/locales/gu.ts b/src/i18n/locales/gu.ts index 334f7831..081d7715 100644 --- a/src/i18n/locales/gu.ts +++ b/src/i18n/locales/gu.ts @@ -7,9 +7,9 @@ export const gu: LanguageTranslation = { browse: 'બ્રાઉજ', tables: 'ટેબલો', refs: 'રેફ્સ', - areas: 'ક્ષેત્રો', dependencies: 'નિર્ભરતાઓ', custom_types: 'કસ્ટમ ટાઇપ', + visuals: 'Visuals', }, menu: { actions: { @@ -234,6 +234,35 @@ export const gu: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Visuals', + tabs: { + areas: 'Areas', + notes: 'નોંધો', + }, + }, + + notes_section: { + filter: 'ફિલ્ટર', + add_note: 'નોંધ ઉમેરો', + no_results: 'કોઈ નોંધો મળી નથી', + clear: 'ફિલ્ટર સાફ કરો', + empty_state: { + title: 'કોઈ નોંધો નથી', + description: + 'કેનવાસ પર ટેક્સ્ટ એનોટેશન ઉમેરવા માટે નોંધ બનાવો', + }, + note: { + empty_note: 'ખાલી નોંધ', + note_actions: { + title: 'નોંધ ક્રિયાઓ', + edit_content: 'સામગ્રી સંપાદિત કરો', + delete_note: 'નોંધ કાઢી નાખો', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -485,6 +514,7 @@ export const gu: LanguageTranslation = { new_relationship: 'નવો સંબંધ', // TODO: Translate new_area: 'New Area', + new_note: 'નવી નોંધ', }, table_node_context_menu: { diff --git a/src/i18n/locales/hi.ts b/src/i18n/locales/hi.ts index 35c49059..457b73a7 100644 --- a/src/i18n/locales/hi.ts +++ b/src/i18n/locales/hi.ts @@ -7,9 +7,9 @@ export const hi: LanguageTranslation = { browse: 'ब्राउज़', tables: 'टेबल', refs: 'रेफ्स', - areas: 'क्षेत्र', dependencies: 'निर्भरताएं', custom_types: 'कस्टम टाइप', + visuals: 'Visuals', }, menu: { actions: { @@ -233,6 +233,35 @@ export const hi: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Visuals', + tabs: { + areas: 'Areas', + notes: 'नोट्स', + }, + }, + + notes_section: { + filter: 'फ़िल्टर', + add_note: 'नोट जोड़ें', + no_results: 'कोई नोट नहीं मिला', + clear: 'फ़िल्टर साफ़ करें', + empty_state: { + title: 'कोई नोट नहीं', + description: + 'कैनवास पर टेक्स्ट एनोटेशन जोड़ने के लिए एक नोट बनाएं', + }, + note: { + empty_note: 'खाली नोट', + note_actions: { + title: 'नोट क्रियाएं', + edit_content: 'सामग्री संपादित करें', + delete_note: 'नोट हटाएं', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -487,6 +516,7 @@ export const hi: LanguageTranslation = { new_relationship: 'नया संबंध', // TODO: Translate new_area: 'New Area', + new_note: 'नया नोट', }, table_node_context_menu: { diff --git a/src/i18n/locales/hr.ts b/src/i18n/locales/hr.ts index 18a3c5a3..47d1caa2 100644 --- a/src/i18n/locales/hr.ts +++ b/src/i18n/locales/hr.ts @@ -7,9 +7,9 @@ export const hr: LanguageTranslation = { browse: 'Pregledaj', tables: 'Tablice', refs: 'Refs', - areas: 'Područja', dependencies: 'Ovisnosti', custom_types: 'Prilagođeni Tipovi', + visuals: 'Vizuali', }, menu: { actions: { @@ -229,6 +229,34 @@ export const hr: LanguageTranslation = { }, }, + visuals_section: { + visuals: 'Vizuali', + tabs: { + areas: 'Područja', + notes: 'Bilješke', + }, + }, + + notes_section: { + filter: 'Filtriraj', + add_note: 'Dodaj Bilješku', + no_results: 'Nije pronađena nijedna bilješka', + clear: 'Očisti Filter', + empty_state: { + title: 'Nema Bilješki', + description: + 'Kreirajte bilješku za dodavanje tekstualnih napomena na platnu', + }, + note: { + empty_note: 'Prazna bilješka', + note_actions: { + title: 'Akcije Bilješke', + edit_content: 'Uredi Sadržaj', + delete_note: 'Obriši Bilješku', + }, + }, + }, + custom_types_section: { custom_types: 'Prilagođeni tipovi', filter: 'Filtriraj', @@ -478,6 +506,7 @@ export const hr: LanguageTranslation = { new_view: 'Novi Pogled', new_relationship: 'Nova veza', new_area: 'Novo područje', + new_note: 'Nova Bilješka', }, table_node_context_menu: { diff --git a/src/i18n/locales/id_ID.ts b/src/i18n/locales/id_ID.ts index 99fddec4..d022d697 100644 --- a/src/i18n/locales/id_ID.ts +++ b/src/i18n/locales/id_ID.ts @@ -7,9 +7,9 @@ export const id_ID: LanguageTranslation = { browse: 'Jelajahi', tables: 'Tabel', refs: 'Refs', - areas: 'Area', dependencies: 'Ketergantungan', custom_types: 'Tipe Kustom', + visuals: 'Visual', }, menu: { actions: { @@ -232,6 +232,35 @@ export const id_ID: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Visual', + tabs: { + areas: 'Areas', + notes: 'Catatan', + }, + }, + + notes_section: { + filter: 'Filter', + add_note: 'Tambah Catatan', + no_results: 'Tidak ada catatan ditemukan', + clear: 'Hapus Filter', + empty_state: { + title: 'Tidak Ada Catatan', + description: + 'Buat catatan untuk menambahkan anotasi teks di kanvas', + }, + note: { + empty_note: 'Catatan kosong', + note_actions: { + title: 'Aksi Catatan', + edit_content: 'Edit Konten', + delete_note: 'Hapus Catatan', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -484,6 +513,7 @@ export const id_ID: LanguageTranslation = { new_relationship: 'Hubungan Baru', // TODO: Translate new_area: 'New Area', + new_note: 'Catatan Baru', }, table_node_context_menu: { diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index ec82a8e2..92d55efa 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -7,9 +7,9 @@ export const ja: LanguageTranslation = { browse: '参照', tables: 'テーブル', refs: '参照', - areas: 'エリア', dependencies: '依存関係', custom_types: 'カスタムタイプ', + visuals: 'ビジュアル', }, menu: { actions: { @@ -237,6 +237,35 @@ export const ja: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'ビジュアル', + tabs: { + areas: 'Areas', + notes: 'ノート', + }, + }, + + notes_section: { + filter: 'フィルター', + add_note: 'ノートを追加', + no_results: 'ノートが見つかりません', + clear: 'フィルターをクリア', + empty_state: { + title: 'ノートがありません', + description: + 'キャンバス上にテキスト注釈を追加するためのノートを作成', + }, + note: { + empty_note: '空のノート', + note_actions: { + title: 'ノートアクション', + edit_content: 'コンテンツを編集', + delete_note: 'ノートを削除', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -489,6 +518,7 @@ export const ja: LanguageTranslation = { new_relationship: '新しいリレーションシップ', // TODO: Translate new_area: 'New Area', + new_note: '新しいメモ', }, table_node_context_menu: { diff --git a/src/i18n/locales/ko_KR.ts b/src/i18n/locales/ko_KR.ts index b3d35270..437492ea 100644 --- a/src/i18n/locales/ko_KR.ts +++ b/src/i18n/locales/ko_KR.ts @@ -7,9 +7,9 @@ export const ko_KR: LanguageTranslation = { browse: '찾아보기', tables: '테이블', refs: 'Refs', - areas: '영역', dependencies: '종속성', custom_types: '사용자 지정 타입', + visuals: '시각화', }, menu: { actions: { @@ -232,6 +232,35 @@ export const ko_KR: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: '시각화', + tabs: { + areas: 'Areas', + notes: '메모', + }, + }, + + notes_section: { + filter: '필터', + add_note: '메모 추가', + no_results: '메모를 찾을 수 없습니다', + clear: '필터 지우기', + empty_state: { + title: '메모 없음', + description: + '캔버스에 텍스트 주석을 추가하려면 메모를 만드세요', + }, + note: { + empty_note: '빈 메모', + note_actions: { + title: '메모 작업', + edit_content: '내용 편집', + delete_note: '메모 삭제', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -481,6 +510,7 @@ export const ko_KR: LanguageTranslation = { new_relationship: '새 연관관계', // TODO: Translate new_area: 'New Area', + new_note: '새 메모', }, table_node_context_menu: { diff --git a/src/i18n/locales/mr.ts b/src/i18n/locales/mr.ts index 5648a200..5fda1e77 100644 --- a/src/i18n/locales/mr.ts +++ b/src/i18n/locales/mr.ts @@ -7,9 +7,9 @@ export const mr: LanguageTranslation = { browse: 'ब्राउज', tables: 'टेबल', refs: 'Refs', - areas: 'क्षेत्रे', dependencies: 'अवलंबने', custom_types: 'कस्टम प्रकार', + visuals: 'Visuals', }, menu: { actions: { @@ -236,6 +236,35 @@ export const mr: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Visuals', + tabs: { + areas: 'Areas', + notes: 'नोट्स', + }, + }, + + notes_section: { + filter: 'फिल्टर', + add_note: 'नोट जोडा', + no_results: 'कोणत्याही नोट्स सापडल्या नाहीत', + clear: 'फिल्टर साफ करा', + empty_state: { + title: 'नोट्स नाहीत', + description: + 'कॅनव्हासवर मजकूर भाष्य जोडण्यासाठी एक नोट तयार करा', + }, + note: { + empty_note: 'रिकामी नोट', + note_actions: { + title: 'नोट क्रिया', + edit_content: 'सामग्री संपादित करा', + delete_note: 'नोट हटवा', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -493,6 +522,7 @@ export const mr: LanguageTranslation = { new_relationship: 'नवीन रिलेशनशिप', // TODO: Translate new_area: 'New Area', + new_note: 'नवीन टीप', }, table_node_context_menu: { diff --git a/src/i18n/locales/ne.ts b/src/i18n/locales/ne.ts index a5b858c7..6b2c129e 100644 --- a/src/i18n/locales/ne.ts +++ b/src/i18n/locales/ne.ts @@ -7,9 +7,9 @@ export const ne: LanguageTranslation = { browse: 'ब्राउज', tables: 'टेबलहरू', refs: 'Refs', - areas: 'क्षेत्रहरू', dependencies: 'निर्भरताहरू', custom_types: 'कस्टम प्रकारहरू', + visuals: 'Visuals', }, menu: { actions: { @@ -233,6 +233,35 @@ export const ne: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Visuals', + tabs: { + areas: 'Areas', + notes: 'टिप्पणीहरू', + }, + }, + + notes_section: { + filter: 'फिल्टर', + add_note: 'टिप्पणी थप्नुहोस्', + no_results: 'कुनै टिप्पणी फेला परेन', + clear: 'फिल्टर खाली गर्नुहोस्', + empty_state: { + title: 'कुनै टिप्पणी छैन', + description: + 'क्यानभासमा पाठ टिप्पणी थप्न टिप्पणी सिर्जना गर्नुहोस्', + }, + note: { + empty_note: 'खाली टिप्पणी', + note_actions: { + title: 'टिप्पणी कार्यहरू', + edit_content: 'सामग्री सम्पादन गर्नुहोस्', + delete_note: 'टिप्पणी मेटाउनुहोस्', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -487,6 +516,7 @@ export const ne: LanguageTranslation = { new_relationship: 'नयाँ सम्बन्ध', // TODO: Translate new_area: 'New Area', + new_note: 'नयाँ नोट', }, table_node_context_menu: { diff --git a/src/i18n/locales/pt_BR.ts b/src/i18n/locales/pt_BR.ts index b16a9c8e..2605b6a1 100644 --- a/src/i18n/locales/pt_BR.ts +++ b/src/i18n/locales/pt_BR.ts @@ -7,9 +7,9 @@ export const pt_BR: LanguageTranslation = { browse: 'Navegar', tables: 'Tabelas', refs: 'Refs', - areas: 'Áreas', dependencies: 'Dependências', custom_types: 'Tipos Personalizados', + visuals: 'Visuais', }, menu: { actions: { @@ -233,6 +233,35 @@ export const pt_BR: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Visuais', + tabs: { + areas: 'Areas', + notes: 'Notas', + }, + }, + + notes_section: { + filter: 'Filtrar', + add_note: 'Adicionar Nota', + no_results: 'Nenhuma nota encontrada', + clear: 'Limpar Filtro', + empty_state: { + title: 'Sem Notas', + description: + 'Crie uma nota para adicionar anotações de texto na tela', + }, + note: { + empty_note: 'Nota vazia', + note_actions: { + title: 'Ações de Nota', + edit_content: 'Editar Conteúdo', + delete_note: 'Excluir Nota', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -486,6 +515,7 @@ export const pt_BR: LanguageTranslation = { new_relationship: 'Novo Relacionamento', // TODO: Translate new_area: 'New Area', + new_note: 'Nova Nota', }, table_node_context_menu: { diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 933b32b2..a92a5935 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -7,9 +7,9 @@ export const ru: LanguageTranslation = { browse: 'Обзор', tables: 'Таблицы', refs: 'Ссылки', - areas: 'Области', dependencies: 'Зависимости', custom_types: 'Пользовательские типы', + visuals: 'Визуальные элементы', }, menu: { actions: { @@ -230,6 +230,35 @@ export const ru: LanguageTranslation = { description: 'Создайте область, чтобы начать', }, }, + + visuals_section: { + visuals: 'Визуальные элементы', + tabs: { + areas: 'Области', + notes: 'Заметки', + }, + }, + + notes_section: { + filter: 'Фильтр', + add_note: 'Добавить Заметку', + no_results: 'Заметки не найдены', + clear: 'Очистить Фильтр', + empty_state: { + title: 'Нет Заметок', + description: + 'Создайте заметку, чтобы добавить текстовые аннотации на холсте', + }, + note: { + empty_note: 'Пустая заметка', + note_actions: { + title: 'Действия с Заметкой', + edit_content: 'Редактировать Содержимое', + delete_note: 'Удалить Заметку', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -481,6 +510,7 @@ export const ru: LanguageTranslation = { new_view: 'Новое представление', new_relationship: 'Создать отношение', new_area: 'Новая область', + new_note: 'Новая Заметка', }, table_node_context_menu: { diff --git a/src/i18n/locales/te.ts b/src/i18n/locales/te.ts index e26d3a72..6e3887df 100644 --- a/src/i18n/locales/te.ts +++ b/src/i18n/locales/te.ts @@ -7,9 +7,9 @@ export const te: LanguageTranslation = { browse: 'బ్రాఉజ్', tables: 'టేబల్లు', refs: 'సంబంధాలు', - areas: 'ప్రదేశాలు', dependencies: 'ఆధారతలు', custom_types: 'కస్టమ్ టైప్స్', + visuals: 'Visuals', }, menu: { actions: { @@ -234,6 +234,35 @@ export const te: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Visuals', + tabs: { + areas: 'Areas', + notes: 'గమనికలు', + }, + }, + + notes_section: { + filter: 'ఫిల్టర్', + add_note: 'గమనిక జోడించండి', + no_results: 'గమనికలు కనుగొనబడలేదు', + clear: 'ఫిల్టర్‌ను క్లియర్ చేయండి', + empty_state: { + title: 'గమనికలు లేవు', + description: + 'కాన్వాస్‌పై టెక్స్ట్ ఉల్లేఖనలను జోడించడానికి ఒక గమనికను సృష్టించండి', + }, + note: { + empty_note: 'ఖాళీ గమనిక', + note_actions: { + title: 'గమనిక చర్యలు', + edit_content: 'కంటెంట్‌ను సవరించండి', + delete_note: 'గమనికను తొలగించండి', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -490,6 +519,7 @@ export const te: LanguageTranslation = { new_relationship: 'కొత్త సంబంధం', // TODO: Translate new_area: 'New Area', + new_note: 'కొత్త నోట్', }, table_node_context_menu: { diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index 5029a671..ea6a1306 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -7,9 +7,9 @@ export const tr: LanguageTranslation = { browse: 'Gözat', tables: 'Tablolar', refs: 'Refs', - areas: 'Alanlar', dependencies: 'Bağımlılıklar', custom_types: 'Özel Tipler', + visuals: 'Görseller', }, menu: { actions: { @@ -233,6 +233,35 @@ export const tr: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Görseller', + tabs: { + areas: 'Areas', + notes: 'Notlar', + }, + }, + + notes_section: { + filter: 'Filtrele', + add_note: 'Not Ekle', + no_results: 'Not bulunamadı', + clear: 'Filtreyi Temizle', + empty_state: { + title: 'Not Yok', + description: + 'Tuval üzerinde metin açıklamaları eklemek için bir not oluşturun', + }, + note: { + empty_note: 'Boş not', + note_actions: { + title: 'Not İşlemleri', + edit_content: 'İçeriği Düzenle', + delete_note: 'Notu Sil', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -475,6 +504,7 @@ export const tr: LanguageTranslation = { new_relationship: 'Yeni İlişki', // TODO: Translate new_area: 'New Area', + new_note: 'Yeni Not', }, table_node_context_menu: { edit_table: 'Tabloyu Düzenle', diff --git a/src/i18n/locales/uk.ts b/src/i18n/locales/uk.ts index f7fa03ed..2b8b87b1 100644 --- a/src/i18n/locales/uk.ts +++ b/src/i18n/locales/uk.ts @@ -7,9 +7,9 @@ export const uk: LanguageTranslation = { browse: 'Огляд', tables: 'Таблиці', refs: 'Зв’язки', - areas: 'Області', dependencies: 'Залежності', custom_types: 'Користувацькі типи', + visuals: 'Візуальні елементи', }, menu: { actions: { @@ -231,6 +231,35 @@ export const uk: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Візуальні елементи', + tabs: { + areas: 'Areas', + notes: 'Нотатки', + }, + }, + + notes_section: { + filter: 'Фільтр', + add_note: 'Додати Нотатку', + no_results: 'Нотатки не знайдено', + clear: 'Очистити Фільтр', + empty_state: { + title: 'Немає Нотаток', + description: + 'Створіть нотатку, щоб додати текстові анотації на полотні', + }, + note: { + empty_note: 'Порожня нотатка', + note_actions: { + title: 'Дії з Нотаткою', + edit_content: 'Редагувати Вміст', + delete_note: 'Видалити Нотатку', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -481,6 +510,7 @@ export const uk: LanguageTranslation = { new_relationship: 'Новий звʼязок', // TODO: Translate new_area: 'New Area', + new_note: 'Нова Нотатка', }, table_node_context_menu: { diff --git a/src/i18n/locales/vi.ts b/src/i18n/locales/vi.ts index 52695c07..afa9b93e 100644 --- a/src/i18n/locales/vi.ts +++ b/src/i18n/locales/vi.ts @@ -7,9 +7,9 @@ export const vi: LanguageTranslation = { browse: 'Duyệt', tables: 'Bảng', refs: 'Refs', - areas: 'Khu vực', dependencies: 'Phụ thuộc', custom_types: 'Kiểu tùy chỉnh', + visuals: 'Hình ảnh', }, menu: { actions: { @@ -232,6 +232,35 @@ export const vi: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Hình ảnh', + tabs: { + areas: 'Areas', + notes: 'Ghi chú', + }, + }, + + notes_section: { + filter: 'Lọc', + add_note: 'Thêm Ghi Chú', + no_results: 'Không tìm thấy ghi chú', + clear: 'Xóa Bộ Lọc', + empty_state: { + title: 'Không Có Ghi Chú', + description: + 'Tạo ghi chú để thêm chú thích văn bản trên canvas', + }, + note: { + empty_note: 'Ghi chú trống', + note_actions: { + title: 'Hành Động Ghi Chú', + edit_content: 'Chỉnh Sửa Nội Dung', + delete_note: 'Xóa Ghi Chú', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -482,6 +511,7 @@ export const vi: LanguageTranslation = { new_relationship: 'Tạo quan hệ mới', // TODO: Translate new_area: 'New Area', + new_note: 'Ghi Chú Mới', }, table_node_context_menu: { diff --git a/src/i18n/locales/zh_CN.ts b/src/i18n/locales/zh_CN.ts index 0cf6b51d..3e847dda 100644 --- a/src/i18n/locales/zh_CN.ts +++ b/src/i18n/locales/zh_CN.ts @@ -7,9 +7,9 @@ export const zh_CN: LanguageTranslation = { browse: '浏览', tables: '表', refs: '引用', - areas: '区域', dependencies: '依赖关系', custom_types: '自定义类型', + visuals: '视觉效果', }, menu: { actions: { @@ -229,6 +229,34 @@ export const zh_CN: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: '视觉效果', + tabs: { + areas: 'Areas', + notes: '笔记', + }, + }, + + notes_section: { + filter: '筛选', + add_note: '添加笔记', + no_results: '未找到笔记', + clear: '清除筛选', + empty_state: { + title: '没有笔记', + description: '创建笔记以在画布上添加文本注释', + }, + note: { + empty_note: '空笔记', + note_actions: { + title: '笔记操作', + edit_content: '编辑内容', + delete_note: '删除笔记', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -477,6 +505,7 @@ export const zh_CN: LanguageTranslation = { new_relationship: '新建关系', // TODO: Translate new_area: 'New Area', + new_note: '新笔记', }, table_node_context_menu: { diff --git a/src/i18n/locales/zh_TW.ts b/src/i18n/locales/zh_TW.ts index 56ea91fb..3bbaf39c 100644 --- a/src/i18n/locales/zh_TW.ts +++ b/src/i18n/locales/zh_TW.ts @@ -7,9 +7,9 @@ export const zh_TW: LanguageTranslation = { browse: '瀏覽', tables: '表格', refs: 'Refs', - areas: '區域', dependencies: '相依性', custom_types: '自定義類型', + visuals: '視覺效果', }, menu: { actions: { @@ -229,6 +229,34 @@ export const zh_TW: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: '視覺效果', + tabs: { + areas: 'Areas', + notes: '筆記', + }, + }, + + notes_section: { + filter: '篩選', + add_note: '新增筆記', + no_results: '未找到筆記', + clear: '清除篩選', + empty_state: { + title: '沒有筆記', + description: '建立筆記以在畫布上新增文字註解', + }, + note: { + empty_note: '空白筆記', + note_actions: { + title: '筆記操作', + edit_content: '編輯內容', + delete_note: '刪除筆記', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -477,6 +505,7 @@ export const zh_TW: LanguageTranslation = { new_relationship: '新建關聯', // TODO: Translate new_area: 'New Area', + new_note: '新筆記', }, table_node_context_menu: { diff --git a/src/lib/clone.ts b/src/lib/clone.ts index 2050764e..e7163c50 100644 --- a/src/lib/clone.ts +++ b/src/lib/clone.ts @@ -6,6 +6,7 @@ import type { DBIndex } from './domain/db-index'; import type { DBRelationship } from './domain/db-relationship'; import type { DBTable } from './domain/db-table'; import type { Diagram } from './domain/diagram'; +import type { Note } from './domain/note'; import { generateId as defaultGenerateId } from './utils'; const generateIdsMapFromTable = ( @@ -49,6 +50,10 @@ const generateIdsMapFromDiagram = ( idsMap.set(area.id, generateId()); }); + diagram.notes?.forEach((note) => { + idsMap.set(note.id, generateId()); + }); + diagram.customTypes?.forEach((customType) => { idsMap.set(customType.id, generateId()); }); @@ -218,6 +223,21 @@ export const cloneDiagram = ( }) .filter((area): area is Area => area !== null) ?? []; + const notes: Note[] = + diagram.notes + ?.map((note) => { + const id = getNewId(note.id); + if (!id) { + return null; + } + + return { + ...note, + id, + } satisfies Note; + }) + .filter((note): note is Note => note !== null) ?? []; + const customTypes: DBCustomType[] = diagram.customTypes ?.map((customType) => { @@ -242,6 +262,7 @@ export const cloneDiagram = ( relationships, tables, areas, + notes, customTypes, createdAt: diagram.createdAt ? new Date(diagram.createdAt) diff --git a/src/lib/domain/diagram.ts b/src/lib/domain/diagram.ts index a0548e41..c53e39cc 100644 --- a/src/lib/domain/diagram.ts +++ b/src/lib/domain/diagram.ts @@ -10,6 +10,8 @@ import { dbTableSchema } from './db-table'; import { areaSchema, type Area } from './area'; import type { DBCustomType } from './db-custom-type'; import { dbCustomTypeSchema } from './db-custom-type'; +import type { Note } from './note'; +import { noteSchema } from './note'; export interface Diagram { id: string; @@ -21,6 +23,7 @@ export interface Diagram { dependencies?: DBDependency[]; areas?: Area[]; customTypes?: DBCustomType[]; + notes?: Note[]; createdAt: Date; updatedAt: Date; } @@ -35,6 +38,7 @@ export const diagramSchema: z.ZodType = z.object({ dependencies: z.array(dbDependencySchema).optional(), areas: z.array(areaSchema).optional(), customTypes: z.array(dbCustomTypeSchema).optional(), + notes: z.array(noteSchema).optional(), createdAt: z.date(), updatedAt: z.date(), }); diff --git a/src/lib/domain/diff/diff-check/__tests__/diff-check.test.ts b/src/lib/domain/diff/diff-check/__tests__/diff-check.test.ts index b161b27e..e5d0da2a 100644 --- a/src/lib/domain/diff/diff-check/__tests__/diff-check.test.ts +++ b/src/lib/domain/diff/diff-check/__tests__/diff-check.test.ts @@ -6,10 +6,12 @@ import type { DBField } from '@/lib/domain/db-field'; import type { DBIndex } from '@/lib/domain/db-index'; import type { DBRelationship } from '@/lib/domain/db-relationship'; import type { Area } from '@/lib/domain/area'; +import type { Note } from '@/lib/domain/note'; import { DatabaseType } from '@/lib/domain/database-type'; import type { TableDiffChanged } from '../../table-diff'; import type { FieldDiffChanged } from '../../field-diff'; import type { AreaDiffChanged } from '../../area-diff'; +import type { NoteDiffChanged } from '../../note-diff'; // Helper function to create a mock diagram function createMockDiagram(overrides?: Partial): Diagram { @@ -81,6 +83,20 @@ function createMockArea(overrides?: Partial): Area { } as Area; } +// Helper function to create a mock note +function createMockNote(overrides?: Partial): Note { + return { + id: 'note-1', + content: 'Test note content', + x: 0, + y: 0, + width: 200, + height: 150, + color: '#3b82f6', + ...overrides, + } as Note; +} + describe('generateDiff', () => { describe('Basic Table Diffing', () => { it('should detect added tables', () => { @@ -466,6 +482,408 @@ describe('generateDiff', () => { }); }); + describe('Note Diffing', () => { + it('should detect added notes when includeNotes is true', () => { + const oldDiagram = createMockDiagram({ notes: [] }); + const newDiagram = createMockDiagram({ + notes: [createMockNote()], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + }, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('note-note-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('added'); + expect(result.changedNotes.has('note-1')).toBe(true); + }); + + it('should not detect note changes when includeNotes is false', () => { + const oldDiagram = createMockDiagram({ notes: [] }); + const newDiagram = createMockDiagram({ + notes: [createMockNote()], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: false, + }, + }); + + expect(result.diffMap.size).toBe(0); + expect(result.changedNotes.size).toBe(0); + }); + + it('should detect removed notes', () => { + const oldDiagram = createMockDiagram({ + notes: [createMockNote()], + }); + const newDiagram = createMockDiagram({ notes: [] }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + }, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('note-note-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('removed'); + expect(result.changedNotes.has('note-1')).toBe(true); + }); + + it('should detect note content changes', () => { + const oldDiagram = createMockDiagram({ + notes: [createMockNote({ content: 'Old content' })], + }); + const newDiagram = createMockDiagram({ + notes: [createMockNote({ content: 'New content' })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + }, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('note-content-note-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('changed'); + expect((diff as NoteDiffChanged)?.attribute).toBe('content'); + expect((diff as NoteDiffChanged)?.oldValue).toBe('Old content'); + expect((diff as NoteDiffChanged)?.newValue).toBe('New content'); + }); + + it('should detect note color changes', () => { + const oldDiagram = createMockDiagram({ + notes: [createMockNote({ color: '#3b82f6' })], + }); + const newDiagram = createMockDiagram({ + notes: [createMockNote({ color: '#ef4444' })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + }, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('note-color-note-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('changed'); + expect((diff as NoteDiffChanged)?.attribute).toBe('color'); + expect((diff as NoteDiffChanged)?.oldValue).toBe('#3b82f6'); + expect((diff as NoteDiffChanged)?.newValue).toBe('#ef4444'); + }); + + it('should detect note position changes', () => { + const oldDiagram = createMockDiagram({ + notes: [createMockNote({ x: 0, y: 0 })], + }); + const newDiagram = createMockDiagram({ + notes: [createMockNote({ x: 100, y: 200 })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + attributes: { + notes: ['content', 'color', 'x', 'y'], + }, + }, + }); + + expect(result.diffMap.size).toBe(2); + expect(result.diffMap.has('note-x-note-1')).toBe(true); + expect(result.diffMap.has('note-y-note-1')).toBe(true); + + const xDiff = result.diffMap.get('note-x-note-1'); + expect((xDiff as NoteDiffChanged)?.oldValue).toBe(0); + expect((xDiff as NoteDiffChanged)?.newValue).toBe(100); + }); + + it('should detect note width changes', () => { + const oldDiagram = createMockDiagram({ + notes: [createMockNote({ width: 200 })], + }); + const newDiagram = createMockDiagram({ + notes: [createMockNote({ width: 300 })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + attributes: { + notes: ['width'], + }, + }, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('note-width-note-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('changed'); + expect((diff as NoteDiffChanged)?.attribute).toBe('width'); + expect((diff as NoteDiffChanged)?.oldValue).toBe(200); + expect((diff as NoteDiffChanged)?.newValue).toBe(300); + }); + + it('should detect note height changes', () => { + const oldDiagram = createMockDiagram({ + notes: [createMockNote({ height: 150 })], + }); + const newDiagram = createMockDiagram({ + notes: [createMockNote({ height: 250 })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + attributes: { + notes: ['height'], + }, + }, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('note-height-note-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('changed'); + expect((diff as NoteDiffChanged)?.attribute).toBe('height'); + expect((diff as NoteDiffChanged)?.oldValue).toBe(150); + expect((diff as NoteDiffChanged)?.newValue).toBe(250); + }); + + it('should detect multiple note dimension changes', () => { + const oldDiagram = createMockDiagram({ + notes: [ + createMockNote({ x: 0, y: 0, width: 200, height: 150 }), + ], + }); + const newDiagram = createMockDiagram({ + notes: [ + createMockNote({ x: 50, y: 75, width: 300, height: 250 }), + ], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + attributes: { + notes: ['x', 'y', 'width', 'height'], + }, + }, + }); + + expect(result.diffMap.size).toBe(4); + expect(result.diffMap.has('note-x-note-1')).toBe(true); + expect(result.diffMap.has('note-y-note-1')).toBe(true); + expect(result.diffMap.has('note-width-note-1')).toBe(true); + expect(result.diffMap.has('note-height-note-1')).toBe(true); + + const widthDiff = result.diffMap.get('note-width-note-1'); + expect((widthDiff as NoteDiffChanged)?.oldValue).toBe(200); + expect((widthDiff as NoteDiffChanged)?.newValue).toBe(300); + + const heightDiff = result.diffMap.get('note-height-note-1'); + expect((heightDiff as NoteDiffChanged)?.oldValue).toBe(150); + expect((heightDiff as NoteDiffChanged)?.newValue).toBe(250); + }); + + it('should detect multiple notes with different changes', () => { + const oldDiagram = createMockDiagram({ + notes: [ + createMockNote({ id: 'note-1', content: 'Note 1' }), + createMockNote({ id: 'note-2', content: 'Note 2' }), + createMockNote({ id: 'note-3', content: 'Note 3' }), + ], + }); + const newDiagram = createMockDiagram({ + notes: [ + createMockNote({ + id: 'note-1', + content: 'Note 1 Updated', + }), // Changed + createMockNote({ id: 'note-2', content: 'Note 2' }), // Unchanged + // note-3 removed + createMockNote({ id: 'note-4', content: 'Note 4' }), // Added + ], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + }, + }); + + // Should detect: 1 content change, 1 removal, 1 addition + expect(result.diffMap.has('note-content-note-1')).toBe(true); // Changed + expect(result.diffMap.has('note-note-3')).toBe(true); // Removed + expect(result.diffMap.has('note-note-4')).toBe(true); // Added + + expect(result.changedNotes.has('note-1')).toBe(true); + expect(result.changedNotes.has('note-3')).toBe(true); + expect(result.changedNotes.has('note-4')).toBe(true); + }); + + it('should use custom note matcher', () => { + const oldDiagram = createMockDiagram({ + notes: [ + createMockNote({ + id: 'note-1', + content: 'Unique content', + color: '#3b82f6', + }), + ], + }); + const newDiagram = createMockDiagram({ + notes: [ + createMockNote({ + id: 'note-2', + content: 'Unique content', + color: '#ef4444', + }), + ], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + matchers: { + note: (note, notes) => + notes.find((n) => n.content === note.content), + }, + }, + }); + + // With content-based matching, note-1 should match note-2 by content + // and detect the color change + const colorChange = result.diffMap.get('note-color-note-1'); + expect(colorChange).toBeDefined(); + expect(colorChange?.type).toBe('changed'); + expect((colorChange as NoteDiffChanged)?.attribute).toBe('color'); + expect((colorChange as NoteDiffChanged)?.oldValue).toBe('#3b82f6'); + expect((colorChange as NoteDiffChanged)?.newValue).toBe('#ef4444'); + }); + + it('should only check specified note change types', () => { + const oldDiagram = createMockDiagram({ + notes: [createMockNote({ id: 'note-1', content: 'Note 1' })], + }); + const newDiagram = createMockDiagram({ + notes: [createMockNote({ id: 'note-2', content: 'Note 2' })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + changeTypes: { + notes: ['added'], // Only check for added notes + }, + }, + }); + + // Should only detect added note (note-2) + const addedNotes = Array.from(result.diffMap.values()).filter( + (diff) => diff.type === 'added' && diff.object === 'note' + ); + expect(addedNotes.length).toBe(1); + + // Should not detect removed note (note-1) + const removedNotes = Array.from(result.diffMap.values()).filter( + (diff) => diff.type === 'removed' && diff.object === 'note' + ); + expect(removedNotes.length).toBe(0); + }); + + it('should only check specified note attributes', () => { + const oldDiagram = createMockDiagram({ + notes: [ + createMockNote({ + id: 'note-1', + content: 'Old content', + color: '#3b82f6', + x: 0, + y: 0, + }), + ], + }); + const newDiagram = createMockDiagram({ + notes: [ + createMockNote({ + id: 'note-1', + content: 'New content', + color: '#ef4444', + x: 100, + y: 200, + }), + ], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + attributes: { + notes: ['content'], // Only check content changes + }, + }, + }); + + // Should only detect content change + const contentChanges = Array.from(result.diffMap.values()).filter( + (diff) => + diff.type === 'changed' && + diff.attribute === 'content' && + diff.object === 'note' + ); + expect(contentChanges.length).toBe(1); + + // Should not detect color or position changes + const otherChanges = Array.from(result.diffMap.values()).filter( + (diff) => + diff.type === 'changed' && + (diff.attribute === 'color' || + diff.attribute === 'x' || + diff.attribute === 'y') && + diff.object === 'note' + ); + expect(otherChanges.length).toBe(0); + }); + }); + describe('Custom Matchers', () => { it('should use custom table matcher to match by name', () => { const oldDiagram = createMockDiagram({ @@ -708,7 +1126,7 @@ describe('generateDiff', () => { }); describe('Complex Scenarios', () => { - it('should detect all dimensional changes for tables and areas', () => { + it('should detect all dimensional changes for tables, areas, and notes', () => { const oldDiagram = createMockDiagram({ tables: [ createMockTable({ @@ -727,6 +1145,15 @@ describe('generateDiff', () => { height: 150, }), ], + notes: [ + createMockNote({ + id: 'note-1', + x: 0, + y: 0, + width: 300, + height: 200, + }), + ], }); const newDiagram = createMockDiagram({ @@ -747,6 +1174,15 @@ describe('generateDiff', () => { height: 175, }), ], + notes: [ + createMockNote({ + id: 'note-1', + x: 40, + y: 50, + width: 350, + height: 225, + }), + ], }); const result = generateDiff({ @@ -754,9 +1190,11 @@ describe('generateDiff', () => { newDiagram, options: { includeAreas: true, + includeNotes: true, attributes: { tables: ['x', 'y', 'width'], areas: ['x', 'y', 'width', 'height'], + notes: ['x', 'y', 'width', 'height'], }, }, }); @@ -772,6 +1210,12 @@ describe('generateDiff', () => { expect(result.diffMap.has('area-width-area-1')).toBe(true); expect(result.diffMap.has('area-height-area-1')).toBe(true); + // Note dimensional changes + expect(result.diffMap.has('note-x-note-1')).toBe(true); + expect(result.diffMap.has('note-y-note-1')).toBe(true); + expect(result.diffMap.has('note-width-note-1')).toBe(true); + expect(result.diffMap.has('note-height-note-1')).toBe(true); + // Verify the correct values const tableWidthDiff = result.diffMap.get('table-width-table-1'); expect((tableWidthDiff as TableDiffChanged)?.oldValue).toBe(100); @@ -784,6 +1228,14 @@ describe('generateDiff', () => { const areaHeightDiff = result.diffMap.get('area-height-area-1'); expect((areaHeightDiff as AreaDiffChanged)?.oldValue).toBe(150); expect((areaHeightDiff as AreaDiffChanged)?.newValue).toBe(175); + + const noteWidthDiff = result.diffMap.get('note-width-note-1'); + expect((noteWidthDiff as NoteDiffChanged)?.oldValue).toBe(300); + expect((noteWidthDiff as NoteDiffChanged)?.newValue).toBe(350); + + const noteHeightDiff = result.diffMap.get('note-height-note-1'); + expect((noteHeightDiff as NoteDiffChanged)?.oldValue).toBe(200); + expect((noteHeightDiff as NoteDiffChanged)?.newValue).toBe(225); }); it('should handle multiple simultaneous changes', () => { @@ -852,6 +1304,7 @@ describe('generateDiff', () => { expect(result.changedTables.size).toBe(0); expect(result.changedFields.size).toBe(0); expect(result.changedAreas.size).toBe(0); + expect(result.changedNotes.size).toBe(0); }); it('should handle diagrams with undefined collections', () => { @@ -859,11 +1312,13 @@ describe('generateDiff', () => { tables: undefined, relationships: undefined, areas: undefined, + notes: undefined, }); const diagram2 = createMockDiagram({ tables: [createMockTable({ id: 'table-1' })], relationships: [createMockRelationship({ id: 'rel-1' })], areas: [createMockArea({ id: 'area-1' })], + notes: [createMockNote({ id: 'note-1' })], }); const result = generateDiff({ @@ -871,6 +1326,7 @@ describe('generateDiff', () => { newDiagram: diagram2, options: { includeAreas: true, + includeNotes: true, }, }); @@ -878,6 +1334,7 @@ describe('generateDiff', () => { expect(result.diffMap.has('table-table-1')).toBe(true); expect(result.diffMap.has('relationship-rel-1')).toBe(true); expect(result.diffMap.has('area-area-1')).toBe(true); + expect(result.diffMap.has('note-note-1')).toBe(true); }); }); }); diff --git a/src/lib/domain/diff/diff-check/diff-check.ts b/src/lib/domain/diff/diff-check/diff-check.ts index e82658ca..087be1ad 100644 --- a/src/lib/domain/diff/diff-check/diff-check.ts +++ b/src/lib/domain/diff/diff-check/diff-check.ts @@ -4,6 +4,7 @@ import type { DBIndex } from '@/lib/domain/db-index'; import type { DBTable } from '@/lib/domain/db-table'; import type { DBRelationship } from '@/lib/domain/db-relationship'; import type { Area } from '@/lib/domain/area'; +import type { Note } from '@/lib/domain/note'; import type { ChartDBDiff, DiffMap, DiffObject } from '@/lib/domain/diff/diff'; import type { FieldDiff, @@ -11,6 +12,7 @@ import type { } from '@/lib/domain/diff/field-diff'; import type { TableDiff, TableDiffAttribute } from '../table-diff'; import type { AreaDiff, AreaDiffAttribute } from '../area-diff'; +import type { NoteDiff, NoteDiffAttribute } from '../note-diff'; import type { IndexDiff } from '../index-diff'; import type { RelationshipDiff } from '../relationship-diff'; @@ -44,10 +46,12 @@ export interface GenerateDiffOptions { includeIndexes?: boolean; includeRelationships?: boolean; includeAreas?: boolean; + includeNotes?: boolean; attributes?: { tables?: TableDiffAttribute[]; fields?: FieldDiffAttribute[]; areas?: AreaDiffAttribute[]; + notes?: NoteDiffAttribute[]; }; changeTypes?: { tables?: TableDiff['type'][]; @@ -55,6 +59,7 @@ export interface GenerateDiffOptions { indexes?: IndexDiff['type'][]; relationships?: RelationshipDiff['type'][]; areas?: AreaDiff['type'][]; + notes?: NoteDiff['type'][]; }; matchers?: { table?: (table: DBTable, tables: DBTable[]) => DBTable | undefined; @@ -65,6 +70,7 @@ export interface GenerateDiffOptions { relationships: DBRelationship[] ) => DBRelationship | undefined; area?: (area: Area, areas: Area[]) => Area | undefined; + note?: (note: Note, notes: Note[]) => Note | undefined; }; } @@ -81,6 +87,7 @@ export function generateDiff({ changedTables: Map; changedFields: Map; changedAreas: Map; + changedNotes: Map; } { // Merge with default options const mergedOptions: GenerateDiffOptions = { @@ -89,6 +96,7 @@ export function generateDiff({ includeIndexes: options.includeIndexes ?? true, includeRelationships: options.includeRelationships ?? true, includeAreas: options.includeAreas ?? false, + includeNotes: options.includeNotes ?? false, attributes: options.attributes ?? {}, changeTypes: options.changeTypes ?? {}, matchers: options.matchers ?? {}, @@ -98,6 +106,7 @@ export function generateDiff({ const changedTables = new Map(); const changedFields = new Map(); const changedAreas = new Map(); + const changedNotes = new Map(); // Use provided matchers or default ones const tableMatcher = mergedOptions.matchers?.table ?? defaultTableMatcher; @@ -106,6 +115,7 @@ export function generateDiff({ const relationshipMatcher = mergedOptions.matchers?.relationship ?? defaultRelationshipMatcher; const areaMatcher = mergedOptions.matchers?.area ?? defaultAreaMatcher; + const noteMatcher = mergedOptions.matchers?.note ?? defaultNoteMatcher; // Compare tables if (mergedOptions.includeTables) { @@ -157,7 +167,26 @@ export function generateDiff({ }); } - return { diffMap: newDiffs, changedTables, changedFields, changedAreas }; + // Compare notes if enabled + if (mergedOptions.includeNotes) { + compareNotes({ + diagram, + newDiagram, + diffMap: newDiffs, + changedNotes, + attributes: mergedOptions.attributes?.notes, + changeTypes: mergedOptions.changeTypes?.notes, + noteMatcher, + }); + } + + return { + diffMap: newDiffs, + changedTables, + changedFields, + changedAreas, + changedNotes, + }; } // Compare tables between diagrams @@ -1019,6 +1048,217 @@ function compareAreas({ } } +// Compare notes between diagrams +function compareNotes({ + diagram, + newDiagram, + diffMap, + changedNotes, + attributes, + changeTypes, + noteMatcher, +}: { + diagram: Diagram; + newDiagram: Diagram; + diffMap: DiffMap; + changedNotes: Map; + attributes?: NoteDiffAttribute[]; + changeTypes?: NoteDiff['type'][]; + noteMatcher: (note: Note, notes: Note[]) => Note | undefined; +}) { + const oldNotes = diagram.notes || []; + const newNotes = newDiagram.notes || []; + + // If changeTypes is empty array, don't check any changes + if (changeTypes && changeTypes.length === 0) { + return; + } + + // If changeTypes is undefined, check all types + const typesToCheck = changeTypes ?? ['added', 'removed', 'changed']; + + // Check for added notes + if (typesToCheck.includes('added')) { + for (const newNote of newNotes) { + if (!noteMatcher(newNote, oldNotes)) { + diffMap.set( + getDiffMapKey({ + diffObject: 'note', + objectId: newNote.id, + }), + { + object: 'note', + type: 'added', + noteAdded: newNote, + } + ); + changedNotes.set(newNote.id, true); + } + } + } + + // Check for removed notes + if (typesToCheck.includes('removed')) { + for (const oldNote of oldNotes) { + if (!noteMatcher(oldNote, newNotes)) { + diffMap.set( + getDiffMapKey({ + diffObject: 'note', + objectId: oldNote.id, + }), + { + object: 'note', + type: 'removed', + noteId: oldNote.id, + } + ); + changedNotes.set(oldNote.id, true); + } + } + } + + // Check for note content and color changes + if (typesToCheck.includes('changed')) { + for (const oldNote of oldNotes) { + const newNote = noteMatcher(oldNote, newNotes); + + if (!newNote) continue; + + // If attributes are specified, only check those attributes + const attributesToCheck: NoteDiffAttribute[] = attributes ?? [ + 'content', + 'color', + ]; + + if ( + attributesToCheck.includes('content') && + oldNote.content !== newNote.content + ) { + diffMap.set( + getDiffMapKey({ + diffObject: 'note', + objectId: oldNote.id, + attribute: 'content', + }), + { + object: 'note', + type: 'changed', + noteId: oldNote.id, + attribute: 'content', + newValue: newNote.content, + oldValue: oldNote.content, + } + ); + changedNotes.set(oldNote.id, true); + } + + if ( + attributesToCheck.includes('color') && + oldNote.color !== newNote.color + ) { + diffMap.set( + getDiffMapKey({ + diffObject: 'note', + objectId: oldNote.id, + attribute: 'color', + }), + { + object: 'note', + type: 'changed', + noteId: oldNote.id, + attribute: 'color', + newValue: newNote.color, + oldValue: oldNote.color, + } + ); + changedNotes.set(oldNote.id, true); + } + + if (attributesToCheck.includes('x') && oldNote.x !== newNote.x) { + diffMap.set( + getDiffMapKey({ + diffObject: 'note', + objectId: oldNote.id, + attribute: 'x', + }), + { + object: 'note', + type: 'changed', + noteId: oldNote.id, + attribute: 'x', + newValue: newNote.x, + oldValue: oldNote.x, + } + ); + changedNotes.set(oldNote.id, true); + } + + if (attributesToCheck.includes('y') && oldNote.y !== newNote.y) { + diffMap.set( + getDiffMapKey({ + diffObject: 'note', + objectId: oldNote.id, + attribute: 'y', + }), + { + object: 'note', + type: 'changed', + noteId: oldNote.id, + attribute: 'y', + newValue: newNote.y, + oldValue: oldNote.y, + } + ); + changedNotes.set(oldNote.id, true); + } + + if ( + attributesToCheck.includes('width') && + oldNote.width !== newNote.width + ) { + diffMap.set( + getDiffMapKey({ + diffObject: 'note', + objectId: oldNote.id, + attribute: 'width', + }), + { + object: 'note', + type: 'changed', + noteId: oldNote.id, + attribute: 'width', + newValue: newNote.width, + oldValue: oldNote.width, + } + ); + changedNotes.set(oldNote.id, true); + } + + if ( + attributesToCheck.includes('height') && + oldNote.height !== newNote.height + ) { + diffMap.set( + getDiffMapKey({ + diffObject: 'note', + objectId: oldNote.id, + attribute: 'height', + }), + { + object: 'note', + type: 'changed', + noteId: oldNote.id, + attribute: 'height', + newValue: newNote.height, + oldValue: oldNote.height, + } + ); + changedNotes.set(oldNote.id, true); + } + } + } +} + const defaultTableMatcher = ( table: DBTable, tables: DBTable[] @@ -1050,3 +1290,7 @@ const defaultRelationshipMatcher = ( const defaultAreaMatcher = (area: Area, areas: Area[]): Area | undefined => { return areas.find((a) => a.id === area.id); }; + +const defaultNoteMatcher = (note: Note, notes: Note[]): Note | undefined => { + return notes.find((n) => n.id === note.id); +}; diff --git a/src/lib/domain/diff/diff.ts b/src/lib/domain/diff/diff.ts index 524214cd..faab4cd7 100644 --- a/src/lib/domain/diff/diff.ts +++ b/src/lib/domain/diff/diff.ts @@ -10,7 +10,9 @@ import type { TableDiff } from './table-diff'; import { createTableDiffSchema } from './table-diff'; import type { AreaDiff } from './area-diff'; import { createAreaDiffSchema } from './area-diff'; -import type { DBField, DBIndex, DBRelationship, DBTable, Area } from '..'; +import type { NoteDiff } from './note-diff'; +import { createNoteDiffSchema } from './note-diff'; +import type { DBField, DBIndex, DBRelationship, DBTable, Area, Note } from '..'; export type ChartDBDiff< TTable = DBTable, @@ -18,12 +20,14 @@ export type ChartDBDiff< TIndex = DBIndex, TRelationship = DBRelationship, TArea = Area, + TNote = Note, > = | TableDiff | FieldDiff | IndexDiff | RelationshipDiff - | AreaDiff; + | AreaDiff + | NoteDiff; export const createChartDBDiffSchema = < TTable = DBTable, @@ -31,20 +35,27 @@ export const createChartDBDiffSchema = < TIndex = DBIndex, TRelationship = DBRelationship, TArea = Area, + TNote = Note, >( tableSchema: z.ZodType, fieldSchema: z.ZodType, indexSchema: z.ZodType, relationshipSchema: z.ZodType, - areaSchema: z.ZodType -): z.ZodType> => { + areaSchema: z.ZodType, + noteSchema: z.ZodType +): z.ZodType< + ChartDBDiff +> => { return z.union([ createTableDiffSchema(tableSchema), createFieldDiffSchema(fieldSchema), createIndexDiffSchema(indexSchema), createRelationshipDiffSchema(relationshipSchema), createAreaDiffSchema(areaSchema), - ]) as z.ZodType>; + createNoteDiffSchema(noteSchema), + ]) as z.ZodType< + ChartDBDiff + >; }; export type DiffMap< @@ -53,7 +64,11 @@ export type DiffMap< TIndex = DBIndex, TRelationship = DBRelationship, TArea = Area, -> = Map>; + TNote = Note, +> = Map< + string, + ChartDBDiff +>; export type DiffObject< TTable = DBTable, @@ -61,12 +76,14 @@ export type DiffObject< TIndex = DBIndex, TRelationship = DBRelationship, TArea = Area, + TNote = Note, > = | TableDiff['object'] | FieldDiff['object'] | IndexDiff['object'] | RelationshipDiff['object'] - | AreaDiff['object']; + | AreaDiff['object'] + | NoteDiff['object']; type ExtractDiffKind = T extends { object: infer O; type: infer Type } ? T extends { attribute: infer A } @@ -80,7 +97,10 @@ export type DiffKind< TIndex = DBIndex, TRelationship = DBRelationship, TArea = Area, -> = ExtractDiffKind>; + TNote = Note, +> = ExtractDiffKind< + ChartDBDiff +>; export const isDiffOfKind = < TTable = DBTable, @@ -88,9 +108,10 @@ export const isDiffOfKind = < TIndex = DBIndex, TRelationship = DBRelationship, TArea = Area, + TNote = Note, >( - diff: ChartDBDiff, - kind: DiffKind + diff: ChartDBDiff, + kind: DiffKind ): boolean => { if ('attribute' in kind) { return ( diff --git a/src/lib/domain/diff/note-diff.ts b/src/lib/domain/diff/note-diff.ts new file mode 100644 index 00000000..5cebb1fc --- /dev/null +++ b/src/lib/domain/diff/note-diff.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; +import type { Note } from '../note'; + +export type NoteDiffAttribute = keyof Pick< + Note, + 'content' | 'color' | 'x' | 'y' | 'width' | 'height' +>; + +const noteDiffAttributeSchema: z.ZodType = z.union([ + z.literal('content'), + z.literal('color'), + z.literal('x'), + z.literal('y'), + z.literal('width'), + z.literal('height'), +]); + +export interface NoteDiffChanged { + object: 'note'; + type: 'changed'; + noteId: string; + attribute: NoteDiffAttribute; + oldValue?: string | number | null; + newValue?: string | number | null; +} + +export const NoteDiffChangedSchema: z.ZodType = z.object({ + object: z.literal('note'), + type: z.literal('changed'), + noteId: z.string(), + attribute: noteDiffAttributeSchema, + oldValue: z.union([z.string(), z.number(), z.null()]).optional(), + newValue: z.union([z.string(), z.number(), z.null()]).optional(), +}); + +export interface NoteDiffRemoved { + object: 'note'; + type: 'removed'; + noteId: string; +} + +export const NoteDiffRemovedSchema: z.ZodType = z.object({ + object: z.literal('note'), + type: z.literal('removed'), + noteId: z.string(), +}); + +export interface NoteDiffAdded { + object: 'note'; + type: 'added'; + noteAdded: T; +} + +export const createNoteDiffAddedSchema = ( + noteSchema: z.ZodType +): z.ZodType> => { + return z.object({ + object: z.literal('note'), + type: z.literal('added'), + noteAdded: noteSchema, + }) as z.ZodType>; +}; + +export type NoteDiff = + | NoteDiffChanged + | NoteDiffRemoved + | NoteDiffAdded; + +export const createNoteDiffSchema = ( + noteSchema: z.ZodType +): z.ZodType> => { + return z.union([ + NoteDiffChangedSchema, + NoteDiffRemovedSchema, + createNoteDiffAddedSchema(noteSchema), + ]) as z.ZodType>; +}; diff --git a/src/lib/domain/index.ts b/src/lib/domain/index.ts index c7e874b8..456f2e88 100644 --- a/src/lib/domain/index.ts +++ b/src/lib/domain/index.ts @@ -11,3 +11,4 @@ export * from './db-relationship'; export * from './db-schema'; export * from './db-table'; export * from './diagram'; +export * from './note'; diff --git a/src/lib/domain/note.ts b/src/lib/domain/note.ts new file mode 100644 index 00000000..495c58c9 --- /dev/null +++ b/src/lib/domain/note.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +export interface Note { + id: string; + content: string; + x: number; + y: number; + width: number; + height: number; + color: string; + order?: number; +} + +export const noteSchema: z.ZodType = z.object({ + id: z.string(), + content: z.string(), + x: z.number(), + y: z.number(), + width: z.number(), + height: z.number(), + color: z.string(), + order: z.number().optional(), +}); diff --git a/src/pages/editor-page/canvas/area-node/area-node.tsx b/src/pages/editor-page/canvas/area-node/area-node.tsx index 5ace33a1..47c3b622 100644 --- a/src/pages/editor-page/canvas/area-node/area-node.tsx +++ b/src/pages/editor-page/canvas/area-node/area-node.tsx @@ -32,7 +32,8 @@ export const AreaNode: React.FC> = React.memo( const [editMode, setEditMode] = useState(false); const [areaName, setAreaName] = useState(area.name); const inputRef = React.useRef(null); - const { openAreaFromSidebar, selectSidebarSection } = useLayout(); + const { openAreaFromSidebar, selectSidebarSection, selectVisualsTab } = + useLayout(); const focused = !!selected && !dragging; @@ -50,9 +51,15 @@ export const AreaNode: React.FC> = React.memo( }, [area.name]); const openAreaInEditor = useCallback(() => { - selectSidebarSection('areas'); + selectSidebarSection('visuals'); + selectVisualsTab('areas'); openAreaFromSidebar(area.id); - }, [selectSidebarSection, openAreaFromSidebar, area.id]); + }, [ + selectSidebarSection, + openAreaFromSidebar, + area.id, + selectVisualsTab, + ]); useClickAway(inputRef, editAreaName); useKeyPressEvent('Enter', editAreaName); diff --git a/src/pages/editor-page/canvas/canvas-context-menu.tsx b/src/pages/editor-page/canvas/canvas-context-menu.tsx index 6fcb8348..bb7c039b 100644 --- a/src/pages/editor-page/canvas/canvas-context-menu.tsx +++ b/src/pages/editor-page/canvas/canvas-context-menu.tsx @@ -2,6 +2,7 @@ import { ContextMenu, ContextMenuContent, ContextMenuItem, + ContextMenuSeparator, ContextMenuTrigger, } from '@/components/context-menu/context-menu'; import { useBreakpoint } from '@/hooks/use-breakpoint'; @@ -10,7 +11,7 @@ import { useDialog } from '@/hooks/use-dialog'; import { useReactFlow } from '@xyflow/react'; import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { Table, Workflow, Group, View } from 'lucide-react'; +import { Table, Workflow, Group, View, StickyNote } from 'lucide-react'; import { useDiagramFilter } from '@/context/diagram-filter-context/use-diagram-filter'; import { useLocalConfig } from '@/hooks/use-local-config'; import { useCanvas } from '@/hooks/use-canvas'; @@ -19,7 +20,8 @@ import { defaultSchemas } from '@/lib/data/default-schemas'; export const CanvasContextMenu: React.FC = ({ children, }) => { - const { createTable, readonly, createArea, databaseType } = useChartDB(); + const { createTable, readonly, createArea, databaseType, createNote } = + useChartDB(); const { schemasDisplayed } = useDiagramFilter(); const { openCreateRelationshipDialog } = useDialog(); const { screenToFlowPosition } = useReactFlow(); @@ -121,6 +123,21 @@ export const CanvasContextMenu: React.FC = ({ [createArea, screenToFlowPosition] ); + const createNoteHandler = useCallback( + (event: React.MouseEvent) => { + const position = screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + + createNote({ + x: position.x, + y: position.y, + }); + }, + [createNote, screenToFlowPosition] + ); + const createRelationshipHandler = useCallback(() => { openCreateRelationshipDialog(); }, [openCreateRelationshipDialog]); @@ -158,6 +175,7 @@ export const CanvasContextMenu: React.FC = ({ {t('canvas_context_menu.new_relationship')} + = ({ {t('canvas_context_menu.new_area')} + + {t('canvas_context_menu.new_note')} + + ); diff --git a/src/pages/editor-page/canvas/canvas.tsx b/src/pages/editor-page/canvas/canvas.tsx index bf0afe29..f8ebf909 100644 --- a/src/pages/editor-page/canvas/canvas.tsx +++ b/src/pages/editor-page/canvas/canvas.tsx @@ -83,6 +83,9 @@ import { useCanvas } from '@/hooks/use-canvas'; import type { AreaNodeType } from './area-node/area-node'; import { AreaNode } from './area-node/area-node'; import type { Area } from '@/lib/domain/area'; +import type { NoteNodeType } from './note-node/note-node'; +import { NoteNode } from './note-node/note-node'; +import type { Note } from '@/lib/domain/note'; import type { TempCursorNodeType } from './temp-cursor-node/temp-cursor-node'; import { TEMP_CURSOR_HANDLE_ID, @@ -123,6 +126,7 @@ export type EdgeType = export type NodeType = | TableNodeType | AreaNodeType + | NoteNodeType | TempCursorNodeType | CreateRelationshipNodeType; @@ -137,6 +141,7 @@ const edgeTypes: EdgeTypes = { const nodeTypes: NodeTypes = { table: TableNode, area: AreaNode, + note: NoteNode, 'temp-cursor': TempCursorNode, 'create-relationship': CreateRelationshipNode, }; @@ -238,6 +243,21 @@ const areaToAreaNode = ( }; }; +const noteToNoteNode = (note: Note): NoteNodeType => { + return { + id: note.id, + type: 'note', + position: { x: note.x, y: note.y }, + data: { note }, + width: note.width, + height: note.height, + zIndex: 50, + style: { + zIndex: 50, + }, + }; +}; + export interface CanvasProps { initialTables: DBTable[]; } @@ -254,6 +274,7 @@ export const Canvas: React.FC = ({ initialTables }) => { const { tables, areas, + notes, relationships, createRelationship, createDependency, @@ -267,6 +288,8 @@ export const Canvas: React.FC = ({ initialTables }) => { readonly, removeArea, updateArea, + removeNote, + updateNote, highlightedCustomType, highlightCustomTypeId, } = useChartDB(); @@ -287,6 +310,7 @@ export const Canvas: React.FC = ({ initialTables }) => { endFloatingEdgeCreation, hoveringTableId, hideCreateRelationshipNode, + events: canvasEvents, } = useCanvas(); const { filter, loading: filterLoading } = useDiagramFilter(); const { checkIfNewTable } = useDiff(); @@ -543,6 +567,7 @@ export const Canvas: React.FC = ({ initialTables }) => { filterLoading, }) ), + ...notes.map((note) => noteToNoteNode(note)), ...prevNodes.filter( (n) => n.type === 'temp-cursor' || @@ -560,6 +585,7 @@ export const Canvas: React.FC = ({ initialTables }) => { }, [ tables, areas, + notes, setNodes, filter, databaseType, @@ -975,6 +1001,13 @@ export const Canvas: React.FC = ({ initialTables }) => { sizeChanges: areaSizeChanges, } = findRelevantNodesChanges(changesToApply, 'area'); + // Then, detect note changes + const { + positionChanges: notePositionChanges, + removeChanges: noteRemoveChanges, + sizeChanges: noteSizeChanges, + } = findRelevantNodesChanges(changesToApply, 'note'); + // Then, detect table changes const { positionChanges, removeChanges, sizeChanges } = findRelevantNodesChanges(changesToApply, 'table'); @@ -1144,6 +1177,49 @@ export const Canvas: React.FC = ({ initialTables }) => { } } + // Handle note changes + if ( + notePositionChanges.length > 0 || + noteRemoveChanges.length > 0 || + noteSizeChanges.length > 0 + ) { + const notesUpdates: Record> = {}; + // Handle note position changes + notePositionChanges.forEach((change) => { + if (change.type === 'position' && change.position) { + notesUpdates[change.id] = { + ...notesUpdates[change.id], + x: change.position.x, + y: change.position.y, + }; + } + }); + + // Handle note size changes + noteSizeChanges.forEach((change) => { + if (change.type === 'dimensions' && change.dimensions) { + notesUpdates[change.id] = { + ...notesUpdates[change.id], + width: change.dimensions.width, + height: change.dimensions.height, + }; + } + }); + + // Handle note removal + noteRemoveChanges.forEach((change) => { + removeNote(change.id); + delete notesUpdates[change.id]; + }); + + // Apply note updates to storage + if (Object.keys(notesUpdates).length > 0) { + for (const [id, updates] of Object.entries(notesUpdates)) { + updateNote(id, updates); + } + } + } + return onNodesChange(changesToApply); }, [ @@ -1153,6 +1229,8 @@ export const Canvas: React.FC = ({ initialTables }) => { findRelevantNodesChanges, updateArea, removeArea, + updateNote, + removeNote, readonly, tables, areas, @@ -1423,23 +1501,35 @@ export const Canvas: React.FC = ({ initialTables }) => { return [...edges, tempEdge]; }, [edges, tempFloatingEdge, cursorPosition, hoveringTableId]); - const onPaneClickHandler = useCallback(() => { - if (tempFloatingEdge) { - endFloatingEdgeCreation(); - setCursorPosition(null); - } + const onPaneClickHandler = useCallback( + (event: React.MouseEvent) => { + if (tempFloatingEdge) { + endFloatingEdgeCreation(); + setCursorPosition(null); + } - // Close CreateRelationshipNode if it exists - hideCreateRelationshipNode(); + // Close CreateRelationshipNode if it exists + hideCreateRelationshipNode(); - // Exit edit table mode - exitEditTableMode(); - }, [ - tempFloatingEdge, - exitEditTableMode, - endFloatingEdgeCreation, - hideCreateRelationshipNode, - ]); + // Exit edit table mode + exitEditTableMode(); + + canvasEvents.emit({ + action: 'pan_click', + data: { + x: event.clientX, + y: event.clientY, + }, + }); + }, + [ + canvasEvents, + tempFloatingEdge, + exitEditTableMode, + endFloatingEdgeCreation, + hideCreateRelationshipNode, + ] + ); return ( diff --git a/src/pages/editor-page/canvas/note-node/note-node.tsx b/src/pages/editor-page/canvas/note-node/note-node.tsx new file mode 100644 index 00000000..422755ea --- /dev/null +++ b/src/pages/editor-page/canvas/note-node/note-node.tsx @@ -0,0 +1,219 @@ +import React, { useCallback, useState, useRef } from 'react'; +import { NodeResizer, type NodeProps, type Node } from '@xyflow/react'; +import { Pencil, Trash2 } from 'lucide-react'; +import type { Note } from '@/lib/domain/note'; +import { useChartDB } from '@/hooks/use-chartdb'; +import { useClickAway, useKeyPressEvent } from 'react-use'; +import { ColorPicker } from '@/components/color-picker/color-picker'; +import { Button } from '@/components/button/button'; +import { cn } from '@/lib/utils'; +import { useCanvas } from '@/hooks/use-canvas'; +import type { CanvasEvent } from '@/context/canvas-context/canvas-context'; +import { useTheme } from '@/hooks/use-theme'; + +export interface NoteNodeProps extends NodeProps { + data: { + note: Note; + }; +} + +export type NoteNodeType = Node<{ note: Note }, 'note'>; + +export const NoteNode: React.FC = ({ data, selected }) => { + const { note } = data; + const { updateNote, removeNote, readonly } = useChartDB(); + const [editMode, setEditMode] = useState(false); + const [content, setContent] = useState(note.content); + const textareaRef = useRef(null); + const { events } = useCanvas(); + const { effectiveTheme } = useTheme(); + + const saveContent = useCallback(() => { + if (!editMode) return; + updateNote(note.id, { content: content.trim() }); + setEditMode(false); + }, [editMode, content, note.id, updateNote]); + + const abortEdit = useCallback(() => { + setEditMode(false); + setContent(note.content); + }, [note.content]); + + const enterEditMode = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (readonly) return; + setEditMode(true); + }, + [readonly] + ); + + const handleDelete = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + removeNote(note.id); + }, + [note.id, removeNote] + ); + + const handleColorChange = useCallback( + (color: string) => { + updateNote(note.id, { color }); + }, + [note.id, updateNote] + ); + + const handleDoubleClick = useCallback< + React.MouseEventHandler + >( + (e) => { + if (!readonly) { + enterEditMode(e); + } + }, + [enterEditMode, readonly] + ); + + useClickAway(textareaRef, saveContent); + useKeyPressEvent('Escape', abortEdit); + + const eventConsumer = useCallback( + (event: CanvasEvent) => { + if (!editMode) { + return; + } + + if (event.action === 'pan_click') { + saveContent(); + } + }, + [editMode, saveContent] + ); + + events.useSubscription(eventConsumer); + + // Focus textarea when entering edit mode + React.useEffect(() => { + if (textareaRef.current && editMode) { + textareaRef.current.focus(); + } + }, [editMode]); + + const getHeaderColor = (color: string) => { + // Return the original color for header (full saturation) + return color; + }; + + const getBodyColor = (color: string) => { + const hex = color.replace('#', ''); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + + const isDark = effectiveTheme === 'dark'; + + if (isDark) { + // Dark mode: darken the color by mixing with dark gray (30% original + 70% dark) + const darkR = Math.round(r * 0.3 + 0 * 0.7); + const darkG = Math.round(g * 0.3 + 0 * 0.7); + const darkB = Math.round(b * 0.3 + 0 * 0.7); + return `rgb(${darkR}, ${darkG}, ${darkB})`; + } else { + // Light mode: lighten the color by mixing with white (30% original + 70% white) + const lightR = Math.round(r * 0.3 + 255 * 0.7); + const lightG = Math.round(g * 0.3 + 255 * 0.7); + const lightB = Math.round(b * 0.3 + 255 * 0.7); + return `rgb(${lightR}, ${lightG}, ${lightB})`; + } + }; + + return ( +
+ {/* Notepad header with binding */} +
+ + + + {/* Note body */} +
+ {/* Corner fold (bottom-right) */} +
+ + {/* Content area */} + {editMode ? ( +