Compare commits

..

5 Commits

Author SHA1 Message Date
3eaf262ee8 bump: bump version 2025-04-03 22:51:11 +08:00
4d0e945a92 temp 2025-03-27 12:07:07 +08:00
c1247eba32 update packages 2025-03-26 01:25:28 +08:00
cbef0943de temp 2025-03-12 00:50:44 +08:00
4a04a432ca temp 2025-03-04 22:57:46 +08:00
60 changed files with 2104 additions and 1470 deletions

1
.npmrc
View File

@@ -1,3 +1,2 @@
//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN} //npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN}
@abearxiong:registry=https://npm.pkg.github.com
//registry.npmjs.org/:_authToken=${NPM_TOKEN} //registry.npmjs.org/:_authToken=${NPM_TOKEN}

View File

@@ -15,9 +15,9 @@
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <!-- <script type="module" src="/src/main.tsx"></script> -->
<!-- <script type="module" src="/aidist/router.es.js"></script> --> <!-- <script type="module" src="/aidist/router.es.js"></script> -->
<!-- <script type="module" src="/template/index.ts"></script> --> <script type="module" src="/template/index.ts"></script>
</body> </body>
</html> </html>

View File

@@ -19,34 +19,32 @@
"author": "abearxiong <xiongxiao@xiongxiao.me>", "author": "abearxiong <xiongxiao@xiongxiao.me>",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.6.1", "@ant-design/icons": "^6.0.0",
"@blueprintjs/core": "^5.17.2",
"@blueprintjs/icons": "^5.19.1",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@kevisual/cache": "^0.0.1", "@kevisual/cache": "^0.0.1",
"@kevisual/query": "0.0.9-alpha.2", "@kevisual/query": "0.0.15",
"@kevisual/router": "0.0.8", "@kevisual/router": "0.0.10",
"@kevisual/system-lib": "0.0.21-beta.2", "@kevisual/system-lib": "0.0.22",
"@kevisual/system-ui": "^0.0.3", "@kevisual/system-ui": "^0.0.3",
"@kevisual/ui": "^0.0.4-alpha-1", "@mui/material": "^7.0.1",
"@mui/material": "^6.4.6", "@tiptap/core": "^2.11.7",
"@tiptap/core": "^2.11.5", "@tiptap/extension-code-block-lowlight": "^2.11.7",
"@tiptap/extension-code-block-lowlight": "^2.11.5", "@tiptap/extension-document": "^2.11.7",
"@tiptap/extension-document": "^2.11.5", "@tiptap/extension-highlight": "^2.11.7",
"@tiptap/extension-highlight": "^2.11.5", "@tiptap/extension-paragraph": "^2.11.7",
"@tiptap/extension-paragraph": "^2.11.5", "@tiptap/extension-placeholder": "^2.11.7",
"@tiptap/extension-placeholder": "^2.11.5", "@tiptap/extension-text": "^2.11.7",
"@tiptap/extension-text": "^2.11.5", "@tiptap/extension-typography": "^2.11.7",
"@tiptap/extension-typography": "^2.11.5", "@tiptap/pm": "^2.11.7",
"@tiptap/pm": "^2.11.5", "@tiptap/starter-kit": "^2.11.7",
"@tiptap/starter-kit": "^2.11.5", "@tiptap/suggestion": "^2.11.7",
"@tiptap/suggestion": "^2.11.5",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/turndown": "^5.0.5", "@types/turndown": "^5.0.5",
"@xyflow/react": "^12.4.4", "@xyflow/react": "^12.5.4",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"eventemitter3": "^5.0.1",
"github-markdown-css": "^5.8.1", "github-markdown-css": "^5.8.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"idb": "^8.0.2", "idb": "^8.0.2",
@@ -54,40 +52,40 @@
"immer": "^10.1.1", "immer": "^10.1.1",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"lucide-react": "^0.477.0", "lucide-react": "^0.487.0",
"marked": "^15.0.7", "marked": "^15.0.7",
"nanoid": "^5.1.2", "nanoid": "^5.1.5",
"react": "^19.0.0", "react": "^19.1.0",
"react-dom": "^19.0.0", "react-dom": "^19.1.0",
"react-draggable": "^4.4.6", "react-draggable": "^4.4.6",
"react-resizable": "^3.0.5", "react-resizable": "^3.0.5",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"react-router": "^7.2.0", "react-router": "^7.4.1",
"react-router-dom": "^7.2.0", "react-router-dom": "^7.4.1",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"tiptap-markdown": "^0.8.10", "tiptap-markdown": "^0.8.10",
"turndown": "^7.2.0", "turndown": "^7.2.0",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.21.0", "@eslint/js": "^9.23.0",
"@kevisual/types": "^0.0.6", "@kevisual/types": "^0.0.6",
"@tailwindcss/vite": "^4.0.9", "@tailwindcss/vite": "^4.1.1",
"@types/node": "^22.13.8", "@types/node": "^22.14.0",
"@types/react": "^19.0.10", "@types/react": "^19.1.0",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.1.1",
"@types/react-resizable": "^3.0.8", "@types/react-resizable": "^3.0.8",
"@vitejs/plugin-basic-ssl": "^2.0.0", "@vitejs/plugin-basic-ssl": "^2.0.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.21.0", "eslint": "^9.23.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19", "eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0", "globals": "^16.0.0",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^3.1.0",
"tailwindcss": "^4.0.9", "tailwindcss": "^4.1.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"typescript-eslint": "^8.25.0", "typescript-eslint": "^8.29.0",
"vite": "^6.2.0" "vite": "^6.2.5"
} }
} }

1013
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,9 +5,11 @@ import { Auth } from './modules/layouts/Auth';
// import { basename } from './modules/basename'; // import { basename } from './modules/basename';
import 'github-markdown-css/github-markdown.css'; import 'github-markdown-css/github-markdown.css';
export const App = () => { export const App = () => {
const url = new URL(location.href);
const id = url.searchParams.get('id') || undefined;
return ( return (
<> <>
<Flow checkLogin={false} /> <Flow checkLogin={false} id={id} />
<ToastContainer /> <ToastContainer />
</> </>
); );

View File

@@ -34,7 +34,8 @@ body {
} }
} }
.markdown-body,.tiptap { .markdown-body,
.tiptap {
ul, ul,
li { li {
list-style: unset; list-style: unset;
@@ -47,4 +48,10 @@ iframe {
border: unset; border: unset;
width: 100%; width: 100%;
height: 100%; height: 100%;
/* will-change: transform; */
/* pointer-events: none; */
position: fixed;
top: 0;
left: 0;
z-index: 99999;
} }

View File

@@ -1,87 +1,16 @@
import { createRoot, Root } from 'react-dom/client'; import { page, app } from './routes';
import { App } from './App.tsx';
import { useContextKey } from '@kevisual/system-lib/dist/web-config';
import './index.css';
import { QueryRouterServer } from '@kevisual/system-lib/dist/router-browser';
import { Editor } from './pages/editor/index.tsx';
import { ExampleApp } from './modules/panels/Example.tsx';
const page = useContextKey('page'); page.addPage('/', 'wallnote');
const wallnoteDom = useContextKey('wallnoteDom', () => {
return document.getElementById('root');
});
const app = useContextKey<QueryRouterServer>('app');
app
.route({
path: 'wallnote',
key: 'getDomId',
description: '获取墙记的dom',
run: async (ctx) => {
console.log('ctx', ctx);
ctx.body = 'wallnoteDom';
},
})
.addTo(app);
let root: Root | null = null;
app
.route({
path: 'wallnote',
key: 'getWallnoteReactDom',
description: '获取墙记的react dom',
run: async (ctx) => {
const root = await useContextKey('wallReactRoot');
if (!root) {
ctx.throw(404, 'wallReactRoot not found');
}
ctx.body = 'wallReactRoot';
},
})
.addTo(app);
app
.route({
path: 'wallnote',
key: 'render',
description: '渲染墙记',
run: async (ctx) => {
root = createRoot(wallnoteDom!);
root.render(<App />);
useContextKey('wallReactRoot', () => root, true);
ctx.body = 'wallReactRoot';
},
})
.addTo(app);
page.addPage('/note/:id', 'wallnote');
page.subscribe(
'wallnote',
() => {
root = createRoot(wallnoteDom!);
root.render(<App />);
},
{ runImmediately: false },
);
page.addPage('/editor', 'editor');
page.subscribe(
'editor',
() => {
root = createRoot(wallnoteDom!);
root.render(<Editor />);
},
{ runImmediately: false },
);
page.addPage('/panels', 'panels');
setTimeout(() => { setTimeout(() => {
page.subscribe( page.subscribe(
'panels', 'wallnote',
() => { () => {
root = createRoot(wallnoteDom!); app.call({
root.render(<ExampleApp />); path: 'wallnote',
key: 'render',
});
}, },
{ runImmediately: true }, { runImmediately: false },
); );
}, 1000); }, 1000);

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
export class ReactRenderer {
component: any;
element: HTMLElement;
ref: React.RefObject<any>;
props: any;
root: any;
constructor(component: any, { props, className }: any) {
this.component = component;
const el = document.createElement('div');
this.element = el;
this.ref = React.createRef();
this.props = {
...props,
ref: this.ref,
};
el.className = className;
this.root = createRoot(this.element);
this.render();
}
updateProps(props: any) {
this.props = {
...this.props,
...props,
};
this.render();
}
render() {
this.root.render(React.createElement(this.component, this.props));
}
destroy() {
this.root.unmount();
}
}
export default ReactRenderer;

4
src/modules/app.ts Normal file
View File

@@ -0,0 +1,4 @@
import { useContextKey } from '@kevisual/system-lib/dist/web-config';
import { QueryRouterServer } from '@kevisual/system-lib/dist/router-browser';
export const app = useContextKey<QueryRouterServer>('app');

View File

@@ -0,0 +1,39 @@
import { TextEditor } from '@/modules/tiptap/editor';
import { useEffect, useRef, useState } from 'react';
import clsx from 'clsx';
type EditorProps = {
className?: string;
value?: string;
id?: string;
onChange?: (value: string) => void;
};
export const Editor = ({ className, value, onChange, id }: EditorProps) => {
const textEditorRef = useRef<TextEditor | null>(null);
const editorRef = useRef<HTMLDivElement>(null);
const [mount, setMount] = useState(false);
useEffect(() => {
const editor = new TextEditor();
textEditorRef.current = editor;
editor.createEditor(editorRef.current!, {
html: value,
onUpdateHtml: (html) => {
onChange?.(html);
},
});
setMount(true);
return () => {
editor.destroy();
};
}, []);
useEffect(() => {
if (textEditorRef.current && id && mount) {
textEditorRef.current.setContent(value || '');
}
}, [id, mount]);
return (
<div className={clsx('w-full h-full editor-container relative', className)}>
<div ref={editorRef} className={clsx('w-full h-full node-editor', className)}></div>
</div>
);
};

View File

@@ -1,33 +0,0 @@
import React, { useEffect } from 'react';
import WindowManager from './components/WindowManager';
import { demoWindows } from './demo/DemoWindows';
import './style.css';
import { useShallow } from 'zustand/react/shallow';
import { usePanelStore } from './store';
import { useListenCmdB } from './hooks/use-listen-b';
export function ExampleApp() {
const { data, toggleAICommand, init } = usePanelStore(
useShallow((state) => {
return {
data: state.data,
toggleAICommand: state.toggleAICommand,
init: state.init,
};
}),
);
useEffect(() => {
init?.();
}, [init]);
useListenCmdB(() => {
toggleAICommand?.();
console.log('toggleAICommand');
});
return (
<div className='h-screen w-screen overflow-hidden bg-gray-800'>
<WindowManager windows={data?.windows || []} showTaskbar={data?.showTaskbar} />
</div>
);
}
export default ExampleApp;

View File

@@ -0,0 +1,52 @@
import React, { useEffect, useRef } from 'react';
import WindowManager from './components/WindowManager';
import { demoWindows } from './demo/DemoWindows';
import './style.css';
import { useShallow } from 'zustand/react/shallow';
import { usePanelStore } from './store';
import { useListenCmdB } from './hooks/use-listen-b';
import { managerRender } from './render/main';
console.log('managerRender', managerRender);
export function Panels() {
const ref = useRef<any>(null);
const { data, toggleAICommand, init } = usePanelStore(
useShallow((state) => {
return {
data: state.data,
toggleAICommand: state.toggleAICommand,
init: state.init,
};
}),
);
useEffect(() => {
init?.();
}, [init]);
useListenCmdB(() => {
handleCommand();
});
const handleCommand = () => {
const windows = ref.current?.getWindows();
const newWindows = toggleAICommand?.(windows);
// saveWindows?.(newWindows);
ref.current?.setWindows(newWindows);
console.log('toggleAICommand', newWindows);
};
useEffect(() => {
console.log('data windows', data);
}, [data]);
return (
<div className='h-screen w-screen overflow-hidden'>
<WindowManager
ref={ref}
windows={data?.windows || []}
// windows={demoWindows.slice(0, 2)}
showTaskbar={data?.showTaskbar}
onCommand={() => {
handleCommand();
}}
/>
</div>
);
}
export default Panels;

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
export class ReactRenderer {
component: any;
element: HTMLElement;
ref: React.RefObject<any>;
props: any;
root: any;
constructor(component: any, { props, className }: any) {
this.component = component;
const el = document.createElement('div');
this.element = el;
this.ref = React.createRef();
this.props = {
...props,
ref: this.ref,
};
el.className = className;
this.root = createRoot(this.element);
this.render();
}
updateProps(props: any) {
this.props = {
...this.props,
...props,
};
this.render();
}
render() {
this.root.render(React.createElement(this.component, this.props));
}
destroy() {
this.root.unmount();
}
}
export default ReactRenderer;

View File

@@ -1,30 +1,32 @@
import React, { useState, useCallback, useRef, useEffect, RefObject } from 'react'; import React, { useState, useCallback, useRef, useEffect, RefObject, useMemo } from 'react';
import { Maximize2, Minimize2, Minimize, Expand, X, SquareMinus, Maximize, ChevronDown } from 'lucide-react'; import { Maximize2, Minimize2, Minimize, Expand, X, SquareMinus, Maximize, ChevronDown, CommandIcon, LogOut } from 'lucide-react';
import { WindowData, WindowPosition } from '../types'; import { WindowData, WindowPosition } from '../types';
import classNames from 'clsx'; import classNames from 'clsx';
import Draggable from 'react-draggable'; import Draggable from 'react-draggable';
import { ResizableBox } from 'react-resizable'; import { ResizableBox } from 'react-resizable';
import { getIconForWindowType } from './WindowIcons'; import { getIconForWindowType } from './WindowIcons';
import { useImperativeHandle } from 'react'; import { useImperativeHandle } from 'react';
import { emitter } from '../modules';
interface WindowManagerProps { interface WindowManagerProps {
windows: WindowData[]; windows: WindowData[];
showTaskbar?: boolean; showTaskbar?: boolean;
onSave?: (windows: WindowData[]) => void; onSave?: (windows: WindowData[]) => void;
onCommand?: () => void;
} }
// Minimum window dimensions // Minimum window dimensions
const MIN_WINDOW_WIDTH = 300; const MIN_WINDOW_WIDTH = 300;
const MIN_WINDOW_HEIGHT = 200; const MIN_WINDOW_HEIGHT = 200;
const WindowManager = React.forwardRef(({ windows: initialWindows, showTaskbar = true, onSave }: WindowManagerProps, ref) => { const WindowManager = React.forwardRef(({ windows: initialWindows, showTaskbar = true, onSave, onCommand }: WindowManagerProps, ref) => {
const [windows, setWindows] = useState<WindowData[]>(initialWindows); const [windows, setWindows] = useState<WindowData[]>(initialWindows);
const [minimizedWindows, setMinimizedWindows] = useState<string[]>([]);
const [fullscreenWindow, setFullscreenWindow] = useState<string | null>(null); const [fullscreenWindow, setFullscreenWindow] = useState<string | null>(null);
const [windowPositions, setWindowPositions] = useState<Record<string, WindowPosition>>({}); const [windowPositions, setWindowPositions] = useState<Record<string, WindowPosition>>({});
const [activeWindow, setActiveWindow] = useState<string | null>(null); const [activeWindow, setActiveWindow] = useState<string | null>(null);
const [maxZIndex, setMaxZIndex] = useState(100); const [maxZIndex, setMaxZIndex] = useState(100);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [mount, setMount] = useState(false); const [mount, setMount] = useState(false);
const [update, setUpdate] = useState(0);
// Create stable refs for each window // Create stable refs for each window
const windowRefs = useRef<Record<string, React.RefObject<HTMLDivElement | null>>>({}); const windowRefs = useRef<Record<string, React.RefObject<HTMLDivElement | null>>>({});
const draggableRefs = useRef<Record<string, React.RefObject<HTMLDivElement | null>>>({}); const draggableRefs = useRef<Record<string, React.RefObject<HTMLDivElement | null>>>({});
@@ -36,6 +38,11 @@ const WindowManager = React.forwardRef(({ windows: initialWindows, showTaskbar =
getWindows: () => { getWindows: () => {
return windows; return windows;
}, },
setWindows: (windows: WindowData[]) => {
console.log('setWindows in manager', windows);
setWindows(windows);
setUpdate((prev) => prev + 1);
},
})); }));
useEffect(() => { useEffect(() => {
console.log('initialWindows', initialWindows); console.log('initialWindows', initialWindows);
@@ -71,7 +78,7 @@ const WindowManager = React.forwardRef(({ windows: initialWindows, showTaskbar =
setWindowPositions(positions); setWindowPositions(positions);
setMaxZIndex(1000 + windows.length); setMaxZIndex(1000 + windows.length);
setMount(true); setMount(true);
}, [windows.length]); }, [windows.length, update]);
useEffect(() => { useEffect(() => {
if (mount) { if (mount) {
const newWindows = windows const newWindows = windows
@@ -106,12 +113,20 @@ const WindowManager = React.forwardRef(({ windows: initialWindows, showTaskbar =
const handleRemoveWindow = useCallback( const handleRemoveWindow = useCallback(
(windowId: string) => { (windowId: string) => {
const window = windows.find((w) => w.id === windowId); const window = windows.find((w) => w.id === windowId);
if (window?.onHidden) { const command = window?.commandList?.find((c) => c.key === 'close');
window.onHidden(); if (command) {
emitter.emit('window-command', { windowData: window, command });
return; return;
} }
setWindows((prev) => prev.filter((w) => w.id !== windowId)); setWindows((prev) => prev.filter((w) => w.id !== windowId));
setMinimizedWindows((prev) => prev.filter((id) => id !== windowId)); setWindows((prev) =>
prev.map((w) => {
if (w.id === windowId) {
return { ...w, isMinimized: false };
}
return w;
}),
);
if (fullscreenWindow === windowId) { if (fullscreenWindow === windowId) {
setFullscreenWindow(null); setFullscreenWindow(null);
} }
@@ -122,36 +137,44 @@ const WindowManager = React.forwardRef(({ windows: initialWindows, showTaskbar =
// Handle window minimize // Handle window minimize
const handleMinimizeWindow = useCallback( const handleMinimizeWindow = useCallback(
(windowId: string) => { (windowId: string) => {
if (minimizedWindows.includes(windowId)) { let needBringToFront = false;
setMinimizedWindows((prev) => prev.filter((id) => id !== windowId)); setWindows((prev) =>
// Bring window to front when unminimizing prev.map((w) => {
bringToFront(windowId); if (w.id === windowId) {
} else { needBringToFront = !w.isMinimized;
setMinimizedWindows((prev) => [...prev, windowId]); return { ...w, isMinimized: !w.isMinimized };
} }
return w;
}),
);
if (fullscreenWindow === windowId) { if (fullscreenWindow === windowId) {
setFullscreenWindow(null); setFullscreenWindow(null);
} }
if (needBringToFront) {
bringToFront(windowId);
}
}, },
[minimizedWindows, fullscreenWindow], [, fullscreenWindow],
); );
// Handle window fullscreen // Handle window fullscreen
const handleFullscreenWindow = useCallback( const handleFullscreenWindow = useCallback((windowId: string) => {
(windowId: string) => { setFullscreenWindow((prev) => (prev === windowId ? null : windowId));
setFullscreenWindow((prev) => (prev === windowId ? null : windowId));
// Ensure window is not minimized when going fullscreen // Ensure window is not minimized when going fullscreen
if (minimizedWindows.includes(windowId)) { setWindows((prev) =>
setMinimizedWindows((prev) => prev.filter((id) => id !== windowId)); prev.map((w) => {
} if (w.id === windowId) {
return { ...w, isMinimized: false };
// Bring to front when going fullscreen }
bringToFront(windowId); return w;
}, }),
[minimizedWindows], );
); // Bring to front when going fullscreen
bringToFront(windowId);
}, []);
// Bring window to front // Bring window to front
const bringToFront = useCallback( const bringToFront = useCallback(
@@ -228,19 +251,19 @@ const WindowManager = React.forwardRef(({ windows: initialWindows, showTaskbar =
// Render the taskbar with minimized windows // Render the taskbar with minimized windows
const renderTaskbar = () => { const renderTaskbar = () => {
const showWindowsList = windows.filter((window) => window.show && window.showTaskbar); const showWindowsList = windows.filter((window) => window.show && window.showTaskbar);
if (showWindowsList.length === 0) return null; // if (showWindowsList.length === 0) return null;
// useEffect(() => { // useEffect(() => {
// const handleResize = () => { // const handleResize = () => {
// const icons = document.querySelectorAll('.more-icon'); // // const icons = document.querySelectorAll('.more-icon');
// icons.forEach((iconEl) => { // // icons.forEach((iconEl) => {
// const icon = iconEl as HTMLElement; // // const icon = iconEl as HTMLElement;
// const button = icon.closest('button'); // // const button = icon.closest('button');
// if (button && button.offsetWidth <= 150) { // // if (button && button.offsetWidth <= 150) {
// icon.style.display = 'none'; // // icon.style.display = 'none';
// } else { // // } else {
// icon.style.display = 'block'; // // icon.style.display = 'block';
// } // // }
// }); // // });
// }; // };
// window.addEventListener('resize', handleResize); // window.addEventListener('resize', handleResize);
@@ -250,10 +273,32 @@ const WindowManager = React.forwardRef(({ windows: initialWindows, showTaskbar =
// window.removeEventListener('resize', handleResize); // window.removeEventListener('resize', handleResize);
// }; // };
// }, []); // }, []);
const showLogout = useMemo(() => {
return localStorage.getItem('token');
}, []);
return ( return (
<div className=' pointer-events-auto fixed w-full overflow-x-auto bottom-0 left-0 right-0 bg-gray-200 text-white p-2 flex space-x-2 z-[9000]'> <div className=' pointer-events-auto fixed w-full overflow-x-auto bottom-0 left-0 right-0 bg-gray-200 text-white p-2 flex space-x-2 z-[9000] h-[40px]'>
{showLogout && (
<div
className='flex items-center space-x-2 cursor-pointer bg-blue-600 rounded-md p-1'
onClick={() => {
context?.app?.call?.({
path: 'user',
key: 'logout',
});
}}>
<LogOut size={16} />
</div>
)}
<div
className='flex items-center space-x-2 cursor-pointer bg-blue-600 rounded-md p-1'
onClick={() => {
onCommand?.();
}}>
<CommandIcon size={16} />
</div>
{showWindowsList.map((window) => { {showWindowsList.map((window) => {
const isMinimized = minimizedWindows.includes(window.id); const isMinimized = window.isMinimized;
return ( return (
<button <button
key={window.id} key={window.id}
@@ -261,10 +306,11 @@ const WindowManager = React.forwardRef(({ windows: initialWindows, showTaskbar =
'px-3 py-1 rounded text-sm max-w-[200px] truncate flex items-center justify-between', 'px-3 py-1 rounded text-sm max-w-[200px] truncate flex items-center justify-between',
isMinimized ? 'bg-gray-600 hover:bg-gray-500' : 'bg-blue-600 hover:bg-blue-500', isMinimized ? 'bg-gray-600 hover:bg-gray-500' : 'bg-blue-600 hover:bg-blue-500',
activeWindow === window.id && 'shadow-lg', activeWindow === window.id && 'shadow-lg',
'bar-button',
'cursor-pointer', 'cursor-pointer',
)} )}
onClick={() => handleMinimizeWindow(window.id)}> onClick={() => handleMinimizeWindow(window.id)}>
<span className='truncate min-w-[16px]'>{window.title}</span> <span className='truncate min-w-[8px]'>{window.title}</span>
<div className='flex items-center space-x-1 ml-2'> <div className='flex items-center space-x-1 ml-2'>
{/* {isMinimized ? <Maximize className='cursor-pointer more-icon' size={16} /> : <SquareMinus className='cursor-pointer more-icon' size={16} />} */} {/* {isMinimized ? <Maximize className='cursor-pointer more-icon' size={16} /> : <SquareMinus className='cursor-pointer more-icon' size={16} />} */}
<ChevronDown className='cursor-pointer' size={16} /> <ChevronDown className='cursor-pointer' size={16} />
@@ -288,13 +334,12 @@ const WindowManager = React.forwardRef(({ windows: initialWindows, showTaskbar =
// Render a fixed position window // Render a fixed position window
const renderFixedWindow = (windowData: WindowData) => { const renderFixedWindow = (windowData: WindowData) => {
const isMinimized = minimizedWindows.includes(windowData.id); const isMinimized = windowData.isMinimized;
const isFullscreen = fullscreenWindow === windowData.id; const isFullscreen = fullscreenWindow === windowData.id;
const position = windowPositions[windowData.id]; const position = windowPositions[windowData.id];
const Icon = getIconForWindowType(windowData.type || 'welcome'); const Icon = getIconForWindowType(windowData.type || 'welcome');
const showRounded = windowData.showRounded ?? true; const showRounded = windowData.showRounded ?? true;
if (!position) return null; if (!position) return null;
if (isMinimized) return null;
// Convert width and height to numbers for Resizable component // Convert width and height to numbers for Resizable component
const width = isFullscreen ? window.innerWidth : position.width; const width = isFullscreen ? window.innerWidth : position.width;
@@ -310,21 +355,25 @@ const WindowManager = React.forwardRef(({ windows: initialWindows, showTaskbar =
const windowRef = windowRefs.current[windowData.id]; const windowRef = windowRefs.current[windowData.id];
const draggableRef = draggableRefs.current[windowData.id]; const draggableRef = draggableRefs.current[windowData.id];
const zIndex = isFullscreen ? 9999 : windowData.id == '__ai__' ? 3000 : position.zIndex;
return ( return (
<div <div
key={windowData.id} key={windowData.id}
className={classNames('absolute pointer-events-auto', windowData.show && 'block', !windowData.show && 'hidden')} className={classNames(
'absolute pointer-events-auto', //
windowData.show && !isMinimized && 'block',
(!windowData.show || isMinimized) && 'hidden',
)}
style={{ style={{
left: isFullscreen ? 0 : position.x, left: isFullscreen ? 0 : position.x,
top: isFullscreen ? 0 : position.y, top: isFullscreen ? 0 : position.y,
width: width, width: width,
height: height, height: height,
zIndex: isFullscreen ? 9999 : position.zIndex, zIndex: zIndex,
}} }}
ref={windowRef}> ref={windowRef}>
<div <div
className={classNames('window-container', isFullscreen && 'fullscreen')} className={classNames('window-container', isFullscreen && 'fullscreen', showTaskbar && 'hidden-taskbar', windowData.show && !isMinimized && 'block')}
style={{ style={{
width: '100%', width: '100%',
height: '100%', height: '100%',
@@ -383,7 +432,7 @@ const WindowManager = React.forwardRef(({ windows: initialWindows, showTaskbar =
</div> </div>
<div className='window-controls'>{renderWindowControls(windowData.id)}</div> <div className='window-controls'>{renderWindowControls(windowData.id)}</div>
</div> </div>
<div className='window-content h-[calc(100%-32px)] overflow-auto p-4'> <div className='window-content h-[calc(100%-32px)] overflow-auto'>
<div className='h-full flex flex-col'> <div className='h-full flex flex-col'>
<WindowContent window={windowData} /> <WindowContent window={windowData} />
</div> </div>
@@ -396,7 +445,6 @@ const WindowManager = React.forwardRef(({ windows: initialWindows, showTaskbar =
</div> </div>
); );
}; };
return ( return (
<div className='h-screen w-screen overflow-hidden' ref={containerRef}> <div className='h-screen w-screen overflow-hidden' ref={containerRef}>
{windows.map((window) => renderFixedWindow(window))} {windows.map((window) => renderFixedWindow(window))}
@@ -409,10 +457,10 @@ export const WindowContent = React.memo((props: { window: WindowData }) => {
const { window } = props; const { window } = props;
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (ref.current) { emitter.emit('window-load', { windowData: window, el: ref.current });
// 获取属性,判断是否加载对应的应用 return () => {
} emitter.emit('window-unload', { windowData: window, el: ref.current });
console.log('window editor render', window); };
}, []); }, []);
return <div data-id={window.id} className='flex-1 overflow-auto editor-window' ref={ref}></div>; return <div data-id={window.id} className='flex-1 overflow-auto editor-window' ref={ref}></div>;
}); });

View File

@@ -1,6 +1,6 @@
import { WindowData } from '../types'; import { WindowData } from '../types';
export const createEditorWindow = (data: any): WindowData => { export const createDemoEditorWindow = (data: any): WindowData => {
return { return {
...data, ...data,
showTitle: true, showTitle: true,
@@ -104,17 +104,17 @@ const windowPositions = {
// Demo windows data using the createEditorWindow function // Demo windows data using the createEditorWindow function
export const demoWindows: WindowData[] = [ export const demoWindows: WindowData[] = [
createEditorWindow({ title: 'Welcome', id: 'window1', type: 'welcome' }), createDemoEditorWindow({ title: 'Welcome', id: 'window1', type: 'welcome' }),
createEditorWindow({ title: 'Image Viewer', id: 'window2', type: 'image' }), createDemoEditorWindow({ title: 'Image Viewer', id: 'window2', type: 'image' }),
createEditorWindow({ title: 'Text Editor', id: 'window3', type: 'document' }), createDemoEditorWindow({ title: 'Text Editor', id: 'window3', type: 'document' }),
createEditorWindow({ title: 'Calculator', id: 'window4', type: 'calculator' }), createDemoEditorWindow({ title: 'Calculator', id: 'window4', type: 'calculator' }),
createEditorWindow({ title: 'Code Editor', id: 'code-editor', type: 'code' }), createDemoEditorWindow({ title: 'Code Editor', id: 'code-editor', type: 'code' }),
createEditorWindow({ title: 'Document', id: 'document', type: 'document' }), createDemoEditorWindow({ title: 'Document', id: 'document', type: 'document' }),
createEditorWindow({ title: 'Analytics', id: 'analytics', type: 'analytics' }), createDemoEditorWindow({ title: 'Analytics', id: 'analytics', type: 'analytics' }),
createEditorWindow({ title: 'Settings', id: 'settings', type: 'settings' }), createDemoEditorWindow({ title: 'Settings', id: 'settings', type: 'settings' }),
createEditorWindow({ title: 'Layers', id: 'layers', type: 'layers' }), createDemoEditorWindow({ title: 'Layers', id: 'layers', type: 'layers' }),
createEditorWindow({ title: 'Database', id: 'database', type: 'database' }), createDemoEditorWindow({ title: 'Database', id: 'database', type: 'database' }),
createEditorWindow({ title: 'Server', id: 'server', type: 'server' }), createDemoEditorWindow({ title: 'Server', id: 'server', type: 'server' }),
createEditorWindow({ title: 'Terminal', id: 'terminal', type: 'terminal' }), createDemoEditorWindow({ title: 'Terminal', id: 'terminal', type: 'terminal' }),
createEditorWindow({ title: 'Command', id: 'command', type: 'command' }), createDemoEditorWindow({ title: 'Command', id: 'command', type: 'command' }),
].map((window) => ({ ...window, position: windowPositions[window.id] })); ].map((window) => ({ ...window, position: windowPositions[window.id] }));

View File

@@ -5,7 +5,7 @@ export const useListenCmdB = (callback: () => void) => {
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
// Check for Command key on macOS // Check for Command key on macOS
if (isMac ? event.metaKey && event.key === 'b' : event.ctrlKey && event.key === 'b') { if ((event.metaKey || event.ctrlKey) && event.key === 'b') {
callback(); callback();
} }
}; };

View File

@@ -1,2 +1,3 @@
import './style.css'; import './style.css';
export { Panels } from './Panels';

View File

@@ -0,0 +1,5 @@
import { useContextKey } from '@kevisual/system-lib/dist/web-config';
import { EventEmitter } from 'eventemitter3';
export const emitter = useContextKey<EventEmitter>('emitter', () => {
return new EventEmitter();
});

View File

@@ -0,0 +1,9 @@
import { useContextKey } from '@kevisual/system-lib/dist/web-config';
import { emitter } from '../modules/index';
import { ManagerRender } from './manager/manager';
export { emitter };
export { useContextKey };
export const managerRender = useContextKey<ManagerRender>('managerRender', () => {
return new ManagerRender();
});

View File

@@ -0,0 +1,115 @@
import { EventEmitter } from 'eventemitter3';
import { WindowData } from '../../types';
import { useContextKey } from '@kevisual/system-lib/dist/web-config';
import { ReactRenderer } from '../../components/ReactRenderer';
import { BaseLoad } from '@kevisual/system-lib/dist/load';
import { QueryRouterServer } from '@kevisual/system-lib/dist/router-browser';
export const emitter = useContextKey<EventEmitter>('emitter', () => {
return new EventEmitter();
});
// const load = new BaseLoad();
class HtmlRender {
render({ renderRoot, data }: any) {
const div = `<div id="${data.id}">${data.title}</div>`;
renderRoot.appendChild(div);
}
destroy() {
// 什么也不做
}
}
export class BaseRender {
/**
* 在页面当中是否加载
* */
isLoaded = false;
status = 'loading';
element?: HTMLElement;
windowData?: WindowData;
render?: ReactRenderer | HtmlRender;
type?: 'react' | 'html';
async load(windowData: WindowData, element: HTMLElement) {
this.isLoaded = true;
this.windowData = windowData;
this.status = 'loaded';
this.element = element;
// @ts-ignore
const app = (await useContextKey('app')) as QueryRouterServer;
const render = windowData.render;
console.log('base render', render, render?.command);
if (render?.command) {
const res = await app.call({
path: render.command.path,
key: render.command.key,
payload: render.command.payload,
});
if (res.code === 200) {
const { lib, type } = res.body;
if (type === 'react') {
const ReactNode = lib;
// 这是一个打开后就不需要再管理的组件
const renderNote = new ReactRenderer(ReactNode, {
props: {
id: windowData.id,
windowData: windowData,
},
className: 'w-full h-full',
});
this.render = renderNote;
this.element.appendChild(renderNote.element);
} else if (type === 'html') {
this.render = new lib() as HtmlRender;
this.render.render({
renderRoot: this.element,
data: windowData,
});
}
} else {
console.log('render error', res);
}
}
}
async unload(windowData: WindowData) {
this.isLoaded = false;
if (this.type === 'react') {
this.render?.destroy();
} else if (this.type === 'html') {
this.render?.destroy();
}
this.status = 'loading';
this.windowData = undefined;
this.element = undefined;
}
}
export class ManagerRender {
constructor() {
const that = this;
emitter.on('window-load', (data: { windowData: WindowData; el: HTMLElement }) => {
that.load(data.windowData, data.el);
});
emitter.on('window-unload', (windowData: WindowData) => {
that.unload(windowData);
});
}
renders = new Map<string, BaseRender>();
load(windowData: WindowData, element: HTMLElement) {
const id = windowData.id;
if (this.renders.has(id)) {
this.renders.get(id)?.load(windowData, element);
} else {
const render = new BaseRender();
this.renders.set(id, render);
render.load(windowData, element);
}
}
unload(windowData: WindowData) {
const id = windowData.id;
if (this.renders.has(id)) {
this.renders.get(id)?.unload(windowData);
}
}
}

View File

@@ -0,0 +1,42 @@
import { useContextKey } from '@kevisual/system-lib/dist/web-config';
import { QueryRouterServer } from '@kevisual/system-lib/dist/router-browser';
import { usePanelStore } from './store';
import { createEditorWindow } from './store/create/create-editor-window';
export const app = useContextKey<QueryRouterServer>('app');
app
.route({
path: 'panels',
key: 'add-editor-window',
})
.define(async (ctx) => {
const { data } = ctx.query;
const state = usePanelStore.getState();
const newWindow = createEditorWindow(data.pageId, data.nodeData, {
id: data.nodeData.id,
title: data.nodeData.title,
show: true,
position: {
x: 0,
y: 0,
width: 600,
height: 400,
zIndex: 1000,
},
});
state.setEditorWindow(newWindow.windowData);
})
.addTo(app);
app
.route({
path: 'panels',
key: 'close-editor-window',
})
.define(async (ctx) => {
const { data } = ctx.query;
const state = usePanelStore.getState();
state.closeEditorWindow(data.id);
})
.addTo(app);

View File

@@ -0,0 +1,56 @@
import { WindowData } from '../../types';
import { getDocumentWidthAndHeight } from '../../utils/document-width';
/**
* 创建编辑器窗口
* @param id 整个页面的的id
* @param nodeData 节点数据
* @param windowData 窗口数据
* @returns
*/
export const createEditorWindow = (pageId: string, nodeData: any, windowData?: WindowData) => {
const { width, height } = getDocumentWidthAndHeight();
return {
nodeData,
windowData: {
id: nodeData.id,
type: 'editor',
title: nodeData.title || '编辑器',
showTitle: true,
showRounded: true,
showTaskbar: true,
showMoreTools: true,
defaultPosition: {
x: width - 1000,
y: 0,
width: 1000,
height: height,
zIndex: 1000,
},
moreTools: [
{
command: {
path: 'window',
key: 'close',
payload: {
id: nodeData.id,
},
},
title: '关闭',
key: 'close',
},
],
render: {
command: {
path: 'editor',
key: 'nodeRender',
payload: {
pageId: pageId,
id: nodeData.id,
},
},
},
...windowData,
},
};
};

View File

@@ -5,6 +5,8 @@ import { query } from '@/modules/query';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { getDocumentWidthAndHeight } from '../utils/document-width'; import { getDocumentWidthAndHeight } from '../utils/document-width';
import { produce } from 'immer'; import { produce } from 'immer';
import { createEditorWindow } from './create/create-editor-window';
import { createDemoEditorWindow } from '../demo/DemoWindows';
interface PanelStore { interface PanelStore {
data?: PanelData; data?: PanelData;
@@ -12,7 +14,10 @@ interface PanelStore {
init?: (id?: string) => Promise<any>; init?: (id?: string) => Promise<any>;
id: string; id: string;
setId: (id: string) => void; setId: (id: string) => void;
toggleAICommand: () => void; toggleAICommand: (windows: WindowData[]) => WindowData[];
saveWindows: (windows: WindowData[]) => void;
setEditorWindow: (windowData: WindowData) => void;
closeEditorWindow: (id: string) => void;
} }
interface PanelData { interface PanelData {
/** /**
@@ -22,7 +27,7 @@ interface PanelData {
/** /**
* 是否显示任务栏 * 是否显示任务栏
*/ */
showTaskbar: boolean; showTaskbar?: boolean;
} }
export const usePanelStore = create<PanelStore>((set, get) => ({ export const usePanelStore = create<PanelStore>((set, get) => ({
@@ -39,60 +44,92 @@ export const usePanelStore = create<PanelStore>((set, get) => ({
}, },
init: async (id?: string) => { init: async (id?: string) => {
const cache = new MyCache<PanelData>(id || 'workspace'); // const cache = new MyCache<PanelData>(id || 'workspace');
if (id) { // if (id) {
// id存在则获取本地和获取远程进行对比如果需要更新则更新 // // id存在则获取本地和获取远程进行对比如果需要更新则更新
if (cache.data) { // if (cache.data) {
const updatedAt = cache.updatedAt; // const updatedAt = cache.updatedAt;
const res = await query.post({ path: 'workspace', key: 'env', id, updatedAt }); // const res = await query.post({ path: 'workspace', key: 'env', id, updatedAt });
if (res.code === 200) { // if (res.code === 200) {
const newData = res.data; // const newData = res.data;
if (newData) { // if (newData) {
cache.setData(newData); // cache.setData(newData);
set({ // set({
data: newData, // data: newData,
id: id, // id: id,
}); // });
} else { // } else {
set({ data: cache.data, id: id }); // set({ data: cache.data, id: id });
} // }
} else { // } else {
toast.error('获取环境失败'); // toast.error('获取环境失败');
return; // return;
} // }
} else { // } else {
const res = await query.post({ path: 'workspace', key: 'env', id }); // const res = await query.post({ path: 'workspace', key: 'env', id });
if (res.code === 200) { // if (res.code === 200) {
const newData = res.data; // const newData = res.data;
if (newData) { // if (newData) {
cache.setData(newData); // cache.setData(newData);
set({ // set({
data: newData, // data: newData,
id: id, // id: id,
}); // });
} // }
} // }
} // }
} else if (cache.data) { // } else if (cache.data) {
set({ // set({
data: cache.data, // data: cache.data,
}); // });
} else { // } else {
set({ // set({
data: { windows: [], showTaskbar: true }, // data: { windows: [], showTaskbar: true },
}); // });
} // }
set({
data: {
// windows: [e.windowData],
windows: [],
showTaskbar: true,
},
});
}, },
toggleAICommand: () => { setEditorWindow: (windowData: WindowData) => {
const { data } = get(); const { data } = get();
if (!data) { if (!data) {
return; return;
} }
const has = data.windows.find((w) => w.id === windowData.id);
if (has) {
data.windows = data.windows.map((w) => {
if (w.id === windowData.id) {
return windowData;
}
return w;
});
} else {
data.windows.push(windowData);
}
console.log('data', data);
set({ data: { ...data, windows: data.windows } });
},
toggleAICommand: (windows: WindowData[]) => {
// const { data } = get();
// if (!data) {
// return;
// }
const data = { windows };
const has = data.windows.find((w) => w.id === '__ai__'); const has = data.windows.find((w) => w.id === '__ai__');
if (has) { if (has) {
data.windows = data.windows.map((w) => { data.windows = data.windows.map((w) => {
if (w.id === '__ai__') { if (w.id === '__ai__') {
return { ...w, show: !w.show }; console.log('w', w.isMinimized, w.show);
if (w.isMinimized || !w.show) {
return { ...w, show: true, isMinimized: false };
}
return { ...w, show: !w.show, isMinimized: false };
} }
return w; return w;
}); });
@@ -100,20 +137,60 @@ export const usePanelStore = create<PanelStore>((set, get) => ({
const { width, height } = getDocumentWidthAndHeight(); const { width, height } = getDocumentWidthAndHeight();
data.windows.push({ data.windows.push({
id: '__ai__', id: '__ai__',
title: 'AI Command', title: '🤖 AI Command',
type: 'commandƒ', type: 'command',
showTitle: true,
position: { position: {
x: 100, x: 100,
y: height - 200, y: height - 200 - 40,
width: width - 200, width: width - 200,
height: 200, height: 200,
zIndex: 1000, zIndex: 1000,
}, },
resizeHandles: ['se', 'sw', 'ne', 'nw', 's', 'w', 'n', 'e'], resizeHandles: ['se', 'sw', 'ne', 'nw', 's', 'w', 'n', 'e'],
show: true, show: true,
render: {
command: {
path: 'editor',
key: 'render',
payload: {
id: '__ai__',
},
},
},
}); });
} }
set({ data: { ...data, windows: data.windows } }); // set({ data: { ...data, windows: data.windows } });
console.log('data', data); console.log('data', data);
return data.windows;
},
saveWindows: (windows: WindowData[]) => {
set({ data: { ...get().data, windows } });
},
closeEditorWindow: (id: string) => {
const { data } = get();
if (!data) {
return;
}
data.windows = data.windows.filter((w) => w.id !== id);
set({ data: { ...data, windows: data.windows } });
}, },
})); }));
// const e = createEditorWindow(
// '123',
// {
// id: '123',
// title: '123',
// type: 'editor',
// position: { x: 0, y: 0, width: 100, height: 100, zIndex: 1000 },
// },
// createDemoEditorWindow({
// id: '123',
// title: '123',
// type: 'editor',
// position: { x: 0, y: 0, width: 100, height: 100, zIndex: 1000 },
// }),
// );
// console.log('e', e);

View File

@@ -22,8 +22,12 @@
right: 0 !important; right: 0 !important;
bottom: 40px !important; /* Leave space for taskbar */ bottom: 40px !important; /* Leave space for taskbar */
width: 100% !important; width: 100% !important;
height: calc(100% - 40px) !important; height: calc(100% - 40px);
z-index: 9999 !important; z-index: 9900 !important;
}
.fullscreen.hidden-taskbar {
height: calc(100% - 0px) !important;
} }
/* Resize handles */ /* Resize handles */

View File

@@ -6,6 +6,19 @@ export interface WindowPosition {
height: number; height: number;
zIndex: number; zIndex: number;
} }
export type WindowCommand = {
path: string;
key?: string;
payload?: any;
};
export type WindowCommandData = {
command: WindowCommand;
title: string;
key: string;
description?: string;
icon?: string;
onClick?: WindowCommand;
};
export interface WindowData { export interface WindowData {
// 窗口的唯一标识 // 窗口的唯一标识
id: string; id: string;
@@ -34,18 +47,13 @@ export interface WindowData {
// 是否显示更多工具 // 是否显示更多工具
showMoreTools?: boolean; showMoreTools?: boolean;
// 更多工具 // 更多工具
moreTools?: MoreTool[]; moreTools?: WindowCommandData[];
// 当隐藏窗口存在,只关闭隐藏窗口,不退出程序 // 工具列表
onHidden?: () => void; commandList?: WindowCommandData[];
} // 渲染
export interface MoreTool { render?: {
// 工具的名称 command: WindowCommand;
title?: string; props?: any;
description?: string; className?: string;
path?: string; };
key?: string;
// 工具的图标
icon?: string;
// 工具的点击事件
onClick?: () => void;
} }

View File

@@ -5,7 +5,7 @@ import Typography from '@tiptap/extension-typography';
import { Markdown } from 'tiptap-markdown'; import { Markdown } from 'tiptap-markdown';
import Placeholder from '@tiptap/extension-placeholder'; import Placeholder from '@tiptap/extension-placeholder';
import { Commands, getSuggestionItems, createSuggestionConfig } from './extensions/suggestions'; import { Commands, getSuggestionItems, createSuggestionConfig, CommandItem } from './extensions/suggestions';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import { all, createLowlight } from 'lowlight'; import { all, createLowlight } from 'lowlight';
import 'highlight.js/styles/github.css'; import 'highlight.js/styles/github.css';
@@ -28,22 +28,28 @@ lowlight.register('markdown', markdown);
export class TextEditor { export class TextEditor {
private editor?: Editor; private editor?: Editor;
private opts?: { markdown?: string; html?: string; items?: CommandItem[]; onUpdateHtml?: (html: string) => void };
private element?: HTMLElement;
private isInitialSetup: boolean = true;
constructor() {} constructor() {}
createEditor(el: HTMLElement, opts?: { markdown?: string; html?: string }) { createEditor(el: HTMLElement, opts?: { markdown?: string; html?: string; items?: CommandItem[]; onUpdateHtml?: (html: string) => void }) {
if (this.editor) { if (this.editor) {
this.destroy(); this.destroy();
} }
this.opts = opts;
this.element = el;
const html = opts?.html || ''; const html = opts?.html || '';
const items = getSuggestionItems(); const items = opts?.items || getSuggestionItems();
const suggestionConfig = createSuggestionConfig(items); const suggestionConfig = createSuggestionConfig(items);
this.isInitialSetup = true;
this.editor = new Editor({ this.editor = new Editor({
element: el, // 指定编辑器容器 element: el, // 指定编辑器容器
extensions: [ extensions: [
StarterKit, // 使用 StarterKit 包含基础功能 StarterKit, // 使用 StarterKit 包含基础功能
Highlight, Highlight,
Placeholder.configure({ Placeholder.configure({
placeholder: 'Type ! to see commands (e.g., !today, !list, !good)...', placeholder: 'Type @ to see commands (e.g., @today, @list @test )...',
}), }),
Typography, Typography,
Markdown, Markdown,
@@ -81,11 +87,37 @@ export class TextEditor {
suggestion: suggestionConfig, suggestion: suggestionConfig,
}), }),
], ],
content: html, // 初始化内容 content: html, // 初始化内容,
onUpdate: () => {
if (this.isInitialSetup) {
this.isInitialSetup = false;
return;
}
if (this.opts?.onUpdateHtml) {
this.opts.onUpdateHtml(this.editor?.getHTML() || '');
}
},
}); });
} }
setContent(html: string) { updateSugestionConfig(items: CommandItem[]) {
this.editor?.commands.setContent(html); if (!this.element) return;
const element = this.element;
if (this.editor) {
const content = this.editor.getHTML(); // Save current content
const opts = { ...this.opts, html: content, items };
this.createEditor(element, opts); // Recreate the editor with the new config
}
}
setContent(html: string, emitUpdate?: boolean) {
this.editor?.commands.setContent(html, emitUpdate);
}
/**
* before set options ,you should has element and editor
* @param opts
*/
setOptions(opts: { markdown?: string; html?: string; items?: CommandItem[]; onUpdateHtml?: (html: string) => void }) {
this.opts = { ...this.opts, ...opts };
this.createEditor(this.element!, this.opts!);
} }
getHtml() { getHtml() {
return this.editor?.getHTML(); return this.editor?.getHTML();
@@ -93,12 +125,6 @@ export class TextEditor {
getContent() { getContent() {
return this.editor?.getText(); return this.editor?.getText();
} }
onContentChange(callback: (html: string) => void) {
this.editor?.off('update'); // 移除之前的监听
this.editor?.on('update', () => {
callback(this.editor?.getHTML() || '');
});
}
foucus() { foucus() {
this.editor?.view?.focus?.(); this.editor?.view?.focus?.();
} }

View File

@@ -16,9 +16,8 @@ export const Commands = Extension.create({
addOptions() { addOptions() {
return { return {
suggestion: { suggestion: {
char: '!', char: '@',
command: ({ editor, range, props }: any) => { command: ({ editor, range, props }: any) => {
console.log('sdfsd')
editor editor
.chain() .chain()
.focus() .focus()

View File

@@ -60,7 +60,7 @@ export const createSuggestionConfig = (items: CommandItem[]) => {
popup = document.createElement('div'); popup = document.createElement('div');
popup.className = 'commands-popup'; popup.className = 'commands-popup';
popup.style.position = 'fixed'; // Use fixed instead of absolute for better viewport positioning popup.style.position = 'fixed'; // Use fixed instead of absolute for better viewport positioning
popup.style.zIndex = '1000'; popup.style.zIndex = '9999';
document.body.appendChild(popup); document.body.appendChild(popup);
popup.appendChild(component.element); popup.appendChild(component.element);

View File

@@ -0,0 +1,40 @@
.demo-login-prompt {
background-color: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
text-align: center;
max-width: 400px;
width: 90%;
}
.demo-login-link {
margin-bottom: 1rem;
font-size: 1.2rem;
}
.demo-login-link a {
color: #4a90e2;
text-decoration: none;
transition: color 0.3s ease;
}
.demo-login-link a:hover {
color: #357abd;
text-decoration: underline;
}
.demo-accounts {
color: #666;
font-size: 0.95rem;
}
.demo-account {
background-color: #f0f0f0;
padding: 0.2rem 0.5rem;
border-radius: 4px;
margin: 0 0.3rem;
color: #333;
font-family: monospace;
}

View File

@@ -0,0 +1,27 @@
import { Button } from '@mui/material';
import './index.css';
export const AppendDemo = () => {
return (
<div className='demo-login-prompt'>
<p className='demo-login-link'>
<a href='/user/login'></a>
</p>
</div>
);
};
export const DemoLogin = () => {
return (
<div className='flex flex-col items-center justify-center h-screen gap-4'>
<AppendDemo />
<Button
variant='contained'
color='primary'
onClick={() => {
window.location.href = `/user/login/?redirect=${window.location.href}&username=demo&password=xiong1015`;
}}>
</Button>
</div>
);
};

View File

@@ -0,0 +1,92 @@
import { TextEditor } from '@/modules/tiptap/editor';
import { useEffect, useRef, useState } from 'react';
import clsx from 'clsx';
import { Save } from 'lucide-react';
import { useWallStore } from '../wall/store/wall';
import { useShallow } from 'zustand/react/shallow';
import { app } from '../editor/app';
/**
* 监听ctrl+s保存内容
* esc退出编辑
* @param saveContent 保存内容
* @param exitEdit 退出编辑
*/
export const useListenCtrlS = (saveContent: () => void, exitEdit: () => void) => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
saveContent();
} else if (event.key === 'Escape') {
exitEdit();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [saveContent]);
};
type EditorProps = {
id?: string;
};
/**
* Node Edit Editor
* @param param0
* @returns
*/
export const NodeTextEditor = ({ id }: EditorProps) => {
const textEditorRef = useRef<TextEditor | null>(null);
const editorRef = useRef<HTMLDivElement>(null);
const { getNodeById, saveNodeById } = useWallStore(useShallow((state) => ({ getNodeById: state.getNodeById, saveNodeById: state.saveNodeById })));
useEffect(() => {
const editor = new TextEditor();
textEditorRef.current = editor;
editor.createEditor(editorRef.current!, { html: '' });
getIdContent();
return () => {
editor.destroy();
};
}, []);
const getIdContent = async () => {
if (!id) return;
const node = await getNodeById(id);
if (node) {
textEditorRef.current?.setContent(node.data.html);
}
};
const saveContent = async () => {
if (!id) return;
const html = await textEditorRef.current?.getHtml();
if (html) {
saveNodeById(id, {
html: html,
});
}
};
const exitEdit = () => {
// 退出编辑
saveContent();
setTimeout(() => {
app.call({
path: 'panels',
key: 'close-editor-window',
payload: {
data: {
id,
},
},
});
}, 100);
};
useListenCtrlS(saveContent, exitEdit);
return (
<div className={clsx('w-full h-full relative')}>
<div ref={editorRef} className={clsx('w-full h-full node-editor')}></div>
<div className='absolute top-2 right-2 cursor-pointer' onClick={() => saveContent()}>
<Save />
</div>
</div>
);
};

5
src/pages/editor/app.ts Normal file
View File

@@ -0,0 +1,5 @@
import { useContextKey } from '@kevisual/system-lib/dist/web-config';
import { QueryRouterServer } from '@kevisual/system-lib/dist/router-browser';
export const app = useContextKey<QueryRouterServer>('app');

View File

@@ -1,32 +1,62 @@
import { TextEditor } from '@/modules/tiptap/editor'; import { TextEditor } from '@/modules/tiptap/editor';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
export const useListenCtrlEnter = (callback: () => void) => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.ctrlKey && event.key === 'Enter') {
event.preventDefault();
callback();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []);
};
type EditorProps = { type EditorProps = {
className?: string; className?: string;
value?: string; value?: string;
id?: string; id?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
}; };
export const Editor = ({ className, value, onChange, id }: EditorProps) => { export const AiEditor = ({ className, value, onChange, id }: EditorProps) => {
const textEditorRef = useRef<TextEditor | null>(null); const textEditorRef = useRef<TextEditor | null>(null);
const editorRef = useRef<HTMLDivElement>(null); const editorRef = useRef<HTMLDivElement>(null);
const [mount, setMount] = useState(false); const [mount, setMount] = useState(false);
useEffect(() => { useEffect(() => {
const editor = new TextEditor(); const editor = new TextEditor();
textEditorRef.current = editor; textEditorRef.current = editor;
editor.createEditor(editorRef.current!, { html: value }); editor.createEditor(editorRef.current!, {
editor.onContentChange((content) => { html: value,
onChange?.(content); onUpdateHtml: (html) => {
onChange?.(html);
},
}); });
setMount(true); setMount(true);
return () => { return () => {
editor.destroy(); editor.destroy();
}; };
}, []); }, []);
useListenCtrlEnter(() => {
context?.app.call({
path: 'command',
key: 'handle',
payload: {
html: textEditorRef.current?.getHtml() || '',
},
});
});
useEffect(() => { useEffect(() => {
if (textEditorRef.current && id && mount) { if (textEditorRef.current && id && mount) {
textEditorRef.current.setContent(value || ''); textEditorRef.current.setContent(value || '');
} }
}, [id, mount]); }, [id, mount]);
return <div ref={editorRef} className={clsx('w-full h-full node-editor', className)}></div>; return (
<div className={clsx('w-full h-full editor-container relative', className)}>
<div ref={editorRef} className={clsx('w-full h-full node-editor', className)}></div>
</div>
);
}; };

6
src/pages/wall/app.ts Normal file
View File

@@ -0,0 +1,6 @@
import { useContextKey } from '@kevisual/system-lib/dist/web-config';
import { QueryRouterServer } from '@kevisual/system-lib/dist/router-browser';
export const app = useContextKey<QueryRouterServer>('app');

View File

@@ -22,3 +22,14 @@ export function SplitButtons({ closeToast }: ToastContentProps) {
</div> </div>
); );
} }
// toast(SplitButtons, {
// closeButton: false,
// className: 'p-0 w-[400px] border border-purple-600/40',
// ariaLabel: 'Email received',
// onClose: (reason) => {
// if (reason === 'success') {
// set({ open: true, selectedNode: data, hasEdited: false });
// }
// },
// });

View File

@@ -1 +1,10 @@
export const BlankNoteText = '<i>double click to edit</i>'; export const BlankNoteText = '<i>double click to edit</i>';
// https://www.reactbits.dev/text-animations/circular-text
export const CircularText =
'<iframe src="//player.bilibili.com/player.html?isOutside=true&aid=114057235467353&bvid=BV1ALPseyE7n&cid=28549582521&p=1" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true"></iframe>';
export const CircularText2 =
'<iframe src="https://kevisual.xiongxiao.me/apps/questions/" sandbox="allow-scripts allow-same-origin" width="100%" height="100%" ></iframe>';
export const CircularText3 = `<iframe srcdoc="<p>Your content here</p>"></iframe>`;

View File

@@ -4,10 +4,10 @@ import { useWallStore } from '../store/wall';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
export const useTabNode = () => { export const useTabNode = () => {
const reactFlowInstance = useReactFlow(); const reactFlowInstance = useReactFlow();
const open = useWallStore(useShallow((state) => state.open));
useEffect(() => { useEffect(() => {
if (open) return;
const listener = (event: any) => { const listener = (event: any) => {
const selected = reactFlowInstance.getNodes().find((node) => node.selected);
if (!selected) return;
if (event.key === 'Tab') { if (event.key === 'Tab') {
const nodes = reactFlowInstance.getNodes(); const nodes = reactFlowInstance.getNodes();
const selectedNode = nodes.find((node) => node.selected); const selectedNode = nodes.find((node) => node.selected);
@@ -56,9 +56,19 @@ export const useTabNode = () => {
event.stopPropagation(); event.stopPropagation();
} }
}; };
const rightClickListener = (event: any) => {
const selected = reactFlowInstance.getNodes().find((node) => node.selected);
if (!selected) return;
if (event.button === 2) {
event.preventDefault();
event.stopPropagation();
}
};
window.addEventListener('keydown', listener); window.addEventListener('keydown', listener);
window.addEventListener('contextmenu', rightClickListener);
return () => { return () => {
window.removeEventListener('keydown', listener); window.removeEventListener('keydown', listener);
window.removeEventListener('contextmenu', rightClickListener);
}; };
}, [reactFlowInstance, open]); }, [reactFlowInstance]);
}; };

View File

@@ -20,7 +20,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCheckDoubleClick } from './hooks/check-double-click'; import { useCheckDoubleClick } from './hooks/check-double-click';
import { randomId } from './utils/random'; import { randomId } from './utils/random';
import { CustomNodeType } from './modules/CustomNode'; import { CustomNodeType } from './modules/CustomNode';
import Drawer from './modules/Drawer';
import { message } from '@/modules/message'; import { message } from '@/modules/message';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { BlankNoteText } from './constants'; import { BlankNoteText } from './constants';
@@ -33,6 +32,9 @@ import { useListenPaster } from './hooks/listen-copy';
import { ContextMenu } from './modules/ContextMenu'; import { ContextMenu } from './modules/ContextMenu';
import { useSelect } from './hooks/use-select'; import { useSelect } from './hooks/use-select';
import clsx from 'clsx'; import clsx from 'clsx';
import { AppendDemo, DemoLogin } from '../demo-login';
import { List } from './pages/List';
type NodeData = { type NodeData = {
id: string; id: string;
position: XYPosition; position: XYPosition;
@@ -47,6 +49,7 @@ export function FlowContent() {
return { return {
nodes: state.nodes, nodes: state.nodes,
saveNodes: state.saveNodes, saveNodes: state.saveNodes,
saveDataNode: state.saveDataNode,
checkAndOpen: state.checkAndOpen, checkAndOpen: state.checkAndOpen,
mouseSelect: state.mouseSelect, // 鼠标模式,不能拖动 mouseSelect: state.mouseSelect, // 鼠标模式,不能拖动
setMouseSelect: state.setMouseSelect, setMouseSelect: state.setMouseSelect,
@@ -69,7 +72,7 @@ export function FlowContent() {
wallStore.saveNodes(reactFlowInstance.getNodes().filter((item) => item.id !== change.id)); wallStore.saveNodes(reactFlowInstance.getNodes().filter((item) => item.id !== change.id));
} }
if (change.type === 'position' && change.dragging === false) { if (change.type === 'position' && change.dragging === false) {
getNewNodes(false); getNewNodes(false, changes);
} }
onNodesChange(changes); onNodesChange(changes);
}, []); }, []);
@@ -96,9 +99,15 @@ export function FlowContent() {
const onNodeDoubleClick = (event, node) => { const onNodeDoubleClick = (event, node) => {
wallStore.checkAndOpen(true, node); wallStore.checkAndOpen(true, node);
}; };
const getNewNodes = (showMessage = true) => { const getNewNodes = (showMessage = true, changes?: NodeChange[]) => {
const nodes = reactFlowInstance.getNodes(); const nodes = reactFlowInstance.getNodes();
wallStore.saveNodes(nodes, { showMessage: showMessage }); // wallStore.saveNodes(nodes, { showMessage: showMessage });
// console.log('change', changes);
const operateNodes = nodes.filter((node) => {
return changes?.some((change) => change.type === 'position' && change.id === node.id);
});
console.log('operateNodes', operateNodes);
wallStore.saveDataNode(operateNodes);
}; };
useEffect(() => { useEffect(() => {
if (mount) { if (mount) {
@@ -125,7 +134,8 @@ export function FlowContent() {
}); });
setTimeout(() => { setTimeout(() => {
wallStore.checkAndOpen(true, newNode); wallStore.checkAndOpen(true, newNode);
getNewNodes(); // getNewNodes();
wallStore.saveDataNode([newNode]);
}, 200); }, 200);
}; };
const hasFoucedNode = useMemo(() => { const hasFoucedNode = useMemo(() => {
@@ -187,11 +197,10 @@ export function FlowContent() {
<Toolbar /> <Toolbar />
</Panel> </Panel>
<Panel> <Panel>
<Drawer />
<SaveModal /> <SaveModal />
{contextMenu && <ContextMenu x={contextMenu.x} y={contextMenu.y} onClose={handleCloseContextMenu} />} {contextMenu && <ContextMenu x={contextMenu.x} y={contextMenu.y} onClose={handleCloseContextMenu} />}
</Panel> </Panel>
</ReactFlow>{' '} </ReactFlow>
{isSelecting && selectionBox && ( {isSelecting && selectionBox && (
<div <div
style={{ style={{
@@ -209,10 +218,14 @@ export function FlowContent() {
</> </>
); );
} }
export const Flow = ({ checkLogin = true }: { checkLogin?: boolean }) => { export const Flow = ({ id }: { checkLogin?: boolean; id?: string }) => {
// const { id } = useParams(); const token = localStorage.getItem('token');
const id = ''; if (!token) {
// const navigate = useNavigate(); return <DemoLogin />;
}
if (!id) {
return <List />;
}
const wallStore = useWallStore( const wallStore = useWallStore(
useShallow((state) => { useShallow((state) => {
return { return {
@@ -224,24 +237,17 @@ export const Flow = ({ checkLogin = true }: { checkLogin?: boolean }) => {
); );
useEffect(() => { useEffect(() => {
wallStore.init(id); if (id) {
console.log('checkLogin', checkLogin, id); wallStore.init(id);
}, [id, checkLogin]); }
}, [id]);
if (!wallStore.loaded) { if (!wallStore.loaded) {
return <div>loading...</div>; return <div>loading...</div>;
} else if (wallStore.loaded === 'error') { } else if (wallStore.loaded === 'error') {
return ( return (
<div className='flex flex-col items-center justify-center h-screen gap-4'> <div className='flex flex-col items-center justify-center h-screen gap-4'>
<div className='text-2xl font-bold'>,</div> <div className='text-2xl font-bold'></div>
<Button
variant='contained'
onClick={() => {
// navigate('/');
wallStore.clearId();
}}>
</Button>
</div> </div>
); );
} }
@@ -251,13 +257,3 @@ export const Flow = ({ checkLogin = true }: { checkLogin?: boolean }) => {
</ReactFlowProvider> </ReactFlowProvider>
); );
}; };
export const FlowStatus = () => {
const { nodes } = useWallStore();
const reactFlow = useReactFlow();
const flowStore = useStore((state) => state);
return (
<div>
<div>: {nodes.length}</div>
</div>
);
};

View File

@@ -1,5 +1,7 @@
@import 'tailwindcss'; @import 'tailwindcss';
:root {
--xy-resize-background-color: #000;
}
@layer components { @layer components {
.node-editor { .node-editor {
@apply w-full h-full bg-white; @apply w-full h-full bg-white;
@@ -27,10 +29,10 @@
:root { :root {
--purple-light: #e0e0ff; /* 默认浅紫色背景 */ --purple-light: #e0e0ff; /* 默认浅紫色背景 */
--black: #000000; /* 默认黑色 */ --black: #000000; /* 默认黑色 */
--white: #ffffff; /* 默认白色 */ --white: #ffffff; /* 默认白色 */
--gray-3: #d3d3d3; /* 默认灰色3 */ --gray-3: #d3d3d3; /* 默认灰色3 */
--gray-2: #e5e5e5; /* 默认灰色2 */ --gray-2: #e5e5e5; /* 默认灰色2 */
} }
.tiptap-preview { .tiptap-preview {
.tiptap { .tiptap {
@@ -40,7 +42,8 @@
} }
} }
.tiptap { .tiptap {
margin: 0.5rem 1rem; /* margin: 0.5rem 1rem; */
margin: 0;
padding: 0.5rem; padding: 0.5rem;
border-radius: 5px; border-radius: 5px;
border: 1px solid #ccc; border: 1px solid #ccc;
@@ -79,7 +82,7 @@
.tiptap h2 { .tiptap h2 {
/* margin-top: 3.5rem; */ /* margin-top: 3.5rem; */
margin-top: 1rem; margin-top: 1rem;
margin-bottom: .5rem; margin-bottom: 0.5rem;
} }
.tiptap h1 { .tiptap h1 {
@@ -130,7 +133,7 @@
} }
.tiptap mark { .tiptap mark {
background-color: #FAF594; background-color: #faf594;
border-radius: 0.4rem; border-radius: 0.4rem;
box-decoration-break: clone; box-decoration-break: clone;
padding: 0.1rem 0.3rem; padding: 0.1rem 0.3rem;

View File

@@ -5,19 +5,17 @@ import { useWallStore } from '../store/wall';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { message } from '@/modules/message'; import { message } from '@/modules/message';
import { app } from '../app';
import hljs from 'highlight.js'; import hljs from 'highlight.js';
import { Edit } from 'lucide-react'; import { Edit } from 'lucide-react';
export type WallData<T = Record<string, any>> = { export type WallData<T = Record<string, any>> = {
html: string; html: string;
width?: number; width?: number;
height?: number; height?: number;
updatedAt?: number;
[key: string]: any; [key: string]: any;
} & T; } & T;
const ShowContent = (props: { data: WallData; selected: boolean }) => { const ShowContent = (props: { data: WallData; id: string; selected: boolean }) => {
const html = props.data.html;
const selected = props.selected;
const showRef = useRef<HTMLDivElement>(null);
if (!html) return <div className='w-full h-full flex items-center justify-center '></div>;
const [highlightHtml, setHighlightHtml] = useState(''); const [highlightHtml, setHighlightHtml] = useState('');
const highlight = async (html: string) => { const highlight = async (html: string) => {
const _html = html.replace(/<pre><code class="language-(\w+)">([\s\S]*?)<\/code><\/pre>/g, (match, p1, p2) => { const _html = html.replace(/<pre><code class="language-(\w+)">([\s\S]*?)<\/code><\/pre>/g, (match, p1, p2) => {
@@ -26,19 +24,23 @@ const ShowContent = (props: { data: WallData; selected: boolean }) => {
return _html; return _html;
}; };
useEffect(() => { useEffect(() => {
highlight(html).then((res) => { highlight(props.data.html).then((res) => {
setHighlightHtml(res); setHighlightHtml(res);
}); });
}, [html]); }, [props.data.html]);
useEffect(() => {
const id = props.id;
const container = document.querySelector('.id' + id);
if (container) {
container.innerHTML = highlightHtml;
}
}, [highlightHtml, props.data.updatedAt]);
return ( return (
<div <div
ref={showRef} className={clsx('p-2 w-full h-full overflow-y-auto scrollbar tiptap bg-white markdown-body', {}, 'id' + props.id)}
className='p-2 w-full h-full overflow-y-auto scrollbar tiptap bg-white markdown-body'
style={{ style={{
pointerEvents: selected ? 'auto' : 'none', pointerEvents: props.selected ? 'auto' : 'none',
}} }}></div>
dangerouslySetInnerHTML={{ __html: highlightHtml }}></div>
); );
}; };
@@ -50,7 +52,7 @@ export const CustomNode = (props: { id: string; data: WallData; selected: boolea
const wallStore = useWallStore( const wallStore = useWallStore(
useShallow((state) => { useShallow((state) => {
return { return {
setSelectedNode: state.setSelectedNode, id: state.id,
saveNodes: state.saveNodes, saveNodes: state.saveNodes,
checkAndOpen: state.checkAndOpen, checkAndOpen: state.checkAndOpen,
}; };
@@ -83,52 +85,50 @@ export const CustomNode = (props: { id: string; data: WallData; selected: boolea
}, },
}; };
}); });
// useEffect(() => {
// if (selected) {
// const handleDelete = (e: KeyboardEvent) => {
// if (e.key === 'Delete') {
// store.deleteNode(props.id);
// }
// };
// window.addEventListener('keydown', handleDelete);
// return () => window.removeEventListener('keydown', handleDelete);
// }
// }, [selected]);
const width = data.width || 100; const width = data.width || 100;
const height = data.height || 100; const height = data.height || 100;
const style: React.CSSProperties = {};
style.width = width;
style.height = height;
const showOpen = () => { const showOpen = () => {
const node = store.getNode(props.id); const node = store.getNode(props.id);
console.log('node eidt', node); console.log('node eidt', node);
if (node) { app.call({
const dataType: string = (node?.data?.dataType as string) || ''; path: 'panels',
if (dataType && dataType?.startsWith('image')) { key: 'add-editor-window',
message.error('不支持编辑图片'); payload: {
return; data: {
} else if (dataType) { pageId: wallStore.id || 'local-browser',
message.error('不支持编辑'); type: 'wallnote',
return; nodeData: node,
} },
wallStore.checkAndOpen(true, node); },
} else { });
message.error('节点不存在');
}
}; };
const handleSize = Math.max(10, 10 / zoom); const handleSize = Math.max(8, 8 / zoom);
return ( return (
<> <>
<div
className={clsx('absolute -top-2 left-0 bg-gray-300 z-10 w-full h-2 custom-dragger cursor-move', {
'opacity-0': !props.selected,
})}
style={{
width: `calc(100% + ${handleSize}px)`,
transform: `translateX(-${handleSize / 2}px)`,
}}></div>
<div <div
ref={contentRef} ref={contentRef}
onDoubleClick={(e) => { onDoubleClick={(e) => {
showOpen(); showOpen();
// e.stopPropagation();
e.preventDefault(); e.preventDefault();
}} }}
className={clsx('w-full h-full border relative border-gray-300 min-w-[100px] min-h-[50px] tiptap-preview')} className={clsx('w-full h-full border relative border-gray-300 min-w-[100px] min-h-[50px] tiptap-preview', {
style={style}> 'pointer-events-none': !props.selected,
<ShowContent data={data} selected={props.selected} /> 'pointer-events-auto': props.selected,
})}
style={{
width: width,
height: height,
}}>
<ShowContent data={data} id={props.id} selected={props.selected} />
</div> </div>
<div className={clsx('absolute top-0 right-0 cursor-pointer', props.selected ? 'opacity-100' : 'opacity-0')}> <div className={clsx('absolute top-0 right-0 cursor-pointer', props.selected ? 'opacity-100' : 'opacity-0')}>
<button <button
@@ -144,6 +144,7 @@ export const CustomNode = (props: { id: string; data: WallData; selected: boolea
minHeight={50} minHeight={50}
onResizeStart={() => {}} onResizeStart={() => {}}
isVisible={props.selected} isVisible={props.selected}
color='#d1d5dc'
onResizeEnd={(e) => { onResizeEnd={(e) => {
const parent = contentRef.current?.parentElement; const parent = contentRef.current?.parentElement;
if (!parent) return; if (!parent) return;
@@ -159,6 +160,7 @@ export const CustomNode = (props: { id: string; data: WallData; selected: boolea
? { ? {
width: handleSize, width: handleSize,
height: handleSize, height: handleSize,
border: 'unset',
} }
: undefined : undefined
} }

View File

@@ -1,122 +0,0 @@
import { useWallStore } from '../store/wall'; // 确保导入正确的路径
import clsx from 'clsx';
import { X } from 'lucide-react'; // 导入 Close 图标
import { Editor } from '@/pages/editor';
import { useEffect, useState } from 'react';
import { useStore, useStoreApi } from '@xyflow/react';
import { BlankNoteText } from '../constants';
import { message } from '@/modules/message';
import { useShallow } from 'zustand/react/shallow';
import { isMac } from '../utils/is-mac';
const Drawer = () => {
const { open, setOpen, selectedNode, setSelectedNode, editValue, setEditValue, hasEdited, setHasEdited } = useWallStore(
useShallow((state) => ({
open: state.open,
setOpen: state.setOpen,
selectedNode: state.selectedNode,
setSelectedNode: state.setSelectedNode,
editValue: state.editValue,
setEditValue: state.setEditValue,
hasEdited: state.hasEdited,
setHasEdited: state.setHasEdited,
})),
);
const store = useStore((state) => state);
const storeApi = useStoreApi();
const [mounted, setMounted] = useState(false);
useEffect(() => {
if (open && selectedNode) {
setEditValue(selectedNode?.data.html, true);
}
}, [open, selectedNode]);
useEffect(() => {
setMounted(true);
return () => {
setOpen(false);
setHasEdited(false);
setSelectedNode(null);
};
}, []);
const listener = async (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setOpen(false);
}
const systemKey = e.metaKey || e.ctrlKey;
// mac command+s windows ctrl+s
if (systemKey && e.key === 's') {
onSave();
e.preventDefault();
e.stopPropagation();
}
};
useEffect(() => {
window.addEventListener('keydown', listener);
return () => {
window.removeEventListener('keydown', listener);
};
}, []);
useEffect(() => {
if (!open && mounted) {
if (hasEdited) {
onSave();
}
}
}, [open, hasEdited, mounted]);
const onSave = () => {
const wallStore = useWallStore.getState();
const selectedNode = wallStore.selectedNode;
const _editValue = wallStore.editValue;
if (selectedNode && _editValue) {
selectedNode.data.html = _editValue;
const newNodes = storeApi.getState().nodes.map((node) => (node.id === selectedNode.id ? selectedNode : node));
storeApi.setState({ nodes: newNodes });
if (wallStore.id) {
message.success('保存到服务器成功', {
closeOnClick: true,
});
} else {
message.success('保存到本地成功', {
closeOnClick: true,
});
}
wallStore.saveNodes(newNodes, { showMessage: false });
}
};
let html = selectedNode?.data?.html || '';
if (html === BlankNoteText) {
html = '';
}
return (
<div
className={clsx(
'transition-all duration-300 bg-white flex flex-col gap-2 h-full w-full overflow-hidden fixed right-0 top-0 z-10',
open ? 'open' : 'hidden',
'w-[800px] xs:w-[100%] sm:w-[100%] md:w-[600px] lg:w-[600px] xl:w-[600px] 2xl:w-[800px]', // 默认宽度,根据屏幕大小适配,小屏幕全屏幕
)}>
<div className='flex justify-between items-center h-10'>
<button onClick={() => setOpen(false)}>
<X className='w-6 h-6 cursor-pointer ml-2' />
</button>
{selectedNode && (
<div>
<button className='bg-blue-500 text-white px-4 py-1 rounded-md mr-4 cursor-pointer' onClick={onSave}>
</button>
</div>
)}
</div>
<div
className='pr-4 mx-4 mb-4 rounded-md pb-4 box-border scrollbar border border-gray-300 '
style={{
height: 'calc(100vh - 2.5rem)',
overflowY: 'auto',
}}>
{selectedNode && open && <Editor className='drawer-editor' value={html} onChange={setEditValue} id={selectedNode.id} />}
</div>
</div>
);
};
export default Drawer;

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useCallback } from 'react';
import { Dialog, DialogTitle, DialogContent, TextField, DialogActions, Button, Chip } from '@mui/material'; import { Dialog, DialogTitle, DialogContent, TextField, DialogActions, Button, Chip } from '@mui/material';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { getNodeData, useWallStore } from '../store/wall'; import { getNodeData, useWallStore } from '../store/wall';
@@ -94,44 +94,51 @@ export const SaveModal = () => {
const { showFormDialog, setShowFormDialog, formDialogData, setFormDialogData } = wallStore; const { showFormDialog, setShowFormDialog, formDialogData, setFormDialogData } = wallStore;
const reactFlowInstance = useReactFlow(); const reactFlowInstance = useReactFlow();
// const navigate = useNavigate(); // const navigate = useNavigate();
const { id } = wallStore; const onSubmit = useCallback(
const onSubmit = async (values) => { async (values) => {
const nodes = reactFlowInstance.getNodes(); const { id } = wallStore;
const data = {
nodes: getNodeData(nodes),
};
const fromData = {
title: values.title,
description: values.description,
summary: values.summary,
tags: values.tags,
markType: 'wallnote' as 'wallnote',
data,
} as Wall;
if (id) {
fromData.id = id;
}
const loading = message.loading('保存中...');
const res = await userWallStore.saveWall(fromData, { refresh: false });
message.close(loading);
if (res.code === 200) {
setShowFormDialog(false);
if (!id) { if (!id) {
// 新创建 message.error('请先保存到账号');
const data = res.data; return;
message.info('redirect to edit page');
wallStore.clear();
setTimeout(() => {
// navigate(`/edit/${data.id}`);
}, 2000);
} else {
// 编辑
wallStore.setData(res.data);
} }
} else { const nodes = reactFlowInstance.getNodes();
message.error('保存失败'); const data = {
} nodes: getNodeData(nodes),
}; };
const fromData = {
title: values.title,
description: values.description,
summary: values.summary,
tags: values.tags,
markType: 'wallnote' as 'wallnote',
data,
} as Wall;
if (id) {
fromData.id = id;
}
const loading = message.loading('保存中...');
const res = await userWallStore.saveWall(fromData, { refresh: false });
message.close(loading);
if (res.code === 200) {
setShowFormDialog(false);
if (!id) {
// 新创建
const data = res.data;
message.info('redirect to edit page');
wallStore.clear();
setTimeout(() => {
// navigate(`/edit/${data.id}`);
}, 2000);
} else {
// 编辑
wallStore.setData(res.data);
}
} else {
message.error('保存失败');
}
},
[reactFlowInstance, wallStore.id],
);
if (!showFormDialog) { if (!showFormDialog) {
return null; return null;
} }

View File

@@ -130,115 +130,42 @@ export const ToolbarContent = ({ open }) => {
} }
}, },
}, },
{
label: '清空',
key: 'clear',
icon: <Trash />,
onClick: async () => {
await wallStore.clear();
message.success('清空成功');
store.setNodes([]);
},
},
]; ];
if (hasLogin) { menuList.push({
menuList.unshift({ label: '删除',
label: '我的笔记', key: 'delete',
key: 'myWall', icon: <Trash />,
icon: <BrickWall />, onClick: async () => {
onClick: () => { const res = await userWallStore.deleteWall(wallStore.id!);
// if (res.code === 200) {
}, // navigate('/');
}); }
} },
if (!hasLogin) { });
menuList.push({
label: '登录',
key: 'login',
icon: <User />,
onClick: () => {
redirectToLogin();
},
});
if (wallStore.id) {
menuList.push({
label: '删除',
key: 'delete',
icon: <Trash />,
onClick: async () => {
const res = await userWallStore.deleteWall(wallStore.id!);
if (res.code === 200) {
// navigate('/');
}
},
});
}
} else {
if (!wallStore.id) {
menuList.push({
label: '保存到账号',
key: 'saveToAccount',
icon: <Save />,
onClick: () => {
wallStore.setShowFormDialog(true);
wallStore.setFormDialogData({
title: '',
description: '',
tags: [],
summary: '',
});
},
});
} else {
menuList.push({
label: '编辑信息',
key: 'saveToAccount',
icon: <Save />,
onClick: () => {
wallStore.setShowFormDialog(true);
const data = wallStore.data;
wallStore.setFormDialogData({
title: data?.title,
description: data?.description,
tags: data?.tags,
summary: data?.summary,
});
},
});
menuList.push({
label: '新增',
key: 'add',
icon: <Plus />,
onClick: () => {
// navigate(`/`);
wallStore.clearQueryWall();
},
});
menuList.push({
label: '删除',
key: 'delete',
icon: <Trash />,
className: 'text-red-500',
onClick: async () => {
const res = await userWallStore.deleteWall(wallStore.id!);
if (res.code === 200) {
message.success('删除成功,返回首页');
wallStore.clearQueryWall();
// navigate('/');
}
},
});
}
menuList.push({ menuList.push({
label: '退出 ', label: '编辑信息',
key: 'logout', key: 'saveToAccount',
icon: <User />, icon: <Save />,
onClick: () => { onClick: () => {
userWallStore.logout(); wallStore.setShowFormDialog(true);
}, const data = wallStore.data;
}); wallStore.setFormDialogData({
} title: data?.title,
description: data?.description,
tags: data?.tags,
summary: data?.summary,
});
},
});
menuList.push({
label: '退出 ',
key: 'logout',
icon: <User />,
onClick: () => {
userWallStore.logout();
},
});
return ( return (
<ClickAwayListener onClickAway={() => wallStore.setToolbarOpen(false)}> <ClickAwayListener onClickAway={() => wallStore.setToolbarOpen(false)}>
<div className=' flex flex-col items-center w-[200px] bg-white border border-gray-300 rounded-md absolute top-0 left-8'> <div className=' flex flex-col items-center w-[200px] bg-white border border-gray-300 rounded-md absolute top-0 left-8'>

View File

@@ -3,9 +3,7 @@ import { useWallStore } from '../store/wall';
import { useUserWallStore } from '../store/user-wall'; import { useUserWallStore } from '../store/user-wall';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { formatDate, formatRelativeDate } from '../../../modules/dayjs'; import { formatDate, formatRelativeDate } from '../../../modules/dayjs';
import { useNavigate } from 'react-router-dom';
export const List = () => { export const List = () => {
const navigate = useNavigate();
const wallStore = useUserWallStore( const wallStore = useUserWallStore(
useShallow((state) => { useShallow((state) => {
return { return {
@@ -20,6 +18,9 @@ export const List = () => {
const init = () => { const init = () => {
wallStore.queryWallList(); wallStore.queryWallList();
}; };
const navigate = (path: string) => {
window.location.href = path;
};
return ( return (
<div className='p-4 bg-white w-full h-full flex flex-col'> <div className='p-4 bg-white w-full h-full flex flex-col'>
<div <div
@@ -37,7 +38,9 @@ export const List = () => {
key={wall.id} key={wall.id}
className='p-4 border border-gray-200 w-80 rounded-md' className='p-4 border border-gray-200 w-80 rounded-md'
onClick={() => { onClick={() => {
navigate(`/edit/${wall.id}`); const url = new URL(location.href);
url.searchParams.set('id', wall.id!);
navigate(url.toString());
}}> }}>
<div> <div>
<div>{wall.title}</div> <div>{wall.title}</div>

View File

@@ -30,8 +30,11 @@ interface UserWallStore {
wallList: Wall[]; wallList: Wall[];
queryWallList: () => Promise<void>; queryWallList: () => Promise<void>;
logout: () => void; logout: () => void;
saveWall: (data: Wall, opts?: { refresh?: boolean, showMessage?: boolean }) => Promise<any>; saveWall: (data: Wall, opts?: { refresh?: boolean; showMessage?: boolean }) => Promise<any>;
queryWall: (id: string) => Promise<any>; saveOneNode: (id: string, node: any) => Promise<any>;
saveDataNodes: (id: string, nodes: any[], opts?: { showMessage?: boolean }) => Promise<any>;
queryWall: (id?: string) => Promise<any>;
queryWallVersion: (id?: string) => Promise<any>;
deleteWall: (id: string) => Promise<any>; deleteWall: (id: string) => Promise<any>;
} }
@@ -66,7 +69,7 @@ export const useUserWallStore = create<UserWallStore>((set, get) => ({
set({ wallList: res.data.list }); set({ wallList: res.data.list });
} }
}, },
saveWall: async (data: Wall, opts?: { refresh?: boolean, showMessage?: boolean }) => { saveWall: async (data: Wall, opts?: { refresh?: boolean; showMessage?: boolean }) => {
const { queryWallList } = get(); const { queryWallList } = get();
const res = await query.post({ const res = await query.post({
path: 'mark', path: 'mark',
@@ -81,7 +84,27 @@ export const useUserWallStore = create<UserWallStore>((set, get) => ({
} }
return res; return res;
}, },
queryWall: async (id: string) => { saveOneNode: async (id: string, node: any) => {
const res = await query.post({
path: 'mark',
key: 'updateNode',
data: { id, node },
});
return res;
},
saveDataNodes: async (id: string, nodeOperateList: any[], opts?: { showMessage?: boolean }) => {
const res = await query.post({
path: 'mark',
key: 'updateNodes',
data: { id, nodeOperateList },
});
if (res.code === 200) {
opts?.showMessage && message.success('保存成功');
return res;
}
return res;
},
queryWall: async (id?: string) => {
const res = await query.post({ const res = await query.post({
path: 'mark', path: 'mark',
key: 'get', key: 'get',
@@ -97,6 +120,14 @@ export const useUserWallStore = create<UserWallStore>((set, get) => ({
}); });
return res; return res;
}, },
queryWallVersion: async (id?: string) => {
const res = await query.post({
path: 'mark',
key: 'getVersion',
id,
});
return res;
},
logout: () => { logout: () => {
set({ user: undefined }); set({ user: undefined });
localStorage.removeItem('token'); localStorage.removeItem('token');

View File

@@ -1,13 +1,12 @@
import { create } from 'zustand'; import { create, StateCreator, StoreApi, UseBoundStore } from 'zustand';
import { XYPosition } from '@xyflow/react'; import { XYPosition } from '@xyflow/react';
import { getWallData, setWallData } from '../utils/db'; import { getCacheWallData, setCacheWallData } from '../utils/db';
import { useUserWallStore } from './user-wall'; import { useUserWallStore } from './user-wall';
import { redirectToLogin } from '@/modules/require-to-login'; import { redirectToLogin } from '@/modules/require-to-login';
import { message } from '@/modules/message'; import { message } from '@/modules/message';
import { randomId } from '../utils/random';
import { DOCS_NODE } from '../docs'; import { DOCS_NODE } from '../docs';
import { toast } from 'react-toastify'; import { useContextKey } from '@kevisual/system-lib/dist/web-config';
import { SplitButtons } from '../components/SplitToast';
type NodeData<T = { [key: string]: any }> = { type NodeData<T = { [key: string]: any }> = {
id: string; id: string;
position: XYPosition; position: XYPosition;
@@ -27,23 +26,14 @@ interface WallState {
// 只做传递 // 只做传递
nodes: NodeData[]; nodes: NodeData[];
setNodes: (nodes: NodeData[]) => void; setNodes: (nodes: NodeData[]) => void;
saveDataNode: (nodes: NodeData[]) => Promise<void>;
saveNodes: (nodes: NodeData[], opts?: { showMessage?: boolean }) => Promise<void>; saveNodes: (nodes: NodeData[], opts?: { showMessage?: boolean }) => Promise<void>;
open: boolean;
setOpen: (open: boolean) => void;
checkAndOpen: (open?: boolean, data?: any) => void; checkAndOpen: (open?: boolean, data?: any) => void;
selectedNode: NodeData | null;
setSelectedNode: (node: NodeData | null) => void;
editValue: string;
setEditValue: (value: string, init?: boolean) => void;
hasEdited: boolean;
setHasEdited: (hasEdited: boolean) => void;
data?: any; data?: any;
setData: (data: any) => void; setData: (data: any) => void;
init: (id?: string | null) => Promise<void>; init: (id?: string) => Promise<void>;
id: string | null; id: string | null;
setId: (id: string | null) => void; setId: (id: string | null) => void;
loading: boolean;
setLoading: (loading: boolean) => void;
loaded: boolean | 'error'; loaded: boolean | 'error';
toolbarOpen: boolean; toolbarOpen: boolean;
setToolbarOpen: (open: boolean) => void; setToolbarOpen: (open: boolean) => void;
@@ -57,27 +47,47 @@ interface WallState {
clearId: () => Promise<void>; clearId: () => Promise<void>;
mouseSelect: boolean; mouseSelect: boolean;
setMouseSelect: (mouseSelect: boolean) => void; setMouseSelect: (mouseSelect: boolean) => void;
getNodeById: (id: string) => Promise<NodeData | null>;
saveNodeById: (id: string, data: any) => Promise<void>;
} }
export class WallStore {
export const useWallStore = create<WallState>((set, get) => ({ private storeMap: Map<string, UseBoundStore<StoreApi<WallState>>> = new Map();
nodes: [], constructor() {
loading: false, this.crateStoreById('today');
setLoading: (loading) => set({ loading }), }
setNodes: (nodes) => { crateStoreById(id: string) {
set({ nodes }); const store = create<WallState>((set, get) => ({
}, nodes: [],
saveNodes: async (nodes: NodeData[], opts) => { setNodes: (nodes) => {
const showMessage = opts?.showMessage ?? true; set({ nodes });
set({ hasEdited: false }); },
if (!get().id) { saveDataNode: async (nodes: NodeData[]) => {
const covertData = getNodeData(nodes); const id = get().id;
setWallData({ nodes: covertData }); if (!id) {
showMessage && message.success('保存到本地'); message.error('没有id');
} else { return;
const { id } = get(); }
const userWallStore = useUserWallStore.getState();
if (id) {
const covertData = getNodeData(nodes); const covertData = getNodeData(nodes);
const nodeOperateList = covertData.map((item) => ({
node: item,
}));
const res = await useUserWallStore.getState().saveDataNodes(id, nodeOperateList);
if (res.code === 200) {
message.success('保存成功');
} else {
message.error('保存失败');
}
},
saveNodes: async (nodes: NodeData[], opts) => {
const showMessage = opts?.showMessage ?? true;
const id = get().id;
if (!id) {
message.error('没有id');
return;
}
const covertData = getNodeData(nodes);
const userWallStore = useUserWallStore.getState();
const res = await userWallStore.saveWall({ const res = await userWallStore.saveWall({
id, id,
data: { data: {
@@ -90,115 +100,140 @@ export const useWallStore = create<WallState>((set, get) => ({
message.success('保存成功', { message.success('保存成功', {
closeOnClick: true, closeOnClick: true,
}); });
const markRes = res.data;
setCacheWallData(markRes, markRes?.id);
} }
} },
} checkAndOpen: (open, data) => {
}, //
open: false, },
setOpen: (open) => { data: null,
set({ open }); setData: (data) => set({ data }),
}, id: null,
checkAndOpen: (open, data) => { setId: (id) => set({ id }),
const state = get(); loaded: false,
if (state.hasEdited || state.open) { init: async (id?: string) => {
toast(SplitButtons, { // 如果登陆了且如果有id从服务器获取
closeButton: false, // 没有id获取缓存
className: 'p-0 w-[400px] border border-purple-600/40', const hasLogin = localStorage.getItem('token');
ariaLabel: 'Email received', const checkVersion = async (): Promise<{ id: string; version: number } | null> => {
onClose: (reason) => { const res = await useUserWallStore.getState().queryWallVersion(id);
if (reason === 'success') { if (res.code === 200) {
set({ open: true, selectedNode: data, hasEdited: false }); const data = res.data;
return data;
} else {
message.error('获取失败,请稍后刷新重试');
return null;
} }
}, };
}); const getNew = async () => {
return; const res = await useUserWallStore.getState().queryWall(id);
} else set({ open, selectedNode: data }); if (res.code === 200) {
}, const data = res.data;
selectedNode: null, set({ nodes: data?.data?.nodes || [], loaded: true, id: data?.id, data });
setSelectedNode: (node) => set({ selectedNode: node }), setCacheWallData(data, data?.id);
editValue: '', }
setEditValue: (value, init = false) => { };
set({ editValue: value }); if (hasLogin) {
if (!init) { const cvData = await checkVersion();
set({ hasEdited: true }); if (cvData) {
const id = cvData?.id;
const cacheData = await getCacheWallData(id);
if (cacheData) {
const version = cacheData?.version;
if (version === cvData?.version) {
set({ nodes: cacheData?.data?.nodes || [], loaded: true, id, data: cacheData });
} else {
getNew();
}
} else {
getNew();
}
}
} else {
// 跳转到登陆页面
redirectToLogin();
}
},
toolbarOpen: false,
setToolbarOpen: (open) => set({ toolbarOpen: open }),
showFormDialog: false,
setShowFormDialog: (show) => set({ showFormDialog: show }),
formDialogData: null,
setFormDialogData: (data) => set({ formDialogData: data }),
clear: async () => {
// if (get().id) {
// set({ nodes: [], data: null });
// await useUserWallStore.getState().saveWall({
// id: get().id!,
// data: {
// nodes: [],
// },
// });
// } else {
// set({ nodes: [], id: null, data: null });
// await setCacheWallData({ nodes: [] });
// }
},
clearId: async () => {
set({ id: null, data: null });
},
exportWall: async (nodes: NodeData[]) => {
const covertData = getNodeData(nodes);
const mark = get().data;
setCacheWallData({ ...mark, data: { ...mark.data, nodes: covertData } }, mark?.id);
// 导出为json
const json = JSON.stringify(covertData);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'wall.json';
a.click();
},
clearQueryWall: async () => {
set({ nodes: [], id: null, data: null, toolbarOpen: false, loaded: false });
},
mouseSelect: true,
setMouseSelect: (mouseSelect) => set({ mouseSelect }),
getNodeById: async (id: string) => {
const data = await getCacheWallData(get().id!);
const nodes = data?.data?.nodes || [];
return nodes.find((node) => node.id === id);
},
saveNodeById: async (id: string, data: any) => {
let node = await get().getNodeById(id);
if (node) {
node.data = {
...node.data,
...data,
updatedAt: new Date().getTime(),
};
const newNodes = get().nodes.map((item) => {
if (item.id === id) {
return node;
}
return item;
});
set({
nodes: newNodes,
});
get().saveNodes(newNodes, { showMessage: false });
}
},
}));
this.storeMap.set(id, store);
return store;
}
getStoreById(id: string) {
const store = this.storeMap.get(id);
if (!store) {
return this.crateStoreById(id);
} }
}, return store;
hasEdited: false, }
setHasEdited: (hasEdited) => set({ hasEdited }), }
data: null, // export const useWallStore =
setData: (data) => set({ data }), const wallStore = useContextKey('wallStore', () => new WallStore());
id: null, export const useWallStore = wallStore.getStoreById('today');
setId: (id) => set({ id }),
loaded: false,
init: async (id?: string | null) => {
// 如果登陆了且如果有id从服务器获取
// 没有id获取缓存
const hasLogin = localStorage.getItem('token');
if (hasLogin && id) {
const res = await useUserWallStore.getState().queryWall(id);
if (res.code === 200) {
set({ nodes: res.data?.data?.nodes || [], loaded: true, id, data: res.data });
} else {
// message.error('获取失败,请稍后刷新重试');
set({ loaded: 'error' });
}
} else if (!hasLogin && id) {
// 没有登陆但是有id从服务器获取
// 跳转到登陆页面
redirectToLogin();
} else {
const data = await getWallData();
const nodes = data?.nodes || [];
if (nodes.length === 0) {
set({
nodes: [...DOCS_NODE], //
loaded: true,
id: null,
data: null,
});
} else {
set({ nodes, loaded: true, id: null, data: null });
}
}
},
toolbarOpen: false,
setToolbarOpen: (open) => set({ toolbarOpen: open }),
showFormDialog: false,
setShowFormDialog: (show) => set({ showFormDialog: show }),
formDialogData: null,
setFormDialogData: (data) => set({ formDialogData: data }),
clear: async () => {
if (get().id) {
set({ nodes: [], selectedNode: null, editValue: '', data: null });
await useUserWallStore.getState().saveWall({
id: get().id!,
data: {
nodes: [],
},
});
} else {
set({ nodes: [], id: null, selectedNode: null, editValue: '', data: null });
await setWallData({ nodes: [] });
}
},
clearId: async () => {
set({ id: null, data: null });
},
exportWall: async (nodes: NodeData[]) => {
const covertData = getNodeData(nodes);
setWallData({ nodes: covertData });
// 导出为json
const json = JSON.stringify(covertData);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'wall.json';
a.click();
},
clearQueryWall: async () => {
set({ nodes: [], id: null, selectedNode: null, editValue: '', data: null, toolbarOpen: false, loaded: false });
},
mouseSelect: true,
setMouseSelect: (mouseSelect) => set({ mouseSelect }),
}));

View File

@@ -2,19 +2,19 @@ import { MyCache } from '@kevisual/cache';
const cache = new MyCache('cacheWall'); const cache = new MyCache('cacheWall');
export async function getWallData() { export async function getCacheWallData(key?: string) {
try { try {
const data = await cache.getData(); const data = await cache.get(key ?? 'cacheWall');
return data; return data;
} catch (e) { } catch (e) {
cache.del(); cache.del();
} }
} }
export async function setWallData(data: any) { export async function setCacheWallData(data: any, key?: string) {
await cache.setData(data); await cache.set(key ?? 'cacheWall', data);
} }
export async function clearWallData() { export async function clearCacheWallData() {
await cache.del(); await cache.del();
} }

84
src/routes.tsx Normal file
View File

@@ -0,0 +1,84 @@
import { createRoot } from 'react-dom/client';
import { App } from './App.tsx';
import { useContextKey } from '@kevisual/system-lib/dist/web-config';
import './index.css';
import { QueryRouterServer } from '@kevisual/system-lib/dist/router-browser';
import { NodeTextEditor } from './pages/editor/NodeTextEditor.tsx';
import { AiEditor } from './pages/editor/index.tsx';
import { Panels } from './modules/panels/index.tsx';
import { Page } from '@kevisual/system-lib/dist/web-page';
import './modules/panels/routes.ts';
export const page = useContextKey<Page>('page');
export const app = useContextKey<QueryRouterServer>('app');
app
.route({
path: 'wallnote',
key: 'render',
})
.define(async (ctx) => {
const rootEl = document.getElementById('root') as HTMLElement;
const root = createRoot(rootEl);
useContextKey('wallnoteRoot', () => root, true);
root.render(<App />);
})
.addTo(app);
app
.route({
path: 'wallnote',
key: 'lib',
description: '获取编辑器',
})
.define(async (ctx) => {
ctx.body = { Panels };
})
.addTo(app);
app
.route({
path: 'editor',
key: 'nodeRender',
description: '获取编辑器',
})
.define(async (ctx) => {
ctx.body = { lib: NodeTextEditor, type: 'react', Panels };
})
.addTo(app);
app
.route({
path: 'editor',
key: 'render',
description: '获取编辑器',
})
.define(async (ctx) => {
ctx.body = { lib: AiEditor, type: 'react', Panels };
})
.addTo(app);
app
.route({
path: 'editor',
key: 'render2',
description: '获取编辑器',
})
.define(async (ctx) => {
class HtmlRender {
render({ renderRoot, data }: any) {
const newDivStr = `<div id="${data.id}">${data.title}</div>`;
const newDiv = document.createElement('div');
newDiv.innerHTML = newDivStr;
renderRoot.appendChild(newDiv);
}
destroy() {
// 什么也不做
}
}
ctx.body = {
lib: HtmlRender,
type: 'html',
};
})
.addTo(app);

View File

@@ -1,8 +1,6 @@
import { createRoot, Root } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { AiApp } from './AiApp';
import { app, initAIAppRootOrCreate, useContextKey } from '../app'; import { app, initAIAppRootOrCreate, useContextKey } from '../app';
import { Editor } from '@/pages/editor/index'; import { Panels } from '@/modules/panels/index';
import { ExampleApp } from '@/modules/panels/Example';
initAIAppRootOrCreate(); initAIAppRootOrCreate();
app app
@@ -12,15 +10,11 @@ app
description: '渲染AI应用', description: '渲染AI应用',
run: async (ctx) => { run: async (ctx) => {
const root = initAIAppRootOrCreate(); const root = initAIAppRootOrCreate();
console.log('ai render');
console.log('ai render', root);
if (!root) { if (!root) {
return; return;
} }
const aiRoot = createRoot(root!); const aiRoot = createRoot(root!);
// aiRoot.render(<Editor />); aiRoot.render(<Panels />);
// aiRoot.render(<AiApp />);
aiRoot.render(<ExampleApp />);
useContextKey('aiRoot', () => aiRoot, true); useContextKey('aiRoot', () => aiRoot, true);
ctx.body = 'aiRoot'; ctx.body = 'aiRoot';
}, },

View File

@@ -28,6 +28,7 @@ export const queryAI = useContextKey<QueryAI>('queryAI', () => {
return new QueryAI(); return new QueryAI();
}); });
export const rootEl = document.getElementById('root') as HTMLElement;
export const initAIAppRootOrCreate = () => { export const initAIAppRootOrCreate = () => {
const root = document.getElementById('ai-root'); const root = document.getElementById('ai-root');
if (!root) { if (!root) {

121
template/command/routes.ts Normal file
View File

@@ -0,0 +1,121 @@
import TurndownService from 'turndown';
import { app, message } from '../app';
// 命令规则
// 1. 命令以 ! 开头
// 2. 命令和内容之间用空格隔开
// 3. 多余的地方不要有!,如果有,使用\! 代替
//
//
// test命令 !a 显示内容 !b 但是会计法 !c 飒短发 !fdsaf s !kong !d d!!的身份 ! 是的! !ene
// 7个
export function parseCommands(text: string) {
//文本以\!的内容都去掉
text = text.replace(/\\!/g, '__REPLACE__RETURN__');
const result: { command: string; content: string }[] = [];
const parts = text.split('!');
for (let i = 1; i < parts.length; i++) {
const part = parts[i].trim();
if (part.length === 0) continue; // 忽略空的部分
const spaceIndex = part.indexOf(' ');
const command = '!' + (spaceIndex === -1 ? part : part.slice(0, spaceIndex));
let content = spaceIndex === -1 ? '' : part.slice(spaceIndex + 1).trim();
if (content.includes('__REPLACE__RETURN__')) {
content = content.replace('__REPLACE__RETURN__', '!');
}
result.push({ command, content });
}
return result;
}
app
.route({
path: 'command',
key: 'handle',
description: '处理命令',
})
.define(async (ctx) => {
const { html } = ctx.query;
// 解析 文本中的 !command 命令
// 1. 当没有命令的时候是保存文本
// 2. 当有命令的时候,查询命令,执行
// - 当命令不存在,直接返回提示
// - 当命令存在,执行命令
const turndown = new TurndownService();
const markdown = turndown.turndown(html);
const commands = parseCommands(markdown);
if (commands.length === 0) {
ctx.body = markdown;
const res = await app.call({ path: 'note', key: 'save', payload: { html } });
if (res.code !== 200) {
message.error(res.message || '保存失败');
ctx.throw(400, res.message || '保存失败');
}
return;
}
console.log('md', markdown);
console.log('commands', commands, commands.length);
const res = await app.call({ path: 'command', key: 'list', payload: { commands } });
})
.addTo(app);
app
.route({
path: 'command',
key: 'list',
description: '命令列表',
metadata: {
command: 'command-list',
prompt: '把当前我的数据中,所有命令列表返回',
},
validator: {
commands: {
type: 'any',
required: false,
},
},
})
.define(async (ctx) => {
const { commands } = ctx.query;
const getRouteInfo = (route: any) => {
return {
path: route.path,
key: route.key,
description: route.description,
metadata: route.metadata,
validator: route.validator,
};
};
if (Array.isArray(commands) && commands.length > 0) {
const routes = ctx.queryRouter.routes;
const commandRoutes = commands.map((command) => {
const route = routes.find((route) => route.metadata?.command === command.command);
if (!route) {
message.error(`命令 ${command.command} 不存在`);
ctx.throw(400, `命令 ${command.command} 不存在`);
}
return {
command,
route: getRouteInfo(route),
};
});
ctx.body = commandRoutes;
} else {
ctx.body = ctx.queryRouter.routes
.map((route) => ({
command: route.metadata?.command,
route: getRouteInfo(route),
}))
.filter((item) => item.command);
}
})
.addTo(app);
setTimeout(async () => {
const res = await app.call({ path: 'command', key: 'list' });
console.log('list', res.body);
}, 2000);

View File

@@ -1,13 +1,16 @@
import { app, page, load } from './app'; import { app, page, load } from './app';
import '../src/routes';
import './ai-app/main'; import './ai-app/main';
import './tailwind.css'; import './tailwind.css';
import './workspace/entry'; import './workspace/entry';
import './routes';
page.addPage('/', 'workspace'); page.addPage('/', 'workspace');
const runLoad = () => { const runLoad = () => {
load.load( load.load(
() => { () => {
console.log('runLoad');
// @TODO 这里需要优化,不能每次都去调用 // @TODO 这里需要优化,不能每次都去调用
page.subscribe( page.subscribe(
'workspace', 'workspace',
@@ -36,4 +39,4 @@ const runLoad = () => {
); );
}; };
// runLoad() runLoad()

2
template/routes.ts Normal file
View File

@@ -0,0 +1,2 @@
import './user/route';
import './command/routes';

View File

@@ -12,14 +12,14 @@
/* # 背景设置为透明 */ /* # 背景设置为透明 */
#ai-root { #ai-root {
/* @apply bg-transparent */ @apply bg-transparent;
@apply border-none box-border border-2 border-gray-300 rounded-md; @apply border-none box-border border-2 border-gray-300 rounded-md;
@apply pointer-events-none; @apply pointer-events-none;
} }
#ai-root { #ai-root {
.node-editor { .node-editor {
@apply pointer-events-auto; @apply pointer-events-auto;
@apply absolute bottom-0 left-0 w-full h-[20vh] border border-gray-300 shadow-md; @apply absolute bottom-0 left-0 w-full border border-gray-300 shadow-md;
.tiptap { .tiptap {
@apply mx-0 h-full overflow-y-auto; @apply mx-0 h-full overflow-y-auto;
} }
@@ -28,5 +28,3 @@
@apply w-full h-full overflow-hidden; @apply w-full h-full overflow-hidden;
} }
} }

View File

@@ -1,6 +1,45 @@
import { app } from '../app'; import { app, message } from '../app';
app.route({ app
path: 'user', .route({
key: 'login', path: 'user',
}); key: 'login',
})
.define(async (ctx) => {
const { username, password } = ctx.query;
if (!username || !password) {
message.error('用户名和密码不能为空');
ctx.throw(400, '用户名和密码不能为空');
}
const res = await fetch('/api/router', {
method: 'POST',
body: JSON.stringify({ path: 'user', key: 'login', username, password }),
}).then((res) => res.json());
if (res.code === 200) {
localStorage.setItem('token', res.data.token);
} else {
message.error(res.message);
ctx.throw(400, res.message);
}
})
.addTo(app);
app
.route({
path: 'user',
key: 'logout',
description: '退出登录',
metadata: {
command: 'logout',
},
})
.define(async (ctx) => {
localStorage.removeItem('token');
fetch('/api/router?path=user&key=logout', {
method: 'POST',
});
setTimeout(() => {
window.location.href = '/user/login';
}, 1000);
})
.addTo(app);

View File

@@ -15,15 +15,16 @@ app
return; return;
} }
console.log('workspace enter'); console.log('workspace enter');
console.log('workspace enter', isRender);
if (!isRender) { if (!isRender) {
app.call({ const res = await app.call({
path: 'wallnote', path: 'wallnote',
key: 'render', key: 'render',
}); });
console.log('res', res);
isRender = true; isRender = true;
} }
ctx.body = ''; ctx.body = '';
}, },
}) })
.addTo(app); .addTo(app);

View File

@@ -0,0 +1,36 @@
把当前我的数据中所有的title和description和path和key列出来生成一个好看的卡片式的列表。只给我返回html的内容其他的东西不返回给我。
```json
[
{
"command": "logout",
"route": {
"path": "user",
"key": "logout",
"description": "退出登录",
"metadata": {
"command": "logout"
},
"validator": {}
}
},
{
"command": "command-list",
"route": {
"path": "command",
"key": "list",
"description": "命令列表",
"metadata": {
"command": "command-list",
"prompt": "把当前我的数据中,所有命令列表返回"
},
"validator": {
"commands": {
"type": "any",
"required": false
}
}
}
}
]
```

View File

@@ -0,0 +1,6 @@
我有一个命令列表,我需要通过查询去获取相应的列表的内容,我提供你查询的方式。我需要你把我文本的内容转为查询的参数的格式。

View File

@@ -0,0 +1,7 @@
我有一些命令匹配的文本,格式是: !command text-content 他是很多类同的命令结合一起的其中text-content可能为空其中命令和内容都可能是乱拼的只要符合 !command ,你就要把内容返回给我。其中如果!单独存在,或者!之前面有内容,都不属于命令都属于上一个命令的文本你需要排出这些错误情况。你需要把命令和文本的内容返回给我一个json数据。返回的格式是[{command,content],你只需要把你对应的内容返回给我,不要返回其他内容。
我给你的命令文本是
!a 显示内容 !b 但是会计法 !c 飒短发 !fdsaf s !d d!!的身份 ! 是的! !ene
PROMPT_TEXT

View File