diff --git a/package-lock.json b/package-lock.json index 72f18891..66140258 100644 --- a/package-lock.json +++ b/package-lock.json @@ -141,7 +141,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1840,7 +1839,6 @@ "resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-2.10.9.tgz", "integrity": "sha512-lhdcgoocOiURwBNR3L8OioCNIaGCZqRfuKioLyaQLjOanl4jr0PQclsGb+w0cmito252vEWpsz2xRqF7y+Flrw==", "license": "MIT", - "peer": true, "dependencies": { "@chakra-ui/hooks": "2.4.5", "@chakra-ui/styled-system": "2.12.4", @@ -1866,7 +1864,6 @@ "resolved": "https://registry.npmjs.org/@chakra-ui/styled-system/-/styled-system-2.12.4.tgz", "integrity": "sha512-oa07UG7Lic5hHSQtGRiMEnYjuhIa8lszyuVhZjZqR2Ap3VMF688y1MVPJ1pK+8OwY5uhXBgVd5c0+rI8aBZlwg==", "license": "MIT", - "peer": true, "dependencies": { "@chakra-ui/utils": "2.2.5", "csstype": "^3.1.2" @@ -2016,7 +2013,6 @@ "url": "https://opencollective.com/csstools" } ], - "peer": true, "engines": { "node": ">=18" }, @@ -2061,7 +2057,6 @@ "url": "https://opencollective.com/csstools" } ], - "peer": true, "engines": { "node": ">=18" } @@ -2164,7 +2159,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2208,7 +2202,6 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2897,6 +2890,7 @@ "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -3019,7 +3013,6 @@ "resolved": "https://registry.npmjs.org/@railmapgen/rmg-runtime/-/rmg-runtime-12.0.3.tgz", "integrity": "sha512-yu5+8i/76+VABIEvk1yUGklZ5NTc9EIKm6gBZxKFa0H47cz7X0ZrHDnQ0ogvMnHmtN93IkO+L5k/l0POSvVrfQ==", "license": "GPL-3.0-only", - "peer": true, "dependencies": { "i18next": "^25.1.2" } @@ -3784,7 +3777,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3916,7 +3910,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", "devOptional": true, - "peer": true, "dependencies": { "undici-types": "~7.12.0" } @@ -3933,7 +3926,6 @@ "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3944,7 +3936,6 @@ "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -4021,7 +4012,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.44.0.tgz", "integrity": "sha512-VGMpFQGUQWYT9LfnPcX8ouFojyrZ/2w3K5BucvxL/spdNehccKhB4jUyB1yBCXpr2XFm0jkECxgrpXBW2ipoAw==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.0", "@typescript-eslint/types": "8.44.0", @@ -4708,7 +4698,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4730,13 +4719,15 @@ "version": "12.1.2", "resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-12.1.2.tgz", "integrity": "sha512-B5IEMzaDxpJRko9sREIBEZOn44tlMX353tMJ1RGYHZrjDFx4on/p6q3YxBvway3nViAM2POmFOS45+1fF9YC8g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ag-grid-community": { "version": "34.1.2", "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-34.1.2.tgz", "integrity": "sha512-PN79zG/tigZaVKkQZXaithLO3j4JWp+9CjqEFth6kFUwMJ0E6OcNzNtn4NFEKM38fEdpGsr+k+gFk5KUE2h5fA==", "license": "MIT", + "peer": true, "dependencies": { "ag-charts-types": "12.1.2" } @@ -4789,6 +4780,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -5157,7 +5149,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -5195,7 +5186,8 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "devOptional": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cac": { "version": "6.7.14", @@ -5404,7 +5396,8 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "devOptional": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/concat-map": { "version": "0.0.1", @@ -5724,7 +5717,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dot-case": { "version": "3.0.4", @@ -6031,7 +6025,6 @@ "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6092,7 +6085,6 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6219,7 +6211,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6910,8 +6901,7 @@ "version": "0.24.8", "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/graphology-utils": { "version": "2.5.2", @@ -7091,7 +7081,6 @@ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" } ], - "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -7668,7 +7657,6 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.0.tgz", "integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==", "dev": true, - "peer": true, "dependencies": { "@asamuzakjp/dom-selector": "^6.5.4", "cssstyle": "^5.3.0", @@ -7879,6 +7867,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -8015,6 +8004,7 @@ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.12.tgz", "integrity": "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==", "license": "MIT", + "peer": true, "dependencies": { "motion-utils": "^12.23.6" } @@ -8023,7 +8013,8 @@ "version": "12.23.6", "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ms": { "version": "2.1.3", @@ -8450,7 +8441,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8494,7 +8484,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -8524,6 +8513,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -8539,6 +8529,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -8551,7 +8542,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prop-types": { "version": "15.8.1", @@ -8599,7 +8591,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8621,7 +8612,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -8713,7 +8703,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -8890,8 +8879,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -9099,7 +9087,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -9453,6 +9440,7 @@ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -9464,6 +9452,7 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "devOptional": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10043,7 +10032,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10164,7 +10152,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -10290,7 +10277,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", diff --git a/src/components/context-menu.tsx b/src/components/context-menu.tsx index 3c223d87..87a48c40 100644 --- a/src/components/context-menu.tsx +++ b/src/components/context-menu.tsx @@ -2,12 +2,23 @@ import { Box, Divider, Portal, useOutsideClick } from '@chakra-ui/react'; import React from 'react'; import { useTranslation } from 'react-i18next'; import useEvent from 'react-use-event-hook'; -import { Id } from '../constants/constants'; +import { Id, LineId, NodeId } from '../constants/constants'; import { MAX_MASTER_NODE_FREE } from '../constants/master'; import { useRootDispatch, useRootSelector } from '../redux'; import { saveGraph } from '../redux/param/param-slice'; import { clearSelected, refreshEdgesThunk, refreshNodesThunk, setSelected } from '../redux/runtime/runtime-slice'; -import { exportSelectedNodesAndEdges, importSelectedNodesAndEdges } from '../util/clipboard'; +import { + exportSelectedNodesAndEdges, + importSelectedNodesAndEdges, + exportNodeSpecificAttrs, + exportEdgeSpecificAttrs, + parseClipboardData, + importNodeSpecificAttrs, + importEdgeSpecificAttrs, + getSelectedElementsType, + NodeSpecificAttrsClipboardData, + EdgeSpecificAttrsClipboardData, +} from '../util/clipboard'; import { pointerPosToSVGCoord, roundToMultiple } from '../util/helpers'; import { MAX_PARALLEL_LINES_FREE } from '../util/parallel'; import { flipSelectedNodes, rotateSelectedNodes } from '../util/transform'; @@ -45,6 +56,12 @@ const ContextMenu: React.FC = ({ isOpen, position, onClose }) ); const menuRef = React.useRef(null); + // Check selection type for copy/paste attributes + const selectionInfo = getSelectedElementsType(graph.current, selected); + const canCopyAttrs = selected.size === 1; + const canPasteAttrs = + selectionInfo.allSameType && (selectionInfo.category === 'node' || selectionInfo.category === 'edge'); + useOutsideClick({ ref: menuRef, handler: onClose, @@ -156,6 +173,52 @@ const ContextMenu: React.FC = ({ isOpen, position, onClose }) } }); + const handleCopyAttrs = useEvent(() => { + if (selected.size !== 1) return; + const [id] = selected; + + if (graph.current.hasNode(id)) { + const s = exportNodeSpecificAttrs(graph.current, id as NodeId); + navigator.clipboard.writeText(s); + } else if (graph.current.hasEdge(id)) { + const s = exportEdgeSpecificAttrs(graph.current, id as LineId); + navigator.clipboard.writeText(s); + } + }); + + const handlePasteAttrs = useEvent(async () => { + try { + const s = await navigator.clipboard.readText(); + const parsed = parseClipboardData(s); + if (!parsed) return; + + if (parsed.type === 'node-attrs' && selectionInfo.category === 'node') { + const nodeIds = new Set(); + selected.forEach(id => { + if (graph.current.hasNode(id)) { + nodeIds.add(id as NodeId); + } + }); + if (importNodeSpecificAttrs(graph.current, nodeIds, parsed.data as NodeSpecificAttrsClipboardData)) { + refreshAndSave(); + } + } else if (parsed.type === 'edge-attrs' && selectionInfo.category === 'edge') { + const edgeIds = new Set(); + selected.forEach(id => { + if (graph.current.hasEdge(id)) { + edgeIds.add(id as LineId); + } + }); + if (importEdgeSpecificAttrs(graph.current, edgeIds, parsed.data as EdgeSpecificAttrsClipboardData)) { + refreshAndSave(); + } + } + } catch (error) { + // Handle clipboard read error + console.warn('Failed to read clipboard:', error); + } + }); + const handleRotate = useEvent((angle: number) => { if (rotateSelectedNodes(graph.current, selected, angle)) { refreshAndSave(); @@ -235,6 +298,25 @@ const ContextMenu: React.FC = ({ isOpen, position, onClose }) {t('contextMenu.delete')} + { + handleCopyAttrs(); + onClose(); + }} + isDisabled={!canCopyAttrs} + > + {t('contextMenu.copyAttrs')} + + { + handlePasteAttrs(); + onClose(); + }} + isDisabled={!canPasteAttrs} + > + {t('contextMenu.pasteAttrs')} + + { handleZIndex(10); diff --git a/src/components/page-header/settings-modal.tsx b/src/components/page-header/settings-modal.tsx index 495563d4..93804ee3 100644 --- a/src/components/page-header/settings-modal.tsx +++ b/src/components/page-header/settings-modal.tsx @@ -408,6 +408,42 @@ const SettingsModal = (props: { isOpen: boolean; onClose: () => void }) => { {t('header.settings.shortcuts.paste')} + + + {isMacClient ? ( + <> + + {' + '} + + {' + '} + c + + ) : ( + <> + ctrl + shift + c + + )} + + {t('header.settings.shortcuts.copyAttrs')} + + + + {isMacClient ? ( + <> + + {' + '} + + {' + '} + v + + ) : ( + <> + ctrl + shift + v + + )} + + {t('header.settings.shortcuts.pasteAttrs')} + {isMacClient ? : ctrl} diff --git a/src/components/panels/details/details.tsx b/src/components/panels/details/details.tsx index e35b4791..a8f47a3b 100644 --- a/src/components/panels/details/details.tsx +++ b/src/components/panels/details/details.tsx @@ -1,9 +1,9 @@ -import { Box, Button, Heading, HStack } from '@chakra-ui/react'; +import { Box, Button, Heading, HStack, VStack } from '@chakra-ui/react'; import { RmgSidePanel, RmgSidePanelBody, RmgSidePanelFooter, RmgSidePanelHeader } from '@railmapgen/rmg-components'; import { nanoid } from 'nanoid'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { Id, StnId } from '../../../constants/constants'; +import { Id, LineId, NodeId, StnId } from '../../../constants/constants'; import { MAX_MASTER_NODE_FREE } from '../../../constants/master'; import { MiscNodeType } from '../../../constants/nodes'; import { useRootDispatch, useRootSelector } from '../../../redux'; @@ -14,7 +14,17 @@ import { refreshEdgesThunk, refreshNodesThunk, } from '../../../redux/runtime/runtime-slice'; -import { exportSelectedNodesAndEdges } from '../../../util/clipboard'; +import { + exportSelectedNodesAndEdges, + exportNodeSpecificAttrs, + exportEdgeSpecificAttrs, + parseClipboardData, + importNodeSpecificAttrs, + importEdgeSpecificAttrs, + getSelectedElementsType, + NodeSpecificAttrsClipboardData, + EdgeSpecificAttrsClipboardData, +} from '../../../util/clipboard'; import { isPortraitClient } from '../../../util/helpers'; import { checkAndChangeStationIntType } from '../../../util/change-types'; import InfoSection from './info-section'; @@ -44,6 +54,12 @@ const DetailsPanel = () => { const isMasterDisabled = !activeSubscriptions.RMP_CLOUD && masterNodesCount + 1 > MAX_MASTER_NODE_FREE; + // Check if we can paste specific attributes + const selectionInfo = getSelectedElementsType(graph.current, selected); + const canCopyAttrs = selected.size === 1; + const canPasteAttrs = + selectionInfo.allSameType && (selectionInfo.category === 'node' || selectionInfo.category === 'edge'); + const handleClose = () => { if (!isPortraitClient()) { dispatch(clearSelected()); @@ -82,6 +98,52 @@ const DetailsPanel = () => { hardRefresh(); }; + const handleCopyAttrs = () => { + if (selected.size !== 1) return; + const id = selectedFirst; + + if (graph.current.hasNode(id)) { + const s = exportNodeSpecificAttrs(graph.current, id as NodeId); + navigator.clipboard.writeText(s); + } else if (graph.current.hasEdge(id)) { + const s = exportEdgeSpecificAttrs(graph.current, id as LineId); + navigator.clipboard.writeText(s); + } + }; + + const handlePasteAttrs = async () => { + try { + const s = await navigator.clipboard.readText(); + const parsed = parseClipboardData(s); + if (!parsed) return; + + if (parsed.type === 'node-attrs' && selectionInfo.category === 'node') { + const nodeIds = new Set(); + selected.forEach(id => { + if (graph.current.hasNode(id)) { + nodeIds.add(id as NodeId); + } + }); + if (importNodeSpecificAttrs(graph.current, nodeIds, parsed.data as NodeSpecificAttrsClipboardData)) { + hardRefresh(); + } + } else if (parsed.type === 'edge-attrs' && selectionInfo.category === 'edge') { + const edgeIds = new Set(); + selected.forEach(id => { + if (graph.current.hasEdge(id)) { + edgeIds.add(id as LineId); + } + }); + if (importEdgeSpecificAttrs(graph.current, edgeIds, parsed.data as EdgeSpecificAttrsClipboardData)) { + hardRefresh(); + } + } + } catch (error) { + // Handle clipboard read error + console.warn('Failed to read clipboard:', error); + } + }; + return ( {t('panel.details.header')} @@ -103,27 +165,37 @@ const DetailsPanel = () => { )} - - {selected.size === 1 && graph.current.hasNode(selectedFirst) && ( - + )} + + + + + + - )} - - - + + ); diff --git a/src/components/svg-wrapper.tsx b/src/components/svg-wrapper.tsx index 161edfb1..aca0965e 100644 --- a/src/components/svg-wrapper.tsx +++ b/src/components/svg-wrapper.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { MdDoubleArrow } from 'react-icons/md'; import useEvent from 'react-use-event-hook'; import { NODES_MOVE_DISTANCE } from '../constants/canvas'; -import { Events, Id, NodeId, RuntimeMode, StnId } from '../constants/constants'; +import { Events, Id, NodeId, RuntimeMode, StnId, LineId } from '../constants/constants'; import { LinePathType } from '../constants/lines'; import { MAX_MASTER_NODE_FREE } from '../constants/master'; import { MiscNodeType } from '../constants/nodes'; @@ -25,7 +25,18 @@ import { showDetailsPanel, } from '../redux/runtime/runtime-slice'; import { checkAndChangeStationIntType } from '../util/change-types'; -import { exportSelectedNodesAndEdges, importSelectedNodesAndEdges } from '../util/clipboard'; +import { + exportSelectedNodesAndEdges, + importSelectedNodesAndEdges, + exportNodeSpecificAttrs, + exportEdgeSpecificAttrs, + parseClipboardData, + importNodeSpecificAttrs, + importEdgeSpecificAttrs, + getSelectedElementsType, + NodeSpecificAttrsClipboardData, + EdgeSpecificAttrsClipboardData, +} from '../util/clipboard'; import { findEdgesConnectedByNodes, findNodesInRectangle } from '../util/graph'; import { getCanvasSize, @@ -323,6 +334,54 @@ const SvgWrapper = () => { const allElements = structuredClone(nodes) as Set; edges.forEach(s => allElements.add(s)); dispatch(setSelected(allElements)); + } else if (e.key === 'C' && (isMacClient ? e.metaKey && e.shiftKey : e.ctrlKey && e.shiftKey)) { + // Copy specific attributes (Ctrl+Shift+C or Cmd+Shift+C) + if (selected.size === 1) { + const [id] = selected; + if (graph.current.hasNode(id)) { + const s = exportNodeSpecificAttrs(graph.current, id as NodeId); + navigator.clipboard.writeText(s); + } else if (graph.current.hasEdge(id)) { + const s = exportEdgeSpecificAttrs(graph.current, id as LineId); + navigator.clipboard.writeText(s); + } + } + } else if (e.key === 'V' && (isMacClient ? e.metaKey && e.shiftKey : e.ctrlKey && e.shiftKey)) { + // Paste specific attributes (Ctrl+Shift+V or Cmd+Shift+V) + try { + const s = await navigator.clipboard.readText(); + const parsed = parseClipboardData(s); + if (!parsed) return; + + const selectionInfo = getSelectedElementsType(graph.current, selected); + if (parsed.type === 'node-attrs' && selectionInfo.category === 'node') { + const nodeIds = new Set(); + selected.forEach(id => { + if (graph.current.hasNode(id)) { + nodeIds.add(id as NodeId); + } + }); + if ( + importNodeSpecificAttrs(graph.current, nodeIds, parsed.data as NodeSpecificAttrsClipboardData) + ) { + refreshAndSave(); + } + } else if (parsed.type === 'edge-attrs' && selectionInfo.category === 'edge') { + const edgeIds = new Set(); + selected.forEach(id => { + if (graph.current.hasEdge(id)) { + edgeIds.add(id as LineId); + } + }); + if ( + importEdgeSpecificAttrs(graph.current, edgeIds, parsed.data as EdgeSpecificAttrsClipboardData) + ) { + refreshAndSave(); + } + } + } catch (error) { + console.warn('Failed to read clipboard:', error); + } } else if ( (isMacClient && e.key === 'z' && e.metaKey && e.shiftKey) || (!isMacClient && e.key === 'y' && e.ctrlKey) diff --git a/src/i18n/translations/en.json b/src/i18n/translations/en.json index 3e06fd79..126e83d4 100644 --- a/src/i18n/translations/en.json +++ b/src/i18n/translations/en.json @@ -673,7 +673,9 @@ "footer": { "duplicate": "Duplicate", "copy": "Copy", - "remove": "Remove" + "remove": "Remove", + "copyAttrs": "Copy Specific Attributes", + "pasteAttrs": "Paste Specific Attributes" } } }, @@ -829,6 +831,8 @@ "cut": "Cut.", "copy": "Copy.", "paste": "Paste.", + "copyAttrs": "Copy specific attributes.", + "pasteAttrs": "Paste specific attributes.", "undo": "Undo.", "redo": "Redo." }, @@ -932,11 +936,13 @@ }, "contextMenu": { + "refresh": "Refresh", "copy": "Copy", "cut": "Cut", "paste": "Paste", "delete": "Delete", - "refresh": "Refresh", + "copyAttrs": "Copy Specific Attributes", + "pasteAttrs": "Paste Specific Attributes", "placeTop": "Place top", "placeBottom": "Place bottom", "placeDefault": "Place to default", diff --git a/src/i18n/translations/ja.json b/src/i18n/translations/ja.json index 3f45fc9e..cde298a9 100644 --- a/src/i18n/translations/ja.json +++ b/src/i18n/translations/ja.json @@ -675,7 +675,9 @@ "footer": { "duplicate": "重複", "copy": "複製", - "remove": "削除" + "remove": "削除", + "copyAttrs": "独自属性をコピー", + "pasteAttrs": "独自属性を貼り付け" } } }, @@ -831,6 +833,8 @@ "cut": "切り取る。", "copy": "複製する。", "paste": "貼り付ける。", + "copyAttrs": "独自属性をコピーする。", + "pasteAttrs": "独自属性を貼り付ける。", "undo": "元に戻す。", "redo": "やり直す。" }, @@ -933,11 +937,13 @@ }, "contextMenu": { + "refresh": "更新", "copy": "コピー", "cut": "切り取り", "paste": "貼り付け", "delete": "削除", - "refresh": "更新", + "copyAttrs": "独自属性をコピー", + "pasteAttrs": "独自属性を貼り付け", "placeTop": "最前面へ", "placeBottom": "最背面へ", "placeDefault": "デフォルト位置へ", diff --git a/src/i18n/translations/ko.json b/src/i18n/translations/ko.json index cfe3613d..8b18cac9 100644 --- a/src/i18n/translations/ko.json +++ b/src/i18n/translations/ko.json @@ -672,7 +672,9 @@ "footer": { "duplicate": "복사", "copy": "복사", - "remove": "삭제" + "remove": "삭제", + "copyAttrs": "고유 속성 복사", + "pasteAttrs": "고유 속성 붙여넣기" } } }, @@ -828,6 +830,8 @@ "cut": "잘라내기.", "copy": "복사하다.", "paste": "붙여넣다.", + "copyAttrs": "고유 속성 복사.", + "pasteAttrs": "고유 속성 붙여넣기.", "undo": "취소하다.", "redo": "다시 하다." }, @@ -931,11 +935,13 @@ }, "contextMenu": { + "refresh": "새로 고침", "copy": "복사", "cut": "잘라내기", "paste": "붙여넣기", "delete": "삭제", - "refresh": "새로 고침", + "copyAttrs": "고유 속성 복사", + "pasteAttrs": "고유 속성 붙여넣기", "placeTop": "맨 앞으로", "placeBottom": "맨 뒤로", "placeDefault": "기본 위치로", diff --git a/src/i18n/translations/zh-Hans.json b/src/i18n/translations/zh-Hans.json index 5374deb3..da0b1752 100644 --- a/src/i18n/translations/zh-Hans.json +++ b/src/i18n/translations/zh-Hans.json @@ -672,7 +672,9 @@ "footer": { "duplicate": "重复", "copy": "复制", - "remove": "移除" + "remove": "移除", + "copyAttrs": "复制独特属性", + "pasteAttrs": "粘贴独特属性" } } }, @@ -828,6 +830,8 @@ "cut": "剪切。", "copy": "复制。", "paste": "粘贴。", + "copyAttrs": "复制独特属性。", + "pasteAttrs": "粘贴独特属性。", "undo": "撤销。", "redo": "重做。" }, @@ -931,11 +935,13 @@ }, "contextMenu": { + "refresh": "刷新", "copy": "复制", "cut": "剪切", "paste": "粘贴", "delete": "删除", - "refresh": "刷新", + "copyAttrs": "复制独特属性", + "pasteAttrs": "粘贴独特属性", "placeTop": "置顶", "placeBottom": "置底", "placeDefault": "还原位置", diff --git a/src/i18n/translations/zh-Hant.json b/src/i18n/translations/zh-Hant.json index 1f9b5a01..0afc8083 100644 --- a/src/i18n/translations/zh-Hant.json +++ b/src/i18n/translations/zh-Hant.json @@ -672,7 +672,9 @@ "footer": { "duplicate": "重複", "copy": "複製", - "remove": "移除" + "remove": "移除", + "copyAttrs": "複製獨特屬性", + "pasteAttrs": "貼上獨特屬性" } } }, @@ -828,6 +830,8 @@ "cut": "剪切。", "copy": "複製。", "paste": "貼上。", + "copyAttrs": "複製獨特屬性。", + "pasteAttrs": "貼上獨特屬性。", "undo": "撤銷。", "redo": "重做。" }, @@ -931,11 +935,13 @@ }, "contextMenu": { + "refresh": "重新整理", "copy": "複製", "cut": "剪下", "paste": "貼上", "delete": "刪除", - "refresh": "重新整理", + "copyAttrs": "複製獨特屬性", + "pasteAttrs": "貼上獨特屬性", "placeTop": "置頂", "placeBottom": "置底", "placeDefault": "還原位置", diff --git a/src/util/clipboard.test.ts b/src/util/clipboard.test.ts new file mode 100644 index 00000000..63b97309 --- /dev/null +++ b/src/util/clipboard.test.ts @@ -0,0 +1,450 @@ +import { MultiDirectedGraph } from 'graphology'; +import { describe, expect, it, beforeEach } from 'vitest'; +import { EdgeAttributes, GraphAttributes, LineId, NodeAttributes, NodeId } from '../constants/constants'; +import { LinePathType, LineStyleType } from '../constants/lines'; +import { StationType } from '../constants/stations'; +import { + exportNodeSpecificAttrs, + exportEdgeSpecificAttrs, + parseClipboardData, + importNodeSpecificAttrs, + importEdgeSpecificAttrs, + getSelectedElementsType, + NodeSpecificAttrsClipboardData, + EdgeSpecificAttrsClipboardData, + CLIPBOARD_VERSION, +} from './clipboard'; +import { CURRENT_VERSION } from './save'; + +describe('Unit tests for specific attributes clipboard functions', () => { + let graph: MultiDirectedGraph; + + beforeEach(() => { + graph = new MultiDirectedGraph(); + }); + + describe('exportNodeSpecificAttrs', () => { + it('should export specific attributes from a node', () => { + const nodeId = 'stn_test1' as NodeId; + graph.addNode(nodeId, { + x: 100, + y: 200, + type: StationType.ShmetroBasic, + visible: true, + zIndex: 0, + [StationType.ShmetroBasic]: { + names: ['测试站', 'Test Station'], + nameOffsetX: 'middle', + nameOffsetY: 'bottom', + }, + } as NodeAttributes); + + const result = exportNodeSpecificAttrs(graph, nodeId); + const parsed = JSON.parse(result) as NodeSpecificAttrsClipboardData; + + expect(parsed.app).toBe('rmp'); + expect(parsed.version).toBe(1); + expect(parsed.type).toBe('node-attrs'); + expect(parsed.nodeType).toBe(StationType.ShmetroBasic); + expect(parsed.specificAttrs).toEqual({ + names: ['测试站', 'Test Station'], + nameOffsetX: 'middle', + nameOffsetY: 'bottom', + }); + }); + + it('should handle node without specific attributes', () => { + const nodeId = 'stn_test2' as NodeId; + graph.addNode(nodeId, { + x: 100, + y: 200, + type: StationType.ShmetroBasic, + visible: true, + zIndex: 0, + } as NodeAttributes); + + const result = exportNodeSpecificAttrs(graph, nodeId); + const parsed = JSON.parse(result) as NodeSpecificAttrsClipboardData; + + expect(parsed.specificAttrs).toEqual({}); + }); + }); + + describe('exportEdgeSpecificAttrs', () => { + it('should export style attributes and roundCornerFactor from an edge', () => { + const nodeId1 = 'stn_node1' as NodeId; + const nodeId2 = 'stn_node2' as NodeId; + const edgeId = 'line_test1' as LineId; + + graph.addNode(nodeId1, { x: 0, y: 0, type: StationType.ShmetroBasic, visible: true, zIndex: 0 }); + graph.addNode(nodeId2, { x: 100, y: 100, type: StationType.ShmetroBasic, visible: true, zIndex: 0 }); + graph.addDirectedEdgeWithKey(edgeId, nodeId1, nodeId2, { + type: LinePathType.Diagonal, + style: LineStyleType.SingleColor, + visible: true, + zIndex: 0, + reconcileId: 'test', + parallelIndex: -1, + [LinePathType.Diagonal]: { + startFrom: 'from', + offsetFrom: 0, + offsetTo: 0, + roundCornerFactor: 15, + }, + [LineStyleType.SingleColor]: { + color: ['shanghai', 'sh1', '#e4002b', '#fff'], + }, + } as EdgeAttributes); + + const result = exportEdgeSpecificAttrs(graph, edgeId); + const parsed = JSON.parse(result) as EdgeSpecificAttrsClipboardData; + + expect(parsed.app).toBe('rmp'); + expect(parsed.version).toBe(1); + expect(parsed.type).toBe('edge-attrs'); + expect(parsed.pathType).toBe(LinePathType.Diagonal); + expect(parsed.styleType).toBe(LineStyleType.SingleColor); + expect(parsed.roundCornerFactor).toBe(15); + expect(parsed.styleAttrs).toEqual({ + color: ['shanghai', 'sh1', '#e4002b', '#fff'], + }); + }); + + it('should not include roundCornerFactor if not present (simple path)', () => { + const nodeId1 = 'stn_node1' as NodeId; + const nodeId2 = 'stn_node2' as NodeId; + const edgeId = 'line_test2' as LineId; + + graph.addNode(nodeId1, { x: 0, y: 0, type: StationType.ShmetroBasic, visible: true, zIndex: 0 }); + graph.addNode(nodeId2, { x: 100, y: 100, type: StationType.ShmetroBasic, visible: true, zIndex: 0 }); + graph.addDirectedEdgeWithKey(edgeId, nodeId1, nodeId2, { + type: LinePathType.Simple, + style: LineStyleType.SingleColor, + visible: true, + zIndex: 0, + reconcileId: 'test', + parallelIndex: -1, + [LinePathType.Simple]: { + offset: 0, + }, + [LineStyleType.SingleColor]: { + color: ['shanghai', 'sh1', '#e4002b', '#fff'], + }, + } as EdgeAttributes); + + const result = exportEdgeSpecificAttrs(graph, edgeId); + const parsed = JSON.parse(result) as EdgeSpecificAttrsClipboardData; + + expect(parsed.roundCornerFactor).toBeUndefined(); + }); + }); + + describe('parseClipboardData', () => { + it('should parse node-attrs clipboard data', () => { + const data: NodeSpecificAttrsClipboardData = { + app: 'rmp', + version: CLIPBOARD_VERSION, + saveVersion: CURRENT_VERSION, + type: 'node-attrs', + nodeType: StationType.ShmetroBasic, + specificAttrs: { names: ['Test'] }, + }; + + const result = parseClipboardData(JSON.stringify(data)); + + expect(result).not.toBeNull(); + expect(result?.type).toBe('node-attrs'); + }); + + it('should parse edge-attrs clipboard data', () => { + const data: EdgeSpecificAttrsClipboardData = { + app: 'rmp', + version: CLIPBOARD_VERSION, + saveVersion: CURRENT_VERSION, + type: 'edge-attrs', + pathType: LinePathType.Diagonal, + styleType: LineStyleType.SingleColor, + roundCornerFactor: 10, + styleAttrs: {}, + }; + + const result = parseClipboardData(JSON.stringify(data)); + + expect(result).not.toBeNull(); + expect(result?.type).toBe('edge-attrs'); + }); + + it('should parse elements clipboard data', () => { + const data = { + app: 'rmp', + version: CLIPBOARD_VERSION, + saveVersion: CURRENT_VERSION, + type: 'elements', + nodesWithAttrs: {}, + edgesWithAttrs: {}, + avgX: 0, + avgY: 0, + }; + + const result = parseClipboardData(JSON.stringify(data)); + + expect(result).not.toBeNull(); + expect(result?.type).toBe('elements'); + }); + + it('should return null for invalid data', () => { + expect(parseClipboardData('not json')).toBeNull(); + expect(parseClipboardData(JSON.stringify({ app: 'other' }))).toBeNull(); + expect(parseClipboardData(JSON.stringify({ app: 'rmp', version: 999 }))).toBeNull(); + // Missing saveVersion should also return null + expect(parseClipboardData(JSON.stringify({ app: 'rmp', version: CLIPBOARD_VERSION }))).toBeNull(); + // Wrong saveVersion should return null + expect( + parseClipboardData( + JSON.stringify({ + app: 'rmp', + version: CLIPBOARD_VERSION, + saveVersion: CURRENT_VERSION + 1, + type: 'elements', + }) + ) + ).toBeNull(); + }); + }); + + describe('importNodeSpecificAttrs', () => { + it('should apply attributes to nodes of the same type', () => { + const nodeId1 = 'stn_test1' as NodeId; + const nodeId2 = 'stn_test2' as NodeId; + + graph.addNode(nodeId1, { + x: 100, + y: 200, + type: StationType.ShmetroBasic, + visible: true, + zIndex: 0, + }); + graph.addNode(nodeId2, { + x: 300, + y: 400, + type: StationType.ShmetroBasic, + visible: true, + zIndex: 0, + }); + + const data: NodeSpecificAttrsClipboardData = { + app: 'rmp', + version: CLIPBOARD_VERSION, + saveVersion: CURRENT_VERSION, + type: 'node-attrs', + nodeType: StationType.ShmetroBasic, + specificAttrs: { names: ['新站', 'New Station'], nameOffsetX: 'left' }, + }; + + const result = importNodeSpecificAttrs(graph, new Set([nodeId1, nodeId2]), data); + + expect(result).toBe(true); + expect(graph.getNodeAttribute(nodeId1, StationType.ShmetroBasic)).toEqual({ + names: ['新站', 'New Station'], + nameOffsetX: 'left', + }); + expect(graph.getNodeAttribute(nodeId2, StationType.ShmetroBasic)).toEqual({ + names: ['新站', 'New Station'], + nameOffsetX: 'left', + }); + }); + + it('should not apply attributes to nodes of different type', () => { + const nodeId = 'stn_test1' as NodeId; + + graph.addNode(nodeId, { + x: 100, + y: 200, + type: StationType.GzmtrBasic, + visible: true, + zIndex: 0, + }); + + const data: NodeSpecificAttrsClipboardData = { + app: 'rmp', + version: CLIPBOARD_VERSION, + saveVersion: CURRENT_VERSION, + type: 'node-attrs', + nodeType: StationType.ShmetroBasic, + specificAttrs: { names: ['新站', 'New Station'] }, + }; + + const result = importNodeSpecificAttrs(graph, new Set([nodeId]), data); + + expect(result).toBe(false); + expect(graph.getNodeAttribute(nodeId, StationType.ShmetroBasic)).toBeUndefined(); + }); + }); + + describe('importEdgeSpecificAttrs', () => { + it('should apply style attributes to edges of the same style type', () => { + const nodeId1 = 'stn_node1' as NodeId; + const nodeId2 = 'stn_node2' as NodeId; + const edgeId = 'line_test1' as LineId; + + graph.addNode(nodeId1, { x: 0, y: 0, type: StationType.ShmetroBasic, visible: true, zIndex: 0 }); + graph.addNode(nodeId2, { x: 100, y: 100, type: StationType.ShmetroBasic, visible: true, zIndex: 0 }); + graph.addDirectedEdgeWithKey(edgeId, nodeId1, nodeId2, { + type: LinePathType.Diagonal, + style: LineStyleType.SingleColor, + visible: true, + zIndex: 0, + reconcileId: 'test', + parallelIndex: -1, + } as EdgeAttributes); + + const data: EdgeSpecificAttrsClipboardData = { + app: 'rmp', + version: CLIPBOARD_VERSION, + saveVersion: CURRENT_VERSION, + type: 'edge-attrs', + pathType: LinePathType.Diagonal, + styleType: LineStyleType.SingleColor, + roundCornerFactor: 20, + styleAttrs: { color: ['beijing', 'bj1', '#c23a30', '#fff'] }, + }; + + const result = importEdgeSpecificAttrs(graph, new Set([edgeId]), data); + + expect(result).toBe(true); + expect(graph.getEdgeAttribute(edgeId, LineStyleType.SingleColor)).toEqual({ + color: ['beijing', 'bj1', '#c23a30', '#fff'], + }); + expect(graph.getEdgeAttribute(edgeId, LinePathType.Diagonal)).toEqual({ + roundCornerFactor: 20, + }); + }); + + it('should not apply roundCornerFactor if path type differs', () => { + const nodeId1 = 'stn_node1' as NodeId; + const nodeId2 = 'stn_node2' as NodeId; + const edgeId = 'line_test1' as LineId; + + graph.addNode(nodeId1, { x: 0, y: 0, type: StationType.ShmetroBasic, visible: true, zIndex: 0 }); + graph.addNode(nodeId2, { x: 100, y: 100, type: StationType.ShmetroBasic, visible: true, zIndex: 0 }); + graph.addDirectedEdgeWithKey(edgeId, nodeId1, nodeId2, { + type: LinePathType.Simple, + style: LineStyleType.SingleColor, + visible: true, + zIndex: 0, + reconcileId: 'test', + parallelIndex: -1, + } as EdgeAttributes); + + const data: EdgeSpecificAttrsClipboardData = { + app: 'rmp', + version: CLIPBOARD_VERSION, + saveVersion: CURRENT_VERSION, + type: 'edge-attrs', + pathType: LinePathType.Diagonal, + styleType: LineStyleType.SingleColor, + roundCornerFactor: 20, + styleAttrs: { color: ['beijing', 'bj1', '#c23a30', '#fff'] }, + }; + + const result = importEdgeSpecificAttrs(graph, new Set([edgeId]), data); + + expect(result).toBe(true); // Style still applied + expect(graph.getEdgeAttribute(edgeId, LineStyleType.SingleColor)).toEqual({ + color: ['beijing', 'bj1', '#c23a30', '#fff'], + }); + // roundCornerFactor should not be applied to Simple path + expect(graph.getEdgeAttribute(edgeId, LinePathType.Simple)).toBeUndefined(); + }); + }); + + describe('getSelectedElementsType', () => { + it('should detect all same node type', () => { + const nodeId1 = 'stn_test1' as NodeId; + const nodeId2 = 'stn_test2' as NodeId; + + graph.addNode(nodeId1, { x: 0, y: 0, type: StationType.ShmetroBasic, visible: true, zIndex: 0 }); + graph.addNode(nodeId2, { x: 100, y: 100, type: StationType.ShmetroBasic, visible: true, zIndex: 0 }); + + const result = getSelectedElementsType(graph, new Set([nodeId1, nodeId2])); + + expect(result.allSameType).toBe(true); + expect(result.category).toBe('node'); + expect(result.nodeType).toBe(StationType.ShmetroBasic); + }); + + it('should detect different node types', () => { + const nodeId1 = 'stn_test1' as NodeId; + const nodeId2 = 'stn_test2' as NodeId; + + graph.addNode(nodeId1, { x: 0, y: 0, type: StationType.ShmetroBasic, visible: true, zIndex: 0 }); + graph.addNode(nodeId2, { x: 100, y: 100, type: StationType.GzmtrBasic, visible: true, zIndex: 0 }); + + const result = getSelectedElementsType(graph, new Set([nodeId1, nodeId2])); + + expect(result.allSameType).toBe(false); + expect(result.category).toBe('node'); + }); + + it('should detect all same edge style type', () => { + const nodeId1 = 'stn_node1' as NodeId; + const nodeId2 = 'stn_node2' as NodeId; + const edgeId1 = 'line_test1' as LineId; + const edgeId2 = 'line_test2' as LineId; + + graph.addNode(nodeId1, { x: 0, y: 0, type: StationType.ShmetroBasic, visible: true, zIndex: 0 }); + graph.addNode(nodeId2, { x: 100, y: 100, type: StationType.ShmetroBasic, visible: true, zIndex: 0 }); + graph.addDirectedEdgeWithKey(edgeId1, nodeId1, nodeId2, { + type: LinePathType.Diagonal, + style: LineStyleType.SingleColor, + visible: true, + zIndex: 0, + reconcileId: 'test1', + parallelIndex: -1, + } as EdgeAttributes); + graph.addDirectedEdgeWithKey(edgeId2, nodeId2, nodeId1, { + type: LinePathType.Simple, + style: LineStyleType.SingleColor, + visible: true, + zIndex: 0, + reconcileId: 'test2', + parallelIndex: -1, + } as EdgeAttributes); + + const result = getSelectedElementsType(graph, new Set([edgeId1, edgeId2])); + + expect(result.allSameType).toBe(true); + expect(result.category).toBe('edge'); + expect(result.edgeStyleType).toBe(LineStyleType.SingleColor); + }); + + it('should detect mixed nodes and edges', () => { + const nodeId = 'stn_test1' as NodeId; + const nodeId2 = 'stn_test2' as NodeId; + const edgeId = 'line_test1' as LineId; + + graph.addNode(nodeId, { x: 0, y: 0, type: StationType.ShmetroBasic, visible: true, zIndex: 0 }); + graph.addNode(nodeId2, { x: 100, y: 100, type: StationType.ShmetroBasic, visible: true, zIndex: 0 }); + graph.addDirectedEdgeWithKey(edgeId, nodeId, nodeId2, { + type: LinePathType.Diagonal, + style: LineStyleType.SingleColor, + visible: true, + zIndex: 0, + reconcileId: 'test', + parallelIndex: -1, + } as EdgeAttributes); + + const result = getSelectedElementsType(graph, new Set([nodeId, edgeId])); + + expect(result.allSameType).toBe(false); + expect(result.category).toBe('mixed'); + }); + + it('should handle empty selection', () => { + const result = getSelectedElementsType(graph, new Set()); + + expect(result.allSameType).toBe(false); + expect(result.category).toBe(null); + }); + }); +}); diff --git a/src/util/clipboard.ts b/src/util/clipboard.ts index 6d1b241b..10f09b0e 100644 --- a/src/util/clipboard.ts +++ b/src/util/clipboard.ts @@ -1,23 +1,83 @@ import { MultiDirectedGraph } from 'graphology'; import { nanoid } from 'nanoid'; -import { EdgeAttributes, GraphAttributes, Id, LineId, NodeAttributes, NodeId } from '../constants/constants'; -import { LinePathType } from '../constants/lines'; +import { + EdgeAttributes, + EdgeType, + GraphAttributes, + Id, + LineId, + NodeAttributes, + NodeId, + NodeType, +} from '../constants/constants'; +import { + ExternalLinePathAttributes, + ExternalLineStyleAttributes, + LinePathType, + LineStyleType, +} from '../constants/lines'; import { MiscNodeType } from '../constants/nodes'; import { makeParallelIndex, NonSimpleLinePathAttributes } from './parallel'; +import { CURRENT_VERSION } from './save'; type NodesWithAttrs = { [key in NodeId]: NodeAttributes }; type EdgesWithAttrs = { [key in LineId]: { attr: EdgeAttributes; source: NodeId; target: NodeId }; }; + +/** + * Clipboard data type discriminator. + * - 'elements': Copy of entire nodes/edges + * - 'node-attrs': Copy of specific attributes for a node + * - 'edge-attrs': Copy of specific attributes for an edge (line) + */ +export type ClipboardType = 'elements' | 'node-attrs' | 'edge-attrs'; + +/** + * Current clipboard format version. Increment this when clipboard data structure changes. + */ +export const CLIPBOARD_VERSION = 1; + interface ClipboardData { app: 'rmp'; version: number; + saveVersion: number; + type: ClipboardType; nodesWithAttrs: NodesWithAttrs; edgesWithAttrs: EdgesWithAttrs; avgX: number; avgY: number; } +/** + * Clipboard data for specific node attributes copy/paste. + */ +export interface NodeSpecificAttrsClipboardData { + app: 'rmp'; + version: number; + saveVersion: number; + type: 'node-attrs'; + nodeType: NodeType; + specificAttrs: Record; +} + +/** + * Clipboard data for specific edge attributes copy/paste. + * For edges, only roundCornerFactor from path (if present) and all style attributes are copied. + */ +export interface EdgeSpecificAttrsClipboardData { + app: 'rmp'; + version: number; + saveVersion: number; + type: 'edge-attrs'; + pathType: EdgeType; + styleType: LineStyleType; + roundCornerFactor?: number; + styleAttrs: Record; +} + +export type SpecificAttrsClipboardData = NodeSpecificAttrsClipboardData | EdgeSpecificAttrsClipboardData; + export const exportSelectedNodesAndEdges = ( graph: MultiDirectedGraph, selected: Set @@ -46,7 +106,9 @@ export const exportSelectedNodesAndEdges = ( }); const data: ClipboardData = { app: 'rmp', - version: 1, + version: CLIPBOARD_VERSION, + saveVersion: CURRENT_VERSION, + type: 'elements', nodesWithAttrs, edgesWithAttrs, avgX: sumX / countNode, @@ -56,7 +118,8 @@ export const exportSelectedNodesAndEdges = ( }; /** - * Import nodes and edges from the clipboard data. Version of the data must be the same as the current. + * Import nodes and edges from the clipboard data. + * Validates that clipboard version matches CLIPBOARD_VERSION and saveVersion matches CURRENT_VERSION. * @param s The text from the clipboard. * @param graph The graph. * @param isMasterDisabled Whether filter master nodes on paste (no subscription only). @@ -73,8 +136,9 @@ export const importSelectedNodesAndEdges = ( x: number, y: number ) => { - const { nodesWithAttrs: nodes, edgesWithAttrs: edges, version } = JSON.parse(s) as ClipboardData; - if (version !== 1) throw Error(`Unrecognized version: ${version}`); + const { nodesWithAttrs: nodes, edgesWithAttrs: edges, version, saveVersion } = JSON.parse(s) as ClipboardData; + if (version !== CLIPBOARD_VERSION) throw Error(`Unrecognized clipboard version: ${version}`); + if (saveVersion !== CURRENT_VERSION) throw Error(`Save version mismatch: ${saveVersion} vs ${CURRENT_VERSION}`); // rename id to be not existed in the current graph const renamedMap: { [key in string]: string } = {}; @@ -138,3 +202,229 @@ export const importSelectedNodesAndEdges = ( edges: new Set(Object.keys(filteredEdges)) as Set, }; }; + +/** + * Export specific attributes of a single node. + * @param graph The graph. + * @param nodeId The ID of the node. + * @returns JSON string of the specific attributes. + */ +export const exportNodeSpecificAttrs = ( + graph: MultiDirectedGraph, + nodeId: NodeId +): string => { + const nodeType = graph.getNodeAttribute(nodeId, 'type'); + const specificAttrs = (graph.getNodeAttribute(nodeId, nodeType) ?? {}) as Record; + + const data: NodeSpecificAttrsClipboardData = { + app: 'rmp', + version: CLIPBOARD_VERSION, + saveVersion: CURRENT_VERSION, + type: 'node-attrs', + nodeType, + specificAttrs, + }; + return JSON.stringify(data); +}; + +/** + * Export specific attributes of a single edge (line). + * For edges, only roundCornerFactor from path (if present) and all style attributes are copied. + * @param graph The graph. + * @param edgeId The ID of the edge. + * @returns JSON string of the specific attributes. + */ +export const exportEdgeSpecificAttrs = ( + graph: MultiDirectedGraph, + edgeId: LineId +): string => { + const pathType = graph.getEdgeAttribute(edgeId, 'type'); + const styleType = graph.getEdgeAttribute(edgeId, 'style'); + const pathAttrs = (graph.getEdgeAttribute(edgeId, pathType) ?? {}) as Record; + const styleAttrs = (graph.getEdgeAttribute(edgeId, styleType) ?? {}) as Record; + + const data: EdgeSpecificAttrsClipboardData = { + app: 'rmp', + version: CLIPBOARD_VERSION, + saveVersion: CURRENT_VERSION, + type: 'edge-attrs', + pathType, + styleType, + styleAttrs, + }; + + // Only include roundCornerFactor if present in path attributes + if ('roundCornerFactor' in pathAttrs) { + data.roundCornerFactor = pathAttrs.roundCornerFactor as number; + } + + return JSON.stringify(data); +}; + +/** + * Parse clipboard text and determine its type. + * @param s The clipboard text. + * @returns The parsed clipboard data, or null if invalid or versions don't match. + */ +export const parseClipboardData = ( + s: string +): { type: ClipboardType; data: ClipboardData | SpecificAttrsClipboardData } | null => { + try { + const parsed = JSON.parse(s); + if (parsed.app !== 'rmp') { + return null; + } + // Validate clipboard version + if (parsed.version !== CLIPBOARD_VERSION) { + return null; + } + // Validate save version + if (parsed.saveVersion !== CURRENT_VERSION) { + return null; + } + + if (parsed.type === 'node-attrs') { + return { type: 'node-attrs', data: parsed as NodeSpecificAttrsClipboardData }; + } else if (parsed.type === 'edge-attrs') { + return { type: 'edge-attrs', data: parsed as EdgeSpecificAttrsClipboardData }; + } else if (parsed.type === 'elements') { + return { type: 'elements', data: parsed as ClipboardData }; + } + return null; + } catch { + return null; + } +}; + +/** + * Import specific attributes to nodes. + * @param graph The graph. + * @param targetIds Set of node IDs to apply the attributes to. + * @param data The clipboard data containing node-specific attributes. + * @returns True if attributes were successfully applied. + */ +export const importNodeSpecificAttrs = ( + graph: MultiDirectedGraph, + targetIds: Set, + data: NodeSpecificAttrsClipboardData +): boolean => { + let success = false; + targetIds.forEach(nodeId => { + const targetNodeType = graph.getNodeAttribute(nodeId, 'type'); + // Only paste if the node types match + if (targetNodeType === data.nodeType) { + graph.mergeNodeAttributes(nodeId, { [targetNodeType]: data.specificAttrs }); + success = true; + } + }); + return success; +}; + +/** + * Import specific attributes to edges. + * For edges, roundCornerFactor is applied if the target path has this attribute. + * Style attrs are only applied if the edge has the same style type. + * @param graph The graph. + * @param targetIds Set of edge IDs to apply the attributes to. + * @param data The clipboard data containing edge-specific attributes. + * @returns True if attributes were successfully applied. + */ +export const importEdgeSpecificAttrs = ( + graph: MultiDirectedGraph, + targetIds: Set, + data: EdgeSpecificAttrsClipboardData +): boolean => { + let success = false; + targetIds.forEach(edgeId => { + const targetPathType = graph.getEdgeAttribute(edgeId, 'type'); + const targetStyleType = graph.getEdgeAttribute(edgeId, 'style'); + + // Apply style attributes if style type matches (required for edge paste) + if (targetStyleType === data.styleType) { + graph.mergeEdgeAttributes(edgeId, { [targetStyleType]: data.styleAttrs }); + success = true; + + // Apply roundCornerFactor if clipboard has it AND target path supports this attribute + if (data.roundCornerFactor !== undefined && hasRoundCornerFactor(targetPathType)) { + const currentPathAttrs = (graph.getEdgeAttribute(edgeId, targetPathType) ?? {}) as Record< + string, + unknown + >; + graph.mergeEdgeAttributes(edgeId, { + [targetPathType]: { ...currentPathAttrs, roundCornerFactor: data.roundCornerFactor }, + }); + } + } + }); + return success; +}; + +/** + * Check if a path type supports roundCornerFactor attribute. + */ +const hasRoundCornerFactor = (pathType: EdgeType): boolean => { + // diagonal, perpendicular, and rotate-perpendicular paths have roundCornerFactor + // simple path does NOT have roundCornerFactor + return ( + pathType === LinePathType.Diagonal || + pathType === LinePathType.Perpendicular || + pathType === LinePathType.RotatePerpendicular + ); +}; + +/** + * Check if all selected elements have the same type. + * @param graph The graph. + * @param selected Set of selected element IDs. + * @returns Object containing whether all are same type, the type category ('node' or 'edge'), and the specific type. + */ +export const getSelectedElementsType = ( + graph: MultiDirectedGraph, + selected: Set +): { + allSameType: boolean; + category: 'node' | 'edge' | 'mixed' | null; + nodeType?: NodeType; + edgeStyleType?: LineStyleType; +} => { + if (selected.size === 0) { + return { allSameType: false, category: null }; + } + + let hasNodes = false; + let hasEdges = false; + let nodeType: NodeType | undefined; + let edgeStyleType: LineStyleType | undefined; + let allSameNodeType = true; + let allSameEdgeStyleType = true; + + selected.forEach(id => { + if (graph.hasNode(id)) { + hasNodes = true; + const type = graph.getNodeAttribute(id, 'type'); + if (nodeType === undefined) { + nodeType = type; + } else if (nodeType !== type) { + allSameNodeType = false; + } + } else if (graph.hasEdge(id)) { + hasEdges = true; + const style = graph.getEdgeAttribute(id, 'style'); + if (edgeStyleType === undefined) { + edgeStyleType = style; + } else if (edgeStyleType !== style) { + allSameEdgeStyleType = false; + } + } + }); + + if (hasNodes && hasEdges) { + return { allSameType: false, category: 'mixed' }; + } else if (hasNodes) { + return { allSameType: allSameNodeType, category: 'node', nodeType }; + } else if (hasEdges) { + return { allSameType: allSameEdgeStyleType, category: 'edge', edgeStyleType }; + } + + return { allSameType: false, category: null }; +};