diff --git a/apps/suika-multiplayer/components.json b/apps/suika-multiplayer/components.json new file mode 100644 index 000000000..2b0833f09 --- /dev/null +++ b/apps/suika-multiplayer/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/apps/suika-multiplayer/package.json b/apps/suika-multiplayer/package.json index 29c846ef7..d3db7f26e 100644 --- a/apps/suika-multiplayer/package.json +++ b/apps/suika-multiplayer/package.json @@ -1,7 +1,7 @@ { "name": "@suika/suika-multiplayer", "version": "0.0.1", - "description": "a graphics editor.", + "description": "a graphics editor with real-time collaboration.", "private": true, "type": "module", "scripts": { @@ -13,15 +13,24 @@ }, "dependencies": { "@floating-ui/react": "^0.22.3", - "@hocuspocus/provider": "^2.13.5", + "@hocuspocus/provider": "^2.15.3", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tooltip": "^1.2.8", "@suika/common": "workspace:^", "@suika/components": "workspace:^", "@suika/core": "workspace:^", "@suika/geo": "workspace:^", "@suika/icons": "workspace:^", + "@tailwindcss/vite": "^4.1.15", "ahooks": "^3.7.4", "axios": "^1.7.3", + "class-variance-authority": "^0.7.1", "classnames": "^2.3.2", + "clsx": "^2.1.1", + "lucide-react": "^0.546.0", "react": "^18.2.0", "react-color": "^2.19.3", "react-dom": "^18.2.0", @@ -29,8 +38,10 @@ "react-scripts": "5.0.1", "sass": "^1.57.1", "stats.js": "^0.17.0", - "y-websocket": "^2.0.3", - "yjs": "^13.6.17" + "tailwind-merge": "^3.3.1", + "tailwindcss": "^4.1.15", + "y-websocket": "^2.1.0", + "yjs": "^13.6.29" }, "devDependencies": { "@types/react": "^18.2.25", @@ -42,6 +53,7 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", "sass": "^1.70.0", + "tw-animate-css": "^1.4.0", "vite": "^5.0.8", "vite-plugin-checker": "^0.6.4" } diff --git a/apps/suika-multiplayer/public/font_files/SourceHanSansCN-Regular.otf b/apps/suika-multiplayer/public/font_files/SourceHanSansCN-Regular.otf new file mode 100644 index 000000000..c13789be2 Binary files /dev/null and b/apps/suika-multiplayer/public/font_files/SourceHanSansCN-Regular.otf differ diff --git a/apps/suika-multiplayer/public/font_files/smiley-sans-oblique.otf b/apps/suika-multiplayer/public/font_files/smiley-sans-oblique.otf new file mode 100644 index 000000000..38a136541 Binary files /dev/null and b/apps/suika-multiplayer/public/font_files/smiley-sans-oblique.otf differ diff --git a/apps/suika-multiplayer/src/api-service/api-service.ts b/apps/suika-multiplayer/src/api-service/api-service.ts index 1ed5f668c..82e9a41ee 100644 --- a/apps/suika-multiplayer/src/api-service/api-service.ts +++ b/apps/suika-multiplayer/src/api-service/api-service.ts @@ -3,7 +3,7 @@ import './api-config'; import axios from 'axios'; interface FileItem { - id: number; + id: string; title: string; createdAt: string; updatedAt: string; @@ -23,7 +23,7 @@ interface LoginRes { interface UserProfileRes { username: string; - id: number; + id: string; } export const ApiService = { @@ -51,11 +51,11 @@ export const ApiService = { }); return res.data; }, - getFile: async (id: number) => { + getFile: async (id: string) => { const res = await axios.get>(`files/${id}`); return res.data; }, - deleteFiles: async (ids: number[]) => { + deleteFiles: async (ids: string[]) => { const res = await axios.delete>('files', { params: { ids, @@ -63,7 +63,7 @@ export const ApiService = { }); return res.data; }, - updateFile: async (id: number, data: Partial) => { + updateFile: async (id: string, data: Partial) => { const res = await axios.patch>(`files/${id}`, { data, }); diff --git a/apps/suika-multiplayer/src/components/Cards/AlignCard.tsx b/apps/suika-multiplayer/src/components/Cards/AlignCard.tsx new file mode 100644 index 000000000..0a927ddc0 --- /dev/null +++ b/apps/suika-multiplayer/src/components/Cards/AlignCard.tsx @@ -0,0 +1,158 @@ +import { isWindows } from '@suika/common'; +import { + alignAndRecord, + AlignType, + type SuikaEditor, + type SuikaGraphics, +} from '@suika/core'; +import { + AlignHCenter, + AlignLeft, + AlignRight, + AlignTop, + AlignVCenter, + IconAlignBottom, +} from '@suika/icons'; +import classNames from 'classnames'; +import { type FC, useContext, useEffect, useState } from 'react'; +import { useIntl } from 'react-intl'; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +import { EditorContext } from '../../context'; +import { type MessageIds } from '../../locale'; +import { BaseCard } from './BaseCard'; + +interface AlignItemProps { + icon: JSX.Element; + alignType: AlignType; + intlId: MessageIds; + editor: SuikaEditor | null; + disabled: boolean; + hotkey?: string; +} + +const AlignItem: FC = ({ + icon, + alignType, + intlId, + editor, + disabled, + hotkey, +}) => { + const intl = useIntl(); + + return ( + + +
{ + if (editor && !disabled) { + alignAndRecord(editor, alignType); + } + }} + > + {icon} +
+
+ + {intl.formatMessage({ id: intlId })} + {hotkey && {hotkey}} + +
+ ); +}; + +export const AlignCard: FC = () => { + const editor = useContext(EditorContext); + const [disabled, setDisable] = useState(true); + + useEffect(() => { + if (editor) { + const selectedEls = editor.selectedElements.getItems(); + setDisable(selectedEls.length < 2); + + const handler = (items: SuikaGraphics[]) => { + setDisable(items.length < 2); + }; + + editor.selectedElements.on('itemsChange', handler); + return () => { + editor.selectedElements.off('itemsChange', handler); + }; + } + }, [editor]); + + const getHotkey = (key: string) => { + return isWindows() ? `Alt ${key}` : `⌥${key}`; + }; + + return ( + +
+ } + alignType={AlignType.Left} + intlId="align.left" + editor={editor} + disabled={disabled} + hotkey={getHotkey('A')} + /> + } + alignType={AlignType.HCenter} + intlId="align.horizontalCenter" + editor={editor} + disabled={disabled} + hotkey={getHotkey('H')} + /> + } + alignType={AlignType.Right} + intlId="align.right" + editor={editor} + disabled={disabled} + hotkey={getHotkey('D')} + /> + } + alignType={AlignType.Top} + intlId="align.top" + editor={editor} + disabled={disabled} + hotkey={getHotkey('W')} + /> + } + alignType={AlignType.VCenter} + intlId="align.verticalCenter" + editor={editor} + disabled={disabled} + hotkey={getHotkey('V')} + /> + } + alignType={AlignType.Bottom} + intlId="align.bottom" + editor={editor} + disabled={disabled} + hotkey={getHotkey('S')} + /> +
+
+ ); +}; diff --git a/apps/suika-multiplayer/src/components/Cards/AlignCard/AlignCard.scss b/apps/suika-multiplayer/src/components/Cards/AlignCard/AlignCard.scss deleted file mode 100644 index e7aac735c..000000000 --- a/apps/suika-multiplayer/src/components/Cards/AlignCard/AlignCard.scss +++ /dev/null @@ -1,25 +0,0 @@ -.suika { - .align-list { - display: flex; - padding: 0 8px; - .align-item { - display: flex; - justify-content: center; - align-items: center; - - margin: 0 1px; - border-radius: 3px; - width: 32px; - height: 32px; - color: #333; - &:hover { - background-color: #f2f2f2; - } - } - - &.disabled { - pointer-events: none; - opacity: 0.3; - } - } -} diff --git a/apps/suika-multiplayer/src/components/Cards/AlignCard/AlignCard.tsx b/apps/suika-multiplayer/src/components/Cards/AlignCard/AlignCard.tsx deleted file mode 100644 index fa2316470..000000000 --- a/apps/suika-multiplayer/src/components/Cards/AlignCard/AlignCard.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import './AlignCard.scss'; - -import { alignAndRecord, AlignType, type SuikaGraphics } from '@suika/core'; -import { - AlignHCenter, - AlignLeft, - AlignRight, - AlignTop, - AlignVCenter, - IconAlignBottom, -} from '@suika/icons'; -import classNames from 'classnames'; -import { type FC, useContext, useEffect, useState } from 'react'; - -import { EditorContext } from '../../../context'; -import { BaseCard } from '../BaseCard'; - -export const AlignCard: FC = () => { - const editor = useContext(EditorContext); - const [disabled, setDisable] = useState(true); - - useEffect(() => { - if (editor) { - const selectedEls = editor.selectedElements.getItems(); - setDisable(selectedEls.length < 2); - - const handler = (items: SuikaGraphics[]) => { - setDisable(items.length < 2); - }; - - editor.selectedElements.on('itemsChange', handler); - return () => { - editor.selectedElements.off('itemsChange', handler); - }; - } - }, [editor]); - - return ( - -
-
{ - editor && alignAndRecord(editor, AlignType.Left); - }} - > - -
-
{ - editor && alignAndRecord(editor, AlignType.HCenter); - }} - > - -
-
{ - editor && alignAndRecord(editor, AlignType.Right); - }} - > - -
-
{ - editor && alignAndRecord(editor, AlignType.Top); - }} - > - -
-
{ - editor && alignAndRecord(editor, AlignType.VCenter); - }} - > - -
-
{ - editor && alignAndRecord(editor, AlignType.Bottom); - }} - > - -
-
-
- ); -}; diff --git a/apps/suika-multiplayer/src/components/Cards/AlignCard/index.ts b/apps/suika-multiplayer/src/components/Cards/AlignCard/index.ts deleted file mode 100644 index 8fe4d812b..000000000 --- a/apps/suika-multiplayer/src/components/Cards/AlignCard/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './AlignCard'; diff --git a/apps/suika-multiplayer/src/components/Cards/BaseCard/index.tsx b/apps/suika-multiplayer/src/components/Cards/BaseCard.tsx similarity index 69% rename from apps/suika-multiplayer/src/components/Cards/BaseCard/index.tsx rename to apps/suika-multiplayer/src/components/Cards/BaseCard.tsx index 7575e9824..b99dfd837 100644 --- a/apps/suika-multiplayer/src/components/Cards/BaseCard/index.tsx +++ b/apps/suika-multiplayer/src/components/Cards/BaseCard.tsx @@ -1,5 +1,3 @@ -import './style.scss'; - import React, { type FC } from 'react'; interface IBaseCardProps { @@ -14,9 +12,9 @@ export const BaseCard: FC = ({ headerAction, }) => { return ( -
+
{title && ( -
+
{title} {headerAction}
diff --git a/apps/suika-multiplayer/src/components/Cards/BaseCard/style.scss b/apps/suika-multiplayer/src/components/Cards/BaseCard/style.scss deleted file mode 100644 index 67b769e8b..000000000 --- a/apps/suika-multiplayer/src/components/Cards/BaseCard/style.scss +++ /dev/null @@ -1,20 +0,0 @@ -.suika { - .info-card { - padding-top: 8px; - padding-bottom: 8px; - border-bottom: 1px solid #e2e2e2; - .info-card-title { - display: flex; - justify-content: space-between; - align-items: center; - - padding: 0 8px 0 16px; - height: 32px; - line-height: 32px; - - color: #333; - font-weight: bold; - font-size: 12px; - } - } -} diff --git a/apps/suika-multiplayer/src/components/Cards/ElementsInfoCard/ElementsInfoCard.tsx b/apps/suika-multiplayer/src/components/Cards/ElementsInfoCard.tsx similarity index 85% rename from apps/suika-multiplayer/src/components/Cards/ElementsInfoCard/ElementsInfoCard.tsx rename to apps/suika-multiplayer/src/components/Cards/ElementsInfoCard.tsx index 74c289978..a4aa9a1d5 100644 --- a/apps/suika-multiplayer/src/components/Cards/ElementsInfoCard/ElementsInfoCard.tsx +++ b/apps/suika-multiplayer/src/components/Cards/ElementsInfoCard.tsx @@ -1,24 +1,22 @@ -import './style.scss'; - import { remainDecimal } from '@suika/common'; import { MutateGraphsAndRecord } from '@suika/core'; import { deg2Rad, normalizeRadian } from '@suika/geo'; import { type FC, useContext, useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; -import { EditorContext } from '../../../context'; -import NumberInput from '../../input/NumberInput'; -import { PercentInput } from '../../input/PercentInput'; -import { BaseCard } from '../BaseCard'; +import { EditorContext } from '../../context'; +import NumberInput from '../input/NumberInput'; +import { PercentInput } from '../input/PercentInput'; +import { BaseCard } from './BaseCard'; /** * 因为运算中会丢失精度 * 如果两个数距离非常非常小,我们认为它相等 */ -const isEqual = (a: number | string, b: number) => { - if (typeof a === 'string') return false; - return Math.abs(a - b) < 0.00000001; -}; +// const isEqual = (a: number | string, b: number) => { +// if (typeof a === 'string') return false; +// return Math.abs(a - b) < 0.00000001; +// }; interface IAttr { label: string; @@ -43,6 +41,10 @@ export const ElementsInfoCards: FC = () => { for (const el of items) { const attrs = el.getInfoPanelAttrs(); for (const attr of attrs) { + // filter typography attributes + if (attr.key === 'fontSize' || attr.key === 'fontFamily') { + continue; + } if (attr.uiType === 'number') { const precision = 2; attr.value = remainDecimal(attr.value as number, precision); @@ -137,7 +139,7 @@ export const ElementsInfoCards: FC = () => { return ( -
+
{attrs.slice(0, 2).map((item) => ( { /> ))}
-
+
{attrs.slice(2, 4).map((item) => ( { /> ))}
-
+
{attrs.slice(4, 6).map((item) => ( { ))}
{attrs.length > 6 && ( -
+
{attrs.slice(6, 8).map((item) => ( {props.label}} + prefix={ + + {props.label} + + } value={props.value} min={props.min} max={props.max} @@ -205,7 +211,11 @@ const NumAttrInput: FC<{ } else { return ( {props.label}} + prefix={ + + {props.label} + + } value={props.value} min={props.min} max={props.max} diff --git a/apps/suika-multiplayer/src/components/Cards/ElementsInfoCard/index.tsx b/apps/suika-multiplayer/src/components/Cards/ElementsInfoCard/index.tsx deleted file mode 100644 index 6844e6c18..000000000 --- a/apps/suika-multiplayer/src/components/Cards/ElementsInfoCard/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './ElementsInfoCard'; diff --git a/apps/suika-multiplayer/src/components/Cards/ElementsInfoCard/style.scss b/apps/suika-multiplayer/src/components/Cards/ElementsInfoCard/style.scss deleted file mode 100644 index 06886905d..000000000 --- a/apps/suika-multiplayer/src/components/Cards/ElementsInfoCard/style.scss +++ /dev/null @@ -1,15 +0,0 @@ -.suika { - .element-info-attrs-row { - display: flex; - margin-left: 8px; - - .suika-info-attrs-label { - display: block; - height: 28px; - width: 28px; - line-height: 28px; - text-align: center; - color: #b3b3b3; - } - } -} diff --git a/apps/suika-multiplayer/src/components/Cards/FillCard/FillCard.tsx b/apps/suika-multiplayer/src/components/Cards/FillCard.tsx similarity index 98% rename from apps/suika-multiplayer/src/components/Cards/FillCard/FillCard.tsx rename to apps/suika-multiplayer/src/components/Cards/FillCard.tsx index 710332d4a..5b05cec05 100644 --- a/apps/suika-multiplayer/src/components/Cards/FillCard/FillCard.tsx +++ b/apps/suika-multiplayer/src/components/Cards/FillCard.tsx @@ -7,8 +7,8 @@ import { import { type FC, useContext, useEffect, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; -import { EditorContext } from '../../../context'; -import { PaintCard } from '../PaintCard'; +import { EditorContext } from '../../context'; +import { PaintCard } from './PaintCard'; export const FillCard: FC = () => { const editor = useContext(EditorContext); diff --git a/apps/suika-multiplayer/src/components/Cards/FillCard/index.ts b/apps/suika-multiplayer/src/components/Cards/FillCard/index.ts deleted file mode 100644 index 2f1820358..000000000 --- a/apps/suika-multiplayer/src/components/Cards/FillCard/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './FillCard'; diff --git a/apps/suika-multiplayer/src/components/Cards/LayerInfoCard/LayerInfoCard.tsx b/apps/suika-multiplayer/src/components/Cards/LayerInfoCard.tsx similarity index 88% rename from apps/suika-multiplayer/src/components/Cards/LayerInfoCard/LayerInfoCard.tsx rename to apps/suika-multiplayer/src/components/Cards/LayerInfoCard.tsx index cf3d1bdf4..b54ceb781 100644 --- a/apps/suika-multiplayer/src/components/Cards/LayerInfoCard/LayerInfoCard.tsx +++ b/apps/suika-multiplayer/src/components/Cards/LayerInfoCard.tsx @@ -1,12 +1,10 @@ -import './LayerInfoCard.scss'; - import { Transaction } from '@suika/core'; import { type FC, useContext, useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; -import { EditorContext } from '../../../context'; -import { PercentInput } from '../../input/PercentInput'; -import { BaseCard } from '../BaseCard'; +import { EditorContext } from '../../context'; +import { PercentInput } from '../input/PercentInput'; +import { BaseCard } from './BaseCard'; export const LayerInfoCard: FC = () => { const [opacity, setOpacity] = useState(1); @@ -58,7 +56,7 @@ export const LayerInfoCard: FC = () => { return ( -
+
{ const editor = useContext(EditorContext); @@ -222,16 +222,10 @@ export const StrokeCard: FC = () => { title={intl.formatMessage({ id: 'stroke' })} paints={strokes} appendedContent={ -
+
+
} diff --git a/apps/suika-multiplayer/src/components/Cards/StrokeCard/index.ts b/apps/suika-multiplayer/src/components/Cards/StrokeCard/index.ts deleted file mode 100644 index 51fe50116..000000000 --- a/apps/suika-multiplayer/src/components/Cards/StrokeCard/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './StrokeCard'; diff --git a/apps/suika-multiplayer/src/components/Cards/TypographyCard.tsx b/apps/suika-multiplayer/src/components/Cards/TypographyCard.tsx new file mode 100644 index 000000000..50f0e09fe --- /dev/null +++ b/apps/suika-multiplayer/src/components/Cards/TypographyCard.tsx @@ -0,0 +1,237 @@ +import { + type ILetterSpacing, + type ILineHeight, + MutateGraphsAndRecord, + SuikaText, +} from '@suika/core'; +import { useContext, useEffect, useState } from 'react'; +import { useIntl } from 'react-intl'; + +import { LetterSpacingInput } from '@/components/input/LetterSpacingInput'; +import { LineHeightInput } from '@/components/input/LineHeightInput'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { FONT_FILES } from '@/constant'; + +import { EditorContext } from '../../context'; +import { FontSizeInput } from '../input/FontSizeInput'; +import { BaseCard } from './BaseCard'; + +export const TypographyCard = () => { + const editor = useContext(EditorContext); + const intl = useIntl(); + const MIXED = intl.formatMessage({ id: 'mixed' }); + + const [fontSize, setFontSize] = useState(12); + const [fontFamily, setFontFamily] = useState('Smiley Sans'); + + const [hasTextSelected, setHasTextSelected] = useState(false); + + const [letterSpacingUnit, setLetterSpacingUnit] = useState({ + value: 0, + units: 'PERCENT', + }); + const [isLetterSpacingMixed, setIsLetterSpacingMixed] = useState(false); + + const [lineHeightUnit, setLineHeightUnit] = useState({ + value: 1, + units: 'RAW', + }); + const [isLineHeightMixed, setIsLineHeightMixed] = useState(false); + + useEffect(() => { + if (!editor) return; + + const updateFontInfo = () => { + const items = editor.selectedElements.getItems(); + + let _fontSize: number | string | undefined; + let _fontFamily: string | undefined; + let _hasTextSelected = false; + for (const item of items) { + if (item instanceof SuikaText) { + _hasTextSelected = true; + const fontSize = item.attrs.fontSize; + if (_fontSize === undefined) { + _fontSize = fontSize; + } else if (_fontSize !== fontSize) { + _fontSize = MIXED; + break; + } + } + } + + for (const item of items) { + if (item instanceof SuikaText) { + const fontFamily = item.attrs.fontFamily; + if (_fontFamily === undefined) { + _fontFamily = fontFamily; + } else if (_fontFamily !== fontFamily) { + _fontFamily = MIXED; + break; + } + } + } + + let _letterSpacing: ILetterSpacing | undefined; + let _isLetterSpacingMixed = false; + for (const item of items) { + if (item instanceof SuikaText) { + const letterSpacing = item.attrs.letterSpacing; + if (_letterSpacing === undefined) { + _letterSpacing = letterSpacing; + } else if ( + _letterSpacing.value !== letterSpacing.value || + _letterSpacing.units !== letterSpacing.units + ) { + _isLetterSpacingMixed = true; + _letterSpacing = { value: 0, units: 'PERCENT' }; + break; + } + } + } + + let _lineHeight: ILineHeight | undefined; + let _isLineHeightMixed = false; + for (const item of items) { + if (item instanceof SuikaText) { + const lineHeight = item.attrs.lineHeight; + if (_lineHeight === undefined) { + _lineHeight = lineHeight; + } else if ( + _lineHeight.value !== lineHeight.value || + _lineHeight.units !== lineHeight.units + ) { + _isLineHeightMixed = true; + _lineHeight = { value: 1, units: 'RAW' }; + break; + } + } + } + + setFontSize(_fontSize as number); + setFontFamily(_fontFamily as string); + setHasTextSelected(_hasTextSelected); + setLetterSpacingUnit(_letterSpacing ?? { value: 0, units: 'PERCENT' }); + setIsLetterSpacingMixed(_isLetterSpacingMixed); + setLineHeightUnit(_lineHeight ?? { value: 1, units: 'RAW' }); + setIsLineHeightMixed(_isLineHeightMixed); + }; + + updateFontInfo(); // init + + editor.sceneGraph.on('render', updateFontInfo); + }, [editor, MIXED]); + + const execUpdateFontSizeCommand = (value: number) => { + if (!editor) return; + const items = editor.selectedElements.getItems(); + MutateGraphsAndRecord.setFontSize(editor, items, value); + editor.render(); + }; + + const execUpdateFontFamilyCommand = (value: string) => { + if (!editor) return; + const items = editor.selectedElements.getItems(); + MutateGraphsAndRecord.setFontFamily(editor, items, value); + editor.render(); + }; + + const execUpdateLetterSpacingCommand = (value: ILetterSpacing) => { + if (!editor) return; + const items = editor.selectedElements.getItems(); + MutateGraphsAndRecord.setLetterSpacing(editor, items, value); + editor.render(); + }; + + const execUpdateLineHeightCommand = (value: ILineHeight) => { + if (!editor) return; + const items = editor.selectedElements.getItems(); + MutateGraphsAndRecord.setLineHeight(editor, items, value); + editor.render(); + }; + + if (!hasTextSelected) { + return null; + } + + return ( + +
+
+ +
+ +
+
+ {intl.formatMessage({ id: 'fontSize' })} +
+ execUpdateFontSizeCommand(fontSize + 1)} + onDecrement={() => { + if (fontSize - 1 < 1) return; + execUpdateFontSizeCommand(fontSize - 1); + }} + onChange={execUpdateFontSizeCommand} + /> +
+
+
+
+ {intl.formatMessage({ id: 'lineHeight' })} +
+ { + execUpdateLineHeightCommand({ value, units: unit }); + }} + /> +
+
+
+ {intl.formatMessage({ id: 'letterSpacing' })} +
+ { + execUpdateLetterSpacingCommand({ value, units: unit }); + }} + /> +
+
+
+
+ ); +}; diff --git a/apps/suika-multiplayer/src/components/ColorPicker/SolidPicker/SolidPicker.tsx b/apps/suika-multiplayer/src/components/ColorPicker/SolidPicker.tsx similarity index 100% rename from apps/suika-multiplayer/src/components/ColorPicker/SolidPicker/SolidPicker.tsx rename to apps/suika-multiplayer/src/components/ColorPicker/SolidPicker.tsx diff --git a/apps/suika-multiplayer/src/components/ColorPicker/SolidPicker/index.ts b/apps/suika-multiplayer/src/components/ColorPicker/SolidPicker/index.ts deleted file mode 100644 index 9f371e8ca..000000000 --- a/apps/suika-multiplayer/src/components/ColorPicker/SolidPicker/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './SolidPicker'; diff --git a/apps/suika-multiplayer/src/components/ContextMenu/ContextMenu.scss b/apps/suika-multiplayer/src/components/ContextMenu/ContextMenu.scss deleted file mode 100644 index e834b7ab7..000000000 --- a/apps/suika-multiplayer/src/components/ContextMenu/ContextMenu.scss +++ /dev/null @@ -1,22 +0,0 @@ -.suika { - .suika-context-menu-mask { - position: fixed; - left: 0; - right: 0; - top: 0; - bottom: 0; - z-index: 20; - } - - .suika-context-menu { - position: fixed; - border-radius: 2px; - padding: 8px 0; - width: 200px; - z-index: 62; - - box-shadow: 0px 5px 10px rgba(0,0,0,0.15); - - background-color: #fff; - } -} \ No newline at end of file diff --git a/apps/suika-multiplayer/src/components/ContextMenu/ContextMenu.tsx b/apps/suika-multiplayer/src/components/ContextMenu/ContextMenu.tsx index a31ee8c7d..d1ca97a63 100644 --- a/apps/suika-multiplayer/src/components/ContextMenu/ContextMenu.tsx +++ b/apps/suika-multiplayer/src/components/ContextMenu/ContextMenu.tsx @@ -1,5 +1,3 @@ -import './ContextMenu.scss'; - import { isWindows } from '@suika/common'; import { arrangeAndRecord, @@ -340,7 +338,7 @@ export const ContextMenu: FC = () => {
e.preventDefault()}> {visible && (
{ setVisible(false); }} @@ -348,7 +346,7 @@ export const ContextMenu: FC = () => { )}
void; + pos: IPoint; + disabledDelete: boolean; + style?: React.CSSProperties; + onDelete: () => void; +}; + +export const PageContextMenu: FC = ({ + visible, + setVisible, + pos, + disabledDelete, + style, + onDelete, +}) => { + const [menuSize, setMenuSize] = useState({ width: 0, height: 0 }); + const menuRef = useRef(null); + + // avoid the right-click menu goes off the screen + const calculateMenuPosition = useCallback(() => { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let left = pos.x + OFFSET_X; + let top = pos.y + OFFSET_Y; + + if (left + menuSize.width > viewportWidth) { + left = pos.x - menuSize.width - OFFSET_X; + } + + if (top < MENU_SPACE_PADDING) { + top = MENU_SPACE_PADDING; + } else if (pos.y + menuSize.height + MENU_SPACE_PADDING > viewportHeight) { + top = viewportHeight - MENU_SPACE_PADDING - menuSize.height; + } + + return { left, top }; + }, [pos.x, pos.y, menuSize]); + + useLayoutEffect(() => { + if (menuRef.current) { + const rect = menuRef.current.getBoundingClientRect(); + setMenuSize({ width: rect.width, height: rect.height }); + } + }, [visible]); + + /** + * contextmenu part showed anyway + */ + const renderNoSelectContextMenu = () => { + return ( + <> + { + setVisible(false); + onDelete(); + }} + > + + + + ); + }; + + return ( +
e.preventDefault()}> + {visible && ( +
{ + setVisible(false); + }} + /> + )} +
+ {renderNoSelectContextMenu()} +
+
+ ); +}; diff --git a/apps/suika-multiplayer/src/components/ContextMenu/index.tsx b/apps/suika-multiplayer/src/components/ContextMenu/index.tsx index 0839b04d7..696b6aff8 100644 --- a/apps/suika-multiplayer/src/components/ContextMenu/index.tsx +++ b/apps/suika-multiplayer/src/components/ContextMenu/index.tsx @@ -1 +1,2 @@ export * from './ContextMenu'; +export * from './PageContextMenu'; diff --git a/apps/suika-multiplayer/src/components/Editor.scss b/apps/suika-multiplayer/src/components/Editor.scss index 92f2d2afe..b448d16b8 100644 --- a/apps/suika-multiplayer/src/components/Editor.scss +++ b/apps/suika-multiplayer/src/components/Editor.scss @@ -2,4 +2,19 @@ .body { position: relative; } + + .suika-editor-left-area { + position: absolute; + left: 0; + top: 0; + box-sizing: border-box; + border-right: 1px solid #e6e6e6; + width: 240px; + height: calc(100vh - 48px); + + display: flex; + flex-direction: column; + + user-select: none; + } } diff --git a/apps/suika-multiplayer/src/components/Editor.tsx b/apps/suika-multiplayer/src/components/Editor.tsx index ecf9dc98f..77ab77292 100644 --- a/apps/suika-multiplayer/src/components/Editor.tsx +++ b/apps/suika-multiplayer/src/components/Editor.tsx @@ -1,39 +1,48 @@ import './Editor.scss'; -import { throttle } from '@suika/common'; -import { SuikaEditor } from '@suika/core'; -import { type FC, useEffect, useRef, useState } from 'react'; +import { pick, throttle } from '@suika/common'; +import { fontManager, type SettingValue, SuikaEditor } from '@suika/core'; +import { type FC, useEffect, useMemo, useRef, useState } from 'react'; + +import { FONT_FILES } from '@/constant'; +import { joinRoom } from '@/store/join-room'; +import { type IUserItem } from '@/type'; -import { ApiService } from '../api-service'; import { EditorContext } from '../context'; -// import { AutoSaveGraphics } from '../store/auto-save-graphs'; -import { joinRoom } from '../store/join-room'; -import { type IUserItem } from '../type'; import { ContextMenu } from './ContextMenu'; +import { useFileInfo, useUserInfo } from './editorHook'; import { Header } from './Header'; import { InfoPanel } from './InfoPanel'; import { LayerPanel } from './LayerPanel'; import { MultiCursorsView } from './MultiCursorsView'; +import { Pages } from './Pages'; +import { ProgressOverlay } from './ProgressOverlay'; const topMargin = 48; const leftRightMargin = 240 * 2; -const Editor: FC = () => { - const containerRef = useRef(null); +const USER_PREFERENCE_KEY = 'suika-user-preference'; +const storeKeys: Partial[] = [ + 'enablePixelGrid', + 'snapToGrid', + 'enableRuler', - const [editor, setEditor] = useState(null); + 'keepToolSelectedAfterUse', + 'invertZoomDirection', + 'highlightLayersOnHover', + 'flipObjectsWhileResizing', + 'snapToObjects', +]; - const [viewWidth, setViewWidth] = useState(0); - const [viewHeight, setViewHeight] = useState(0); +const Editor: FC = () => { + const containerRef = useRef(null); - const [users, setUsers] = useState([]); - const [awarenessClientId, setAwarenessClientId] = useState(-1); + const [suikaEditor, setEditor] = useState(null); - const [title, setTitle] = useState(''); - const userInfo = useRef<{ username: string; id: number } | null>(); + const [progress, setProgress] = useState(0); - useEffect(() => { - let fileId: number | undefined; + /** + * let fileId: number | undefined; const pathname = location.pathname; const suffix = '/design/'; if (pathname.startsWith(suffix)) { @@ -43,83 +52,140 @@ const Editor: FC = () => { console.error('请提供正确格式图纸 id'); return; } + */ + const roomId = useMemo(() => { + const pathname = location.pathname; + const suffix = '/design/'; + if (pathname.startsWith(suffix)) { + return pathname.slice(suffix.length); + } + return undefined; + }, [location.pathname]); - // 1. 请求用户名 - // 2. 请求文件元数据 - Promise.all([ApiService.getUserInfo(), ApiService.getFile(fileId)]).then( - ([userData, fileData]) => { - userInfo.current = userData.data; - if (!fileData.data) { - console.error('图纸 id 不存在'); - return; - } - setTitle(fileData.data.title); - initEditor('' + fileId); - }, - ); - }, [containerRef]); + const userInfo = useUserInfo(); + const fileInfo = useFileInfo(roomId!); + + const [awarenessClientId, setAwarenessClientId] = useState(-1); - const initEditor = (fileId: string) => { - if (!containerRef.current) return; - const width = document.body.clientWidth - leftRightMargin; - const height = document.body.clientHeight - topMargin; - setViewWidth(width); - setViewHeight(height); - - const editor = new SuikaEditor({ - containerElement: containerRef.current, - width, - height, - offsetY: 48, - offsetX: 240, - showPerfMonitor: false, - }); - (window as any).editor = editor; - - const suikaBinding = joinRoom(editor, fileId, userInfo.current!); - setAwarenessClientId(suikaBinding.awareness.clientID); - suikaBinding.on('usersChange', setUsers); - - const changeViewport = throttle( - () => { - const width = document.body.clientWidth - leftRightMargin; - const height = document.body.clientHeight - topMargin; - setViewWidth(width); - setViewHeight(height); - editor.viewportManager.setViewportSize({ - width, - height, + const [viewWidth, setViewWidth] = useState(0); + const [viewHeight, setViewHeight] = useState(0); + + const [users, setUsers] = useState([]); + + useEffect(() => { + if (containerRef.current) { + const width = document.body.clientWidth - leftRightMargin; + const height = document.body.clientHeight - topMargin; + setViewWidth(width); + setViewHeight(height); + + const userPreferenceEncoded = localStorage.getItem(USER_PREFERENCE_KEY); + const userPreference = userPreferenceEncoded + ? (JSON.parse(userPreferenceEncoded) as Partial) + : undefined; + + const editorReference: { value: SuikaEditor | null } = { + value: null, + }; + + let isCanceled = false; + + const changeViewport = throttle( + () => { + const editor = editorReference.value; + if (!editor) return; + + const width = document.body.clientWidth - leftRightMargin; + const height = document.body.clientHeight - topMargin; + setViewWidth(width); + setViewHeight(height); + editor.viewportManager.setViewportSize({ + width, + height, + }); + editor.render(); + }, + 10, + { leading: false }, + ); + + (async () => { + await fontManager.loadFonts(FONT_FILES); + setProgress(100); + if (isCanceled) return; + + const editor = new SuikaEditor({ + containerElement: containerRef.current!, + width: document.body.clientWidth - leftRightMargin, + height: document.body.clientHeight - topMargin, + offsetY: 48, + offsetX: 240, + showPerfMonitor: false, + userPreference: userPreference, }); - editor.render(); - }, - 10, - { leading: false }, - ); - window.addEventListener('resize', changeViewport); - setEditor(editor); - - return () => { - editor.destroy(); - window.removeEventListener('resize', changeViewport); - changeViewport.cancel(); - suikaBinding.destroy(); - }; - }; + editorReference.value = editor; + + editor.setting.on( + 'update', + (value: SettingValue, changedKey: keyof SettingValue) => { + if (!storeKeys.includes(changedKey)) return; + + localStorage.setItem( + USER_PREFERENCE_KEY, + JSON.stringify(pick(value, storeKeys)), + ); + }, + ); + + (window as any).editor = editor; + + window.addEventListener('resize', changeViewport); + + setEditor(editor); + })(); + + return () => { + isCanceled = true; + + editorReference.value?.destroy(); + window.removeEventListener('resize', changeViewport); + changeViewport.cancel(); + }; + } + }, [containerRef]); + + useEffect(() => { + if (suikaEditor && fileInfo && userInfo) { + const binding = joinRoom(suikaEditor, fileInfo.id, userInfo); + setAwarenessClientId(binding.awareness.clientID); + binding.on('usersChange', (users: IUserItem[]) => { + console.log('usersChange', users); + setUsers(users); + }); + + return () => { + binding.destroy(); + }; + } + }, [suikaEditor, fileInfo, userInfo]); return (
- -
+ + +
{/* body */}
- +
+ + +
{' '}
= ({ title }) => { +export const Header: FC = ({ title, userInfo }) => { return (
<div className="sk-right-area"> + <div>{userInfo?.username ?? ''}</div> <LocaleSelector /> <ZoomActions /> </div> diff --git a/apps/suika-multiplayer/src/components/Header/components/Title/index.tsx b/apps/suika-multiplayer/src/components/Header/components/Title/index.tsx index c6e16a27b..58a63ed42 100644 --- a/apps/suika-multiplayer/src/components/Header/components/Title/index.tsx +++ b/apps/suika-multiplayer/src/components/Header/components/Title/index.tsx @@ -10,7 +10,6 @@ interface IProps { const Title: FC<IProps> = ({ value }) => { return ( <div className="suika-header-title"> - <GithubOutlined /> <a href="https://github.com/F-star/suika" target="_blank"> {value} </a> diff --git a/apps/suika-multiplayer/src/components/Header/components/Toolbar/Toolbar.tsx b/apps/suika-multiplayer/src/components/Header/components/Toolbar/Toolbar.tsx index 8859226db..62d324f56 100644 --- a/apps/suika-multiplayer/src/components/Header/components/Toolbar/Toolbar.tsx +++ b/apps/suika-multiplayer/src/components/Header/components/Toolbar/Toolbar.tsx @@ -1,7 +1,6 @@ import './Toolbar.scss'; import { isWindows } from '@suika/common'; -import { Button } from '@suika/components'; import { EllipseOutlined, FrameOutlined, @@ -20,6 +19,13 @@ import classNames from 'classnames'; import { useContext, useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; +import { Button } from '@/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; + import { EditorContext } from '../../../../context'; import { type MessageIds } from '../../../../locale'; import { ToolBtn } from './components/ToolBtn'; @@ -149,26 +155,32 @@ export const ToolBar = () => { {enableTools.map((toolType) => { const tool = keyMap[toolType]; return ( - <ToolBtn - key={tool.name} - className={classNames({ active: currTool === tool.name })} - tooltipContent={intl.formatMessage({ id: tool.intlId })} - hotkey={tool.hotkey} - onMouseDown={() => { - editor?.toolManager.setActiveTool(tool.name); - }} - > - {tool.icon} - </ToolBtn> + <Tooltip key={tool.name} delayDuration={20}> + <TooltipTrigger> + <ToolBtn + key={tool.name} + className={classNames({ active: currTool === tool.name })} + onMouseDown={() => { + editor?.toolManager.setActiveTool(tool.name); + }} + > + {tool.icon} + </ToolBtn> + </TooltipTrigger> + <TooltipContent> + {intl.formatMessage({ id: tool.intlId })} + {tool.hotkey && ( + <span className="ml-4 text-[#ccc]">{tool.hotkey}</span> + )} + </TooltipContent> + </Tooltip> ); })} {isPathEditorActive && ( <Button - style={{ - marginLeft: '16px', - userSelect: 'none', - }} + className="ml-4 user-select-none" + variant="outline" onClick={() => { if (editor) { editor.pathEditor.inactive(); diff --git a/apps/suika-multiplayer/src/components/Header/components/Toolbar/components/ToolBtn/ToolBtn.scss b/apps/suika-multiplayer/src/components/Header/components/Toolbar/components/ToolBtn/ToolBtn.scss index 3ab59fa54..04ac8ef22 100644 --- a/apps/suika-multiplayer/src/components/Header/components/Toolbar/components/ToolBtn/ToolBtn.scss +++ b/apps/suika-multiplayer/src/components/Header/components/Toolbar/components/ToolBtn/ToolBtn.scss @@ -5,39 +5,16 @@ align-items: center; position: relative; - margin: 0 2px; + margin: 0 4px; border-radius: 4px; - width: 36px; - height: 32px; + width: 32px; + height: 30px; color: #333; cursor: pointer; - .tooltip { - display: none; - position: absolute; - bottom: -30px; - - border-radius: 4px; - padding: 4px 8px; - - color: #fff; - font-size: 12px; - background-color: #333333ee; - white-space: nowrap; - z-index: 2; - - .tool-hotkey { - margin-left: 8px; - color: #ccc; - } - } - &:hover { background-color: #f2f2f2; - .tooltip { - display: block; - } } &.active { background-color: #0f8fff; diff --git a/apps/suika-multiplayer/src/components/Header/components/Toolbar/components/ToolBtn/ToolBtn.tsx b/apps/suika-multiplayer/src/components/Header/components/Toolbar/components/ToolBtn/ToolBtn.tsx index e8c6ee7ad..773505c52 100644 --- a/apps/suika-multiplayer/src/components/Header/components/Toolbar/components/ToolBtn/ToolBtn.tsx +++ b/apps/suika-multiplayer/src/components/Header/components/Toolbar/components/ToolBtn/ToolBtn.tsx @@ -6,18 +6,10 @@ import React, { type FC } from 'react'; interface IToolBtn { className?: string; children?: React.ReactNode; - tooltipContent: string; - hotkey: string; onMouseDown: () => void; } -export const ToolBtn: FC<IToolBtn> = ({ - children, - onMouseDown, - className, - tooltipContent, - hotkey, -}) => { +export const ToolBtn: FC<IToolBtn> = ({ children, onMouseDown, className }) => { return ( <div className={classNames('tool-btn', className)} @@ -26,10 +18,6 @@ export const ToolBtn: FC<IToolBtn> = ({ }} > {children} - <div className="tooltip"> - {tooltipContent} - {hotkey && <span className="tool-hotkey">{hotkey}</span>} - </div> </div> ); }; diff --git a/apps/suika-multiplayer/src/components/Header/components/Toolbar/menu/Menu.tsx b/apps/suika-multiplayer/src/components/Header/components/Toolbar/menu/Menu.tsx index e2eadd33e..4d9f71e3f 100644 --- a/apps/suika-multiplayer/src/components/Header/components/Toolbar/menu/Menu.tsx +++ b/apps/suika-multiplayer/src/components/Header/components/Toolbar/menu/Menu.tsx @@ -33,12 +33,26 @@ export const Menu: FC = () => { const items: IDropdownProps['items'] = [ { - key: 'import', - label: t({ id: 'import.originFile' }), - }, - { - key: 'export', - label: t({ id: 'export.originFile' }), + key: 'file', + label: t({ id: 'file' }), + children: [ + { + key: 'import', + label: t({ id: 'import.originFile' }), + }, + { + key: 'export', + label: t({ id: 'export.originFile' }), + }, + { + key: 'exportCurrentPageAsSVG', + label: t({ id: 'export.currentPageAsSVG' }), + }, + { + key: 'exportCurrentPageAsPNG', + label: t({ id: 'export.currentPageAsPNG' }), + }, + ], }, { type: 'divider', @@ -88,6 +102,12 @@ export const Menu: FC = () => { case 'export': exportService.exportOriginFile(editor); break; + case 'exportCurrentPageAsSVG': + exportService.exportCurrentPageSVG(editor); + break; + case 'exportCurrentPageAsPNG': + exportService.exportCurrentPagePNG(editor); + break; case 'keepToolSelectedAfterUse': case 'invertZoomDirection': case 'highlightLayersOnHover': diff --git a/apps/suika-multiplayer/src/components/InfoPanel/InfoPanel.tsx b/apps/suika-multiplayer/src/components/InfoPanel/InfoPanel.tsx index defb29a6b..460c2a057 100644 --- a/apps/suika-multiplayer/src/components/InfoPanel/InfoPanel.tsx +++ b/apps/suika-multiplayer/src/components/InfoPanel/InfoPanel.tsx @@ -1,5 +1,3 @@ -import './style.scss'; - import { type SuikaGraphics } from '@suika/core'; import { type FC, useContext, useEffect, useState } from 'react'; import { FormattedMessage } from 'react-intl'; @@ -10,6 +8,7 @@ import { ElementsInfoCards } from '../Cards/ElementsInfoCard'; import { FillCard } from '../Cards/FillCard'; import { LayerInfoCard } from '../Cards/LayerInfoCard'; import { StrokeCard } from '../Cards/StrokeCard'; +import { TypographyCard } from '../Cards/TypographyCard'; import { DebugPanel } from '../DebugPanel'; enum PanelType { @@ -38,18 +37,22 @@ export const InfoPanel: FC = () => { }, [editor]); return ( - <div className="info-panel" onKeyDown={(e) => e.stopPropagation()}> + <div + className="absolute top-0 right-0 z-[40] border-l border-[#e6e6e6] w-[240px] h-[calc(100vh-48px)] text-xs bg-white select-none" + onKeyDown={(e) => e.stopPropagation()} + > {type === PanelType.SelectedElements && ( <> <AlignCard /> <ElementsInfoCards /> + <TypographyCard /> <LayerInfoCard /> <FillCard key="fill" /> <StrokeCard key="stroke" /> </> )} {type === PanelType.Global && ( - <div className="empty-text"> + <div className="text-[#b3b3b3] p-4"> <FormattedMessage id="noSelectedShapes" /> </div> )} diff --git a/apps/suika-multiplayer/src/components/InfoPanel/style.scss b/apps/suika-multiplayer/src/components/InfoPanel/style.scss deleted file mode 100644 index 89cfb0855..000000000 --- a/apps/suika-multiplayer/src/components/InfoPanel/style.scss +++ /dev/null @@ -1,22 +0,0 @@ -.suika { - .info-panel { - position: absolute; - top: 0; - right: 0; - z-index: 40; - - border-left: 1px solid #e6e6e6; - width: 240px; - height: calc(100vh - 48px); - - font-size: 12px; - background-color: #fff; - user-select: none; - - .empty-text { - color: #b3b3b3; - padding: 16px; - } - - } -} diff --git a/apps/suika-multiplayer/src/components/LayerPanel/LayerPanel.scss b/apps/suika-multiplayer/src/components/LayerPanel/LayerPanel.scss index e3a6e5841..686e8453a 100644 --- a/apps/suika-multiplayer/src/components/LayerPanel/LayerPanel.scss +++ b/apps/suika-multiplayer/src/components/LayerPanel/LayerPanel.scss @@ -1,14 +1,34 @@ .suika { .layer-panel { - position: absolute; - left: 0; - top: 0; - box-sizing: border-box; - border-right: 1px solid #e6e6e6; - width: 240px; - height: calc(100vh - 48px); + width: 100%; overflow: auto; - user-select: none; + + // hide scrollbar by default + scrollbar-width: none; // Firefox + -ms-overflow-style: none; // IE/Edge + &::-webkit-scrollbar { + width: 0; + height: 0; + } + // show scrollbar when hover + &:hover { + scrollbar-width: thin; // Firefox + -ms-overflow-style: auto; // IE/Edge + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; + &:hover { + background: rgba(0, 0, 0, 0.4); + } + } + } } } diff --git a/apps/suika-multiplayer/src/components/LayerPanel/LayerPanel.tsx b/apps/suika-multiplayer/src/components/LayerPanel/LayerPanel.tsx index 9082845fe..58f6d1f21 100644 --- a/apps/suika-multiplayer/src/components/LayerPanel/LayerPanel.tsx +++ b/apps/suika-multiplayer/src/components/LayerPanel/LayerPanel.tsx @@ -4,7 +4,7 @@ import { type IObject, MutateGraphsAndRecord } from '@suika/core'; import { type FC, useContext, useEffect, useState } from 'react'; import { EditorContext } from '../../context'; -import { LayerTree } from './LayerTree/LayerTree'; +import { LayerTree } from './LayerTree'; export const LayerPanel: FC = () => { const editor = useContext(EditorContext); diff --git a/apps/suika-multiplayer/src/components/LayerPanel/LayerTree/LayerTree.scss b/apps/suika-multiplayer/src/components/LayerPanel/LayerTree/LayerTree.scss new file mode 100644 index 000000000..df2bd5c59 --- /dev/null +++ b/apps/suika-multiplayer/src/components/LayerPanel/LayerTree/LayerTree.scss @@ -0,0 +1,5 @@ +.suika { + .suika-layer-tree { + overflow: auto; + } +} diff --git a/apps/suika-multiplayer/src/components/LayerPanel/LayerTree/LayerTree.tsx b/apps/suika-multiplayer/src/components/LayerPanel/LayerTree/LayerTree.tsx index 3b043d7b0..2e7fe7c0a 100644 --- a/apps/suika-multiplayer/src/components/LayerPanel/LayerTree/LayerTree.tsx +++ b/apps/suika-multiplayer/src/components/LayerPanel/LayerTree/LayerTree.tsx @@ -25,7 +25,7 @@ export const LayerTree: FC<IProps> = ({ zoomGraphicsToFit, }) => { return ( - <div> + <div className="suika-layer-tree"> {[...treeData].reverse().map((item) => ( <LayerItem type={item.type} diff --git a/apps/suika-multiplayer/src/components/LayerPanel/LayerTree/index.ts b/apps/suika-multiplayer/src/components/LayerPanel/LayerTree/index.ts index b053b2adc..00ff25865 100644 --- a/apps/suika-multiplayer/src/components/LayerPanel/LayerTree/index.ts +++ b/apps/suika-multiplayer/src/components/LayerPanel/LayerTree/index.ts @@ -1 +1,2 @@ export * from './LayerItem'; +export * from './LayerTree'; diff --git a/apps/suika-multiplayer/src/components/MultiCursorsView/MultiCursorsView.tsx b/apps/suika-multiplayer/src/components/MultiCursorsView/MultiCursorsView.tsx index 9e429e729..cd5018270 100644 --- a/apps/suika-multiplayer/src/components/MultiCursorsView/MultiCursorsView.tsx +++ b/apps/suika-multiplayer/src/components/MultiCursorsView/MultiCursorsView.tsx @@ -32,7 +32,7 @@ export const MultiCursorsView: FC<IProps> = ({ const editor = useContext(EditorContext); const toViewportPos = (pos: IPoint) => { - return editor!.toViewportPt(pos.x, pos.y); + return editor!.toViewportPt(pos); }; const [, setViewportId] = useState({}); // to force rerender component diff --git a/apps/suika-multiplayer/src/components/Pages/PageItem.scss b/apps/suika-multiplayer/src/components/Pages/PageItem.scss new file mode 100644 index 000000000..a61f9a31e --- /dev/null +++ b/apps/suika-multiplayer/src/components/Pages/PageItem.scss @@ -0,0 +1,62 @@ +.suika { + .sk-page-item { + display: flex; + align-items: center; + + box-sizing: border-box; + border: 1px solid transparent; + padding-right: 4px; + height: 32px; + font-size: 12px; + color: #333; + + user-select: none; + + white-space: nowrap; + overflow: hidden; + + &:hover:not(.sk-editing) { + background-color: #f2f2f2; + color: #333; + } + + .sk-layer-icon { + width: 18px; + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + color: #000000; + } + + .sk-layout-name { + padding: 0 4px; + border: 1px solid transparent; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 1; + flex-grow: 1; + + height: 32px; + line-height: 32px; + font-family: Arial, sans-serif; + } + + input { + box-sizing: border-box; + + padding: 0 4px; + width: 100%; + height: 22px; + font-size: 12px; + line-height: 12px; + + font-family: Arial, sans-serif; + color: #333; + border: 1px solid #0d99ff; + outline: 1px solid #0d99ff; + + cursor: default; + } + } +} diff --git a/apps/suika-multiplayer/src/components/Pages/PageItem.tsx b/apps/suika-multiplayer/src/components/Pages/PageItem.tsx new file mode 100644 index 000000000..006babab2 --- /dev/null +++ b/apps/suika-multiplayer/src/components/Pages/PageItem.tsx @@ -0,0 +1,107 @@ +import './PageItem.scss'; + +import { CheckOutlined } from '@suika/icons'; +import classNames from 'classnames'; +import { type FC, useEffect, useRef, useState } from 'react'; + +interface IProps { + id: string; + name: string; + activeId: string; + + /** set highlight id */ + setName: (id: string, newName: string) => void; + setSelectedGraph: ( + objId: string, + event: React.MouseEvent<Element, MouseEvent>, + ) => void; + + onContextMenu: (e: React.MouseEvent<Element, MouseEvent>, id: string) => void; +} + +export const PageItem: FC<IProps> = ({ + name, + id, + activeId, + + setName, + setSelectedGraph, + onContextMenu, +}) => { + const [isEditing, setIsEditing] = useState(false); + const inputRef = useRef<HTMLInputElement>(null); + + const [layoutName, setLayoutName] = useState(name); + + useEffect(() => { + setLayoutName(name); + }, [name]); + + const handleDbClick = () => { + setIsEditing(true); + setTimeout(() => { + const inputEl = inputRef.current; + if (inputEl) { + inputEl.value = name; + inputEl.select(); + } + }, 0); + }; + + const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === 'Enter' && !e.nativeEvent.isComposing) { + e.currentTarget.blur(); + } + }; + + const handleBlur = () => { + const inputVal = inputRef.current?.value; + if (inputVal) { + if (setName) { + setName(id, inputVal); + setLayoutName(inputVal); + } + } + setIsEditing(false); + }; + + return ( + <> + <div + className={classNames('sk-page-item', { + 'sk-editing': isEditing, + })} + onMouseDown={(e) => { + // only left click + if (e.button !== 0) return; + setSelectedGraph && setSelectedGraph(id, e); + }} + onContextMenu={(e) => { + e.preventDefault(); + onContextMenu(e, id); + }} + > + <div className="sk-layer-icon" style={{ marginLeft: 20 }}> + {activeId === id && <CheckOutlined />} + </div> + {!isEditing && ( + <span + key={'span'} + className="sk-layout-name" + onDoubleClick={handleDbClick} + > + {layoutName} + </span> + )} + {isEditing && ( + <input + ref={inputRef} + onMouseDown={(e) => e.stopPropagation()} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + /> + )} + </div> + </> + ); +}; diff --git a/apps/suika-multiplayer/src/components/Pages/Pages.scss b/apps/suika-multiplayer/src/components/Pages/Pages.scss new file mode 100644 index 000000000..0ab47cda7 --- /dev/null +++ b/apps/suika-multiplayer/src/components/Pages/Pages.scss @@ -0,0 +1,39 @@ +.suika { + .suika-page-list { + .info-card { + padding-bottom: 0; + } + } + .suika-page-list-content { + padding-bottom: 8px; + max-height: 268px; + overflow: auto; + + // hide scrollbar by default + scrollbar-width: none; // Firefox + -ms-overflow-style: none; // IE/Edge + &::-webkit-scrollbar { + width: 0; + height: 0; + } + // show scrollbar when hover + &:hover { + scrollbar-width: thin; // Firefox + -ms-overflow-style: auto; // IE/Edge + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; + &:hover { + background: rgba(0, 0, 0, 0.4); + } + } + } + } +} diff --git a/apps/suika-multiplayer/src/components/Pages/Pages.tsx b/apps/suika-multiplayer/src/components/Pages/Pages.tsx new file mode 100644 index 000000000..be0de82e5 --- /dev/null +++ b/apps/suika-multiplayer/src/components/Pages/Pages.tsx @@ -0,0 +1,139 @@ +import './Pages.scss'; + +import { IconButton } from '@suika/components'; +import { + addAndSwitchCanvasRecord, + MutateGraphsAndRecord, + removeGraphicsAndRecord, + type SuikaCanvas, + switchCanvasRecord, +} from '@suika/core'; +import { AddOutlined } from '@suika/icons'; +import { type FC, useContext, useEffect, useState } from 'react'; + +import { EditorContext } from '../../context'; +import { BaseCard } from '../Cards/BaseCard'; +import { PageContextMenu } from '../ContextMenu'; +import { PageItem } from './PageItem'; + +export const Pages: FC = () => { + const editor = useContext(EditorContext); + + const [currPageId, setCurrPageId] = useState<string>(''); + const [pageItems, setPageItems] = useState< + { + id: string; + name: string; + }[] + >([]); + const [menuVisible, setMenuVisible] = useState(false); + const [menuPos, setMenuPos] = useState({ x: 0, y: 0 }); + const [canvasIdByMenu, setCanvasIdByMenu] = useState<string>(''); + + useEffect(() => { + if (!editor) return; + + const updatePageItems = () => { + const pages = editor.doc.graphicsStoreManager.getCanvasItemsData(); + setPageItems(pages); + setCurrPageId(editor.doc.getCurrentCanvas().attrs.id); + }; + + updatePageItems(); + + editor.sceneGraph.on('render', () => { + updatePageItems(); + }); + }, [editor]); + + const setName = (id: string, newName: string) => { + if (editor) { + const graphics = editor.doc.getGraphicsById(id); + if (graphics && graphics.attrs.objectName !== newName) { + MutateGraphsAndRecord.setGraphName(editor, graphics, newName); + editor.render(); + } + } + }; + + const switchPage = (canvasId: string) => { + if (!editor) return; + switchCanvasRecord(editor, canvasId); + editor.render(); + }; + + const createNewPage = () => { + if (!editor) return; + + addAndSwitchCanvasRecord(editor, undefined); + editor.render(); + }; + + const handleContextMenu = ( + e: React.MouseEvent<Element, MouseEvent>, + id: string, + ) => { + setCanvasIdByMenu(id); + setMenuVisible(true); + setMenuPos({ x: e.clientX, y: e.clientY }); + }; + + return ( + <div className="suika-page-list"> + <BaseCard + title="Pages" + headerAction={ + <IconButton + onClick={() => { + createNewPage(); + }} + > + <AddOutlined /> + </IconButton> + } + > + <div className="suika-page-list-content"> + {pageItems.map((item) => ( + <PageItem + key={item.id} + id={item.id} + name={item.name} + activeId={currPageId} + setName={setName} + setSelectedGraph={switchPage} + onContextMenu={handleContextMenu} + /> + ))} + </div> + </BaseCard> + <PageContextMenu + visible={menuVisible} + setVisible={setMenuVisible} + pos={menuPos} + style={{ + width: 150, + }} + disabledDelete={pageItems.length <= 1} + onDelete={() => { + if (!editor) return; + const canvas = editor.doc.getGraphicsById( + canvasIdByMenu, + ) as SuikaCanvas; + if (!canvas) return; + + const isCurrentCanvasToDelete = canvas.attrs.id === currPageId; + + const newCurrentCanvas = + canvas.getNextSibling() || canvas.getPrevSibling(); + + editor.commandManager.batchCommandStart(); + removeGraphicsAndRecord(editor, [canvas]); + if (isCurrentCanvasToDelete && newCurrentCanvas) { + switchCanvasRecord(editor, newCurrentCanvas.attrs.id); + } + editor.commandManager.batchCommandEnd(); + }} + /> + </div> + ); +}; diff --git a/apps/suika-multiplayer/src/components/Pages/index.ts b/apps/suika-multiplayer/src/components/Pages/index.ts new file mode 100644 index 000000000..7ed0ee470 --- /dev/null +++ b/apps/suika-multiplayer/src/components/Pages/index.ts @@ -0,0 +1 @@ +export * from './Pages'; diff --git a/apps/suika-multiplayer/src/components/ProgressOverlay.tsx b/apps/suika-multiplayer/src/components/ProgressOverlay.tsx new file mode 100644 index 000000000..1de48c75a --- /dev/null +++ b/apps/suika-multiplayer/src/components/ProgressOverlay.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react'; + +import { Progress } from './ui/progress'; + +interface IProps { + value: number; +} + +export const ProgressOverlay = ({ value }: IProps) => { + const [visible, setVisible] = useState(true); + const [displayProgress, setDisplayProgress] = useState(0); + + useEffect(() => { + if (value >= 100) { + setDisplayProgress(100); + setTimeout(() => { + setVisible(false); + }, 150); + } + }, [value]); + + useEffect(() => { + let currentProgress = 0; + const interval = setInterval(() => { + if (currentProgress < 100) { + // slow start, fast end + const progressRatio = currentProgress / 100; + const increment = progressRatio * progressRatio * 5 + 1; + currentProgress = Math.min(95, currentProgress + increment); + setDisplayProgress(Math.max(Math.floor(currentProgress), value)); + } else { + clearInterval(interval); + } + }, 80); + + return () => { + clearInterval(interval); + }; + }, [value]); + + return ( + <div + style={{ display: visible ? '' : 'none' }} + className="fixed inset-0 flex items-center justify-center z-100 bg-[#e6e6e6]" + > + <div className="w-[200px]"> + <Progress value={displayProgress} className="w-[60%]" /> + </div> + </div> + ); +}; diff --git a/apps/suika-multiplayer/src/components/ZoomActions/ZoomActions.tsx b/apps/suika-multiplayer/src/components/ZoomActions/ZoomActions.tsx index 7701715b3..d40e1b9d5 100644 --- a/apps/suika-multiplayer/src/components/ZoomActions/ZoomActions.tsx +++ b/apps/suika-multiplayer/src/components/ZoomActions/ZoomActions.tsx @@ -59,7 +59,10 @@ export const ZoomActions: FC = () => { <ZoomInput defaultValue={zoom} onChange={(newZoom) => { - editor?.viewportManager.setZoomAndUpdateViewport(newZoom); + editor?.viewportManager.setZoom( + newZoom, + editor.viewportManager.getSceneCenter(), + ); editor?.render(); }} /> diff --git a/apps/suika-multiplayer/src/components/editorHook.ts b/apps/suika-multiplayer/src/components/editorHook.ts new file mode 100644 index 000000000..4643f71cd --- /dev/null +++ b/apps/suika-multiplayer/src/components/editorHook.ts @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react'; + +import { ApiService } from '@/api-service'; + +export const useUserInfo = () => { + const [userInfo, setUserInfo] = useState<{ + username: string; + id: string; + } | null>(null); + useEffect(() => { + ApiService.getUserInfo().then((res) => { + console.log(res.data); + setUserInfo(res.data); + }); + }, []); + return userInfo; +}; + +export const useFileInfo = (fileId: string) => { + const [fileInfo, setFileInfo] = useState<{ + title: string; + id: string; + } | null>(null); + useEffect(() => { + ApiService.getFile(fileId).then((res) => { + setFileInfo(res.data); + }); + }, [fileId]); + return fileInfo; +}; diff --git a/apps/suika-multiplayer/src/components/input/CustomRuleInput/index.tsx b/apps/suika-multiplayer/src/components/input/CustomRuleInput/index.tsx index 3796a0702..09866a907 100644 --- a/apps/suika-multiplayer/src/components/input/CustomRuleInput/index.tsx +++ b/apps/suika-multiplayer/src/components/input/CustomRuleInput/index.tsx @@ -30,6 +30,7 @@ const CustomRuleInput: FC<ICustomRuleInputProps> = (props) => { {prefix && <div className="suika-custom-input-prefix">{prefix}</div>} <input ref={inputRef} + style={{ marginLeft: prefix ? 0 : 8 }} className="custom-rule-input" defaultValue={value} onMouseUp={(e) => { diff --git a/apps/suika-multiplayer/src/components/input/CustomRuleInput/style.scss b/apps/suika-multiplayer/src/components/input/CustomRuleInput/style.scss index acddadf2c..5e568d63e 100644 --- a/apps/suika-multiplayer/src/components/input/CustomRuleInput/style.scss +++ b/apps/suika-multiplayer/src/components/input/CustomRuleInput/style.scss @@ -5,17 +5,19 @@ box-sizing: border-box; border: 1px solid transparent; - margin: 2px 4px; - border-radius: 2px; + margin: 4px; + border-radius: 4px; width: 100px; - height: 28px; + height: 30px; text-align: center; + + background-color: #f5f5f5; + &:hover { border-color: #e6e6e6; } &:focus-within { border-color: #0d99ff; - outline: 1px solid #0d99ff; } .suika-custom-input-prefix { @@ -29,5 +31,6 @@ box-sizing: border-box; width: 100%; font-size: 12px; + background-color: transparent; } } diff --git a/apps/suika-multiplayer/src/components/input/FontSizeInput.tsx b/apps/suika-multiplayer/src/components/input/FontSizeInput.tsx new file mode 100644 index 000000000..eea387f04 --- /dev/null +++ b/apps/suika-multiplayer/src/components/input/FontSizeInput.tsx @@ -0,0 +1,157 @@ +import { parseToNumber } from '@suika/common'; +import { CheckIcon, ChevronDownIcon } from 'lucide-react'; +import { type FC, useEffect, useRef, useState } from 'react'; + +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; + +const FONT_SIZE_OPTIONS = [ + 10, 11, 12, 13, 14, 15, 16, 20, 24, 32, 36, 40, 48, 64, 96, 128, +]; + +interface FontSizeInputProps { + value: number | string; + min?: number; + max?: number; + onChange: (newValue: number) => void; + onIncrement?: () => void; + onDecrement?: () => void; + className?: string; +} + +const isNumberStr = (str: string) => { + return !Number.isNaN(Number(str)); +}; + +export const FontSizeInput: FC<FontSizeInputProps> = ({ + value, + min = 1, + max = 100, + onChange, + onIncrement, + onDecrement, + className, +}) => { + const inputRef = useRef<HTMLInputElement>(null); + const isActive = useRef(false); + const [isOpen, setIsOpen] = useState(false); + const [isFocused, setIsFocused] = useState(false); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.value = String(value); + } + }, [value]); + + const handleInputChange = (str: string) => { + str = str.trim(); + let num = parseToNumber(str); + if (!Number.isNaN(num)) { + const currentNum = typeof value === 'number' ? value : Number(value); + if (num !== currentNum || Number.isNaN(currentNum)) { + num = Math.max(min, num); + num = Math.min(max, num); + onChange(num); + } + } + }; + + const handleSelectSize = (size: number) => { + onChange(size); + }; + + const displayValue = isNumberStr(String(value)) ? value : String(value); + + return ( + <Popover open={isOpen} onOpenChange={setIsOpen}> + <div + className={cn( + 'relative flex items-center border border-transparent w-full h-9 rounded-md focus-within:border-[#0d99ff] box-border', + (isFocused || isOpen) && 'border-[#0d99ff]', + className, + )} + > + <div className="relative flex-1 min-w-0 h-full"> + <PopoverTrigger asChild> + <div className="absolute left-0 top-0 h-full w-full" /> + </PopoverTrigger> + <input + ref={inputRef} + className={cn( + 'relative z-10 h-full w-full rounded-l-md border-r-0 border-transparent bg-[#f5f5f5] px-3 py-1 text-sm text-left focus:outline-none transition-colors disabled:cursor-not-allowed disabled:opacity-50', + )} + defaultValue={value} + onMouseUp={(e) => { + const el = e.currentTarget; + if (!isActive.current) { + el.select(); + } + isActive.current = true; + }} + onKeyDown={(e) => { + if (e.key === 'ArrowUp') { + e.preventDefault(); + onIncrement?.(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + onDecrement?.(); + } else if (e.key === 'Enter' && !e.nativeEvent.isComposing) { + e.currentTarget.blur(); + } + }} + onFocus={() => { + setIsFocused(true); + }} + onBlur={(e) => { + isActive.current = false; + setIsFocused(false); + const selection = window.getSelection(); + selection && selection.removeAllRanges(); + if (inputRef.current) { + const str = inputRef.current.value.trim(); + handleInputChange(str); + e.target.value = String(displayValue); + } + }} + /> + </div> + <button + type="button" + className={cn( + 'flex h-full shrink-0 items-center justify-center rounded-r-md border-l-0 border-transparent bg-[#f5f5f5] px-2 text-sm transition-colors focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50', + )} + onClick={() => setIsOpen(!isOpen)} + > + <ChevronDownIcon className="size-4 opacity-50" /> + </button> + </div> + <PopoverContent className="w-auto p-1" align="start" sideOffset={4}> + <div className="max-h-[300px] min-w-[8rem] overflow-y-auto"> + {FONT_SIZE_OPTIONS.map((size) => ( + <div + key={size} + className={cn( + 'relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground', + typeof value === 'number' && + value === size && + 'bg-accent text-accent-foreground', + )} + onClick={() => handleSelectSize(size)} + > + <span className="absolute left-2 flex size-3.5 items-center justify-center"> + {typeof value === 'number' && value === size && ( + <CheckIcon className="size-4" /> + )} + </span> + {size} + </div> + ))} + </div> + </PopoverContent> + </Popover> + ); +}; diff --git a/apps/suika-multiplayer/src/components/input/LetterSpacingInput.tsx b/apps/suika-multiplayer/src/components/input/LetterSpacingInput.tsx new file mode 100644 index 000000000..6840001c8 --- /dev/null +++ b/apps/suika-multiplayer/src/components/input/LetterSpacingInput.tsx @@ -0,0 +1,105 @@ +import React, { useEffect, useState } from 'react'; +import { useIntl } from 'react-intl'; + +import { cn } from '@/lib/utils'; + +export interface LetterSpacingInputProps { + value: number; + unit: 'PIXELS' | 'PERCENT'; + onChange: (value: number, unit: 'PIXELS' | 'PERCENT') => void; + className?: string; + mixed?: boolean; +} + +export const LetterSpacingInput: React.FC<LetterSpacingInputProps> = ({ + value, + unit, + onChange, + className, + mixed = false, +}) => { + const intl = useIntl(); + const [inputValue, setInputValue] = useState(''); + const [isFocused, setIsFocused] = useState(false); + + // Helper to format the display value + const formatDisplayValue = (val: number, u: 'PIXELS' | 'PERCENT') => { + return `${val}${u === 'PIXELS' ? 'px' : '%'}`; + }; + + // Sync internal state with props when not focused + useEffect(() => { + if (!isFocused) { + if (mixed) { + setInputValue(intl.formatMessage({ id: 'mixed' })); + } else { + setInputValue(formatDisplayValue(value, unit)); + } + } + }, [value, unit, isFocused, mixed, intl]); + + const handleBlur = () => { + setIsFocused(false); + const selection = window.getSelection(); + selection && selection.removeAllRanges(); + parseAndSubmit(inputValue); + }; + + const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (!e.nativeEvent.isComposing && e.key === 'Enter') { + e.currentTarget.blur(); + } + }; + + const parseAndSubmit = (rawStr: string) => { + const str = rawStr.trim(); + + // Regex to capture number and optional suffix (px or %) + // Matches numbers like "10", "10.5", ".5", "-10", "-10.5", "10px", "10%", "10 px", "-10px", "-10%" + const match = str.match(/^(-?\d*\.?\d+)\s*(px|%)?$/); + + if (match) { + const num = parseFloat(match[1]); + const suffix = match[2]; + + let newUnit = unit; + if (suffix === 'px') { + newUnit = 'PIXELS'; + } else if (suffix === '%') { + newUnit = 'PERCENT'; + } + + // Update parent + onChange(num, newUnit); + + // Force update local state to ensure correct formatting + // (e.g. if user typed "10", we want "10px" or "10%") + setInputValue(formatDisplayValue(num, newUnit)); + } else { + // Invalid input (e.g. empty string or non-numeric), revert to current props + if (mixed) { + setInputValue(intl.formatMessage({ id: 'mixed' })); + } else { + setInputValue(formatDisplayValue(value, unit)); + } + } + }; + + return ( + <input + type="text" + className={cn( + 'flex h-9 w-full rounded-md border border-transparent bg-[#f5f5f5] px-3 py-1 text-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground hover:border-[#e6e6e6] focus-visible:outline-none focus-visible:border-[#0d99ff] disabled:cursor-not-allowed disabled:opacity-50', + className, + )} + value={inputValue} + onChange={(e) => setInputValue(e.target.value)} + onFocus={(e) => { + setIsFocused(true); + e.target.select(); + }} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + /> + ); +}; diff --git a/apps/suika-multiplayer/src/components/input/LineHeightInput.tsx b/apps/suika-multiplayer/src/components/input/LineHeightInput.tsx new file mode 100644 index 000000000..6de2f2270 --- /dev/null +++ b/apps/suika-multiplayer/src/components/input/LineHeightInput.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useState } from 'react'; +import { useIntl } from 'react-intl'; + +import { cn } from '@/lib/utils'; + +export interface LineHeightInputProps { + value: number; + unit: 'PIXELS' | 'PERCENT' | 'RAW'; + onChange: (value: number, unit: 'PIXELS' | 'PERCENT' | 'RAW') => void; + className?: string; + mixed?: boolean; +} + +export const LineHeightInput: React.FC<LineHeightInputProps> = ({ + value, + unit, + onChange, + className, + mixed = false, +}) => { + const intl = useIntl(); + const [inputValue, setInputValue] = useState(''); + const [isFocused, setIsFocused] = useState(false); + + // Helper to format the display value + const formatDisplayValue = (val: number, u: 'PIXELS' | 'PERCENT' | 'RAW') => { + if (u === 'RAW') { + return intl.formatMessage({ id: 'auto' }); + } + if (u === 'PIXELS') { + return `${val}`; + } + return `${val}%`; + }; + + // Sync internal state with props when not focused + useEffect(() => { + if (!isFocused) { + if (mixed) { + setInputValue(intl.formatMessage({ id: 'mixed' })); + } else { + setInputValue(formatDisplayValue(value, unit)); + } + } + }, [value, unit, isFocused, mixed, intl]); + + const handleBlur = () => { + setIsFocused(false); + const selection = window.getSelection(); + selection && selection.removeAllRanges(); + parseAndSubmit(inputValue); + }; + + const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (!e.nativeEvent.isComposing && e.key === 'Enter') { + e.currentTarget.blur(); + } + }; + + const parseAndSubmit = (rawStr: string) => { + const str = rawStr.trim(); + + // If empty string, set to Auto (RAW type with value 1) + if (str === '') { + onChange(1, 'RAW'); + setInputValue(intl.formatMessage({ id: 'auto' })); + return; + } + + // Regex to capture number and optional suffix (px or %) + // Matches numbers like "10", "10.5", ".5", "10px", "10%", "10 px" + // Also allows negative temporarily to check min value logic, though regex captures it. + const match = str.match(/^(-?\d*\.?\d+)\s*(px|%)?$/); + + if (match) { + let num = parseFloat(match[1]); + const suffix = match[2]; + + let newUnit: 'PIXELS' | 'PERCENT' | 'RAW' = 'PIXELS'; + if (suffix === 'px') { + newUnit = 'PIXELS'; + } else if (suffix === '%') { + newUnit = 'PERCENT'; + } + // If no suffix, default to PIXELS (pure numbers typically represent pixels) + + // Min value check + if (num < 0) num = 0; + + // Update parent + onChange(num, newUnit); + + // Force update local state to ensure correct formatting + // (e.g. if user typed "10", we want "10" for PIXELS or "10%" for PERCENT) + setInputValue(formatDisplayValue(num, newUnit)); + } else { + // Invalid input (e.g. non-numeric), revert to current props + if (mixed) { + setInputValue(intl.formatMessage({ id: 'mixed' })); + } else { + setInputValue(formatDisplayValue(value, unit)); + } + } + }; + + return ( + <input + type="text" + className={cn( + 'flex h-9 w-full rounded-md border border-transparent bg-[#f5f5f5] px-3 py-1 text-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground hover:border-[#e6e6e6] focus-visible:outline-none focus-visible:border-[#0d99ff] disabled:cursor-not-allowed disabled:opacity-50', + className, + )} + value={inputValue} + onChange={(e) => setInputValue(e.target.value)} + onFocus={(e) => { + setIsFocused(true); + const input = e.target; + const value = input.value; + // If value ends with '%', only select the number part (excluding the '%') + if (value.endsWith('%')) { + const numberLength = value.length - 1; + input.setSelectionRange(0, numberLength); + } else { + input.select(); + } + }} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + /> + ); +}; diff --git a/apps/suika-multiplayer/src/components/ui/button.tsx b/apps/suika-multiplayer/src/components/ui/button.tsx new file mode 100644 index 000000000..a7027a597 --- /dev/null +++ b/apps/suika-multiplayer/src/components/ui/button.tsx @@ -0,0 +1,60 @@ +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: + 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: + 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', + secondary: + 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: + 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2 has-[>svg]:px-3', + sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', + lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', + icon: 'size-9', + 'icon-sm': 'size-8', + 'icon-lg': 'size-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<'button'> & + VariantProps<typeof buttonVariants> & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : 'button'; + + return ( + <Comp + data-slot="button" + className={cn(buttonVariants({ variant, size, className }))} + {...props} + /> + ); +} + +export { Button, buttonVariants }; diff --git a/apps/suika-multiplayer/src/components/ui/popover.tsx b/apps/suika-multiplayer/src/components/ui/popover.tsx new file mode 100644 index 000000000..babecafad --- /dev/null +++ b/apps/suika-multiplayer/src/components/ui/popover.tsx @@ -0,0 +1,40 @@ +import * as PopoverPrimitive from '@radix-ui/react-popover'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Popover({ + ...props +}: React.ComponentProps<typeof PopoverPrimitive.Root>) { + return <PopoverPrimitive.Root data-slot="popover" {...props} />; +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { + return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />; +} + +function PopoverContent({ + className, + align = 'start', + sideOffset = 4, + ...props +}: React.ComponentProps<typeof PopoverPrimitive.Content>) { + return ( + <PopoverPrimitive.Portal> + <PopoverPrimitive.Content + data-slot="popover-content" + align={align} + sideOffset={sideOffset} + className={cn( + 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-none', + className, + )} + {...props} + /> + </PopoverPrimitive.Portal> + ); +} + +export { Popover, PopoverContent, PopoverTrigger }; diff --git a/apps/suika-multiplayer/src/components/ui/progress.tsx b/apps/suika-multiplayer/src/components/ui/progress.tsx new file mode 100644 index 000000000..570c30671 --- /dev/null +++ b/apps/suika-multiplayer/src/components/ui/progress.tsx @@ -0,0 +1,29 @@ +import * as ProgressPrimitive from '@radix-ui/react-progress'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Progress({ + className, + value, + ...props +}: React.ComponentProps<typeof ProgressPrimitive.Root>) { + return ( + <ProgressPrimitive.Root + data-slot="progress" + className={cn( + 'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', + className, + )} + {...props} + > + <ProgressPrimitive.Indicator + data-slot="progress-indicator" + className="bg-primary h-full w-full flex-1 transition-all" + style={{ transform: `translateX(-${100 - (value || 0)}%)` }} + /> + </ProgressPrimitive.Root> + ); +} + +export { Progress }; diff --git a/apps/suika-multiplayer/src/components/ui/select.tsx b/apps/suika-multiplayer/src/components/ui/select.tsx new file mode 100644 index 000000000..b512824ee --- /dev/null +++ b/apps/suika-multiplayer/src/components/ui/select.tsx @@ -0,0 +1,185 @@ +import * as SelectPrimitive from '@radix-ui/react-select'; +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Select({ + ...props +}: React.ComponentProps<typeof SelectPrimitive.Root>) { + return <SelectPrimitive.Root data-slot="select" {...props} />; +} + +function SelectGroup({ + ...props +}: React.ComponentProps<typeof SelectPrimitive.Group>) { + return <SelectPrimitive.Group data-slot="select-group" {...props} />; +} + +function SelectValue({ + ...props +}: React.ComponentProps<typeof SelectPrimitive.Value>) { + return <SelectPrimitive.Value data-slot="select-value" {...props} />; +} + +function SelectTrigger({ + className, + size = 'default', + children, + ...props +}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { + size?: 'sm' | 'default'; +}) { + return ( + <SelectPrimitive.Trigger + data-slot="select-trigger" + data-size={size} + className={cn( + "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className, + )} + {...props} + > + {children} + <SelectPrimitive.Icon asChild> + <ChevronDownIcon className="size-4 opacity-50" /> + </SelectPrimitive.Icon> + </SelectPrimitive.Trigger> + ); +} + +function SelectContent({ + className, + children, + position = 'popper', + align = 'center', + ...props +}: React.ComponentProps<typeof SelectPrimitive.Content>) { + return ( + <SelectPrimitive.Portal> + <SelectPrimitive.Content + data-slot="select-content" + className={cn( + 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md', + position === 'popper' && + 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', + className, + )} + position={position} + align={align} + {...props} + > + <SelectScrollUpButton /> + <SelectPrimitive.Viewport + className={cn( + 'p-1', + position === 'popper' && + 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1', + )} + > + {children} + </SelectPrimitive.Viewport> + <SelectScrollDownButton /> + </SelectPrimitive.Content> + </SelectPrimitive.Portal> + ); +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps<typeof SelectPrimitive.Label>) { + return ( + <SelectPrimitive.Label + data-slot="select-label" + className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)} + {...props} + /> + ); +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps<typeof SelectPrimitive.Item>) { + return ( + <SelectPrimitive.Item + data-slot="select-item" + className={cn( + "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", + className, + )} + {...props} + > + <span className="absolute left-2 flex size-3.5 items-center justify-center"> + <SelectPrimitive.ItemIndicator> + <CheckIcon className="size-4" /> + </SelectPrimitive.ItemIndicator> + </span> + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> + </SelectPrimitive.Item> + ); +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps<typeof SelectPrimitive.Separator>) { + return ( + <SelectPrimitive.Separator + data-slot="select-separator" + className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)} + {...props} + /> + ); +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) { + return ( + <SelectPrimitive.ScrollUpButton + data-slot="select-scroll-up-button" + className={cn( + 'flex cursor-default items-center justify-center py-1', + className, + )} + {...props} + > + <ChevronUpIcon className="size-4" /> + </SelectPrimitive.ScrollUpButton> + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) { + return ( + <SelectPrimitive.ScrollDownButton + data-slot="select-scroll-down-button" + className={cn( + 'flex cursor-default items-center justify-center py-1', + className, + )} + {...props} + > + <ChevronDownIcon className="size-4" /> + </SelectPrimitive.ScrollDownButton> + ); +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/apps/suika-multiplayer/src/components/ui/tooltip.tsx b/apps/suika-multiplayer/src/components/ui/tooltip.tsx new file mode 100644 index 000000000..ec4d07fe7 --- /dev/null +++ b/apps/suika-multiplayer/src/components/ui/tooltip.tsx @@ -0,0 +1,59 @@ +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps<typeof TooltipPrimitive.Provider>) { + return ( + <TooltipPrimitive.Provider + data-slot="tooltip-provider" + delayDuration={delayDuration} + {...props} + /> + ); +} + +function Tooltip({ + ...props +}: React.ComponentProps<typeof TooltipPrimitive.Root>) { + return ( + <TooltipProvider> + <TooltipPrimitive.Root data-slot="tooltip" {...props} /> + </TooltipProvider> + ); +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { + return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />; +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps<typeof TooltipPrimitive.Content>) { + return ( + <TooltipPrimitive.Portal> + <TooltipPrimitive.Content + data-slot="tooltip-content" + sideOffset={sideOffset} + className={cn( + 'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance', + className, + )} + {...props} + > + {children} + <TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" /> + </TooltipPrimitive.Content> + </TooltipPrimitive.Portal> + ); +} + +export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }; diff --git a/apps/suika-multiplayer/src/constant.ts b/apps/suika-multiplayer/src/constant.ts index 5172e7db8..1718d588c 100644 --- a/apps/suika-multiplayer/src/constant.ts +++ b/apps/suika-multiplayer/src/constant.ts @@ -1,3 +1,8 @@ export const DOUBLE_PI = Math.PI * 2; export const HALF_PI = Math.PI / 2; + +export const FONT_FILES = { + 'Smiley Sans': '/font_files/smiley-sans-oblique.otf', + 'Source Han Sans CN': '/font_files/SourceHanSansCN-Regular.otf', +}; diff --git a/apps/suika-multiplayer/src/index.css b/apps/suika-multiplayer/src/index.css index b03f9f81d..04d6aef27 100644 --- a/apps/suika-multiplayer/src/index.css +++ b/apps/suika-multiplayer/src/index.css @@ -1,6 +1,131 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + body { margin: 0; width: 100%; height: 100vh; overflow: hidden; } + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + + --spacing: 0.21rem; + --radius: 0.4rem; + --text-sm: 0.75rem; +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/apps/suika-multiplayer/src/lib/utils.ts b/apps/suika-multiplayer/src/lib/utils.ts new file mode 100644 index 000000000..9ad0df426 --- /dev/null +++ b/apps/suika-multiplayer/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/suika-multiplayer/src/locale/en.json b/apps/suika-multiplayer/src/locale/en.json index 9b1d002f4..8e20db8c1 100644 --- a/apps/suika-multiplayer/src/locale/en.json +++ b/apps/suika-multiplayer/src/locale/en.json @@ -30,6 +30,8 @@ "arrange.front": "Bring to Front", "arrange.back": "Send to Back", + "page.delete": "Delete page", + "zoom.zoomIn": "Zoom in", "zoom.zoomOut": "Zoom out", "zoom.zoomToFit": "Zoom to fit", @@ -54,8 +56,11 @@ "flip.horizontal": "Flip horizontal", "flip.vertical": "Flip vertical", + "file": "File", "import.originFile": "Import local file", "export.originFile": "Save local copy", + "export.currentPageAsSVG": "Export current page as SVG", + "export.currentPageAsPNG": "Export current page as PNG", "preference": "Preference", "keepToolSelectedAfterUse": "Keep tool selected after use", @@ -66,5 +71,19 @@ "done": "Done", - "layer": "Layer" + "layer": "Layer", + + "typography": "Typography", + + "fontSize": "Font size", + "letterSpacing": "Letter spacing", + "lineHeight": "Line height", + "auto": "Auto", + + "align.left": "Align left", + "align.horizontalCenter": "Align horizontal centers", + "align.right": "Align right", + "align.top": "Align top", + "align.verticalCenter": "Align vertical centers", + "align.bottom": "Align bottom" } diff --git a/apps/suika-multiplayer/src/locale/zh.json b/apps/suika-multiplayer/src/locale/zh.json index 88787158d..b95cf356c 100644 --- a/apps/suika-multiplayer/src/locale/zh.json +++ b/apps/suika-multiplayer/src/locale/zh.json @@ -30,6 +30,8 @@ "arrange.front": "移到顶部", "arrange.back": "移到底部", + "page.delete": "删除页面", + "zoom.zoomIn": "放大", "zoom.zoomOut": "缩小", "zoom.zoomToFit": "适应画布", @@ -53,8 +55,11 @@ "flip.horizontal": "水平翻转", "flip.vertical": "垂直翻转", + "file": "文件", "import.originFile": "从本地导入", "export.originFile": "导出到本地", + "export.currentPageAsSVG": "导出当前页面为 SVG", + "export.currentPageAsPNG": "导出当前页面为 PNG", "preference": "偏好设置", "keepToolSelectedAfterUse": "使用工具后保持选中状态", @@ -65,5 +70,19 @@ "done": "完成", - "layer": "图层" + "layer": "图层", + + "typography": "文字", + + "fontSize": "字号", + "letterSpacing": "字间距", + "lineHeight": "行高", + "auto": "自动", + + "align.left": "左对齐", + "align.horizontalCenter": "左右居中对齐", + "align.right": "右对齐", + "align.top": "顶部对齐", + "align.verticalCenter": "上下居中对齐", + "align.bottom": "底部对齐" } diff --git a/apps/suika-multiplayer/src/store/join-room.ts b/apps/suika-multiplayer/src/store/join-room.ts index 585c87ca9..1b9bc67e7 100644 --- a/apps/suika-multiplayer/src/store/join-room.ts +++ b/apps/suika-multiplayer/src/store/join-room.ts @@ -6,16 +6,26 @@ import { SuikaBinding } from './y-suika'; export const joinRoom = ( editor: SuikaEditor, roomId: string, - user: { username: string; id: number }, + user: { username: string; id: string }, ) => { const host = import.meta.env.DEV ? 'localhost:5356' : location.host; + // 读取 cookies 的 access_token + const accessToken = document.cookie + .split('; ') + .find((row) => row.startsWith('access_token=')) + ?.split('=')[1]; + const provider = new HocuspocusProvider({ url: `ws://${host}/join/room/`, - name: roomId, - token: document.cookie.slice(13), + name: roomId + '', + token: accessToken, onAuthenticationFailed: (data) => { - console.log('权限不足', data); + console.log('authentication failed', data); + // TODO: jump to login page + if (!import.meta.env.DEV) { + location.href = '/login'; + } }, }); diff --git a/apps/suika-multiplayer/src/store/y-suika.ts b/apps/suika-multiplayer/src/store/y-suika.ts index 483c33ee9..c9a284b6e 100644 --- a/apps/suika-multiplayer/src/store/y-suika.ts +++ b/apps/suika-multiplayer/src/store/y-suika.ts @@ -30,7 +30,7 @@ export class SuikaBinding { private yMap: YMap<Record<string, any>>, private editor: SuikaEditor, public awareness: NonNullable<HocuspocusProvider['awareness']>, - private user: { username: string; id: number }, + private user: { username: string; id: string }, ) { this.doc = yMap.doc!; // data @@ -51,6 +51,7 @@ export class SuikaBinding { // editor --> remote private suikaObserve = (ops: IChanges) => { + console.log('ops', ops); const yMap = this.yMap; devLog('[[editor --> remote]]'); devLog(ops); @@ -74,6 +75,7 @@ export class SuikaBinding { // remote --> editor private yMapObserve = (event: YMapEvent<any>) => { + console.log('--- yMapObserve'); const yMap = this.yMap; if (event.transaction.origin == this) { return; @@ -89,7 +91,7 @@ export class SuikaBinding { for (const [id, { action }] of event.changes.keys) { if (action === 'delete') { changes.deleted.add(id); - return; + continue; } const attrs = yMap.get(id) as GraphicsAttrs; if (action === 'add' && attrs.type !== GraphicsType.Document) { @@ -130,6 +132,7 @@ export class SuikaBinding { destroy() { // data + console.log('destroy'); this.yMap.unobserve(this.yMapObserve); this.editor.doc.off('sceneChange', this.suikaObserve); diff --git a/apps/suika-multiplayer/tsconfig.json b/apps/suika-multiplayer/tsconfig.json index 139499070..ce58f3771 100644 --- a/apps/suika-multiplayer/tsconfig.json +++ b/apps/suika-multiplayer/tsconfig.json @@ -21,8 +21,10 @@ "@suika/icons": ["../../packages/icons/src"], "@suika/components": ["../../packages/components/src"], "@suika/geo": ["../../packages/geo/src"], - "@suika/core": ["../../packages/core/src"] - } + "@suika/core": ["../../packages/core/src"], + "@/*": ["./src/*"] + }, + "baseUrl": "." }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] diff --git a/apps/suika-multiplayer/vite.config.ts b/apps/suika-multiplayer/vite.config.ts index 59a22a887..b73e39c11 100644 --- a/apps/suika-multiplayer/vite.config.ts +++ b/apps/suika-multiplayer/vite.config.ts @@ -1,10 +1,17 @@ +import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; +import path from 'path'; import { defineConfig } from 'vite'; import checker from 'vite-plugin-checker'; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react(), checker({ typescript: true })], + plugins: [react(), tailwindcss(), checker({ typescript: true })], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, base: './', server: { port: 6168, diff --git a/packages/core/src/editor.ts b/packages/core/src/editor.ts index 21aa5be52..ee7161ad1 100644 --- a/packages/core/src/editor.ts +++ b/packages/core/src/editor.ts @@ -138,6 +138,7 @@ export class SuikaEditor { const canvas = new SuikaCanvas( { + id: '0-1', objectName: 'Page 1', }, { @@ -179,6 +180,7 @@ export class SuikaEditor { if (!this.doc.getChildren().length) { const canvas = new SuikaCanvas( { + id: '0-1', objectName: 'Page 1', }, { @@ -267,6 +269,10 @@ export class SuikaEditor { continue; } graphics.updateAttrs(partialAttrs); + // 处理父子关系 + if (partialAttrs.parentIndex) { + graphics.insertAtParent(partialAttrs.parentIndex.position); + } } for (const id of changes.deleted) { diff --git a/packages/core/src/graphics/text/text.ts b/packages/core/src/graphics/text/text.ts index 34879d37e..66befcfc2 100644 --- a/packages/core/src/graphics/text/text.ts +++ b/packages/core/src/graphics/text/text.ts @@ -98,8 +98,12 @@ export class SuikaText extends SuikaGraphics<TextAttrs> { if (width === this.attrs.width && height === this.attrs.height) { return; } - this.attrs.width = width; - this.attrs.height = height; + // this.attrs.width = width; + // this.attrs.height = height; + this.updateAttrs({ + width, + height, + }); this.clearBboxCache(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 564b422fc..1a4897dfb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,7 +288,7 @@ importers: version: 6.8.9(react@18.3.1)(typescript@5.7.2) react-scripts: specifier: 5.0.1 - version: 5.0.1(@babel/plugin-syntax-flow@7.21.4(@babel/core@7.24.5))(@babel/plugin-transform-react-jsx@7.21.0(@babel/core@7.24.5))(@swc/core@1.3.53)(@types/babel__core@7.20.5)(esbuild@0.17.18)(eslint@8.57.1)(react@18.3.1)(sass@1.62.0)(ts-node@10.9.2(@swc/core@1.3.53)(@types/node@20.11.5)(typescript@5.7.2))(type-fest@2.19.0)(typescript@5.7.2) + version: 5.0.1(@babel/plugin-syntax-flow@7.21.4(@babel/core@7.26.10))(@babel/plugin-transform-react-jsx@7.21.0(@babel/core@7.26.10))(@swc/core@1.3.53)(@types/babel__core@7.20.5)(esbuild@0.17.18)(eslint@8.57.1)(react@18.3.1)(sass@1.62.0)(ts-node@10.9.2(@swc/core@1.3.53)(@types/node@20.11.5)(typescript@5.7.2))(type-fest@2.19.0)(typescript@5.7.2) sass: specifier: ^1.57.1 version: 1.62.0 @@ -342,8 +342,23 @@ importers: specifier: ^0.22.3 version: 0.22.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@hocuspocus/provider': - specifier: ^2.13.5 - version: 2.13.5(y-protocols@1.0.6(yjs@13.6.24))(yjs@13.6.24) + specifier: ^2.15.3 + version: 2.15.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@18.3.5(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-progress': + specifier: ^1.1.8 + version: 1.1.8(@types/react-dom@18.3.5(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-select': + specifier: ^2.2.6 + version: 2.2.6(@types/react-dom@18.3.5(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': + specifier: ^1.2.3 + version: 1.2.4(@types/react@18.3.20)(react@18.3.1) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@18.3.5(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@suika/common': specifier: workspace:^ version: link:../../packages/common @@ -359,15 +374,27 @@ importers: '@suika/icons': specifier: workspace:^ version: link:../../packages/icons + '@tailwindcss/vite': + specifier: ^4.1.15 + version: 4.1.15(vite@5.4.14(@types/node@20.11.5)(less@4.2.2)(lightningcss@1.30.2)(sass@1.62.0)(terser@5.39.0)) ahooks: specifier: ^3.7.4 version: 3.7.6(react@18.3.1) axios: specifier: ^1.7.3 version: 1.7.3 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 classnames: specifier: ^2.3.2 version: 2.5.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + lucide-react: + specifier: ^0.546.0 + version: 0.546.0(react@18.3.1) react: specifier: ^18.2.0 version: 18.3.1 @@ -382,19 +409,25 @@ importers: version: 6.8.9(react@18.3.1)(typescript@5.7.2) react-scripts: specifier: 5.0.1 - version: 5.0.1(@babel/plugin-syntax-flow@7.21.4(@babel/core@7.26.10))(@babel/plugin-transform-react-jsx@7.21.0(@babel/core@7.26.10))(@swc/core@1.3.53)(@types/babel__core@7.20.5)(esbuild@0.17.18)(eslint@8.57.1)(react@18.3.1)(sass@1.62.0)(ts-node@10.9.2(@swc/core@1.3.53)(@types/node@20.11.5)(typescript@5.7.2))(type-fest@2.19.0)(typescript@5.7.2) + version: 5.0.1(@babel/plugin-syntax-flow@7.21.4(@babel/core@7.24.5))(@babel/plugin-transform-react-jsx@7.21.0(@babel/core@7.24.5))(@swc/core@1.3.53)(@types/babel__core@7.20.5)(esbuild@0.17.18)(eslint@8.57.1)(react@18.3.1)(sass@1.62.0)(ts-node@10.9.2(@swc/core@1.3.53)(@types/node@20.11.5)(typescript@5.7.2))(type-fest@2.19.0)(typescript@5.7.2) sass: specifier: ^1.57.1 version: 1.62.0 stats.js: specifier: ^0.17.0 version: 0.17.0 + tailwind-merge: + specifier: ^3.3.1 + version: 3.3.1 + tailwindcss: + specifier: ^4.1.15 + version: 4.1.15 y-websocket: - specifier: ^2.0.3 - version: 2.0.3(yjs@13.6.24) + specifier: ^2.1.0 + version: 2.1.0(yjs@13.6.29) yjs: - specifier: ^13.6.17 - version: 13.6.24 + specifier: ^13.6.29 + version: 13.6.29 devDependencies: '@types/react': specifier: ^18.2.25 @@ -420,6 +453,9 @@ importers: eslint-plugin-react-refresh: specifier: ^0.4.5 version: 0.4.19(eslint@8.57.1) + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 vite: specifier: ^5.0.8 version: 5.4.14(@types/node@20.11.5)(less@4.2.2)(lightningcss@1.30.2)(sass@1.62.0)(terser@5.39.0) @@ -2276,12 +2312,12 @@ packages: typescript: optional: true - '@hocuspocus/common@2.13.5': - resolution: {integrity: sha512-8D9FzhZFlt0WsgXw5yT2zwSxi6z9d4V2vUz6co2vo3Cj+Y2bvGZsdDiTvU/MerGcCLME5k/w6PwLPojLYH/4pg==} - '@hocuspocus/common@2.15.2': resolution: {integrity: sha512-wU1wxXNnQQMXyeL3mdSDYiQsm/r/QyJVjjQhF7sUBrLnjdsN7bA1cvfcSvJBr1ymrMSeYRmUL3UlQmEHEOaP7w==} + '@hocuspocus/common@2.15.3': + resolution: {integrity: sha512-Rzh1HF0a2o/tf90A3w2XNdXd9Ym3aQzMDfD3lAUONCX9B9QOdqdyiORrj6M25QEaJrEIbXFy8LtAFcL0wRdWzA==} + '@hocuspocus/extension-database@2.15.2': resolution: {integrity: sha512-BkYDfKA99udx7AEkqWReBS61kvGMC9SqoPJs3v8xNgpaj2GGyMJQlUdQRMhPyZTn2osV+pqhk8Hn7xUJCW1RJg==} peerDependencies: @@ -2290,8 +2326,8 @@ packages: '@hocuspocus/extension-logger@2.15.2': resolution: {integrity: sha512-nqSnSFI+xO7dBTsgzSANKvx09ptq8J4Doz3AdLgxfaweYC85qFao7mAx1ZCtWoVHseVwBYua6S3dTwQq5IsWEg==} - '@hocuspocus/provider@2.13.5': - resolution: {integrity: sha512-G3S0OiFSYkmbOwnbhV7FyJs4OBqB/+1YT9c44Ujux1RKowGm5H8+0p3FUHfXwd/3v9V0jE+E1FnFKoGonJSQwA==} + '@hocuspocus/provider@2.15.3': + resolution: {integrity: sha512-oadN05m+KL4ylNKVo5YspNG4MXkT2Y+FUFzrgigpQeTjQibkPUwCNmUnkUxMgrGRgxb+O0lJCfirFIJMxedctA==} peerDependencies: y-protocols: ^1.0.6 yjs: ^13.6.8 @@ -11430,8 +11466,8 @@ packages: peerDependencies: yjs: ^13.0.0 - y-websocket@2.0.3: - resolution: {integrity: sha512-fWmz2EhmocEx5U8IzVV3rVcsbhRuZIwg9hsOVNfAtflii8BX68s1KNwop+h+vcJSjh+mvLeYMic7XIolVZ5mzQ==} + y-websocket@2.1.0: + resolution: {integrity: sha512-WHYDRqomaGkkaujtowCDwL8KYk+t1zQCGIgKyvxvchhjTQlMgWXRHJK+FDEcWmHA7I7o/4fy0eniOrtmz0e4mA==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} hasBin: true peerDependencies: @@ -11478,6 +11514,10 @@ packages: resolution: {integrity: sha512-xn/pYLTZa3uD1uDG8lpxfLRo5SR/rp0frdASOl2a71aYNvUXdWcLtVL91s2y7j+Q8ppmjZ9H3jsGVgoFMbT2VA==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yjs@13.6.29: + resolution: {integrity: sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -14492,11 +14532,11 @@ snapshots: optionalDependencies: typescript: 5.7.2 - '@hocuspocus/common@2.13.5': + '@hocuspocus/common@2.15.2': dependencies: lib0: 0.2.94 - '@hocuspocus/common@2.15.2': + '@hocuspocus/common@2.15.3': dependencies: lib0: 0.2.94 @@ -14518,14 +14558,14 @@ snapshots: - y-protocols - yjs - '@hocuspocus/provider@2.13.5(y-protocols@1.0.6(yjs@13.6.24))(yjs@13.6.24)': + '@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)': dependencies: - '@hocuspocus/common': 2.13.5 + '@hocuspocus/common': 2.15.3 '@lifeomic/attempt': 3.1.0 lib0: 0.2.94 ws: 8.18.1 - y-protocols: 1.0.6(yjs@13.6.24) - yjs: 13.6.24 + y-protocols: 1.0.6(yjs@13.6.29) + yjs: 13.6.29 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -26014,11 +26054,11 @@ snapshots: xtend@4.0.2: {} - y-leveldb@0.1.2(yjs@13.6.24): + y-leveldb@0.1.2(yjs@13.6.29): dependencies: level: 6.0.1 lib0: 0.2.94 - yjs: 13.6.24 + yjs: 13.6.29 optional: true y-protocols@1.0.6(yjs@13.6.24): @@ -26026,15 +26066,20 @@ snapshots: lib0: 0.2.94 yjs: 13.6.24 - y-websocket@2.0.3(yjs@13.6.24): + y-protocols@1.0.6(yjs@13.6.29): + dependencies: + lib0: 0.2.94 + yjs: 13.6.29 + + y-websocket@2.1.0(yjs@13.6.29): dependencies: lib0: 0.2.94 lodash.debounce: 4.0.8 - y-protocols: 1.0.6(yjs@13.6.24) - yjs: 13.6.24 + y-protocols: 1.0.6(yjs@13.6.29) + yjs: 13.6.29 optionalDependencies: ws: 6.2.2 - y-leveldb: 0.1.2(yjs@13.6.24) + y-leveldb: 0.1.2(yjs@13.6.29) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -26082,6 +26127,10 @@ snapshots: dependencies: lib0: 0.2.101 + yjs@13.6.29: + dependencies: + lib0: 0.2.101 + yn@3.1.1: {} yocto-queue@0.1.0: {}