From 0faafe0292adb0166e30249997cbd9ac40c4f89b Mon Sep 17 00:00:00 2001 From: bryzZz Date: Sat, 8 Jun 2024 20:17:32 +0800 Subject: [PATCH 1/3] add line height --- src/renderer/src/components/NoteEdit.tsx | 2 +- src/renderer/src/lib/drawable/Node/State.ts | 6 +- src/renderer/src/lib/drawable/Note.ts | 17 +-- src/renderer/src/lib/utils/text.ts | 109 +++++++++----------- 4 files changed, 63 insertions(+), 71 deletions(-) diff --git a/src/renderer/src/components/NoteEdit.tsx b/src/renderer/src/components/NoteEdit.tsx index bc69564d5..ff754699c 100644 --- a/src/renderer/src/components/NoteEdit.tsx +++ b/src/renderer/src/components/NoteEdit.tsx @@ -87,7 +87,7 @@ export const NoteEdit: React.FC = () => { tabIndex={-1} style={style} className={twMerge( - 'fixed overflow-hidden whitespace-pre-wrap border-none bg-bg-secondary text-base leading-none outline outline-1 outline-text-primary', + 'fixed overflow-hidden whitespace-pre-wrap border-none bg-bg-secondary text-base leading-[1.2] outline outline-1 outline-text-primary', !isOpen && 'hidden' )} placeholder="Придумайте заметку" diff --git a/src/renderer/src/lib/drawable/Node/State.ts b/src/renderer/src/lib/drawable/Node/State.ts index a955c3821..f0e90270e 100644 --- a/src/renderer/src/lib/drawable/Node/State.ts +++ b/src/renderer/src/lib/drawable/Node/State.ts @@ -133,7 +133,11 @@ export class State extends Shape { y: y + paddingY, textAlign: 'left', color: this.data.name !== '' ? style.titleColor : style.titleColorUndefined, - font: `${fontSize}px/1 'Fira Sans'`, + font: { + fontSize, + lineHeight: 1, + fontFamily: 'Fira Sans', + }, }); ctx.closePath(); diff --git a/src/renderer/src/lib/drawable/Note.ts b/src/renderer/src/lib/drawable/Note.ts index 5af24cd92..ef1441d1a 100644 --- a/src/renderer/src/lib/drawable/Note.ts +++ b/src/renderer/src/lib/drawable/Note.ts @@ -72,12 +72,12 @@ export class Note extends Shape { } 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), + ...prepareText(this.data.text || placeholder, 200 - 2 * 10, { + fontSize: 16, + lineHeight: 1.2, + fontFamily: 'Fira Sans', + }), hasText: Boolean(this.data.text), }; } @@ -88,7 +88,6 @@ export class Note extends Shape { const { x, y, width, height } = this.drawBounds; const textToDraw = this.textData.hasText ? this.textData.textArray : placeholder; const { padding, fontSize, color, borderRadius } = this.computedStyles; - const font = `${fontSize}px/1 'Fira Sans'`; ctx.fillStyle = 'black'; ctx.globalAlpha = 0.3; @@ -104,7 +103,11 @@ export class Note extends Shape { y: y + padding, textAlign: 'left', color, - font, + font: { + fontSize, + lineHeight: 1.2, + fontFamily: 'Fira Sans', + }, }); if (this.isSelected) { diff --git a/src/renderer/src/lib/utils/text.ts b/src/renderer/src/lib/utils/text.ts index 569b10432..49d47693b 100644 --- a/src/renderer/src/lib/utils/text.ts +++ b/src/renderer/src/lib/utils/text.ts @@ -1,23 +1,10 @@ -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 canvas = document.createElement('canvas'); +canvas.width = 1000; +canvas.height = 1000; +const measureCtx = canvas.getContext('2d') as CanvasRenderingContext2D; const textMap = new Map>(); -export const getTextWidth = (ctx: CanvasRenderingContext2D, text: string, font: string): number => { + +export const getTextWidth = (text: string, font: string): number => { if (textMap.has(font)) { const cache = textMap.get(font)!; const width = cache.get(text); @@ -29,29 +16,29 @@ export const getTextWidth = (ctx: CanvasRenderingContext2D, text: string, font: textMap.set(font, new Map()); const cache = textMap.get(font)!; - const previousTextBaseline = ctx.textBaseline; - const previousFont = ctx.font; + const previousTextBaseline = measureCtx.textBaseline; + const previousFont = measureCtx.font; - ctx.textBaseline = 'bottom'; - ctx.font = font; + measureCtx.textBaseline = 'bottom'; + measureCtx.font = font; - const width = ctx.measureText(text).width; + const width = measureCtx.measureText(text).width; - ctx.textBaseline = previousTextBaseline; - ctx.font = previousFont; + measureCtx.textBaseline = previousTextBaseline; + measureCtx.font = previousFont; cache.set(text, width); return width; }; // Вспомогательная функция для prepareText для разбивки слова на строки -const splitWord = (ctx: CanvasRenderingContext2D, word: string, font: string, maxWidth: number) => { +const splitWord = (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); + const charWidth = getTextWidth(char, font); if (Math.floor(newLineWidth + charWidth) <= maxWidth) { newLineWidth += charWidth; @@ -73,20 +60,32 @@ const splitWord = (ctx: CanvasRenderingContext2D, word: string, font: string, ma }; }; -export const prepareText = ( - ctx: CanvasRenderingContext2D, - text: string, - font: string, - maxWidth: number -) => { - const textHeight = getTextHeight(ctx, 'M', font); +interface Font { + fontSize?: number; + lineHeight?: number; + fontFamily?: string; +} + +interface DrawTextOptions { + x: number; + y: number; + font?: Font; + textAlign?: CanvasTextAlign; + color?: string; +} + +export const prepareText = (text: string, maxWidth: number, font?: Font) => { + const { fontSize = 16, lineHeight = 1.2, fontFamily = 'sans-serif' } = font || {}; + + const textHeight = lineHeight * fontSize; const textArray: string[] = []; const initialTextArray = text.split('\n'); + const fontString = `${fontSize}px/${lineHeight} '${fontFamily}'`; - const spaceWidth = getTextWidth(ctx, ' ', font); + const spaceWidth = getTextWidth(' ', fontString); for (const line of initialTextArray) { - const textWidth = getTextWidth(ctx, line, font); + const textWidth = getTextWidth(line, fontString); if (textWidth <= maxWidth) { textArray.push(line); @@ -98,13 +97,13 @@ export const prepareText = ( let newLineWidth = 0; for (const word of words) { - const wordWidth = getTextWidth(ctx, word, font); + const wordWidth = getTextWidth(word, fontString); // Развилка когда слово больще целой строки, приходится это слово разбивать if (wordWidth >= maxWidth) { textArray.push(newLine.join(' ')); - const splitted = splitWord(ctx, word, font, maxWidth); + const splitted = splitWord(word, fontString, maxWidth); textArray.push(...splitted.lines.map((line) => line.join(' '))); newLine = splitted.newLine; newLineWidth = splitted.newLineWidth; @@ -132,47 +131,33 @@ export const prepareText = ( 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 { x, y, color = '#FFF', font = {}, textAlign = 'left' } = options; + const { fontSize = 16, lineHeight = 1.2, fontFamily = 'sans-serif' } = font || {}; - const textHeight = getTextHeight(ctx, 'M', font); + const textHeight = lineHeight * fontSize; const prevFont = ctx.font; const prevFillStyle = ctx.fillStyle; const prevTextAlign = ctx.textAlign; const prevTextBaseline = ctx.textBaseline; - ctx.font = font; + ctx.font = `${fontSize}px/${lineHeight} '${fontFamily}'`; ctx.fillStyle = color; ctx.textAlign = textAlign; - ctx.textBaseline = textBaseline; + ctx.textBaseline = 'bottom'; if (!Array.isArray(text)) { - ctx.fillText(text, x, y + textHeight * 0.05); + ctx.fillText(text, x, y + textHeight); } else { for (let i = 0; i < text.length; i++) { - ctx.fillText(text[i], x, y + i * textHeight + textHeight * 0.05); + const lineY = y + i * textHeight + textHeight - textHeight * 0.05; + + ctx.fillText(text[i], x, lineY); } } From 8fe95e313e026abecadb285c4ebabb360240f30a Mon Sep 17 00:00:00 2001 From: bryzZz Date: Sun, 9 Jun 2024 16:19:27 +0800 Subject: [PATCH 2/3] add ui --- src/renderer/src/assets/icons/clear.svg | 1 + src/renderer/src/assets/icons/font_size.svg | 1 + .../src/components/ComponentFormFields.tsx | 1 + .../components/CreateModal/CreateModal.tsx | 2 +- .../src/components/DiagramContextMenu.tsx | 167 ------- .../DiagramContextMenu/ContextMenu.tsx | 66 +++ .../DiagramContextMenu/ContextMenuContext.ts | 13 + .../DiagramContextMenu/DiagramContextMenu.tsx | 429 ++++++++++++++++++ .../DiagramContextMenu/Menus/NoteMenu.tsx | 98 ++++ .../components/DiagramContextMenu/index.ts | 1 + src/renderer/src/components/NoteEdit.tsx | 1 + .../components/UI/ColorInput/ColorInput.tsx | 51 ++- src/renderer/src/hooks/useClickOutside.ts | 17 +- .../data/EditorController/NotesController.ts | 49 ++ .../src/lib/data/EditorModel/EditorModel.ts | 30 ++ src/renderer/src/lib/data/History.ts | 39 ++ src/renderer/src/lib/drawable/Note.ts | 21 +- src/renderer/src/lib/utils/text.ts | 2 +- src/renderer/src/theme.ts | 2 + src/renderer/src/types/diagram.ts | 3 + tailwind.config.js | 4 + 21 files changed, 809 insertions(+), 189 deletions(-) create mode 100644 src/renderer/src/assets/icons/clear.svg create mode 100644 src/renderer/src/assets/icons/font_size.svg delete mode 100644 src/renderer/src/components/DiagramContextMenu.tsx create mode 100644 src/renderer/src/components/DiagramContextMenu/ContextMenu.tsx create mode 100644 src/renderer/src/components/DiagramContextMenu/ContextMenuContext.ts create mode 100644 src/renderer/src/components/DiagramContextMenu/DiagramContextMenu.tsx create mode 100644 src/renderer/src/components/DiagramContextMenu/Menus/NoteMenu.tsx create mode 100644 src/renderer/src/components/DiagramContextMenu/index.ts diff --git a/src/renderer/src/assets/icons/clear.svg b/src/renderer/src/assets/icons/clear.svg new file mode 100644 index 000000000..98a25d94d --- /dev/null +++ b/src/renderer/src/assets/icons/clear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/renderer/src/assets/icons/font_size.svg b/src/renderer/src/assets/icons/font_size.svg new file mode 100644 index 000000000..b80a6bdce --- /dev/null +++ b/src/renderer/src/assets/icons/font_size.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/renderer/src/components/ComponentFormFields.tsx b/src/renderer/src/components/ComponentFormFields.tsx index fe2497c14..39ae469a8 100644 --- a/src/renderer/src/components/ComponentFormFields.tsx +++ b/src/renderer/src/components/ComponentFormFields.tsx @@ -89,6 +89,7 @@ export const ComponentFormFields: React.FC = ({ handleInputChange('labelColor', value)} /> diff --git a/src/renderer/src/components/CreateModal/CreateModal.tsx b/src/renderer/src/components/CreateModal/CreateModal.tsx index 1ca43fa48..95bda79d4 100644 --- a/src/renderer/src/components/CreateModal/CreateModal.tsx +++ b/src/renderer/src/components/CreateModal/CreateModal.tsx @@ -283,7 +283,7 @@ export const CreateModal: React.FC = ({
Цвет: - +
); diff --git a/src/renderer/src/components/DiagramContextMenu.tsx b/src/renderer/src/components/DiagramContextMenu.tsx deleted file mode 100644 index cfc182aed..000000000 --- a/src/renderer/src/components/DiagramContextMenu.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import React, { Fragment, useLayoutEffect, useState } from 'react'; - -import { useFloating, offset, flip, shift } from '@floating-ui/react'; -import { twMerge } from 'tailwind-merge'; - -import { ReactComponent as InitialIcon } from '@renderer/assets/icons/arrow_down_right.svg'; -import { ReactComponent as CameraIcon } from '@renderer/assets/icons/center_focus_2.svg'; -import { ReactComponent as ChoiceStateIcon } from '@renderer/assets/icons/choice_state.svg'; -import { ReactComponent as CodeAllIcon } from '@renderer/assets/icons/code_all_2.svg'; -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 FinalStateIcon } from '@renderer/assets/icons/final_state.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'; -import { useDiagramContextMenu } from '@renderer/hooks/useDiagramContextMenu'; -import { getVirtualElement } from '@renderer/utils'; - -const contextData = { - copy: { - icon: , - combination: 'Ctrl+C', - }, - paste: { - icon: , - combination: 'Ctrl+V', - }, - pasteState: { - icon: , - combination: undefined, - }, - pasteFinalState: { - icon: , - combination: undefined, - }, - pasteChoiceState: { - icon: , - combination: undefined, - }, - pasteEvent: { - icon: , - combination: undefined, - }, - initialState: { - icon: , - combination: undefined, - }, - showCodeAll: { - icon: , - combination: undefined, - }, - edit: { - icon: , - combination: undefined, - }, - centerCamera: { - icon: , - combination: undefined, - }, - delete: { - icon: , - combination: 'Del', - }, - source: { - icon: undefined, - combination: undefined, - }, - target: { - icon: undefined, - combination: undefined, - }, - note: { - icon: , - combination: undefined, - }, -}; - -export const DiagramContextMenu: React.FC = () => { - const { position, items, isOpen, onClose } = useDiagramContextMenu(); - //Проверка на открытие дополнительных окон, пока реализовал таким методом, чтобы проверить и распределить данные как следует - const [openMenu, setOpenMenu] = useState(''); - - const { refs, floatingStyles } = useFloating({ - placement: 'bottom', - middleware: [offset(), flip(), shift({ padding: 5 })], - }); - - useClickOutside(refs.floating.current, onClose, !isOpen); - - // TODO(bryzZz) Два эффекта просто на синхронизацию разных состояний, нужно переделать на один источник истины - useLayoutEffect(() => { - refs.setPositionReference(getVirtualElement(position)); - }, [position, refs]); - - useLayoutEffect(() => { - setOpenMenu(''); - }, [isOpen]); - - return ( -
- {items.map(({ label, type, isFolder, children, action }, i) => ( - - - {openMenu === type && isFolder && ( - //Крайняя мера, которую я не хотел добавлять сюда, я про стили и про дублирующий код -
- {children && - children.map(({ label, action }, i) => ( - - ))} -
- )} -
- ))} -
- ); -}; diff --git a/src/renderer/src/components/DiagramContextMenu/ContextMenu.tsx b/src/renderer/src/components/DiagramContextMenu/ContextMenu.tsx new file mode 100644 index 000000000..2e19eded1 --- /dev/null +++ b/src/renderer/src/components/DiagramContextMenu/ContextMenu.tsx @@ -0,0 +1,66 @@ +import React, { ComponentProps } from 'react'; + +import { twMerge } from 'tailwind-merge'; + +import { ContextMenuContext, useContextMenuContext } from './ContextMenuContext'; + +interface ContextMenuProps { + children: React.ReactNode; + onClose: () => void; +} + +export const ContextMenu: React.FC = ({ children, onClose }) => { + return ( + +
{children}
+
+ ); +}; + +interface MenuItemProps extends ComponentProps<'div'> { + closeable?: boolean; +} +export const MenuItem: React.FC = ({ + closeable = true, + className, + onClick, + ...props +}) => { + const { onClose } = useContextMenuContext(); + + return ( +
{ + onClick?.(e); + + if (closeable) onClose(); + }} + {...props} + /> + ); +}; + +type SubMenuContainerProps = ComponentProps<'div'>; +export const SubMenuContainer: React.FC = ({ className, ...props }) => { + return
; +}; + +interface SubMenuProps extends ComponentProps<'div'> { + position: 'left' | 'right'; +} +export const SubMenu: React.FC = ({ className, position, ...props }) => { + return ( +