Feat(Menu): Add tool menu via drag and drop
Drag and drop a tool card to add tool menu
This commit is contained in:
56
package-lock.json
generated
56
package-lock.json
generated
@@ -15,6 +15,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ant-design/icons": "^5.3.1",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@electron-toolkit/eslint-config-prettier": "^2.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^1.0.1",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
@@ -602,6 +605,59 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz",
|
||||
"integrity": "sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.1.0.tgz",
|
||||
"integrity": "sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
|
||||
"integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron-toolkit/eslint-config-prettier": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@electron-toolkit/eslint-config-prettier/-/eslint-config-prettier-2.0.0.tgz",
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ant-design/icons": "^5.3.1",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@electron-toolkit/eslint-config-prettier": "^2.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^1.0.1",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
|
||||
@@ -55,13 +55,14 @@
|
||||
.scroll {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
ul {
|
||||
> li {
|
||||
> li, > div > li {
|
||||
padding: 2px 14px;
|
||||
&.item {
|
||||
position: relative;
|
||||
margin: 4px 14px;
|
||||
font-size: 1.4em;
|
||||
|
||||
>.menu-bt {
|
||||
@@ -78,6 +79,10 @@
|
||||
height: 40px;
|
||||
font-size: constants.$SIZE_ICON_SM;
|
||||
cursor: pointer;
|
||||
|
||||
img{
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -86,6 +91,7 @@
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transition: all 0.2s;
|
||||
background-color: constants.$origin-color;
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
@@ -94,7 +100,12 @@
|
||||
|
||||
&.active {
|
||||
color: constants.$origin-color;
|
||||
background-color: constants.$main-color !important;
|
||||
background-color: constants.$main-color;
|
||||
|
||||
img {
|
||||
filter: drop-shadow(1000px 0 0 constants.$origin-color);
|
||||
transform: translate(-1000px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,11 +147,11 @@
|
||||
|
||||
&.active {
|
||||
color: constants.$origin-color;
|
||||
background-color: constants.$main-color !important;
|
||||
background-color: constants.$main-color;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover a {
|
||||
&:hover a:not(.active) {
|
||||
background-color: constants.$background-color;
|
||||
}
|
||||
}
|
||||
@@ -149,7 +160,7 @@
|
||||
|
||||
&:hover {
|
||||
>.menu-bt {
|
||||
a {
|
||||
a:not(.active) {
|
||||
background-color: constants.$background-color;
|
||||
}
|
||||
}
|
||||
@@ -171,6 +182,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.delete {
|
||||
.menu-bt {
|
||||
border: {
|
||||
width: 1px;
|
||||
color: constants.$error-secondary-color;
|
||||
style: dashed;
|
||||
};
|
||||
filter: drop-shadow(1000px 0 0 constants.$error-secondary-color);
|
||||
transform: translate(-1000px);
|
||||
|
||||
> a {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +284,7 @@
|
||||
}
|
||||
|
||||
.menu-bt {
|
||||
.text {
|
||||
.text, .extend {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
[data-component=component-drag-handle] {
|
||||
background-color: transparent;
|
||||
color: unset;
|
||||
cursor: grab;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
@use '@/assets/css/constants' as constants;
|
||||
|
||||
[data-component=component-repository-card] {
|
||||
height: 100%;
|
||||
|
||||
.repository-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -12,23 +14,25 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
|
||||
.version-select {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
width: 9em;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.upgrade-bt {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
font-size: 1.8em;
|
||||
>:not(.version-select) {
|
||||
font-size: 1.6em;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
padding-top: 50px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 20px;
|
||||
color: constants.$production-color;
|
||||
font-size: constants.$SIZE_ICON_XL;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@use '@/assets/css/constants' as constants;
|
||||
|
||||
[data-component=component-store-card] {
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
.store-card {
|
||||
@@ -14,10 +15,31 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
justify-content: space-between;
|
||||
|
||||
.version {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.operation {
|
||||
display: flex;
|
||||
font-size: 1.6em;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
|
||||
> *:hover {
|
||||
color: constants.$font-secondary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
padding-top: 40px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 20px;
|
||||
color: constants.$production-color;
|
||||
font-size: constants.$SIZE_ICON_XL;
|
||||
@@ -28,12 +50,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.version {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 10px;
|
||||
}
|
||||
|
||||
.info {
|
||||
padding-top: 20px;
|
||||
|
||||
@@ -43,8 +59,16 @@
|
||||
}
|
||||
|
||||
.tool-desc {
|
||||
margin-top: 10px;
|
||||
margin: {
|
||||
top: 10px;
|
||||
left: auto;
|
||||
right: auto;
|
||||
};
|
||||
color: constants.$font-secondary-color;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-height: 40px;
|
||||
width: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,17 +92,16 @@
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:hover {
|
||||
.header {
|
||||
.version {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.operation {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 12px;
|
||||
font-size: 1.6em;
|
||||
gap: 4px;
|
||||
|
||||
> *:hover {
|
||||
color: constants.$font-secondary-color;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
[data-component=tools-framework] {
|
||||
.left-panel {
|
||||
background-color: constants.$origin-color;
|
||||
|
||||
.menu-droppable {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
|
||||
> .card-box {
|
||||
> .card-box, > div {
|
||||
width: 180px;
|
||||
height: 290px;
|
||||
flex: 0 0 auto;
|
||||
@@ -77,6 +77,7 @@
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
|
||||
:first-child, :last-child {
|
||||
height: 0;
|
||||
@@ -99,7 +100,7 @@
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
|
||||
> .card-box {
|
||||
> .card-box, > div {
|
||||
width: 180px;
|
||||
height: 290px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
> .card-box {
|
||||
> div {
|
||||
width: 180px;
|
||||
height: 290px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
> .card-box {
|
||||
> div {
|
||||
width: 180px;
|
||||
height: 290px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
1
src/renderer/src/assets/svg/handle.svg
Normal file
1
src/renderer/src/assets/svg/handle.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M7 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 2zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 14zm6-8a2 2 0 1 0-.001-4.001A2 2 0 0 0 13 6zm0 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 14z" /></svg>
|
||||
|
After Width: | Height: | Size: 325 B |
@@ -3,10 +3,11 @@ import Icon from '@ant-design/icons'
|
||||
import Submenu from '@/components/common/Sidebar/Submenu'
|
||||
|
||||
type ItemProps = {
|
||||
icon?: IconComponent
|
||||
icon?: IconComponent | string
|
||||
text?: string
|
||||
path: string
|
||||
children?: ReactNode
|
||||
extend?: ReactNode
|
||||
end?: boolean
|
||||
}
|
||||
|
||||
@@ -42,9 +43,19 @@ const Item = (props: ItemProps) => {
|
||||
}
|
||||
>
|
||||
<div className={'icon-box'}>
|
||||
{props.icon && <Icon className={'icon'} component={props.icon} />}
|
||||
{props.icon &&
|
||||
(typeof props.icon === 'string' ? (
|
||||
<img
|
||||
className={'icon'}
|
||||
src={`data:image/svg+xml;base64,${props.icon}`}
|
||||
alt={'icon'}
|
||||
/>
|
||||
) : (
|
||||
<Icon className={'icon'} component={props.icon} />
|
||||
))}
|
||||
</div>
|
||||
<span className={'text'}>{props.text}</span>
|
||||
<div className={'extend'}>{props.extend}</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
{props.children && (
|
||||
|
||||
@@ -4,7 +4,7 @@ const Separate = ({
|
||||
className,
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => {
|
||||
return <div className={`separate ${className ? ` ${className}` : ''}`} {...props} />
|
||||
return <div className={`separate${className ? ` ${className}` : ''}`} {...props} />
|
||||
}
|
||||
|
||||
export default Separate
|
||||
|
||||
27
src/renderer/src/components/dnd/DragHandle.tsx
Normal file
27
src/renderer/src/components/dnd/DragHandle.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { HandleContextInst } from '@/components/dnd/HandleContext'
|
||||
import Icon from '@ant-design/icons'
|
||||
import '@/assets/css/components/dnd/drag-handle.scss'
|
||||
|
||||
interface DragHandleProps {
|
||||
padding?: string | number
|
||||
}
|
||||
|
||||
const DragHandle = ({ padding }: DragHandleProps) => {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const { attributes, listeners, ref } = useContext(HandleContextInst)
|
||||
|
||||
return (
|
||||
<button
|
||||
data-component={'component-drag-handle'}
|
||||
style={{ padding }}
|
||||
ref={ref}
|
||||
className={'drag-handle'}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<Icon component={IconOxygenHandle} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default DragHandle
|
||||
47
src/renderer/src/components/dnd/Draggable.tsx
Normal file
47
src/renderer/src/components/dnd/Draggable.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { CSSProperties, PropsWithChildren } from 'react'
|
||||
import { useDraggable } from '@dnd-kit/core'
|
||||
import { HandleContext, HandleContextInst } from '@/components/dnd/HandleContext'
|
||||
|
||||
interface DraggableProps extends PropsWithChildren {
|
||||
id: string
|
||||
data: ToolMenuItem
|
||||
}
|
||||
|
||||
const Draggable = ({ id, data, children }: DraggableProps) => {
|
||||
const {
|
||||
attributes,
|
||||
isDragging,
|
||||
listeners,
|
||||
setNodeRef: draggableRef,
|
||||
setActivatorNodeRef,
|
||||
transform
|
||||
} = useDraggable({
|
||||
id,
|
||||
data
|
||||
})
|
||||
const context = useMemo<HandleContext>(
|
||||
() => ({
|
||||
attributes,
|
||||
listeners,
|
||||
ref: setActivatorNodeRef
|
||||
}),
|
||||
[attributes, listeners, setActivatorNodeRef]
|
||||
)
|
||||
const style: CSSProperties | undefined = transform
|
||||
? {
|
||||
opacity: isDragging ? 0 : undefined,
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
zIndex: 10000
|
||||
}
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<HandleContextInst.Provider value={context}>
|
||||
<div ref={draggableRef} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
</HandleContextInst.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default Draggable
|
||||
22
src/renderer/src/components/dnd/DraggableOverlay.tsx
Normal file
22
src/renderer/src/components/dnd/DraggableOverlay.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { PropsWithChildren } from 'react'
|
||||
import { defaultDropAnimationSideEffects, DragOverlay, DropAnimation } from '@dnd-kit/core'
|
||||
|
||||
interface DraggableOverlayProps extends PropsWithChildren {
|
||||
isDelete?: boolean
|
||||
}
|
||||
|
||||
const dropAnimationConfig: DropAnimation = {
|
||||
sideEffects: defaultDropAnimationSideEffects({
|
||||
styles: {
|
||||
active: {
|
||||
opacity: '0.4'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const DraggableOverlay = ({ children }: DraggableOverlayProps) => {
|
||||
return <DragOverlay dropAnimation={dropAnimationConfig}>{children}</DragOverlay>
|
||||
}
|
||||
|
||||
export default DraggableOverlay
|
||||
16
src/renderer/src/components/dnd/Droppable.tsx
Normal file
16
src/renderer/src/components/dnd/Droppable.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from 'react'
|
||||
import { useDroppable } from '@dnd-kit/core'
|
||||
|
||||
interface DroppableProps extends DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
|
||||
id: string
|
||||
}
|
||||
|
||||
const Droppable = ({ id, ...props }: DroppableProps) => {
|
||||
const { setNodeRef: droppableRef } = useDroppable({
|
||||
id
|
||||
})
|
||||
|
||||
return <div {...props} ref={droppableRef} />
|
||||
}
|
||||
|
||||
export default Droppable
|
||||
13
src/renderer/src/components/dnd/HandleContext.ts
Normal file
13
src/renderer/src/components/dnd/HandleContext.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { DraggableSyntheticListeners } from '@dnd-kit/core'
|
||||
|
||||
export interface HandleContext {
|
||||
attributes: Record<string, any>
|
||||
listeners: DraggableSyntheticListeners
|
||||
ref(node: HTMLElement | null): void
|
||||
}
|
||||
|
||||
export const HandleContextInst = createContext<HandleContext>({
|
||||
attributes: {},
|
||||
listeners: undefined,
|
||||
ref() {}
|
||||
})
|
||||
54
src/renderer/src/components/dnd/Sortable.tsx
Normal file
54
src/renderer/src/components/dnd/Sortable.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { CSSProperties, PropsWithChildren } from 'react'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { HandleContext, HandleContextInst } from '@/components/dnd/HandleContext'
|
||||
|
||||
interface SortableProps extends PropsWithChildren {
|
||||
id: string
|
||||
data: ToolMenuItem
|
||||
isDelete?: boolean
|
||||
}
|
||||
|
||||
const Sortable = ({ id, data, isDelete, children }: SortableProps) => {
|
||||
const {
|
||||
attributes,
|
||||
isDragging,
|
||||
listeners,
|
||||
setNodeRef: draggableRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition
|
||||
} = useSortable({
|
||||
id,
|
||||
data
|
||||
})
|
||||
const context = useMemo<HandleContext>(
|
||||
() => ({
|
||||
attributes,
|
||||
listeners,
|
||||
ref: setActivatorNodeRef
|
||||
}),
|
||||
[attributes, listeners, setActivatorNodeRef]
|
||||
)
|
||||
const style: CSSProperties | undefined = transform
|
||||
? {
|
||||
opacity: isDragging ? 0.4 : undefined,
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
zIndex: 10000,
|
||||
transition
|
||||
}
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<HandleContextInst.Provider value={context}>
|
||||
<div
|
||||
ref={draggableRef}
|
||||
style={style}
|
||||
className={isDragging && isDelete ? 'delete' : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</HandleContextInst.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sortable
|
||||
@@ -1,14 +1,17 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes, ReactNode } from 'react'
|
||||
import { DetailedHTMLProps, HTMLAttributes } from 'react'
|
||||
import VanillaTilt, { TiltOptions } from 'vanilla-tilt'
|
||||
import '@/assets/css/components/tools/repository-card.scss'
|
||||
import Card from '@/components/common/Card'
|
||||
import FlexBox from '@/components/common/FlexBox'
|
||||
import Draggable from '@/components/dnd/Draggable'
|
||||
import DragHandle from '@/components/dnd/DragHandle.tsx'
|
||||
|
||||
interface RepositoryCardProps
|
||||
extends DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
|
||||
icon: ReactNode
|
||||
icon: string
|
||||
toolName: string
|
||||
toolId: string
|
||||
ver: string
|
||||
options?: TiltOptions
|
||||
onOpen?: () => void
|
||||
onEdit?: () => void
|
||||
@@ -25,6 +28,7 @@ const RepositoryCard = ({
|
||||
icon,
|
||||
toolName,
|
||||
toolId,
|
||||
ver,
|
||||
options = {
|
||||
reverse: true,
|
||||
max: 8,
|
||||
@@ -48,6 +52,7 @@ const RepositoryCard = ({
|
||||
}, [options])
|
||||
|
||||
return (
|
||||
<Draggable id={toolId} data={{ icon, toolName, toolId, authorUsername: '!', ver }}>
|
||||
<Card
|
||||
data-component={'component-repository-card'}
|
||||
style={{ overflow: 'visible', ...style }}
|
||||
@@ -55,10 +60,16 @@ const RepositoryCard = ({
|
||||
{...props}
|
||||
>
|
||||
<FlexBox className={'repository-card'}>
|
||||
<div className={'icon'}>{icon}</div>
|
||||
<div className={'header'}>
|
||||
{children}
|
||||
<DragHandle />
|
||||
</div>
|
||||
<div className={'icon'}>
|
||||
<img src={`data:image/svg+xml;base64,${icon}`} alt={'Icon'} />
|
||||
</div>
|
||||
<div className={'info'}>
|
||||
{toolName && <div className={'tool-name'}>{toolName}</div>}
|
||||
{toolId && <div className={'tool-id'}>{`ID: ${toolId}`}</div>}
|
||||
<div className={'tool-name'}>{toolName}</div>
|
||||
<div className={'tool-id'}>{`ID: ${toolId}`}</div>
|
||||
</div>
|
||||
<div className={'operation'}>
|
||||
{onOpen && (
|
||||
@@ -90,9 +101,9 @@ const RepositoryCard = ({
|
||||
</AntdButton>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</FlexBox>
|
||||
</Card>
|
||||
</Draggable>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes, MouseEvent, ReactNode } from 'react'
|
||||
import { DetailedHTMLProps, HTMLAttributes, MouseEvent } from 'react'
|
||||
import VanillaTilt, { TiltOptions } from 'vanilla-tilt'
|
||||
import protocolCheck from 'custom-protocol-check'
|
||||
import Icon from '@ant-design/icons'
|
||||
@@ -10,9 +10,11 @@ import { r_tool_add_favorite, r_tool_remove_favorite } from '@/services/tool'
|
||||
import Card from '@/components/common/Card'
|
||||
import FlexBox from '@/components/common/FlexBox'
|
||||
import { getUserId } from '@/util/auth.tsx'
|
||||
import DragHandle from '@/components/dnd/DragHandle.tsx'
|
||||
import Draggable from '@/components/dnd/Draggable.tsx'
|
||||
|
||||
interface StoreCardProps extends DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
|
||||
icon: ReactNode
|
||||
icon: string
|
||||
toolName: string
|
||||
toolId: string
|
||||
toolDesc: string
|
||||
@@ -176,6 +178,10 @@ const StoreCard = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Draggable
|
||||
id={`${author.username}:${toolId}:${ver}`}
|
||||
data={{ icon, toolName, toolId, authorUsername: author.username, ver: 'latest' }}
|
||||
>
|
||||
<Card
|
||||
data-component={'component-store-card'}
|
||||
style={{ overflow: 'visible', ...style }}
|
||||
@@ -184,12 +190,62 @@ const StoreCard = ({
|
||||
onClick={handleCardOnClick}
|
||||
>
|
||||
<FlexBox className={'store-card'}>
|
||||
<div className={'icon'}>{icon}</div>
|
||||
<div className={'header'}>
|
||||
<div className={'version'}>
|
||||
<AntdTag>
|
||||
{platform.slice(0, 1)}-{ver}
|
||||
</AntdTag>
|
||||
</div>
|
||||
<div className={'operation'}>
|
||||
{platform !== 'ANDROID' && supportPlatform.includes('ANDROID') && (
|
||||
<AntdTooltip title={'Android 端'}>
|
||||
<Icon
|
||||
component={IconOxygenMobile}
|
||||
onClick={handleOnAndroidBtnClick}
|
||||
/>
|
||||
</AntdTooltip>
|
||||
)}
|
||||
{platform === 'DESKTOP' && supportPlatform.includes('WEB') && (
|
||||
<AntdTooltip title={'Web 端'}>
|
||||
<Icon
|
||||
component={IconOxygenBrowser}
|
||||
onClick={handleOnWebBtnClick}
|
||||
/>
|
||||
</AntdTooltip>
|
||||
)}
|
||||
{platform === 'WEB' && supportPlatform.includes('DESKTOP') && (
|
||||
<AntdTooltip title={'桌面端'}>
|
||||
<Icon
|
||||
component={IconOxygenDesktop}
|
||||
onClick={handleOnDesktopBtnClick}
|
||||
/>
|
||||
</AntdTooltip>
|
||||
)}
|
||||
<AntdTooltip title={'源码'}>
|
||||
<Icon
|
||||
component={IconOxygenCode}
|
||||
onClick={handleOnSourceBtnClick}
|
||||
/>
|
||||
</AntdTooltip>
|
||||
{author.id !== userId && (
|
||||
<AntdTooltip title={favorite_ ? '取消收藏' : '收藏'}>
|
||||
<Icon
|
||||
component={
|
||||
favorite_ ? IconOxygenStarFilled : IconOxygenStar
|
||||
}
|
||||
style={{
|
||||
color: favorite_ ? COLOR_PRODUCTION : undefined
|
||||
}}
|
||||
onClick={handleOnStarBtnClick}
|
||||
/>
|
||||
</AntdTooltip>
|
||||
)}
|
||||
<DragHandle />
|
||||
</div>
|
||||
</div>
|
||||
<div className={'icon'}>
|
||||
<img src={`data:image/svg+xml;base64,${icon}`} alt={'Icon'} />
|
||||
</div>
|
||||
<div className={'info'}>
|
||||
<div className={'tool-name'}>{toolName}</div>
|
||||
<div className={'tool-id'}>{`ID: ${toolId}`}</div>
|
||||
@@ -214,43 +270,9 @@ const StoreCard = ({
|
||||
</AntdTooltip>
|
||||
</div>
|
||||
)}
|
||||
<div className={'operation'}>
|
||||
{platform !== 'ANDROID' && supportPlatform.includes('ANDROID') && (
|
||||
<AntdTooltip title={'Android 端'}>
|
||||
<Icon
|
||||
component={IconOxygenMobile}
|
||||
onClick={handleOnAndroidBtnClick}
|
||||
/>
|
||||
</AntdTooltip>
|
||||
)}
|
||||
{platform === 'DESKTOP' && supportPlatform.includes('WEB') && (
|
||||
<AntdTooltip title={'Web 端'}>
|
||||
<Icon component={IconOxygenBrowser} onClick={handleOnWebBtnClick} />
|
||||
</AntdTooltip>
|
||||
)}
|
||||
{platform === 'WEB' && supportPlatform.includes('DESKTOP') && (
|
||||
<AntdTooltip title={'桌面端'}>
|
||||
<Icon
|
||||
component={IconOxygenDesktop}
|
||||
onClick={handleOnDesktopBtnClick}
|
||||
/>
|
||||
</AntdTooltip>
|
||||
)}
|
||||
<AntdTooltip title={'源码'}>
|
||||
<Icon component={IconOxygenCode} onClick={handleOnSourceBtnClick} />
|
||||
</AntdTooltip>
|
||||
{author.id !== userId && (
|
||||
<AntdTooltip title={favorite_ ? '取消收藏' : '收藏'}>
|
||||
<Icon
|
||||
component={favorite_ ? IconOxygenStarFilled : IconOxygenStar}
|
||||
style={{ color: favorite_ ? COLOR_PRODUCTION : undefined }}
|
||||
onClick={handleOnStarBtnClick}
|
||||
/>
|
||||
</AntdTooltip>
|
||||
)}
|
||||
</div>
|
||||
</FlexBox>
|
||||
</Card>
|
||||
</Draggable>
|
||||
{contextHolder}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const PRODUCTION_NAME = 'Oxygen Toolbox'
|
||||
export const STORAGE_TOKEN_KEY = 'JWT_TOKEN'
|
||||
export const STORAGE_USER_INFO_KEY = 'USER_INFO'
|
||||
export const STORAGE_FAVORITE_KEY = 'FAVORITE'
|
||||
export const STORAGE_TOOL_MENU_ITEM_KEY = 'TOOL_MENU_ITEM'
|
||||
export const COLOR_ORIGIN = 'white'
|
||||
export const COLOR_PRODUCTION = '#4E47BB'
|
||||
export const COLOR_MAIN = COLOR_PRODUCTION
|
||||
|
||||
8
src/renderer/src/global.d.ts
vendored
8
src/renderer/src/global.d.ts
vendored
@@ -661,3 +661,11 @@ interface ToolFavoriteAddRemoveParam {
|
||||
toolId: string
|
||||
platform: Platform
|
||||
}
|
||||
|
||||
interface ToolMenuItem {
|
||||
icon: string
|
||||
toolName: string
|
||||
toolId: string
|
||||
authorUsername: string
|
||||
ver: string
|
||||
}
|
||||
|
||||
@@ -134,12 +134,7 @@ const Store = () => {
|
||||
return (
|
||||
<StoreCard
|
||||
key={firstTool!.id}
|
||||
icon={
|
||||
<img
|
||||
src={`data:image/svg+xml;base64,${firstTool!.icon}`}
|
||||
alt={'Icon'}
|
||||
/>
|
||||
}
|
||||
icon={firstTool!.icon}
|
||||
toolName={firstTool!.name}
|
||||
toolId={firstTool!.toolId}
|
||||
toolDesc={firstTool!.description}
|
||||
|
||||
@@ -197,12 +197,7 @@ const User = () => {
|
||||
return (
|
||||
<StoreCard
|
||||
key={firstTool!.id}
|
||||
icon={
|
||||
<img
|
||||
src={`data:image/svg+xml;base64,${firstTool!.icon}`}
|
||||
alt={'Icon'}
|
||||
/>
|
||||
}
|
||||
icon={firstTool!.icon}
|
||||
toolName={firstTool!.name}
|
||||
toolId={firstTool!.toolId}
|
||||
toolDesc={firstTool!.description}
|
||||
|
||||
@@ -32,8 +32,11 @@ const View = () => {
|
||||
.compile(files, importMap, toolVo.entryPoint)
|
||||
.then((result) => {
|
||||
const output = result.outputFiles[0].text
|
||||
setCompiledCode('')
|
||||
setTimeout(() => {
|
||||
setCompiledCode(`${output}\n${baseDist}`)
|
||||
})
|
||||
})
|
||||
.catch((reason) => {
|
||||
void message.error(`编译失败:${reason}`)
|
||||
})
|
||||
@@ -72,9 +75,7 @@ const View = () => {
|
||||
break
|
||||
case DATABASE_NO_RECORD_FOUND:
|
||||
void message.error('未找到指定工具')
|
||||
setTimeout(() => {
|
||||
navigateToRepository(navigate)
|
||||
}, 3000)
|
||||
break
|
||||
default:
|
||||
void message.error('获取工具信息失败,请稍后重试')
|
||||
@@ -103,11 +104,11 @@ const View = () => {
|
||||
return
|
||||
}
|
||||
if (username === '!' && !ver) {
|
||||
navigateToView(navigate, '!', toolId!, platform as Platform)
|
||||
navigateToView(navigate, '!', toolId!, platform as Platform, 'latest')
|
||||
return
|
||||
}
|
||||
getTool()
|
||||
}, [])
|
||||
}, [username, toolId, ver, searchParams])
|
||||
|
||||
return (
|
||||
<FitFullscreen data-component={'tools-view'}>
|
||||
|
||||
@@ -166,9 +166,10 @@ const ToolCard = ({ tools, onDelete, onUpgrade, onSubmit, onCancel }: ToolCardPr
|
||||
|
||||
return (
|
||||
<RepositoryCard
|
||||
icon={<img src={`data:image/svg+xml;base64,${selectedTool.icon}`} alt={'Icon'} />}
|
||||
icon={selectedTool.icon}
|
||||
toolName={selectedTool.name}
|
||||
toolId={selectedTool.toolId}
|
||||
ver={selectedTool.ver}
|
||||
onOpen={handleOnOpenTool}
|
||||
onEdit={handleOnEditTool()}
|
||||
onSource={handleOnSourceTool()}
|
||||
@@ -563,6 +564,8 @@ const Tools = () => {
|
||||
))}
|
||||
{hasNextPage && <LoadMoreCard onClick={handleOnLoadMore} />}
|
||||
</FlexBox>
|
||||
{starToolData.length ? (
|
||||
<>
|
||||
<FlexBox className={'favorite-divider'}>
|
||||
<div />
|
||||
<div className={'divider-text'}>收藏</div>
|
||||
@@ -574,7 +577,8 @@ const Tools = () => {
|
||||
if (
|
||||
!previousValue.some(
|
||||
(value) =>
|
||||
value.author.id === currentValue.author.id &&
|
||||
value.author.id ===
|
||||
currentValue.author.id &&
|
||||
value.toolId === currentValue.toolId
|
||||
)
|
||||
) {
|
||||
@@ -588,7 +592,9 @@ const Tools = () => {
|
||||
value.author.id === item.author.id &&
|
||||
value.toolId === item.toolId
|
||||
)
|
||||
const webTool = tools.find((value) => value.platform === 'WEB')
|
||||
const webTool = tools.find(
|
||||
(value) => value.platform === 'WEB'
|
||||
)
|
||||
const desktopTool = tools.find(
|
||||
(value) => value.platform === 'DESKTOP'
|
||||
)
|
||||
@@ -603,25 +609,26 @@ const Tools = () => {
|
||||
return (
|
||||
<StoreCard
|
||||
key={firstTool!.id}
|
||||
icon={
|
||||
<img
|
||||
src={`data:image/svg+xml;base64,${firstTool!.icon}`}
|
||||
alt={'Icon'}
|
||||
/>
|
||||
}
|
||||
icon={firstTool!.icon}
|
||||
toolName={firstTool!.name}
|
||||
toolId={firstTool!.toolId}
|
||||
toolDesc={firstTool!.description}
|
||||
author={firstTool!.author}
|
||||
ver={firstTool!.ver}
|
||||
platform={firstTool!.platform}
|
||||
supportPlatform={tools.map((value) => value.platform)}
|
||||
supportPlatform={tools.map(
|
||||
(value) => value.platform
|
||||
)}
|
||||
favorite={firstTool!.favorite}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{hasNextStarPage && <LoadMoreCard onClick={handleOnLoadMoreStar} />}
|
||||
{hasNextStarPage && (
|
||||
<LoadMoreCard onClick={handleOnLoadMoreStar} />
|
||||
)}
|
||||
</FlexBox>
|
||||
</>
|
||||
) : undefined}
|
||||
</FlexBox>
|
||||
</HideScrollbar>
|
||||
</FitFullscreen>
|
||||
|
||||
@@ -1,13 +1,90 @@
|
||||
import { DndContext, DragOverEvent, DragStartEvent } from '@dnd-kit/core'
|
||||
import Droppable from '@/components/dnd/Droppable'
|
||||
import type { DragEndEvent } from '@dnd-kit/core/dist/types'
|
||||
import '@/assets/css/pages/tools-framework.scss'
|
||||
import { tools } from '@/router/tools'
|
||||
import FitFullscreen from '@/components/common/FitFullscreen'
|
||||
import Sidebar from '@/components/common/Sidebar'
|
||||
import FullscreenLoadingMask from '@/components/common/FullscreenLoadingMask'
|
||||
import { arrayMove, SortableContext } from '@dnd-kit/sortable'
|
||||
import { getViewPath } from '@/util/navigation.tsx'
|
||||
import Sortable from '@/components/dnd/Sortable.tsx'
|
||||
import DragHandle from '@/components/dnd/DragHandle.tsx'
|
||||
import DraggableOverlay from '@/components/dnd/DraggableOverlay.tsx'
|
||||
import { getToolMenuItem, saveToolMenuItem } from '@/util/common.tsx'
|
||||
|
||||
const ToolsFramework = () => {
|
||||
const [isDelete, setIsDelete] = useState(false)
|
||||
const [toolMenuItem, setToolMenuItem] = useState<ToolMenuItem[]>(getToolMenuItem)
|
||||
const [activeItem, setActiveItem] = useState<ToolMenuItem | null>(null)
|
||||
|
||||
const handleOnDragStart = ({ active }: DragStartEvent) => {
|
||||
setActiveItem(active.data.current as ToolMenuItem)
|
||||
}
|
||||
|
||||
const handleOnDragOver = ({ over }: DragOverEvent) => {
|
||||
setIsDelete(over === null)
|
||||
}
|
||||
|
||||
const handleOnDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
if (over && active.id !== over?.id) {
|
||||
const activeIndex = toolMenuItem.findIndex(
|
||||
({ authorUsername, toolId, ver }) =>
|
||||
`${authorUsername}:${toolId}:${ver}` === active.id
|
||||
)
|
||||
const overIndex = toolMenuItem.findIndex(
|
||||
({ authorUsername, toolId, ver }) =>
|
||||
`${authorUsername}:${toolId}:${ver}` === over.id
|
||||
)
|
||||
setToolMenuItem(arrayMove(toolMenuItem, activeIndex, overIndex))
|
||||
}
|
||||
|
||||
if (!over) {
|
||||
setToolMenuItem(
|
||||
toolMenuItem.filter(
|
||||
({ authorUsername, toolId, ver }) =>
|
||||
`${authorUsername}:${toolId}:${ver}` !== active?.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (!active.data.current?.sortable && over) {
|
||||
const newItem = active.data.current as ToolMenuItem
|
||||
if (
|
||||
toolMenuItem.findIndex(
|
||||
({ authorUsername, toolId, ver }) =>
|
||||
authorUsername === newItem.authorUsername &&
|
||||
toolId === newItem.toolId &&
|
||||
ver === newItem.ver
|
||||
) === -1
|
||||
) {
|
||||
setToolMenuItem([...toolMenuItem, newItem])
|
||||
}
|
||||
}
|
||||
|
||||
console.log('active', active)
|
||||
console.log('over', over)
|
||||
|
||||
setActiveItem(null)
|
||||
}
|
||||
|
||||
const handleOnDragCancel = () => {
|
||||
setActiveItem(null)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
saveToolMenuItem(toolMenuItem)
|
||||
}, [toolMenuItem])
|
||||
|
||||
return (
|
||||
<>
|
||||
<FitFullscreen data-component={'tools-framework'} className={'flex-horizontal'}>
|
||||
<DndContext
|
||||
onDragStart={handleOnDragStart}
|
||||
onDragOver={handleOnDragOver}
|
||||
onDragEnd={handleOnDragEnd}
|
||||
onDragCancel={handleOnDragCancel}
|
||||
>
|
||||
<div className={'left-panel'}>
|
||||
<Sidebar title={'氧工具'}>
|
||||
<Sidebar.ItemList>
|
||||
@@ -24,34 +101,70 @@ const ToolsFramework = () => {
|
||||
text={tools[1].name}
|
||||
/>
|
||||
</Sidebar.ItemList>
|
||||
<Sidebar.Separate style={{ marginBottom: 0 }} />
|
||||
<Sidebar.Separate />
|
||||
<Droppable id={'menu'} className={'menu-droppable'}>
|
||||
<Sidebar.Scroll>
|
||||
<Sidebar.ItemList>
|
||||
{tools.map((tool) => {
|
||||
return tool.menu &&
|
||||
tool.id !== 'tools-store' &&
|
||||
tool.id !== 'tools-repository' ? (
|
||||
<Sidebar.Item
|
||||
path={tool.absolutePath}
|
||||
icon={tool.icon}
|
||||
text={tool.name}
|
||||
key={tool.id}
|
||||
<SortableContext
|
||||
items={toolMenuItem.map(
|
||||
({ authorUsername, toolId, ver }) =>
|
||||
`${authorUsername}:${toolId}:${ver}`
|
||||
)}
|
||||
>
|
||||
{toolMenuItem.map(
|
||||
({
|
||||
icon,
|
||||
toolName,
|
||||
toolId,
|
||||
authorUsername,
|
||||
ver
|
||||
}) => (
|
||||
<Sortable
|
||||
id={`${authorUsername}:${toolId}:${ver}`}
|
||||
data={{
|
||||
icon,
|
||||
toolName,
|
||||
toolId,
|
||||
authorUsername,
|
||||
ver
|
||||
}}
|
||||
isDelete={isDelete}
|
||||
>
|
||||
{tool.children &&
|
||||
tool.children.map((subTool) => {
|
||||
return (
|
||||
<Sidebar.Item
|
||||
path={subTool.absolutePath}
|
||||
text={subTool.name}
|
||||
key={subTool.id}
|
||||
path={getViewPath(
|
||||
authorUsername,
|
||||
toolId,
|
||||
import.meta.env.VITE_PLATFORM,
|
||||
ver
|
||||
)}
|
||||
icon={icon}
|
||||
text={toolName}
|
||||
key={`${authorUsername}:${toolId}`}
|
||||
extend={<DragHandle padding={10} />}
|
||||
/>
|
||||
</Sortable>
|
||||
)
|
||||
})}
|
||||
</Sidebar.Item>
|
||||
) : undefined
|
||||
})}
|
||||
)}
|
||||
</SortableContext>
|
||||
<DraggableOverlay>
|
||||
{activeItem && (
|
||||
<Sidebar.Item
|
||||
path={getViewPath(
|
||||
activeItem.authorUsername,
|
||||
activeItem.toolId,
|
||||
import.meta.env.VITE_PLATFORM,
|
||||
activeItem.ver
|
||||
)}
|
||||
icon={activeItem.icon}
|
||||
text={activeItem.toolName}
|
||||
key={`${activeItem.authorUsername}:${activeItem.toolId}:${activeItem.ver}`}
|
||||
extend={<DragHandle padding={10} />}
|
||||
/>
|
||||
)}
|
||||
</DraggableOverlay>
|
||||
</Sidebar.ItemList>
|
||||
</Sidebar.Scroll>
|
||||
</Droppable>
|
||||
</Sidebar>
|
||||
</div>
|
||||
<div className={'right-panel'}>
|
||||
@@ -65,6 +178,7 @@ const ToolsFramework = () => {
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</div>
|
||||
</DndContext>
|
||||
</FitFullscreen>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import FullscreenLoadingMask from '@/components/common/FullscreenLoadingMask'
|
||||
import { floor } from 'lodash'
|
||||
import { getLocalStorage, setLocalStorage } from '@/util/browser.tsx'
|
||||
import { STORAGE_TOOL_MENU_ITEM_KEY } from '@/constants/common.constants.ts'
|
||||
|
||||
export const randomInt = (start: number, end: number) => {
|
||||
if (start > end) {
|
||||
@@ -133,3 +135,15 @@ const formatByte = (size: number, unit: ByteUnit): string => {
|
||||
}
|
||||
|
||||
export const checkDesktop = () => import.meta.env.VITE_PLATFORM === 'DESKTOP'
|
||||
|
||||
export const saveToolMenuItem = (toolMenuItem: ToolMenuItem[]) => {
|
||||
setLocalStorage(STORAGE_TOOL_MENU_ITEM_KEY, JSON.stringify(toolMenuItem))
|
||||
}
|
||||
|
||||
export const getToolMenuItem = (): ToolMenuItem[] => {
|
||||
const s = getLocalStorage(STORAGE_TOOL_MENU_ITEM_KEY)
|
||||
if (!s) {
|
||||
return []
|
||||
}
|
||||
return JSON.parse(s) as ToolMenuItem[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user