diff --git a/demos/CyberiadaSchemas/CyberiadaFormat-Blinker.graphml b/demos/CyberiadaSchemas/CyberiadaFormat-Blinker.graphml
index d6cbf91ca..d05a28dc9 100644
--- a/demos/CyberiadaSchemas/CyberiadaFormat-Blinker.graphml
+++ b/demos/CyberiadaSchemas/CyberiadaFormat-Blinker.graphml
@@ -5,6 +5,7 @@
+
@@ -59,6 +60,11 @@ timer1.start(1000)
+
+
diff --git a/src/renderer/src/assets/icons/note.svg b/src/renderer/src/assets/icons/note.svg
new file mode 100644
index 000000000..9ec18afad
--- /dev/null
+++ b/src/renderer/src/assets/icons/note.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/src/renderer/src/components/DiagramContextMenu.tsx b/src/renderer/src/components/DiagramContextMenu.tsx
index e0ede1ee7..2b5d6c955 100644
--- a/src/renderer/src/components/DiagramContextMenu.tsx
+++ b/src/renderer/src/components/DiagramContextMenu.tsx
@@ -10,6 +10,7 @@ import { ReactComponent as CopyIcon } from '@renderer/assets/icons/copy.svg';
import { ReactComponent as DeleteIcon } from '@renderer/assets/icons/delete.svg';
import { ReactComponent as EditIcon } from '@renderer/assets/icons/edit.svg';
import { ReactComponent as EventIcon } from '@renderer/assets/icons/event_add.svg';
+import { ReactComponent as NoteIcon } from '@renderer/assets/icons/note.svg';
import { ReactComponent as PasteIcon } from '@renderer/assets/icons/paste.svg';
import { ReactComponent as StateIcon } from '@renderer/assets/icons/state_add.svg';
import { useClickOutside } from '@renderer/hooks/useClickOutside';
@@ -88,6 +89,10 @@ export const DiagramContextMenu: React.FC = (props) =>
icon: undefined,
combination: undefined,
},
+ note: {
+ icon: ,
+ combination: undefined,
+ },
};
return (
diff --git a/src/renderer/src/components/DiagramEditor.tsx b/src/renderer/src/components/DiagramEditor.tsx
index 9d0ad7d5a..feb8ab0ed 100644
--- a/src/renderer/src/components/DiagramEditor.tsx
+++ b/src/renderer/src/components/DiagramEditor.tsx
@@ -12,6 +12,7 @@ import { defaultTransColor } from '@renderer/utils';
import { CreateModal, CreateModalResult } from './CreateModal/CreateModal';
import { EventsModal, EventsModalData } from './EventsModal/EventsModal';
+import { NoteEdit } from './NoteEdit';
import { StateNameModal } from './StateNameModal';
export interface DiagramEditorProps {
@@ -43,8 +44,6 @@ export const DiagramEditor: React.FC = memo(
useEffect(() => {
if (!containerRef.current) return;
- console.log('init editor');
-
const editor = new CanvasEditor(containerRef.current, manager);
//Функция очистки всех данных
@@ -184,6 +183,7 @@ export const DiagramEditor: React.FC = memo(
+ {editor && }
{editor && (
= ({ editor }) => {
+ const [isOpen, open, close] = useModal(false);
+ const [note, setNote] = useState(null);
+ const [style, setStyle] = useState({} as CSSProperties);
+ const ref = useRef(null);
+
+ const handleSubmit = useCallback(() => {
+ const el = ref.current;
+ const value = (el?.textContent ?? '').trim();
+
+ if (!el || !note) return;
+
+ editor.container.machineController.changeNoteText(note.id, value);
+ }, [editor, note]);
+
+ const handleClose = useCallback(() => {
+ handleSubmit();
+ note?.setVisible(true);
+
+ close();
+ }, [close, handleSubmit, note]);
+
+ useEffect(() => {
+ window.addEventListener('wheel', handleClose);
+ return () => window.removeEventListener('wheel', handleClose);
+ }, [handleClose]);
+
+ useEffect(() => {
+ editor.container.notesController.on('change', (note) => {
+ const el = ref.current;
+ if (!el) return;
+
+ const globalOffset = editor.container.app.mouse.getOffset();
+ const statePos = note.computedPosition;
+ const position = {
+ x: statePos.x + globalOffset.x,
+ y: statePos.y + globalOffset.y,
+ };
+ const { width } = note.drawBounds;
+ const scale = editor.container.app.manager.data.scale;
+ const padding = 10 / scale;
+ const fontSize = 16 / scale;
+ const borderRadius = 6 / scale;
+
+ note.setVisible(false);
+
+ setNote(note);
+ setStyle({
+ left: position.x + 'px',
+ top: position.y + 'px',
+ width: width + 'px',
+ minHeight: fontSize + padding * 2 + 'px',
+ fontSize: fontSize + 'px',
+ padding: padding + 'px',
+ borderRadius: borderRadius + 'px',
+ });
+ el.textContent = note.data.text;
+ setTimeout(() => placeCaretAtEnd(el), 0); // А ты думал легко сфокусировать и установить картеку в конец?
+ open();
+ });
+ }, [editor, open]);
+
+ return (
+
+ );
+};
diff --git a/src/renderer/src/components/UI/TextAreaAutoResize/TextAreaAutoResize.tsx b/src/renderer/src/components/UI/TextAreaAutoResize/TextAreaAutoResize.tsx
new file mode 100644
index 000000000..077622218
--- /dev/null
+++ b/src/renderer/src/components/UI/TextAreaAutoResize/TextAreaAutoResize.tsx
@@ -0,0 +1,33 @@
+import { ComponentProps, forwardRef } from 'react';
+
+import './style.css';
+import { twMerge } from 'tailwind-merge';
+
+interface TextAreaAutoResizeProps extends ComponentProps<'span'> {
+ placeholder?: string;
+}
+
+/*
+ Поле ввода которое автоматически растягивается по высоте при вводе текста
+*/
+export const TextAreaAutoResize = forwardRef(
+ ({ className, placeholder, onKeyDown, ...props }, ref) => {
+ return (
+ {
+ if (event.key === 'Enter') {
+ document.execCommand('insertLineBreak');
+ event.preventDefault();
+ }
+ onKeyDown?.(event);
+ }}
+ {...props}
+ />
+ );
+ }
+);
diff --git a/src/renderer/src/components/UI/TextAreaAutoResize/index.ts b/src/renderer/src/components/UI/TextAreaAutoResize/index.ts
new file mode 100644
index 000000000..d4d0c6a8e
--- /dev/null
+++ b/src/renderer/src/components/UI/TextAreaAutoResize/index.ts
@@ -0,0 +1 @@
+export * from './TextAreaAutoResize';
diff --git a/src/renderer/src/components/UI/TextAreaAutoResize/style.css b/src/renderer/src/components/UI/TextAreaAutoResize/style.css
new file mode 100644
index 000000000..019f8d872
--- /dev/null
+++ b/src/renderer/src/components/UI/TextAreaAutoResize/style.css
@@ -0,0 +1,4 @@
+.textarea[contenteditable]:empty::before {
+ content: attr(data-placeholder);
+ @apply text-border-primary;
+}
diff --git a/src/renderer/src/components/UI/index.ts b/src/renderer/src/components/UI/index.ts
index 3a0100341..d3a32fc7e 100644
--- a/src/renderer/src/components/UI/index.ts
+++ b/src/renderer/src/components/UI/index.ts
@@ -5,5 +5,6 @@ export * from './WithHint';
export * from './Modal';
export * from './ColorInput';
export * from './TextField';
-export * from './Checkbox';
export * from './TextInput';
+export * from './Checkbox';
+export * from './TextAreaAutoResize';
diff --git a/src/renderer/src/hooks/useDiagramContextMenu.ts b/src/renderer/src/hooks/useDiagramContextMenu.ts
index 8cb61bd1a..63d7af9df 100644
--- a/src/renderer/src/hooks/useDiagramContextMenu.ts
+++ b/src/renderer/src/hooks/useDiagramContextMenu.ts
@@ -55,6 +55,19 @@ export const useDiagramContextMenu = (editor: CanvasEditor | null, manager: Edit
});
},
},
+ {
+ label: 'Вставить заметку',
+ type: 'note',
+ action: () => {
+ const note = editor?.container.machineController.createNote({
+ position: canvasPos,
+ placeInCenter: true,
+ text: '',
+ });
+
+ editor.container.notesController.emit('change', note);
+ },
+ },
{
label: 'Посмотреть код',
type: 'showCodeAll',
@@ -260,6 +273,25 @@ export const useDiagramContextMenu = (editor: CanvasEditor | null, manager: Edit
]);
}
);
+
+ editor.container.notesController.on('contextMenu', ({ note, position }) => {
+ handleEvent(position, [
+ {
+ label: 'Редактировать',
+ type: 'edit',
+ action: () => {
+ editor.container.notesController.emit('change', note);
+ },
+ },
+ {
+ label: 'Удалить',
+ type: 'delete',
+ action: () => {
+ editor?.container.machineController.deleteNote(note.id);
+ },
+ },
+ ]);
+ });
}, [editor]);
return { isOpen, onClose, items, position };
diff --git a/src/renderer/src/lib/basic/Container.ts b/src/renderer/src/lib/basic/Container.ts
index 7544ed992..19259883e 100644
--- a/src/renderer/src/lib/basic/Container.ts
+++ b/src/renderer/src/lib/basic/Container.ts
@@ -6,6 +6,7 @@ import { MyMouseEvent } from '@renderer/types/mouse';
import { CanvasEditor } from '../CanvasEditor';
import { EventEmitter } from '../common/EventEmitter';
import { MachineController } from '../data/MachineController';
+import { NotesController } from '../data/NotesController';
import { StatesController } from '../data/StatesController';
import { TransitionsController } from '../data/TransitionsController';
import { Children } from '../drawable/Children';
@@ -34,6 +35,7 @@ export class Container extends EventEmitter {
machineController!: MachineController;
statesController!: StatesController;
transitionsController!: TransitionsController;
+ notesController!: NotesController;
children: Children;
private mouseDownNode: Node | null = null; // Для оптимизации чтобы на каждый mousemove не искать
@@ -45,6 +47,7 @@ export class Container extends EventEmitter {
this.machineController = new MachineController(this);
this.statesController = new StatesController(this);
this.transitionsController = new TransitionsController(this);
+ this.notesController = new NotesController(this);
this.children = new Children(this.machineController);
// Порядок важен, система очень тонкая
diff --git a/src/renderer/src/lib/data/EditorManager/EditorManager.test.ts b/src/renderer/src/lib/data/EditorManager/EditorManager.test.ts
index 95a0d0090..fb42fe53a 100644
--- a/src/renderer/src/lib/data/EditorManager/EditorManager.test.ts
+++ b/src/renderer/src/lib/data/EditorManager/EditorManager.test.ts
@@ -5,7 +5,7 @@ import { EditorManager } from './EditorManager';
import { emptyElements } from '../../../types/diagram';
const em = new EditorManager();
-em.init('basename', 'name', { ...emptyElements(), transitions: [] });
+em.init('basename', 'name', { ...emptyElements(), transitions: [], notes: [] });
describe('states', () => {
describe('create', () => {
diff --git a/src/renderer/src/lib/data/EditorManager/EditorManager.ts b/src/renderer/src/lib/data/EditorManager/EditorManager.ts
index cf65fda12..08b2d84ae 100644
--- a/src/renderer/src/lib/data/EditorManager/EditorManager.ts
+++ b/src/renderer/src/lib/data/EditorManager/EditorManager.ts
@@ -22,6 +22,7 @@ import {
ChangeTransitionParameters,
ChangeStateEventsParams,
AddComponentParams,
+ CreateNoteParameters,
} from '@renderer/types/EditorManager';
import { Point, Rectangle } from '@renderer/types/graphics';
@@ -59,6 +60,11 @@ export class EditorManager {
transitions: elements.transitions.reduce((acc, cur, i) => {
acc[i] = cur;
+ return acc;
+ }, {}),
+ notes: elements.notes.reduce((acc, cur, i) => {
+ acc[i] = cur;
+
return acc;
}, {}),
};
@@ -561,4 +567,72 @@ export class EditorManager {
return true;
}
+
+ createNote(params: CreateNoteParameters) {
+ const { id, text, placeInCenter = false } = params;
+ let position = params.position;
+
+ const getNewId = () => {
+ const nanoid = customAlphabet('abcdefghijklmnopqstuvwxyz', 20);
+
+ let id = nanoid();
+ while (this.data.elements.notes.hasOwnProperty(id)) {
+ id = nanoid();
+ }
+
+ return id;
+ };
+
+ const centerPosition = () => {
+ return {
+ x: position.x - 200 / 2,
+ y: position.y - 36 / 2,
+ };
+ };
+
+ position = placeInCenter ? centerPosition() : position;
+
+ const newId = id ?? getNewId();
+
+ this.data.elements.notes[newId] = {
+ text,
+ position,
+ };
+
+ this.triggerDataUpdate('elements.notes');
+
+ return newId;
+ }
+
+ changeNoteText(id: string, text: string) {
+ if (!this.data.elements.notes.hasOwnProperty(id)) return false;
+
+ this.data.elements.notes[id].text = text;
+
+ this.triggerDataUpdate('elements.notes');
+
+ return true;
+ }
+
+ changeNotePosition(id: string, position: Point) {
+ const note = this.data.elements.notes[id];
+ if (!note) return false;
+
+ note.position = position;
+
+ this.triggerDataUpdate('elements.notes');
+
+ return true;
+ }
+
+ deleteNote(id: string) {
+ const note = this.data.elements.notes[id];
+ if (!note) return false;
+
+ delete this.data.elements.notes[id];
+
+ this.triggerDataUpdate('elements.notes');
+
+ return true;
+ }
}
diff --git a/src/renderer/src/lib/data/EditorManager/FilesManager.ts b/src/renderer/src/lib/data/EditorManager/FilesManager.ts
index 3b45bd3e4..1f8dfd3b6 100644
--- a/src/renderer/src/lib/data/EditorManager/FilesManager.ts
+++ b/src/renderer/src/lib/data/EditorManager/FilesManager.ts
@@ -39,6 +39,7 @@ export class FilesManager {
Compiler.compile(this.data.elements.platform, {
...this.data.elements,
transitions: Object.values(this.data.elements.transitions),
+ notes: Object.values(this.data.elements.notes),
});
}
diff --git a/src/renderer/src/lib/data/EditorManager/Serializer.ts b/src/renderer/src/lib/data/EditorManager/Serializer.ts
index 7e5360eae..eeb60cacd 100644
--- a/src/renderer/src/lib/data/EditorManager/Serializer.ts
+++ b/src/renderer/src/lib/data/EditorManager/Serializer.ts
@@ -15,15 +15,16 @@ export class Serializer {
switch (saveMode) {
case 'JSON':
return JSON.stringify(
- { ...this.data.elements, transitions: Object.values(this.data.elements.transitions) },
+ {
+ ...this.data.elements,
+ transitions: Object.values(this.data.elements.transitions),
+ notes: Object.values(this.data.elements.notes),
+ },
undefined,
2
);
case 'Cyberiada':
- return exportGraphml({
- ...this.data.elements,
- transitions: Object.values(this.data.elements.transitions),
- });
+ return exportGraphml(this.data.elements);
}
}
diff --git a/src/renderer/src/lib/data/GraphmlParser.ts b/src/renderer/src/lib/data/GraphmlParser.ts
index 2d70360d1..7833a9078 100644
--- a/src/renderer/src/lib/data/GraphmlParser.ts
+++ b/src/renderer/src/lib/data/GraphmlParser.ts
@@ -10,6 +10,8 @@ import {
Elements,
Condition,
Variable,
+ Note,
+ InnerElements,
} from '@renderer/types/diagram';
import { ArgumentProto, Platform } from '@renderer/types/platform';
@@ -80,9 +82,10 @@ interface DataNodeProcessArgs {
parentNode?: Node;
state?: State;
transition?: Transition;
+ note?: Note;
}
-const dataKeys = ['gFormat', 'dData', 'dName', 'dInitial', 'dGeometry', 'dColor'] as const;
+const dataKeys = ['gFormat', 'dData', 'dName', 'dInitial', 'dGeometry', 'dColor', 'dNote'] as const;
type DataKey = (typeof dataKeys)[number];
function isDataKey(key: string): key is DataKey {
@@ -175,6 +178,11 @@ const dataNodeProcess: DataNodeProcess = {
x: x,
y: y,
};
+ } else if (data.note !== undefined) {
+ data.note.position = {
+ x: x,
+ y: y,
+ };
} else {
throw new Error('Непредвиденный вызов функции dGeometry');
}
@@ -184,6 +192,13 @@ const dataNodeProcess: DataNodeProcess = {
data.transition.color = data.node.content;
}
},
+ dNote(data: DataNodeProcessArgs) {
+ if (data.note !== undefined) {
+ data.note.text = data.node.content;
+ } else {
+ throw new Error('Непредвиденный вызов функции dNote');
+ }
+ },
};
const randomColor = (): string => {
@@ -566,6 +581,16 @@ function createEmptyState(): State {
};
}
+function createEmptyNote(): Note {
+ return {
+ position: {
+ x: 0,
+ y: 0,
+ },
+ text: '',
+ };
+}
+
// Обработка нод
function processNode(
elements: Elements,
@@ -574,8 +599,12 @@ function processNode(
awailableDataProperties: Map>,
parent?: Node,
component?: OuterComponent
-): State {
- const state: State = createEmptyState();
+): State | Note {
+ // Если находим dNote среди дата-нод, то создаем пустую заметку, а состояние делаем undefined
+ const note: Note | undefined = node.data?.find((dataNode) => dataNode.key === 'dNote')
+ ? createEmptyNote()
+ : undefined;
+ const state: State | undefined = note == undefined ? createEmptyState() : undefined;
if (node.data !== undefined) {
for (const dataNode of node.data) {
if (awailableDataProperties.get('node')?.has(dataNode.key)) {
@@ -588,6 +617,7 @@ function processNode(
parentNode: node,
state: state,
component: component,
+ note: note,
});
}
} else {
@@ -596,7 +626,7 @@ function processNode(
}
}
- if (parent !== undefined) {
+ if (parent !== undefined && state !== undefined) {
state.parent = parent.id;
}
@@ -604,7 +634,13 @@ function processNode(
processGraph(elements, node.graph, meta, awailableDataProperties, node);
}
- return state;
+ if (state !== undefined) {
+ return state;
+ } else if (note !== undefined) {
+ return note;
+ } else {
+ throw new Error('Отсутствует состояние или заметка для данного узла!');
+ }
}
function emptyOuterComponent(): OuterComponent {
@@ -617,6 +653,10 @@ function emptyOuterComponent(): OuterComponent {
};
}
+function isState(value): value is State {
+ return (value as State).events !== undefined;
+}
+
function processGraph(
elements: Elements,
graph: Graph,
@@ -659,7 +699,19 @@ function processGraph(
for (const idx in graph.node) {
const node = graph.node[idx];
- elements.states[node.id] = processNode(elements, node, meta, awailableDataProperties, parent);
+ const processResult: State | Note = processNode(
+ elements,
+ node,
+ meta,
+ awailableDataProperties,
+ parent
+ );
+
+ if (isState(processResult)) {
+ elements.states[node.id] = processResult;
+ } else {
+ elements.notes.push(processResult);
+ }
}
if (graph.edge) {
@@ -735,6 +787,7 @@ export function importGraphml(
const elements: Elements = {
states: {},
transitions: [],
+ notes: [],
initialState: {
target: '',
position: { x: 0, y: 0 },
@@ -782,6 +835,7 @@ export function importGraphml(
return {
states: {},
transitions: [],
+ notes: [],
initialState: {
target: '',
position: { x: 0, y: 0 },
@@ -849,7 +903,7 @@ function getOperandString(operand: Variable | string | Condition[] | number) {
type Platforms = 'ArduinoUno' | 'BearlogaDefend';
type PlatformDataKeys = { [key in Platforms]: ExportKeyNode[] };
type ProcessDependPlatform = {
- [key in Platforms]: (elements: Elements, subplatform?: string) => CyberiadaXML;
+ [key in Platforms]: (elements: InnerElements, subplatform?: string) => CyberiadaXML;
};
const PlatformKeys: PlatformDataKeys = {
ArduinoUno: [
@@ -889,6 +943,10 @@ const PlatformKeys: PlatformDataKeys = {
'@id': 'dColor',
'@for': 'edge',
},
+ {
+ '@id': 'dNote',
+ '@for': 'node',
+ },
],
BearlogaDefend: [
{
@@ -923,6 +981,10 @@ const PlatformKeys: PlatformDataKeys = {
'@id': 'dGeometry',
'@for': 'node',
},
+ {
+ '@id': 'dNote',
+ '@for': 'node',
+ },
],
};
@@ -947,7 +1009,7 @@ type CyberiadaXML = {
// Но, думаю, в целом это правильное решение разделить обработку каждой платформы
// TODO: Разбить этот монолит на функции
const processDependPlatform: ProcessDependPlatform = {
- ArduinoUno(elements: Elements): CyberiadaXML {
+ ArduinoUno(elements: InnerElements): CyberiadaXML {
const keyNodes = PlatformKeys.ArduinoUno;
const description = 'name/ Схема\ndescription/ Схема, сгенерированная с помощью Lapki IDE\n';
const nodes: Map = new Map([
@@ -1078,6 +1140,25 @@ const processDependPlatform: ProcessDependPlatform = {
content: content,
});
+ for (const noteEntry of Object.entries(elements.notes)) {
+ const noteId = noteEntry[0];
+ const note = noteEntry[1];
+ nodes.set(noteId, {
+ '@id': noteId,
+ data: [
+ {
+ '@key': 'dNote',
+ content: note.text,
+ },
+ {
+ '@key': 'dGeometry',
+ '@x': note.position.x,
+ '@y': note.position.y,
+ content: '',
+ },
+ ],
+ });
+ }
if (state.parent !== undefined) {
const parent = nodes.get(state.parent);
if (parent !== undefined) {
@@ -1106,7 +1187,7 @@ const processDependPlatform: ProcessDependPlatform = {
}
}
- for (const transition of elements.transitions) {
+ for (const transition of Object.values(elements.transitions)) {
const edge: ExportEdge = {
'@source': transition.source,
'@target': transition.target,
@@ -1165,7 +1246,7 @@ const processDependPlatform: ProcessDependPlatform = {
};
},
- BearlogaDefend(elements: Elements, subplatform?: string): CyberiadaXML {
+ BearlogaDefend(elements: InnerElements, subplatform?: string): CyberiadaXML {
const keyNodes = PlatformKeys.BearlogaDefend;
let description = '';
if (subplatform !== undefined) {
@@ -1303,7 +1384,7 @@ const processDependPlatform: ProcessDependPlatform = {
}
}
- for (const transition of elements.transitions) {
+ for (const transition of Object.values(elements.transitions)) {
const edge: ExportEdge = {
'@source': transition.source,
'@target': transition.target,
@@ -1344,6 +1425,25 @@ const processDependPlatform: ProcessDependPlatform = {
graph.edge.push(edge);
}
+ for (const noteEntry of Object.entries(elements.notes)) {
+ const noteId = noteEntry[0];
+ const note = noteEntry[1];
+ nodes.set(noteId, {
+ '@id': noteId,
+ data: [
+ {
+ '@key': 'dNote',
+ content: note.text,
+ },
+ {
+ '@key': 'dGeometry',
+ '@x': note.position.x,
+ '@y': note.position.y,
+ content: '',
+ },
+ ],
+ });
+ }
graph.node.push(...nodes.values());
return {
'?xml': {
@@ -1363,7 +1463,7 @@ const processDependPlatform: ProcessDependPlatform = {
},
};
-export function exportGraphml(elements: Elements): string {
+export function exportGraphml(elements: InnerElements): string {
const builder = new XMLBuilder({
textNodeName: 'content',
ignoreAttributes: false,
@@ -1371,7 +1471,7 @@ export function exportGraphml(elements: Elements): string {
format: true,
});
let xml = {};
- if (elements.platform == 'ArduinoUno') {
+ if (elements.platform.startsWith('Arduino')) {
xml = processDependPlatform.ArduinoUno(elements);
} else if (elements.platform.startsWith('BearlogaDefend')) {
const subplatform = elements.platform.split('-')[1];
diff --git a/src/renderer/src/lib/data/MachineController/Initializer.ts b/src/renderer/src/lib/data/MachineController/Initializer.ts
index db58e616b..31eb98658 100644
--- a/src/renderer/src/lib/data/MachineController/Initializer.ts
+++ b/src/renderer/src/lib/data/MachineController/Initializer.ts
@@ -1,3 +1,4 @@
+import { Note } from '@renderer/lib/drawable/Note';
import { InitialState } from '@renderer/types/diagram';
import { MachineController } from './MachineController';
@@ -18,6 +19,7 @@ export class Initializer {
this.initStates();
this.initTransitions();
+ this.initNotes();
this.initPlatform();
this.initComponents();
@@ -33,6 +35,9 @@ export class Initializer {
private get transitions() {
return this.machineController.transitions;
}
+ private get notes() {
+ return this.machineController.notes;
+ }
private get platform() {
return this.machineController.platform;
}
@@ -90,6 +95,14 @@ export class Initializer {
}
}
+ private initNotes() {
+ const items = this.container.app.manager.data.elements.notes;
+
+ for (const id in items) {
+ this.createNoteView(id);
+ }
+ }
+
private initComponents() {
const items = this.container.app.manager.data.elements.components;
@@ -142,6 +155,13 @@ export class Initializer {
this.container.transitionsController.watchTransition(transition);
}
+ private createNoteView(id: string) {
+ const note = new Note(this.container, id);
+ this.notes.set(id, note);
+ this.container.children.add('note', note.id);
+ this.container.notesController.watch(note);
+ }
+
private createInitialStateView(data: InitialState) {
const target = this.states.get(data.target);
if (!target) return;
diff --git a/src/renderer/src/lib/data/MachineController/MachineController.ts b/src/renderer/src/lib/data/MachineController/MachineController.ts
index f6814a4c8..12a0a117b 100644
--- a/src/renderer/src/lib/data/MachineController/MachineController.ts
+++ b/src/renderer/src/lib/data/MachineController/MachineController.ts
@@ -1,3 +1,4 @@
+import { Note } from '@renderer/lib/drawable/Note';
import {
Action,
Condition,
@@ -11,6 +12,7 @@ import {
AddComponentParams,
ChangeStateEventsParams,
ChangeTransitionParameters,
+ CreateNoteParameters,
CreateStateParameters,
} from '@renderer/types/EditorManager';
import { Point } from '@renderer/types/graphics';
@@ -52,6 +54,7 @@ export class MachineController {
states: Map = new Map();
transitions: Map = new Map();
+ notes: Map = new Map();
platform!: PlatformManager;
@@ -980,4 +983,77 @@ export class MachineController {
}
return vacant;
}
+
+ createNote(params: CreateNoteParameters, canUndo = true) {
+ const newNoteId = this.container.app.manager.createNote(params);
+ const note = new Note(this.container, newNoteId);
+
+ this.notes.set(newNoteId, note);
+ this.container.notesController.watch(note);
+ this.container.children.add('note', newNoteId);
+
+ this.container.isDirty = true;
+
+ if (canUndo) {
+ this.undoRedo.do({
+ type: 'createNote',
+ args: { id: newNoteId, params },
+ });
+ }
+
+ return note;
+ }
+
+ changeNoteText = (id: string, text: string, canUndo = true) => {
+ const note = this.notes.get(id);
+ if (!note) return;
+
+ if (canUndo) {
+ this.undoRedo.do({
+ type: 'changeNoteText',
+ args: { id, text, prevText: note.data.text },
+ });
+ }
+
+ this.container.app.manager.changeNoteText(id, text);
+ note.prepareText();
+
+ this.container.isDirty = true;
+ };
+
+ changeNotePosition(id: string, startPosition: Point, endPosition: Point, canUndo = true) {
+ const note = this.notes.get(id);
+ if (!note) return;
+
+ if (canUndo) {
+ this.undoRedo.do({
+ type: 'changeNotePosition',
+ args: { id, startPosition, endPosition },
+ });
+ }
+
+ this.container.app.manager.changeNotePosition(id, endPosition);
+
+ this.container.isDirty = true;
+ }
+
+ deleteNote(id: string, canUndo = true) {
+ const note = this.notes.get(id);
+ if (!note) return;
+
+ if (canUndo) {
+ this.undoRedo.do({
+ type: 'deleteNote',
+ args: { id, prevData: structuredClone(note.data) },
+ });
+ }
+
+ this.container.app.manager.deleteNote(id);
+
+ this.container.children.remove('note', id);
+ this.container.notesController.unwatch(note);
+ this.notes.delete(id);
+
+ this.container.isDirty = true;
+ }
}
diff --git a/src/renderer/src/lib/data/NotesController.ts b/src/renderer/src/lib/data/NotesController.ts
new file mode 100644
index 000000000..75f2390b0
--- /dev/null
+++ b/src/renderer/src/lib/data/NotesController.ts
@@ -0,0 +1,45 @@
+import { Point } from '@renderer/types/graphics';
+import { MyMouseEvent } from '@renderer/types/mouse';
+
+import { Container } from '../basic/Container';
+import { EventEmitter } from '../common/EventEmitter';
+import { Note } from '../drawable/Note';
+
+interface NotesControllerEvents {
+ change: Note;
+ contextMenu: { note: Note; position: Point };
+}
+
+export class NotesController extends EventEmitter {
+ constructor(public container: Container) {
+ super();
+ }
+
+ handleDoubleClick = (note: Note) => {
+ this.emit('change', note);
+ };
+
+ handleContextMenu = (note: Note, e: { event: MyMouseEvent }) => {
+ this.emit('contextMenu', { note, position: { x: e.event.x, y: e.event.y } });
+ };
+
+ handleDragEnd = (note: Note, e: { dragStartPosition: Point; dragEndPosition: Point }) => {
+ this.container.machineController.changeNotePosition(
+ note.id,
+ e.dragStartPosition,
+ e.dragEndPosition
+ );
+ };
+
+ watch(note: Note) {
+ note.on('dblclick', this.handleDoubleClick.bind(this, note));
+ note.on('contextmenu', this.handleContextMenu.bind(this, note));
+ note.on('dragend', this.handleDragEnd.bind(this, note));
+ }
+
+ unwatch(note: Note) {
+ note.off('dblclick', this.handleDoubleClick.bind(this, note));
+ note.off('contextmenu', this.handleContextMenu.bind(this, note));
+ note.off('dragend', this.handleDragEnd.bind(this, note));
+ }
+}
diff --git a/src/renderer/src/lib/data/UndoRedo.ts b/src/renderer/src/lib/data/UndoRedo.ts
index 00e730617..367f507b1 100644
--- a/src/renderer/src/lib/data/UndoRedo.ts
+++ b/src/renderer/src/lib/data/UndoRedo.ts
@@ -1,18 +1,20 @@
import { useSyncExternalStore } from 'react';
import {
+ State as StateData,
+ Transition as TransitionData,
+ Note as NoteData,
Action as EventAction,
Event,
Component,
- Transition as TransitionData,
EventData,
- State as StateData,
InitialState,
} from '@renderer/types/diagram';
import {
AddComponentParams,
ChangeStateEventsParams,
ChangeTransitionParameters,
+ CreateNoteParameters,
CreateStateParameters,
} from '@renderer/types/EditorManager';
import { Point } from '@renderer/types/graphics';
@@ -60,6 +62,11 @@ export type PossibleActions = {
addComponent: { args: AddComponentParams };
removeComponent: { args: RemoveComponentParams; prevComponent: Component };
editComponent: { args: EditComponentParams; prevComponent: Component };
+
+ createNote: { id: string; params: CreateNoteParameters };
+ changeNotePosition: { id: string; startPosition: Point; endPosition: Point };
+ changeNoteText: { id: string; text: string; prevText: string };
+ deleteNote: { id: string; prevData: NoteData };
};
export type PossibleActionTypes = keyof PossibleActions;
export type Action = {
@@ -220,6 +227,23 @@ export const actionFunctions: ActionFunctions = {
false
),
}),
+
+ createNote: (sM, { id, params }) => ({
+ redo: sM.createNote.bind(sM, { id, ...params }, false),
+ undo: sM.deleteNote.bind(sM, id, false),
+ }),
+ changeNoteText: (sM, { id, text, prevText }) => ({
+ redo: sM.changeNoteText.bind(sM, id, text, false),
+ undo: sM.changeNoteText.bind(sM, id, prevText, false),
+ }),
+ changeNotePosition: (sM, { id, startPosition, endPosition }) => ({
+ redo: sM.changeNotePosition.bind(sM, id, startPosition, endPosition, false),
+ undo: sM.changeNotePosition.bind(sM, id, endPosition, startPosition, false),
+ }),
+ deleteNote: (sM, { id, prevData }) => ({
+ redo: sM.deleteNote.bind(sM, id, false),
+ undo: sM.createNote.bind(sM, { id, ...prevData }, false),
+ }),
};
export const actionDescriptions: ActionDescriptions = {
@@ -321,6 +345,22 @@ export const actionDescriptions: ActionDescriptions = {
description: `Было: ${JSON.stringify(prev)}\nСтало: ${JSON.stringify(newComp)}`,
};
},
+
+ createNote: (args) => ({ name: 'Создание заметки', description: `Id: ${args.id}` }),
+ changeNoteText: (args) => ({
+ name: 'Изменение текста заметки',
+ description: `ID: ${args.id}\nБыло: "${args.prevText}"\nСтало: "${args.text}"`,
+ }),
+ changeNotePosition: (args) => ({
+ name: 'Перемещение заметки',
+ description: `Id: "${args.id}"\nБыло: ${JSON.stringify(
+ args.startPosition
+ )}\nСтало: ${JSON.stringify(args.endPosition)}`,
+ }),
+ deleteNote: (args) => ({
+ name: 'Удаление заметки',
+ description: `ID: ${args.id} Текст: ${args.prevData.text}`,
+ }),
};
export const STACK_SIZE_LIMIT = 100;
diff --git a/src/renderer/src/lib/drawable/Children.ts b/src/renderer/src/lib/drawable/Children.ts
index 1549215a7..ad4ca58a9 100644
--- a/src/renderer/src/lib/drawable/Children.ts
+++ b/src/renderer/src/lib/drawable/Children.ts
@@ -1,9 +1,11 @@
+import { Node } from './Node';
import { State } from './State';
import { Transition } from './Transition';
import { MachineController } from '../data/MachineController';
-type CbListItem = State | Transition;
+type ListType = 'state' | 'transition' | 'note';
+
/**
* Пока что это странный класс предназначенный только для отрисовки,
* у {@link Container} и {@link Node} объявляется этот класс и рендер идёт по дереву
@@ -12,22 +14,31 @@ type CbListItem = State | Transition;
export class Children {
private statesList = [] as string[];
private transitionsList = [] as string[];
+ private notesList = [] as string[];
constructor(public stateMachine: MachineController) {}
- forEach(cb: (item: CbListItem) => void) {
+ private getList(type: ListType) {
+ if (type === 'state') {
+ return this.statesList;
+ }
+ if (type === 'transition') {
+ return this.transitionsList;
+ }
+ return this.notesList;
+ }
+
+ forEach(cb: (item: Node) => void) {
this.statesList.forEach((id) => {
- cb(this.stateMachine.states.get(id) as State);
+ cb(this.stateMachine.states.get(id) as Node);
});
this.transitionsList.forEach((id) => {
- cb(this.stateMachine.transitions.get(id) as Transition);
+ cb(this.stateMachine.transitions.get(id) as Node);
});
- }
- forEachState(cb: (item: State) => void) {
- this.statesList.forEach((id) => {
- cb(this.stateMachine.states.get(id) as State);
+ this.notesList.forEach((id) => {
+ cb(this.stateMachine.notes.get(id) as Node);
});
}
@@ -42,6 +53,7 @@ export class Children {
clear() {
this.statesList.length = 0;
this.transitionsList.length = 0;
+ this.notesList.length = 0;
}
// Для того чтобы можно было перебрать экземпляр класса с помощью for of
@@ -73,17 +85,14 @@ export class Children {
};
}
- add(type: 'state' | 'transition', id: string) {
- if (type === 'state') {
- this.statesList.push(id);
- }
- if (type === 'transition') {
- this.transitionsList.push(id);
- }
+ add(type: ListType, id: string) {
+ const list = this.getList(type);
+
+ list.push(id);
}
- remove(type: 'state' | 'transition', id: string) {
- const list = type === 'state' ? this.statesList : this.transitionsList;
+ remove(type: ListType, id: string) {
+ const list = this.getList(type);
const index = list.findIndex((item) => id === item);
if (index !== -1) {
@@ -97,20 +106,28 @@ export class Children {
return this.stateMachine.states.get(id);
}
- getByIndex(index: number) {
+ getByIndex(index: number): Node | undefined {
if (index < this.statesList.length) {
const id = this.statesList[index];
- return this.stateMachine.states.get(id) as State;
+ return this.stateMachine.states.get(id);
+ }
+
+ index -= this.statesList.length;
+
+ if (index < this.transitionsList.length) {
+ const id = this.transitionsList[index];
+
+ return this.stateMachine.transitions.get(id);
}
- const id = this.transitionsList[index - this.statesList.length];
+ const id = this.notesList[index - this.transitionsList.length];
- return this.stateMachine.transitions.get(id) as Transition;
+ return this.stateMachine.notes.get(id);
}
- moveToEnd(type: 'state' | 'transition', id: string) {
- const list = type === 'state' ? this.statesList : this.transitionsList;
+ moveToEnd(type: ListType, id: string) {
+ const list = this.getList(type);
const index = list.findIndex((item) => id === item);
@@ -120,7 +137,7 @@ export class Children {
}
get size() {
- return this.statesList.length + this.transitionsList.length;
+ return this.statesList.length + this.transitionsList.length + this.notesList.length;
}
get isEmpty() {
diff --git a/src/renderer/src/lib/drawable/InitialStateMark.ts b/src/renderer/src/lib/drawable/InitialStateMark.ts
index 771ac6dbb..672b5f81a 100644
--- a/src/renderer/src/lib/drawable/InitialStateMark.ts
+++ b/src/renderer/src/lib/drawable/InitialStateMark.ts
@@ -4,14 +4,8 @@ import { Node } from './Node';
import { Container } from '../basic/Container';
import { transitionStyle } from '../styles';
-import {
- degrees_to_radians,
- drawCircle,
- drawCurvedLine,
- drawText,
- drawTriangle,
- getLine,
-} from '../utils';
+import { degrees_to_radians, drawCircle, drawCurvedLine, drawTriangle, getLine } from '../utils';
+import { drawText } from '../utils/text';
/**
* Класс для отрисовки начального состояния
@@ -44,8 +38,8 @@ export class InitialStateMark extends Node {
if (!this.target) return;
const { x, y, width, height } = this.drawBounds;
+ const fontSize = 24 / this.container.app.manager.data.scale;
- ctx.lineWidth = 2;
ctx.fillStyle = getColor('primary');
ctx.beginPath();
@@ -55,11 +49,10 @@ export class InitialStateMark extends Node {
drawText(ctx, 'Начало', {
x: x + width / 2,
y: y + height / 2,
+ font: `bold ${fontSize}px/1 "Fira Sans"`,
color: '#FFF',
- align: 'center',
- baseline: 'middle',
- fontWeight: 'bold',
- fontSize: 24 / this.container.app.manager.data.scale,
+ textAlign: 'center',
+ textBaseline: 'middle',
});
const line = getLine(this.target.drawBounds, this.drawBounds, 10, 3, 3);
diff --git a/src/renderer/src/lib/drawable/Node.ts b/src/renderer/src/lib/drawable/Node.ts
index 053cf65c9..52bbbdca3 100644
--- a/src/renderer/src/lib/drawable/Node.ts
+++ b/src/renderer/src/lib/drawable/Node.ts
@@ -61,6 +61,7 @@ export abstract class Node extends EventEmitter {
abstract get bounds(): Rectangle;
abstract set bounds(bounds: Rectangle);
+ abstract draw(ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement): unknown;
// Позиция рассчитанная с возможным родителем
get compoundPosition() {
diff --git a/src/renderer/src/lib/drawable/Note.ts b/src/renderer/src/lib/drawable/Note.ts
new file mode 100644
index 000000000..911c85237
--- /dev/null
+++ b/src/renderer/src/lib/drawable/Note.ts
@@ -0,0 +1,83 @@
+import { getColor } from '@renderer/theme';
+
+import { Node } from './Node';
+
+import { Container } from '../basic/Container';
+import { drawText, prepareText } from '../utils/text';
+
+const placeholder = 'Придумайте заметку';
+
+export class Note extends Node {
+ private textData = {
+ height: 100,
+ textArray: [] as string[],
+ hasText: false,
+ };
+ private visible = true;
+
+ constructor(container: Container, id: string, parent?: Node) {
+ super(container, id, parent);
+
+ this.prepareText();
+ }
+
+ get data() {
+ return this.container.app.manager.data.elements.notes[this.id];
+ }
+
+ get bounds() {
+ return { ...this.data.position, width: 200, height: 10 * 2 + this.textData.height };
+ }
+
+ set bounds(value) {
+ this.data.position.x = value.x;
+ this.data.position.y = value.y;
+ }
+
+ setVisible(value: boolean) {
+ this.visible = value;
+ this.container.isDirty = true;
+ }
+
+ prepareText() {
+ const canvas = document.createElement('canvas');
+ canvas.width = 200;
+ canvas.height = 9999;
+ const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
+ this.textData = {
+ ...prepareText(ctx, this.data.text || placeholder, '16px/1 "Fira Sans"', 200 - 2 * 10),
+ hasText: Boolean(this.data.text),
+ };
+ }
+
+ draw(ctx: CanvasRenderingContext2D, _canvas: HTMLCanvasElement) {
+ if (!this.visible) return;
+
+ const { x, y, width, height } = this.drawBounds;
+ const textToDraw = this.textData.hasText ? this.textData.textArray : placeholder;
+ const scale = this.container.app.manager.data.scale;
+ const padding = 10 / scale;
+ const fontSize = 16 / scale;
+ const font = `${fontSize}px/1 'Fira Sans'`;
+ const color = this.textData.hasText ? getColor('text-primary') : getColor('border-primary');
+
+ ctx.fillStyle = 'black';
+ ctx.globalAlpha = 0.3;
+
+ ctx.beginPath();
+ ctx.roundRect(x, y, width, height, 6 / scale);
+ ctx.fill();
+
+ ctx.globalAlpha = 1;
+
+ drawText(ctx, textToDraw, {
+ x: x + padding,
+ y: y + padding,
+ textAlign: 'left',
+ color,
+ font,
+ });
+
+ ctx.closePath();
+ }
+}
diff --git a/src/renderer/src/lib/drawable/State.ts b/src/renderer/src/lib/drawable/State.ts
index ba667fdd8..7661a97e7 100644
--- a/src/renderer/src/lib/drawable/State.ts
+++ b/src/renderer/src/lib/drawable/State.ts
@@ -6,6 +6,7 @@ import { Node } from './Node';
import { icons } from './Picto';
import { Container } from '../basic/Container';
+import { drawText } from '../utils/text';
const style = theme.colors.diagram.state;
@@ -113,9 +114,6 @@ export class State extends Node {
const { height, width, fontSize, paddingX, paddingY } = this.computedTitleSizes;
- ctx.font = `${fontSize}px/0 Fira Sans`;
- ctx.textBaseline = 'hanging';
-
ctx.beginPath();
ctx.fillStyle = style.titleBg;
@@ -128,12 +126,13 @@ export class State extends Node {
]);
ctx.fill();
- ctx.fillStyle = this.data.name !== '' ? style.titleColor : style.titleColorUndefined;
- ctx.fillText(
- this.data.name !== '' ? this.data.name : 'Без названия',
- x + paddingX,
- y + paddingY
- );
+ drawText(ctx, this.data.name || 'Без названия', {
+ x: x + paddingX,
+ y: y + paddingY,
+ textAlign: 'left',
+ color: this.data.name !== '' ? style.titleColor : style.titleColorUndefined,
+ font: `${fontSize}px/1 'Fira Sans'`,
+ });
ctx.closePath();
}
diff --git a/src/renderer/src/lib/utils.ts b/src/renderer/src/lib/utils/index.ts
similarity index 89%
rename from src/renderer/src/lib/utils.ts
rename to src/renderer/src/lib/utils/index.ts
index fd9d294da..c27b1ec0e 100644
--- a/src/renderer/src/lib/utils.ts
+++ b/src/renderer/src/lib/utils/index.ts
@@ -300,48 +300,6 @@ export const drawImageFit = (
);
};
-interface DrawTextOptions {
- x: number;
- y: number;
- color?: string;
- align?: CanvasTextAlign;
- baseline?: CanvasTextBaseline;
- fontWeight?: 'normal' | 'medium' | 'semibold' | 'bold';
- fontSize?: number;
- fontFamily?: string;
-}
-
-export const drawText = (ctx: CanvasRenderingContext2D, text: string, options: DrawTextOptions) => {
- const {
- x,
- y,
- color = '#FFF',
- align = 'left',
- baseline = 'bottom',
- fontWeight = 'normal',
- fontSize = 16,
- fontFamily = 'Fira Sans',
- } = options;
-
- // Как я понял это луший вариант для перфоманса чем ctx.save() и ctx.restore()
- const prevTextAlign = ctx.textAlign;
- const prevTextBaseline = ctx.textBaseline;
- const prevFont = ctx.font;
- const prevFillStyle = ctx.fillStyle;
-
- ctx.textAlign = align;
- ctx.textBaseline = baseline;
- ctx.font = `${fontWeight} ${fontSize}px "${fontFamily}"`;
- ctx.fillStyle = color;
-
- ctx.fillText(text, x, y);
-
- ctx.textAlign = prevTextAlign;
- ctx.textBaseline = prevTextBaseline;
- ctx.font = prevFont;
- ctx.fillStyle = prevFillStyle;
-};
-
export const drawCurvedLine = (
ctx: CanvasRenderingContext2D,
line: TransitionLine,
diff --git a/src/renderer/src/lib/utils/text.ts b/src/renderer/src/lib/utils/text.ts
new file mode 100644
index 000000000..569b10432
--- /dev/null
+++ b/src/renderer/src/lib/utils/text.ts
@@ -0,0 +1,183 @@
+export const getTextHeight = (
+ ctx: CanvasRenderingContext2D,
+ text: string,
+ font: string
+): number => {
+ const previousTextBaseline = ctx.textBaseline;
+ const previousFont = ctx.font;
+
+ ctx.textBaseline = 'bottom';
+ ctx.font = font;
+ const { actualBoundingBoxAscent: height } = ctx.measureText(text);
+
+ ctx.textBaseline = previousTextBaseline;
+ ctx.font = previousFont;
+
+ return Math.ceil(height);
+};
+
+const textMap = new Map>();
+export const getTextWidth = (ctx: CanvasRenderingContext2D, text: string, font: string): number => {
+ if (textMap.has(font)) {
+ const cache = textMap.get(font)!;
+ const width = cache.get(text);
+ if (width !== undefined) {
+ return width;
+ }
+ }
+
+ textMap.set(font, new Map());
+ const cache = textMap.get(font)!;
+
+ const previousTextBaseline = ctx.textBaseline;
+ const previousFont = ctx.font;
+
+ ctx.textBaseline = 'bottom';
+ ctx.font = font;
+
+ const width = ctx.measureText(text).width;
+
+ ctx.textBaseline = previousTextBaseline;
+ ctx.font = previousFont;
+
+ cache.set(text, width);
+ return width;
+};
+
+// Вспомогательная функция для prepareText для разбивки слова на строки
+const splitWord = (ctx: CanvasRenderingContext2D, word: string, font: string, maxWidth: number) => {
+ const lines: string[][] = [];
+ const newLine: string[] = [];
+ let newLineWidth = 0;
+
+ for (const char of word) {
+ const charWidth = getTextWidth(ctx, char, font);
+
+ if (Math.floor(newLineWidth + charWidth) <= maxWidth) {
+ newLineWidth += charWidth;
+ newLine.push(char);
+ continue;
+ }
+
+ lines.push([newLine.join('')]);
+ newLine.length = 0;
+
+ newLineWidth = charWidth;
+ newLine.push(char);
+ }
+
+ return {
+ lines,
+ newLine: [newLine.join('')],
+ newLineWidth,
+ };
+};
+
+export const prepareText = (
+ ctx: CanvasRenderingContext2D,
+ text: string,
+ font: string,
+ maxWidth: number
+) => {
+ const textHeight = getTextHeight(ctx, 'M', font);
+ const textArray: string[] = [];
+ const initialTextArray = text.split('\n');
+
+ const spaceWidth = getTextWidth(ctx, ' ', font);
+
+ for (const line of initialTextArray) {
+ const textWidth = getTextWidth(ctx, line, font);
+
+ if (textWidth <= maxWidth) {
+ textArray.push(line);
+ continue;
+ }
+
+ const words = line.split(' ');
+ let newLine: string[] = [];
+ let newLineWidth = 0;
+
+ for (const word of words) {
+ const wordWidth = getTextWidth(ctx, word, font);
+
+ // Развилка когда слово больще целой строки, приходится это слово разбивать
+ if (wordWidth >= maxWidth) {
+ textArray.push(newLine.join(' '));
+
+ const splitted = splitWord(ctx, word, font, maxWidth);
+ textArray.push(...splitted.lines.map((line) => line.join(' ')));
+ newLine = splitted.newLine;
+ newLineWidth = splitted.newLineWidth;
+ continue;
+ }
+
+ if (Math.floor(newLineWidth + spaceWidth + wordWidth) <= maxWidth) {
+ newLineWidth += spaceWidth + wordWidth;
+ newLine.push(word);
+ continue;
+ }
+
+ textArray.push(newLine.join(' '));
+ newLine.length = 0;
+
+ newLineWidth = wordWidth;
+ newLine.push(word);
+ }
+
+ if (newLine.length > 0) {
+ textArray.push(newLine.join(' '));
+ }
+ }
+
+ return { height: textArray.length * textHeight, textArray };
+};
+
+interface DrawTextOptions {
+ x: number;
+ y: number;
+ textBaseline?: CanvasTextBaseline;
+ textAlign?: CanvasTextAlign;
+ font?: string;
+ color?: string;
+}
+
+// TODO Весь текст через эту функцию
+export const drawText = (
+ ctx: CanvasRenderingContext2D,
+ text: string | string[],
+ options: DrawTextOptions
+) => {
+ const {
+ x,
+ y,
+ color = '#FFF',
+ font = ctx.font,
+ textAlign = 'left',
+ textBaseline = 'top',
+ } = options;
+
+ const textHeight = getTextHeight(ctx, 'M', font);
+
+ const prevFont = ctx.font;
+ const prevFillStyle = ctx.fillStyle;
+ const prevTextAlign = ctx.textAlign;
+ const prevTextBaseline = ctx.textBaseline;
+
+ ctx.font = font;
+ ctx.fillStyle = color;
+ ctx.textAlign = textAlign;
+ ctx.textBaseline = textBaseline;
+
+ if (!Array.isArray(text)) {
+ ctx.fillText(text, x, y + textHeight * 0.05);
+ } else {
+ for (let i = 0; i < text.length; i++) {
+ ctx.fillText(text[i], x, y + i * textHeight + textHeight * 0.05);
+ }
+ }
+
+ ctx.font = prevFont;
+ ctx.fillStyle = prevFillStyle;
+ ctx.textAlign = prevTextAlign;
+ ctx.textBaseline = prevTextBaseline;
+};
diff --git a/src/renderer/src/types/EditorManager.ts b/src/renderer/src/types/EditorManager.ts
index 98ede825e..a5542a25a 100644
--- a/src/renderer/src/types/EditorManager.ts
+++ b/src/renderer/src/types/EditorManager.ts
@@ -51,6 +51,13 @@ export interface CreateTransitionParameters {
condition: Condition | undefined;
}
+export interface CreateNoteParameters {
+ id?: string;
+ position: Point;
+ text: string;
+ placeInCenter?: boolean;
+}
+
export interface ChangeTransitionParameters {
id: string;
source: string;
diff --git a/src/renderer/src/types/diagram.ts b/src/renderer/src/types/diagram.ts
index 1f55f5d05..0a39c0c23 100644
--- a/src/renderer/src/types/diagram.ts
+++ b/src/renderer/src/types/diagram.ts
@@ -70,11 +70,17 @@ export type Component = {
parameters: { [key: string]: string };
};
+export type Note = {
+ position: Point;
+ text: string;
+};
+
// Это описание типа схемы которая хранится в json файле
export type Elements = {
states: { [id: string]: State };
transitions: Transition[];
components: { [id: string]: Component };
+ notes: Note[];
initialState: InitialState | null;
@@ -84,8 +90,9 @@ export type Elements = {
};
// Данные внутри редактора хранятся немного по-другому и это их описание
-export interface InnerElements extends Omit {
+export interface InnerElements extends Omit {
transitions: Record;
+ notes: Record;
}
export function emptyElements(): InnerElements {
@@ -93,6 +100,7 @@ export function emptyElements(): InnerElements {
states: {},
transitions: {},
components: {},
+ notes: {},
initialState: null,
platform: '',
diff --git a/src/renderer/src/utils/index.ts b/src/renderer/src/utils/index.ts
index dabf5539c..d13ef351c 100644
--- a/src/renderer/src/utils/index.ts
+++ b/src/renderer/src/utils/index.ts
@@ -84,6 +84,16 @@ export const defaultTransColor = '#0000FF';
// пресеты цветов
export const presetColors = ['#119da4', '#591f0a', '#f26419', '#1f487e', '#4b296b'];
+export const placeCaretAtEnd = (el: HTMLElement) => {
+ el.focus();
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ range.collapse(false);
+ const sel = window.getSelection();
+ sel?.removeAllRanges();
+ sel?.addRange(range);
+};
+
export const escapeRegExp = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
};