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 };