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
)}
>
-
-
-
+
+
+
+ {/* */}
+
+
+
+ {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 ? (
+
+
+ );
+};
+
+NoteNode.displayName = 'NoteNode';
diff --git a/src/pages/editor-page/editor-sidebar/editor-sidebar.tsx b/src/pages/editor-page/editor-sidebar/editor-sidebar.tsx
index f98def14..edbc77bc 100644
--- a/src/pages/editor-page/editor-sidebar/editor-sidebar.tsx
+++ b/src/pages/editor-page/editor-sidebar/editor-sidebar.tsx
@@ -42,8 +42,12 @@ export interface SidebarItem {
export interface EditorSidebarProps {}
export const EditorSidebar: React.FC = () => {
- const { selectSidebarSection, selectedSidebarSection, showSidePanel } =
- useLayout();
+ const {
+ selectSidebarSection,
+ selectedSidebarSection,
+ showSidePanel,
+ selectVisualsTab,
+ } = useLayout();
const { t } = useTranslation();
const { isMd: isDesktop } = useBreakpoint('md');
const { effectiveTheme } = useTheme();
@@ -101,15 +105,6 @@ export const EditorSidebar: React.FC = () => {
},
active: selectedSidebarSection === 'refs',
},
- {
- title: t('editor_sidebar.areas'),
- icon: Group,
- onClick: () => {
- showSidePanel();
- selectSidebarSection('areas');
- },
- active: selectedSidebarSection === 'areas',
- },
...(supportsCustomTypes(databaseType)
? [
{
@@ -123,6 +118,16 @@ export const EditorSidebar: React.FC = () => {
},
]
: []),
+ {
+ title: t('editor_sidebar.visuals'),
+ icon: Group,
+ onClick: () => {
+ showSidePanel();
+ selectSidebarSection('visuals');
+ selectVisualsTab('areas');
+ },
+ active: selectedSidebarSection === 'visuals',
+ },
],
[
selectSidebarSection,
@@ -130,6 +135,7 @@ export const EditorSidebar: React.FC = () => {
t,
showSidePanel,
databaseType,
+ selectVisualsTab,
]
);
diff --git a/src/pages/editor-page/side-panel/side-panel.tsx b/src/pages/editor-page/side-panel/side-panel.tsx
index aa54ad5c..9a2fe0b6 100644
--- a/src/pages/editor-page/side-panel/side-panel.tsx
+++ b/src/pages/editor-page/side-panel/side-panel.tsx
@@ -13,11 +13,11 @@ import type { SidebarSection } from '@/context/layout-context/layout-context';
import { useTranslation } from 'react-i18next';
import { useChartDB } from '@/hooks/use-chartdb';
import { useBreakpoint } from '@/hooks/use-breakpoint';
-import { AreasSection } from './areas-section/areas-section';
import { CustomTypesSection } from './custom-types-section/custom-types-section';
import { supportsCustomTypes } from '@/lib/domain/database-capabilities';
import { DBMLSection } from './dbml-section/dbml-section';
import { RefsSection } from './refs-section/refs-section';
+import { VisualsSection } from './visuals-section/visuals-section';
export interface SidePanelProps {}
@@ -54,6 +54,9 @@ export const SidePanel: React.FC = () => {
{t('side_panel.areas_section.areas')}
+
+ {t('side_panel.visuals_section.visuals')}
+
{supportsCustomTypes(databaseType) ? (
{t(
@@ -72,8 +75,8 @@ export const SidePanel: React.FC = () => {
) : selectedSidebarSection === 'refs' ? (
- ) : selectedSidebarSection === 'areas' ? (
-
+ ) : selectedSidebarSection === 'visuals' ? (
+
) : (
)}
diff --git a/src/pages/editor-page/side-panel/areas-section/areas-list/area-list-item/area-list-item.tsx b/src/pages/editor-page/side-panel/visuals-section/areas-tab/areas-list/area-list-item/area-list-item.tsx
similarity index 100%
rename from src/pages/editor-page/side-panel/areas-section/areas-list/area-list-item/area-list-item.tsx
rename to src/pages/editor-page/side-panel/visuals-section/areas-tab/areas-list/area-list-item/area-list-item.tsx
diff --git a/src/pages/editor-page/side-panel/areas-section/areas-list/areas-list.tsx b/src/pages/editor-page/side-panel/visuals-section/areas-tab/areas-list/areas-list.tsx
similarity index 100%
rename from src/pages/editor-page/side-panel/areas-section/areas-list/areas-list.tsx
rename to src/pages/editor-page/side-panel/visuals-section/areas-tab/areas-list/areas-list.tsx
diff --git a/src/pages/editor-page/side-panel/areas-section/areas-section.tsx b/src/pages/editor-page/side-panel/visuals-section/areas-tab/areas-tab.tsx
similarity index 94%
rename from src/pages/editor-page/side-panel/areas-section/areas-section.tsx
rename to src/pages/editor-page/side-panel/visuals-section/areas-tab/areas-tab.tsx
index c24da0f8..3973db05 100644
--- a/src/pages/editor-page/side-panel/areas-section/areas-section.tsx
+++ b/src/pages/editor-page/side-panel/visuals-section/areas-tab/areas-tab.tsx
@@ -1,5 +1,4 @@
import React, { useCallback, useMemo } from 'react';
-import { AreaList } from './areas-list/areas-list';
import { Button } from '@/components/button/button';
import { Group, X } from 'lucide-react';
import { Input } from '@/components/input/input';
@@ -10,10 +9,11 @@ import { EmptyState } from '@/components/empty-state/empty-state';
import { ScrollArea } from '@/components/scroll-area/scroll-area';
import { useTranslation } from 'react-i18next';
import { useViewport } from '@xyflow/react';
+import { AreaList } from './areas-list/areas-list';
-export interface AreasSectionProps {}
+export interface AreasTabProps {}
-export const AreasSection: React.FC = () => {
+export const AreasTab: React.FC = () => {
const { createArea, areas, readonly } = useChartDB();
const viewport = useViewport();
const { t } = useTranslation();
@@ -58,11 +58,8 @@ export const AreasSection: React.FC = () => {
}, []);
return (
-
-
+
+
);
};
diff --git a/src/pages/editor-page/side-panel/visuals-section/notes-tab/notes-list/note-list-item/note-list-item.tsx b/src/pages/editor-page/side-panel/visuals-section/notes-tab/notes-list/note-list-item/note-list-item.tsx
new file mode 100644
index 00000000..5ad19e62
--- /dev/null
+++ b/src/pages/editor-page/side-panel/visuals-section/notes-tab/notes-list/note-list-item/note-list-item.tsx
@@ -0,0 +1,157 @@
+import React, { useCallback } from 'react';
+import {
+ GripVertical,
+ Trash2,
+ EllipsisVertical,
+ CircleDotDashed,
+} from 'lucide-react';
+import { useSortable } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import type { Note } from '@/lib/domain/note';
+import { useChartDB } from '@/hooks/use-chartdb';
+import { useTranslation } from 'react-i18next';
+import { ColorPicker } from '@/components/color-picker/color-picker';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/dropdown-menu/dropdown-menu';
+import { ListItemHeaderButton } from '@/pages/editor-page/side-panel/list-item-header-button/list-item-header-button';
+import { useFocusOn } from '@/hooks/use-focus-on';
+import { mergeRefs } from '@/lib/utils';
+
+export interface NoteListItemProps {
+ note: Note;
+}
+
+export const NoteListItem = React.forwardRef
(
+ ({ note }, forwardedRef) => {
+ const { updateNote, removeNote, readonly } = useChartDB();
+ const { t } = useTranslation();
+ const { focusOnNote } = useFocusOn();
+
+ const { attributes, listeners, setNodeRef, transform, transition } =
+ useSortable({
+ id: note.id,
+ });
+
+ // Merge the forwarded ref with the sortable ref
+ const combinedRef = mergeRefs(forwardedRef, setNodeRef);
+
+ const style = {
+ transform: CSS.Translate.toString(transform),
+ transition,
+ };
+
+ const handleDelete = useCallback(() => {
+ removeNote(note.id);
+ }, [note.id, removeNote]);
+
+ const handleColorChange = useCallback(
+ (color: string) => {
+ updateNote(note.id, { color });
+ },
+ [note.id, updateNote]
+ );
+
+ const handleFocusOnNote = useCallback(
+ (event: React.MouseEvent) => {
+ event.stopPropagation();
+ focusOnNote(note.id);
+ },
+ [focusOnNote, note.id]
+ );
+
+ const renderDropDownMenu = useCallback(
+ () => (
+
+
+
+
+
+
+
+
+ {t(
+ 'side_panel.notes_section.note.note_actions.title'
+ )}
+
+
+
+
+ {t(
+ 'side_panel.notes_section.note.note_actions.delete_note'
+ )}
+
+
+
+
+
+ ),
+ [handleDelete, t]
+ );
+
+ return (
+
+
+ {!readonly ? (
+
+
+
+ ) : null}
+
+
+
+ {note.content || (
+
+ {t(
+ 'side_panel.notes_section.note.empty_note'
+ )}
+
+ )}
+
+
+
+
+
+ {!readonly ? renderDropDownMenu() : null}
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+);
+
+NoteListItem.displayName = 'NoteListItem';
diff --git a/src/pages/editor-page/side-panel/visuals-section/notes-tab/notes-list/notes-list.tsx b/src/pages/editor-page/side-panel/visuals-section/notes-tab/notes-list/notes-list.tsx
new file mode 100644
index 00000000..8381bff6
--- /dev/null
+++ b/src/pages/editor-page/side-panel/visuals-section/notes-tab/notes-list/notes-list.tsx
@@ -0,0 +1,123 @@
+import React, { useCallback, useMemo } from 'react';
+import { NoteListItem } from './note-list-item/note-list-item';
+import type { Note } from '@/lib/domain/note';
+import { useLayout } from '@/hooks/use-layout';
+import {
+ closestCenter,
+ DndContext,
+ type DragEndEvent,
+ PointerSensor,
+ useSensor,
+ useSensors,
+} from '@dnd-kit/core';
+import {
+ arrayMove,
+ SortableContext,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+import { useChartDB } from '@/hooks/use-chartdb.ts';
+
+export interface NotesListProps {
+ notes: Note[];
+}
+
+export const NotesList: React.FC = ({ notes }) => {
+ const { updateNote } = useChartDB();
+
+ const { openedNoteInSidebar } = useLayout();
+ const lastSelectedNote = React.useRef(null);
+ const refs = useMemo(
+ () =>
+ notes.reduce(
+ (acc, note) => {
+ acc[note.id] = React.createRef();
+ return acc;
+ },
+ {} as Record>
+ ),
+ [notes]
+ );
+
+ const scrollToNote = useCallback(
+ (id: string) =>
+ refs[id]?.current?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start',
+ }),
+ [refs]
+ );
+
+ const sensors = useSensors(useSensor(PointerSensor));
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+
+ if (active?.id !== over?.id && !!over && !!active) {
+ const oldIndex = notes.findIndex((note) => note.id === active.id);
+ const newIndex = notes.findIndex((note) => note.id === over.id);
+
+ const newNotesOrder = arrayMove(notes, oldIndex, newIndex);
+
+ newNotesOrder.forEach((note, index) => {
+ updateNote(note.id, { order: index });
+ });
+ }
+ };
+
+ const handleScrollToNote = useCallback(() => {
+ if (
+ openedNoteInSidebar &&
+ lastSelectedNote.current !== openedNoteInSidebar
+ ) {
+ lastSelectedNote.current = openedNoteInSidebar;
+ scrollToNote(openedNoteInSidebar);
+ }
+ }, [scrollToNote, openedNoteInSidebar]);
+
+ React.useEffect(() => {
+ handleScrollToNote();
+ }, [openedNoteInSidebar, handleScrollToNote]);
+
+ return (
+
+
+
+ {notes
+ .sort((note1: Note, note2: Note) => {
+ if (note1.order && note2.order === undefined) {
+ return -1;
+ }
+
+ if (note1.order === undefined && note2.order) {
+ return 1;
+ }
+
+ if (
+ note1.order !== undefined &&
+ note2.order !== undefined
+ ) {
+ return note1.order - note2.order;
+ }
+
+ // if both notes don't have order, sort by content
+ return note1.content.localeCompare(note2.content);
+ })
+ .map((note) => (
+
+ ))}
+
+
+
+ );
+};
diff --git a/src/pages/editor-page/side-panel/visuals-section/notes-tab/notes-tab.tsx b/src/pages/editor-page/side-panel/visuals-section/notes-tab/notes-tab.tsx
new file mode 100644
index 00000000..ac449632
--- /dev/null
+++ b/src/pages/editor-page/side-panel/visuals-section/notes-tab/notes-tab.tsx
@@ -0,0 +1,118 @@
+import React, { useCallback, useMemo } from 'react';
+import { Button } from '@/components/button/button';
+import { StickyNote, X } from 'lucide-react';
+import { Input } from '@/components/input/input';
+import type { Note } from '@/lib/domain/note';
+import { useChartDB } from '@/hooks/use-chartdb';
+import { useLayout } from '@/hooks/use-layout';
+import { EmptyState } from '@/components/empty-state/empty-state';
+import { ScrollArea } from '@/components/scroll-area/scroll-area';
+import { useTranslation } from 'react-i18next';
+import { useViewport } from '@xyflow/react';
+import { NotesList } from './notes-list/notes-list';
+
+export interface NotesTabProps {}
+
+export const NotesTab: React.FC = () => {
+ const { createNote, notes, readonly } = useChartDB();
+ const viewport = useViewport();
+ const { t } = useTranslation();
+ const { openNoteFromSidebar } = useLayout();
+ const [filterText, setFilterText] = React.useState('');
+ const filterInputRef = React.useRef(null);
+
+ const filteredNotes = useMemo(() => {
+ const filterNoteContent: (note: Note) => boolean = (note) =>
+ !filterText?.trim?.() ||
+ note.content.toLowerCase().includes(filterText.toLowerCase());
+
+ return notes.filter(filterNoteContent);
+ }, [notes, filterText]);
+
+ const createNoteWithLocation = useCallback(async () => {
+ const padding = 80;
+ const centerX = -viewport.x / viewport.zoom + padding / viewport.zoom;
+ const centerY = -viewport.y / viewport.zoom + padding / viewport.zoom;
+ const note = await createNote({
+ x: centerX,
+ y: centerY,
+ });
+ if (openNoteFromSidebar) {
+ openNoteFromSidebar(note.id);
+ }
+ }, [
+ createNote,
+ openNoteFromSidebar,
+ viewport.x,
+ viewport.y,
+ viewport.zoom,
+ ]);
+
+ const handleCreateNote = useCallback(async () => {
+ setFilterText('');
+ createNoteWithLocation();
+ }, [createNoteWithLocation, setFilterText]);
+
+ const handleClearFilter = useCallback(() => {
+ setFilterText('');
+ }, []);
+
+ return (
+
+
+
+ setFilterText(e.target.value)}
+ />
+
+ {!readonly ? (
+
+ ) : null}
+
+
+
+ {notes.length === 0 ? (
+
+ ) : filterText && filteredNotes.length === 0 ? (
+
+
+ {t('side_panel.notes_section.no_results')}
+
+
+
+ ) : (
+
+ )}
+
+
+
+ );
+};
diff --git a/src/pages/editor-page/side-panel/visuals-section/visuals-section.tsx b/src/pages/editor-page/side-panel/visuals-section/visuals-section.tsx
new file mode 100644
index 00000000..79a4c53e
--- /dev/null
+++ b/src/pages/editor-page/side-panel/visuals-section/visuals-section.tsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from '@/components/tabs/tabs';
+import { AreasTab } from './areas-tab/areas-tab';
+import { NotesTab } from './notes-tab/notes-tab';
+import { useTranslation } from 'react-i18next';
+import { useLayout } from '@/hooks/use-layout';
+import type { VisualsTab } from '@/context/layout-context/layout-context';
+import { Separator } from '@/components/separator/separator';
+import { Group, StickyNote } from 'lucide-react';
+
+export interface VisualsSectionProps {}
+
+export const VisualsSection: React.FC = () => {
+ const { t } = useTranslation();
+ const { selectedVisualsTab, selectVisualsTab } = useLayout();
+
+ return (
+
+ selectVisualsTab(value as VisualsTab)}
+ className="flex flex-1 flex-col overflow-hidden"
+ >
+
+
+
+
+ {t('side_panel.visuals_section.tabs.areas')}
+
+
+
+ {t('side_panel.visuals_section.tabs.notes')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};