generated from template/vite-react-template
temp: fix bugs
This commit is contained in:
parent
5ef42ee9de
commit
a92e377d9f
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "wallnote",
|
"name": "wallnote",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.3",
|
"version": "0.0.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"user": "apps",
|
"user": "apps",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -11,8 +11,8 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"deploy": "rsync -avz --delete dist/ light:~/apps/ai/dist",
|
"deploy": "rsync -avz --delete dist/ light:~/apps/ai/dist",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepub": "envision switchOrg apps",
|
"prepub": "pnpm build && envision switch apps",
|
||||||
"pub": "envision deploy ./dist -k wallnote -v 0.0.3 -y y",
|
"pub": "envision deploy ./dist -k wallnote -v 0.0.6 -y y",
|
||||||
"ev": "npm run build && npm run deploy"
|
"ev": "npm run build && npm run deploy"
|
||||||
},
|
},
|
||||||
"stackblitz": {
|
"stackblitz": {
|
||||||
|
14
src/App.tsx
14
src/App.tsx
@ -1,5 +1,5 @@
|
|||||||
import { Flow } from './pages/wall';
|
import { Flow } from './pages/wall';
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { Editor } from './pages/editor';
|
import { Editor } from './pages/editor';
|
||||||
import { ToastContainer } from 'react-toastify';
|
import { ToastContainer } from 'react-toastify';
|
||||||
import 'react-toastify/dist/ReactToastify.css';
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
@ -7,7 +7,7 @@ import { List } from './pages/wall/pages/List';
|
|||||||
import { Auth } from './modules/layouts/Auth';
|
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';
|
||||||
|
import { App as WallShareApp } from './pages/wall-share';
|
||||||
export const App = () => {
|
export const App = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -21,6 +21,16 @@ export const App = () => {
|
|||||||
<Route path='/edit/:id' element={<Flow checkLogin={true} />} />
|
<Route path='/edit/:id' element={<Flow checkLogin={true} />} />
|
||||||
<Route path='/list' element={<List />} />
|
<Route path='/list' element={<List />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route
|
||||||
|
path='/share/*'
|
||||||
|
element={
|
||||||
|
<Auth auth={false}>
|
||||||
|
<WallShareApp />
|
||||||
|
</Auth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route path='*' element={<Navigate to='/' />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
@ -3,7 +3,13 @@ import { useUserWallStore } from '../../pages/wall/store/user-wall';
|
|||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
export const Auth = ({ children, auth = true }: { children?: React.ReactNode; auth?: boolean }) => {
|
/**
|
||||||
|
*
|
||||||
|
* @param children
|
||||||
|
* @param auth 是否必须要登陆
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const Auth = ({ children, auth = true }: { children?: React.ReactNode; auth?: boolean; canNoAuth?: boolean; isOutlet?: boolean }) => {
|
||||||
const userStore = useUserWallStore(
|
const userStore = useUserWallStore(
|
||||||
useShallow((state) => {
|
useShallow((state) => {
|
||||||
return { user: state.user, queryMe: state.queryMe };
|
return { user: state.user, queryMe: state.queryMe };
|
||||||
@ -20,5 +26,8 @@ export const Auth = ({ children, auth = true }: { children?: React.ReactNode; au
|
|||||||
}
|
}
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
if (auth) {
|
||||||
|
return <>{userStore.user && <Outlet />}</>;
|
||||||
|
}
|
||||||
return <>{<Outlet />}</>;
|
return <>{<Outlet />}</>;
|
||||||
};
|
};
|
||||||
|
13
src/pages/wall-share/index.tsx
Normal file
13
src/pages/wall-share/index.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Routes, Route } from 'react-router-dom';
|
||||||
|
|
||||||
|
const WallShare = () => {
|
||||||
|
return <div>WallShare</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const App = () => {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path='/:id' element={<WallShare />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
};
|
24
src/pages/wall/components/SplitToast.tsx
Normal file
24
src/pages/wall/components/SplitToast.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { ToastContentProps } from 'react-toastify';
|
||||||
|
|
||||||
|
export function SplitButtons({ closeToast }: ToastContentProps) {
|
||||||
|
return (
|
||||||
|
// using a grid with 3 columns
|
||||||
|
<div className='grid grid-cols-[1fr_1px_80px] w-full'>
|
||||||
|
<div className='flex flex-col p-4'>
|
||||||
|
<h3 className='text-zinc-800 text-sm font-semibold'>提示</h3>
|
||||||
|
<p className='text-sm'>有未保存的内容,是否继续打开?</p>
|
||||||
|
</div>
|
||||||
|
{/* that's the vertical line which separate the text and the buttons*/}
|
||||||
|
<div className='bg-zinc-900/20 h-full' />
|
||||||
|
<div className='grid grid-rows-[1fr_1px_1fr] h-full'>
|
||||||
|
{/*specifying a custom closure reason that can be used with the onClose callback*/}
|
||||||
|
<button onClick={() => closeToast('cancle')} className='text-purple-600'>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<div className='bg-zinc-900/20 w-full' />
|
||||||
|
{/*specifying a custom closure reason that can be used with the onClose callback*/}
|
||||||
|
<button onClick={() => closeToast('success')}>继续</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
12
src/pages/wall/docs.ts
Normal file
12
src/pages/wall/docs.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export const DOCS_NODE = {
|
||||||
|
id: 'e15owpuh9cv3fgwx5zymtc',
|
||||||
|
data: {
|
||||||
|
html: '<h1>Wallnote 基本使用介绍 v0.0.6</h1><p></p><p>可拖拽的随笔记功能。</p><ul class="tight" data-tight="true"><li><p>纯网页界面,数据存储在浏览器(不登陆情况下,只有单个页面)</p></li><li><p>这个墙随便拖动</p></li><li><p>双击空格添加一条记录,并打开编辑,esc关闭</p></li><li><p>富文本编辑器(md语法)</p></li><li><p>点击节点聚焦后,delete删除</p></li><li><p>右键空白处粘贴</p><ul class="tight" data-tight="true"><li><p>html的内容,编辑会丢失样式</p></li><li><p>图片的内容(粘贴后不能编辑)</p></li><li><p>文本内容</p></li><li><p>复制的节点信息</p></li></ul></li><li><p>边框可拖动大小</p></li></ul><h3>注意</h3><ul class="tight" data-tight="true"><li><p>点击节点聚焦后,如果有滚动条,节点内容才能滚动</p></li><li><p>图片复制,只能是二进制,文件夹的图片复制后无效。比如snipaste 贴图复制(Can To Do)。</p></li></ul><p></p><h2>登录后功能</h2><ul class="tight" data-tight="true"><li><p>保存而不是临时编辑</p></li></ul><p></p><h2>TODO</h2><ul class="tight" data-tight="true"><li><p>do do do</p></li><li><p>ai ++++</p></li></ul>',
|
||||||
|
width: 583,
|
||||||
|
height: 448,
|
||||||
|
},
|
||||||
|
type: 'wallnote',
|
||||||
|
position: { x: -901.1464949275596, y: -672.8095405534519 },
|
||||||
|
measured: { width: 583, height: 448 },
|
||||||
|
selected: true,
|
||||||
|
};
|
@ -26,13 +26,12 @@ export const clipboardRead = async () => {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case 'text/plain':
|
case 'text/plain':
|
||||||
const textPlain = await data.text();
|
const textPlain = await data.text();
|
||||||
// const jsonContent = parseIfJson(textPlain);
|
const jsonContent = parseIfJson(textPlain);
|
||||||
// if (jsonContent) {
|
if (jsonContent && jsonContent.type === 'wallnote') {
|
||||||
// typesDataList.push({ type: 'text/json', data: jsonContent, blob: data });
|
typesDataList.push({ type: 'text/json', data: jsonContent, blob: data });
|
||||||
// } else {
|
} else {
|
||||||
// typesDataList.push({ type: 'text/plain', data: textPlain, blob: data });
|
typesDataList.push({ type: 'text/plain', data: textPlain, blob: data });
|
||||||
// }
|
}
|
||||||
typesDataList.push({ type: 'text/plain', data: textPlain, blob: data });
|
|
||||||
break;
|
break;
|
||||||
case 'text/html':
|
case 'text/html':
|
||||||
const textHtml = await data.text();
|
const textHtml = await data.text();
|
||||||
|
@ -40,7 +40,15 @@ type NodeData = {
|
|||||||
export function FlowContent() {
|
export function FlowContent() {
|
||||||
const reactFlowInstance = useReactFlow();
|
const reactFlowInstance = useReactFlow();
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState<NodeData>([]);
|
const [nodes, setNodes, onNodesChange] = useNodesState<NodeData>([]);
|
||||||
const wallStore = useWallStore((state) => state);
|
const wallStore = useWallStore(
|
||||||
|
useShallow((state) => {
|
||||||
|
return {
|
||||||
|
nodes: state.nodes,
|
||||||
|
saveNodes: state.saveNodes,
|
||||||
|
checkAndOpen: state.checkAndOpen,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
const store = useStore((state) => state);
|
const store = useStore((state) => state);
|
||||||
const [mount, setMount] = useState(false);
|
const [mount, setMount] = useState(false);
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
@ -65,8 +73,7 @@ export function FlowContent() {
|
|||||||
};
|
};
|
||||||
}, [wallStore.nodes]);
|
}, [wallStore.nodes]);
|
||||||
const onNodeDoubleClick = (event, node) => {
|
const onNodeDoubleClick = (event, node) => {
|
||||||
wallStore.setOpen(true);
|
wallStore.checkAndOpen(true, node);
|
||||||
wallStore.setSelectedNode(node);
|
|
||||||
};
|
};
|
||||||
const getNewNodes = (showMessage = true) => {
|
const getNewNodes = (showMessage = true) => {
|
||||||
const nodes = reactFlowInstance.getNodes();
|
const nodes = reactFlowInstance.getNodes();
|
||||||
@ -79,7 +86,7 @@ export function FlowContent() {
|
|||||||
}
|
}
|
||||||
}, [nodes, mount]);
|
}, [nodes, mount]);
|
||||||
useTabNode();
|
useTabNode();
|
||||||
useListenPaster();
|
// useListenPaster();
|
||||||
// 添加新节点的函数
|
// 添加新节点的函数
|
||||||
const onPaneDoubleClick = (event) => {
|
const onPaneDoubleClick = (event) => {
|
||||||
// 计算节点位置
|
// 计算节点位置
|
||||||
@ -97,8 +104,7 @@ export function FlowContent() {
|
|||||||
return newNodes;
|
return newNodes;
|
||||||
});
|
});
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
wallStore.setSelectedNode(newNode);
|
wallStore.checkAndOpen(true, newNode);
|
||||||
wallStore.setOpen(true);
|
|
||||||
getNewNodes();
|
getNewNodes();
|
||||||
}, 200);
|
}, 200);
|
||||||
};
|
};
|
||||||
@ -126,6 +132,8 @@ export function FlowContent() {
|
|||||||
zoomOnScroll={true}
|
zoomOnScroll={true}
|
||||||
preventScrolling={!hasFoucedNode}
|
preventScrolling={!hasFoucedNode}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
|
minZoom={0.05}
|
||||||
|
maxZoom={20}
|
||||||
nodeTypes={CustomNodeType}>
|
nodeTypes={CustomNodeType}>
|
||||||
<Controls />
|
<Controls />
|
||||||
<MiniMap />
|
<MiniMap />
|
||||||
@ -149,6 +157,7 @@ export const Flow = ({ checkLogin = true }: { checkLogin?: boolean }) => {
|
|||||||
return {
|
return {
|
||||||
loaded: state.loaded,
|
loaded: state.loaded,
|
||||||
init: state.init,
|
init: state.init,
|
||||||
|
clearId: state.clearId,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -168,6 +177,7 @@ export const Flow = ({ checkLogin = true }: { checkLogin?: boolean }) => {
|
|||||||
variant='contained'
|
variant='contained'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate('/');
|
navigate('/');
|
||||||
|
wallStore.clearId();
|
||||||
}}>
|
}}>
|
||||||
转到首页
|
转到首页
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -1,24 +1,41 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { ToolbarItem, MenuItem } from './toolbar/Toolbar';
|
import { ToolbarItem, MenuItem } from './toolbar/Toolbar';
|
||||||
import { ClipboardPaste } from 'lucide-react';
|
import { ClipboardPaste, Copy } from 'lucide-react';
|
||||||
import { clipboardRead } from '../hooks/listen-copy';
|
import { clipboardRead } from '../hooks/listen-copy';
|
||||||
import { useReactFlow, useStore } from '@xyflow/react';
|
import { useReactFlow, useStore } from '@xyflow/react';
|
||||||
import { randomId } from '../utils/random';
|
import { randomId } from '../utils/random';
|
||||||
import { message } from '@/modules/message';
|
import { message } from '@/modules/message';
|
||||||
import { useWallStore } from '../store/wall';
|
import { useWallStore } from '../store/wall';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { min, max } from 'lodash-es';
|
import { getImageWidthHeightByBase64, getTextWidthHeight } from '../utils/get-image-rect';
|
||||||
import { getImageWidthHeightByBase64 } from '../utils/get-image-rect';
|
|
||||||
interface ContextMenuProps {
|
interface ContextMenuProps {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
type NewNodeData = {
|
||||||
|
id: string;
|
||||||
|
type: 'wallnote';
|
||||||
|
position: { x: number; y: number };
|
||||||
|
data: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
html: string;
|
||||||
|
dataType?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
class HasTypeCheck {
|
class HasTypeCheck {
|
||||||
constructor(list: any[]) {
|
newNodeData: NewNodeData;
|
||||||
|
constructor(list: any[], position: { x: number; y: number }) {
|
||||||
this.list = list;
|
this.list = list;
|
||||||
|
this.newNodeData = {
|
||||||
|
id: randomId(),
|
||||||
|
type: 'wallnote',
|
||||||
|
position,
|
||||||
|
data: { width: 0, height: 0, html: '' },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
list: { type?: string; data: any }[];
|
list: { type?: string; data: any; base64?: string }[];
|
||||||
hasType = (type = 'type/html') => {
|
hasType = (type = 'type/html') => {
|
||||||
return this.list.some((item) => item.type === type);
|
return this.list.some((item) => item.type === type);
|
||||||
};
|
};
|
||||||
@ -30,14 +47,20 @@ class HasTypeCheck {
|
|||||||
if (hasHtml) {
|
if (hasHtml) {
|
||||||
return {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
data: this.getType('text/html')?.data || '',
|
data: {
|
||||||
|
html: this.getType('text/html')?.data || '',
|
||||||
|
dataType: 'text/html',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const hasText = this.hasType('text/plain');
|
const hasText = this.hasType('text/plain');
|
||||||
if (hasText) {
|
if (hasText) {
|
||||||
return {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
data: this.getType('text/plain')?.data || '',
|
data: {
|
||||||
|
html: this.getType('text/plain')?.data || '',
|
||||||
|
dataType: 'text/plain',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@ -57,6 +80,53 @@ class HasTypeCheck {
|
|||||||
code: 404,
|
code: 404,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
async getData() {
|
||||||
|
const json = this.getJson();
|
||||||
|
if (json.code === 200) {
|
||||||
|
if (json.data.type === 'wallnote') {
|
||||||
|
const { selected, ...rest } = json.data;
|
||||||
|
const newNodeData = {
|
||||||
|
...this.newNodeData,
|
||||||
|
...rest,
|
||||||
|
id: this.newNodeData.id,
|
||||||
|
position: this.newNodeData.position,
|
||||||
|
};
|
||||||
|
this.newNodeData = newNodeData;
|
||||||
|
return this.newNodeData;
|
||||||
|
} else {
|
||||||
|
this.newNodeData.data.html = JSON.stringify(json.data, null, 2);
|
||||||
|
return this.newNodeData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = this.getText();
|
||||||
|
if (text.code === 200) {
|
||||||
|
const { html, dataType } = text.data || { html: '', dataType: 'text/html' };
|
||||||
|
this.newNodeData.data.html = html;
|
||||||
|
let maxWidth = 600;
|
||||||
|
let fontSize = 16;
|
||||||
|
let maxHeight = 400;
|
||||||
|
let minHeight = 100;
|
||||||
|
if (dataType === 'text/html') {
|
||||||
|
maxWidth = 400;
|
||||||
|
fontSize = 10;
|
||||||
|
maxHeight = 200;
|
||||||
|
minHeight = 50;
|
||||||
|
}
|
||||||
|
const wh = await getTextWidthHeight({ str: html, width: 400, maxHeight, minHeight, fontSize });
|
||||||
|
this.newNodeData.data.width = wh.width;
|
||||||
|
this.newNodeData.data.height = wh.height;
|
||||||
|
return this.newNodeData;
|
||||||
|
}
|
||||||
|
// 图片
|
||||||
|
const { base64, type } = this.list[0];
|
||||||
|
const rect = await getImageWidthHeightByBase64(base64);
|
||||||
|
this.newNodeData.data.width = rect.width;
|
||||||
|
this.newNodeData.data.height = rect.height;
|
||||||
|
this.newNodeData.data.dataType = type;
|
||||||
|
this.newNodeData.data.html = `<img src="${base64}" alt="图片" />`;
|
||||||
|
return this.newNodeData;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export const ContextMenu: React.FC<ContextMenuProps> = ({ x, y, onClose }) => {
|
export const ContextMenu: React.FC<ContextMenuProps> = ({ x, y, onClose }) => {
|
||||||
const reactFlowInstance = useReactFlow();
|
const reactFlowInstance = useReactFlow();
|
||||||
@ -69,71 +139,53 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({ x, y, onClose }) => {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// const
|
const copyMenu = {
|
||||||
const menuList: MenuItem[] = [
|
label: '复制',
|
||||||
{
|
icon: <Copy />,
|
||||||
label: '粘贴',
|
key: 'copy',
|
||||||
icon: <ClipboardPaste />,
|
onClick: async () => {
|
||||||
key: 'paste',
|
const nodes = reactFlowInstance.getNodes();
|
||||||
onClick: async () => {
|
const selectedNode = nodes.find((node) => node.selected);
|
||||||
const readList = await clipboardRead();
|
if (!selectedNode) {
|
||||||
const check = new HasTypeCheck(readList);
|
message.error('没有选中节点');
|
||||||
if (readList.length <= 0) {
|
return;
|
||||||
message.error('粘贴为空');
|
}
|
||||||
return;
|
const copyData = JSON.stringify(selectedNode);
|
||||||
}
|
navigator.clipboard.writeText(copyData);
|
||||||
let content: string = '';
|
message.success('复制成功');
|
||||||
let hasContent = false;
|
setTimeout(() => {
|
||||||
const text = check.getText();
|
onClose();
|
||||||
let width = 100;
|
}, 1000);
|
||||||
let height = 100;
|
},
|
||||||
if (text.code === 200) {
|
};
|
||||||
content = text.data;
|
const pasteMenu = {
|
||||||
hasContent = true;
|
label: '粘贴',
|
||||||
width = min([content.length * 16, 600])!;
|
icon: <ClipboardPaste />,
|
||||||
height = max([200, (content.length * 16) / 400])!;
|
key: 'paste',
|
||||||
}
|
onClick: async () => {
|
||||||
console.log('result', readList);
|
const readList = await clipboardRead();
|
||||||
if (!hasContent) {
|
const flowPosition = reactFlowInstance.screenToFlowPosition({ x, y });
|
||||||
const json = check.getJson();
|
const check = new HasTypeCheck(readList, flowPosition);
|
||||||
if (json.code === 200) {
|
if (readList.length <= 0) {
|
||||||
content = JSON.stringify(json.data, null, 2);
|
message.error('粘贴为空');
|
||||||
hasContent = true;
|
return;
|
||||||
}
|
}
|
||||||
}
|
const newNodeData = await check.getData();
|
||||||
let noEdit = false;
|
const nodes = store.nodes;
|
||||||
if (!hasContent) {
|
const _nodes = [...nodes, newNodeData];
|
||||||
content = readList[0].data || '';
|
wallStore.setNodes(_nodes);
|
||||||
const base64 = readList[0].base64;
|
wallStore.saveNodes(_nodes);
|
||||||
const rect = await getImageWidthHeightByBase64(base64);
|
// reactFlowInstance.setNodes(_nodes);
|
||||||
width = rect.width;
|
},
|
||||||
height = rect.height;
|
};
|
||||||
noEdit = true;
|
const menuList = useMemo(() => {
|
||||||
}
|
const selected = store.nodes.find((node) => node.selected);
|
||||||
|
if (selected) {
|
||||||
|
return [copyMenu, pasteMenu] as MenuItem[];
|
||||||
|
}
|
||||||
|
return [pasteMenu] as MenuItem[];
|
||||||
|
}, [store.nodes]);
|
||||||
|
|
||||||
const flowPosition = reactFlowInstance.screenToFlowPosition({ x, y });
|
|
||||||
const nodes = store.nodes;
|
|
||||||
const newNodeData: any = {
|
|
||||||
id: randomId(),
|
|
||||||
type: 'wallnote',
|
|
||||||
position: flowPosition,
|
|
||||||
data: {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
html: content,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if (noEdit) {
|
|
||||||
newNodeData.data.noEdit = true;
|
|
||||||
}
|
|
||||||
const newNodes = [newNodeData];
|
|
||||||
const _nodes = [...nodes, ...newNodes];
|
|
||||||
wallStore.setNodes(_nodes);
|
|
||||||
wallStore.saveNodes(_nodes);
|
|
||||||
// reactFlowInstance.setNodes(_nodes);
|
|
||||||
},
|
|
||||||
}, //
|
|
||||||
];
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useRef, memo, useEffect, useMemo, useState } from 'react';
|
import { useRef, memo, useEffect, useMemo, useState } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { NodeResizer, useStore } from '@xyflow/react';
|
import { NodeResizer, useStore, useReactFlow } from '@xyflow/react';
|
||||||
import { useWallStore } from '../store/wall';
|
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';
|
||||||
@ -45,13 +45,14 @@ const ShowContent = (props: { data: WallData; selected: boolean }) => {
|
|||||||
export const CustomNode = (props: { id: string; data: WallData; selected: boolean }) => {
|
export const CustomNode = (props: { id: string; data: WallData; selected: boolean }) => {
|
||||||
const data = props.data;
|
const data = props.data;
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const selected = props.selected;
|
const reactFlowInstance = useReactFlow();
|
||||||
|
const zoom = reactFlowInstance.getViewport().zoom;
|
||||||
const wallStore = useWallStore(
|
const wallStore = useWallStore(
|
||||||
useShallow((state) => {
|
useShallow((state) => {
|
||||||
return {
|
return {
|
||||||
setOpen: state.setOpen,
|
|
||||||
setSelectedNode: state.setSelectedNode,
|
setSelectedNode: state.setSelectedNode,
|
||||||
saveNodes: state.saveNodes,
|
saveNodes: state.saveNodes,
|
||||||
|
checkAndOpen: state.checkAndOpen,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -102,16 +103,17 @@ export const CustomNode = (props: { id: string; data: WallData; selected: boolea
|
|||||||
const node = store.getNode(props.id);
|
const node = store.getNode(props.id);
|
||||||
console.log('node eidt', node);
|
console.log('node eidt', node);
|
||||||
if (node) {
|
if (node) {
|
||||||
if (node.data?.noEdit) {
|
const dataType: string = (node?.data?.dataType as string) || '';
|
||||||
message.error('不支持编辑');
|
if (dataType && dataType?.startsWith('image')) {
|
||||||
|
message.error('不支持编辑图片');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
wallStore.setOpen(true);
|
wallStore.checkAndOpen(true, node);
|
||||||
wallStore.setSelectedNode(node);
|
|
||||||
} else {
|
} else {
|
||||||
message.error('节点不存在');
|
message.error('节点不存在');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleSize = Math.max(10, 10 / zoom);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@ -125,7 +127,7 @@ export const CustomNode = (props: { id: string; data: WallData; selected: boolea
|
|||||||
style={style}>
|
style={style}>
|
||||||
<ShowContent data={data} selected={props.selected} />
|
<ShowContent data={data} selected={props.selected} />
|
||||||
</div>
|
</div>
|
||||||
<div className={clsx('absolute top-0 right-0', props.selected ? 'opacity-100' : 'opacity-0')}>
|
<div className={clsx('absolute top-0 right-0 cursor-pointer', props.selected ? 'opacity-100' : 'opacity-0')}>
|
||||||
<button
|
<button
|
||||||
className='w-6 h-6 flex items-center justify-center'
|
className='w-6 h-6 flex items-center justify-center'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -149,6 +151,14 @@ export const CustomNode = (props: { id: string; data: WallData; selected: boolea
|
|||||||
if (!heightNum || !widthNum) return;
|
if (!heightNum || !widthNum) return;
|
||||||
store.updateWallRect(props.id, { width: widthNum, height: heightNum });
|
store.updateWallRect(props.id, { width: widthNum, height: heightNum });
|
||||||
}}
|
}}
|
||||||
|
handleStyle={
|
||||||
|
props.selected
|
||||||
|
? {
|
||||||
|
width: handleSize,
|
||||||
|
height: handleSize,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -9,7 +9,7 @@ import { message } from '@/modules/message';
|
|||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { isMac } from '../utils/is-mac';
|
import { isMac } from '../utils/is-mac';
|
||||||
const Drawer = () => {
|
const Drawer = () => {
|
||||||
const { open, setOpen, selectedNode, setSelectedNode, editValue, setEditValue } = useWallStore(
|
const { open, setOpen, selectedNode, setSelectedNode, editValue, setEditValue, hasEdited, setHasEdited } = useWallStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
open: state.open,
|
open: state.open,
|
||||||
setOpen: state.setOpen,
|
setOpen: state.setOpen,
|
||||||
@ -17,18 +17,23 @@ const Drawer = () => {
|
|||||||
setSelectedNode: state.setSelectedNode,
|
setSelectedNode: state.setSelectedNode,
|
||||||
editValue: state.editValue,
|
editValue: state.editValue,
|
||||||
setEditValue: state.setEditValue,
|
setEditValue: state.setEditValue,
|
||||||
|
hasEdited: state.hasEdited,
|
||||||
|
setHasEdited: state.setHasEdited,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const store = useStore((state) => state);
|
const store = useStore((state) => state);
|
||||||
const storeApi = useStoreApi();
|
const storeApi = useStoreApi();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && selectedNode) {
|
if (open && selectedNode) {
|
||||||
setEditValue(selectedNode?.data.html);
|
setEditValue(selectedNode?.data.html, true);
|
||||||
}
|
}
|
||||||
}, [open, selectedNode]);
|
}, [open, selectedNode]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
return () => {
|
return () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
setHasEdited(false);
|
||||||
setSelectedNode(null);
|
setSelectedNode(null);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@ -52,6 +57,15 @@ const Drawer = () => {
|
|||||||
window.removeEventListener('keydown', listener);
|
window.removeEventListener('keydown', listener);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('editValue', editValue, open, mounted);
|
||||||
|
if (!open && mounted) {
|
||||||
|
console.log('hasEdited', hasEdited);
|
||||||
|
if (hasEdited) {
|
||||||
|
onSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, hasEdited, mounted]);
|
||||||
const onSave = () => {
|
const onSave = () => {
|
||||||
const wallStore = useWallStore.getState();
|
const wallStore = useWallStore.getState();
|
||||||
const selectedNode = wallStore.selectedNode;
|
const selectedNode = wallStore.selectedNode;
|
||||||
|
@ -3,9 +3,10 @@ import { Dialog, DialogTitle, DialogContent, TextField, DialogActions, Button, C
|
|||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { getNodeData, useWallStore } from '../store/wall';
|
import { getNodeData, useWallStore } from '../store/wall';
|
||||||
import { useReactFlow, useStore } from '@xyflow/react';
|
import { useReactFlow, useStore } from '@xyflow/react';
|
||||||
import { useUserWallStore } from '../store/user-wall';
|
import { useUserWallStore, Wall } from '../store/user-wall';
|
||||||
import { message } from '@/modules/message';
|
import { message } from '@/modules/message';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { WallData } from './CustomNode';
|
||||||
|
|
||||||
function FormDialog({ open, handleClose, handleSubmit, initialData }) {
|
function FormDialog({ open, handleClose, handleSubmit, initialData }) {
|
||||||
const [data, setData] = useState(initialData || { title: '', description: '', summary: '', tags: [] });
|
const [data, setData] = useState(initialData || { title: '', description: '', summary: '', tags: [] });
|
||||||
@ -106,7 +107,10 @@ export const SaveModal = () => {
|
|||||||
tags: values.tags,
|
tags: values.tags,
|
||||||
markType: 'wallnote' as 'wallnote',
|
markType: 'wallnote' as 'wallnote',
|
||||||
data,
|
data,
|
||||||
};
|
} as Wall;
|
||||||
|
if (id) {
|
||||||
|
fromData.id = id;
|
||||||
|
}
|
||||||
const loading = message.loading('保存中...');
|
const loading = message.loading('保存中...');
|
||||||
const res = await userWallStore.saveWall(fromData, { refresh: false });
|
const res = await userWallStore.saveWall(fromData, { refresh: false });
|
||||||
message.close(loading);
|
message.close(loading);
|
||||||
|
@ -6,7 +6,7 @@ type User = {
|
|||||||
username: string;
|
username: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
};
|
};
|
||||||
type Wall = {
|
export type Wall = {
|
||||||
id?: string;
|
id?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
@ -4,7 +4,10 @@ import { getWallData, setWallData } 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 { toast } from 'react-toastify';
|
||||||
|
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,10 +30,13 @@ interface WallState {
|
|||||||
saveNodes: (nodes: NodeData[], opts?: { showMessage?: boolean }) => Promise<void>;
|
saveNodes: (nodes: NodeData[], opts?: { showMessage?: boolean }) => Promise<void>;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
|
checkAndOpen: (open?: boolean, data?: any) => void;
|
||||||
selectedNode: NodeData | null;
|
selectedNode: NodeData | null;
|
||||||
setSelectedNode: (node: NodeData | null) => void;
|
setSelectedNode: (node: NodeData | null) => void;
|
||||||
editValue: string;
|
editValue: string;
|
||||||
setEditValue: (value: string) => void;
|
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 | null) => Promise<void>;
|
||||||
@ -48,17 +54,8 @@ interface WallState {
|
|||||||
clear: () => Promise<void>;
|
clear: () => Promise<void>;
|
||||||
exportWall: (nodes: NodeData[]) => Promise<void>;
|
exportWall: (nodes: NodeData[]) => Promise<void>;
|
||||||
clearQueryWall: () => Promise<void>;
|
clearQueryWall: () => Promise<void>;
|
||||||
|
clearId: () => Promise<void>;
|
||||||
}
|
}
|
||||||
const initialNodes = [
|
|
||||||
// { id: '1', type: 'wall', position: { x: 0, y: 0 }, data: { html: '1' } },
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
type: 'wall',
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
data: { html: 'sadfsdaf1 sadfsdaf1 sadfsdaf1 sadfsdaf1 sadfsdaf1 sadfsdaf1 sadfsdaf1 sadfsdaf1', width: 410, height: 212 },
|
|
||||||
},
|
|
||||||
// { id: '2', type: 'wall', position: { x: 0, y: 100 }, data: { html: '3332' } },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const useWallStore = create<WallState>((set, get) => ({
|
export const useWallStore = create<WallState>((set, get) => ({
|
||||||
nodes: [],
|
nodes: [],
|
||||||
@ -69,10 +66,11 @@ export const useWallStore = create<WallState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
saveNodes: async (nodes: NodeData[], opts) => {
|
saveNodes: async (nodes: NodeData[], opts) => {
|
||||||
console.log('nodes', nodes, opts, opts?.showMessage ?? true);
|
console.log('nodes', nodes, opts, opts?.showMessage ?? true);
|
||||||
|
const showMessage = opts?.showMessage ?? true;
|
||||||
|
set({ hasEdited: false });
|
||||||
if (!get().id) {
|
if (!get().id) {
|
||||||
const covertData = getNodeData(nodes);
|
const covertData = getNodeData(nodes);
|
||||||
setWallData({ nodes: covertData });
|
setWallData({ nodes: covertData });
|
||||||
const showMessage = opts?.showMessage ?? true;
|
|
||||||
showMessage && message.success('保存到本地');
|
showMessage && message.success('保存到本地');
|
||||||
} else {
|
} else {
|
||||||
const { id } = get();
|
const { id } = get();
|
||||||
@ -88,19 +86,45 @@ export const useWallStore = create<WallState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
// console.log('saveNodes res', res);
|
// console.log('saveNodes res', res);
|
||||||
message.success('保存成功', {
|
showMessage &&
|
||||||
closeOnClick: true,
|
message.success('保存成功', {
|
||||||
});
|
closeOnClick: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
open: false,
|
open: false,
|
||||||
setOpen: (open) => set({ open }),
|
setOpen: (open) => {
|
||||||
|
set({ open });
|
||||||
|
},
|
||||||
|
checkAndOpen: (open, data) => {
|
||||||
|
const state = get();
|
||||||
|
if (state.hasEdited || state.open) {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else set({ open, selectedNode: data });
|
||||||
|
},
|
||||||
selectedNode: null,
|
selectedNode: null,
|
||||||
setSelectedNode: (node) => set({ selectedNode: node }),
|
setSelectedNode: (node) => set({ selectedNode: node }),
|
||||||
editValue: '',
|
editValue: '',
|
||||||
setEditValue: (value) => set({ editValue: value }),
|
setEditValue: (value, init = false) => {
|
||||||
|
set({ editValue: value });
|
||||||
|
if (!init) {
|
||||||
|
set({ hasEdited: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hasEdited: false,
|
||||||
|
setHasEdited: (hasEdited) => set({ hasEdited }),
|
||||||
data: null,
|
data: null,
|
||||||
setData: (data) => set({ data }),
|
setData: (data) => set({ data }),
|
||||||
id: null,
|
id: null,
|
||||||
@ -124,7 +148,17 @@ export const useWallStore = create<WallState>((set, get) => ({
|
|||||||
redirectToLogin();
|
redirectToLogin();
|
||||||
} else {
|
} else {
|
||||||
const data = await getWallData();
|
const data = await getWallData();
|
||||||
set({ nodes: data?.nodes || [], loaded: true });
|
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,
|
toolbarOpen: false,
|
||||||
@ -135,7 +169,7 @@ export const useWallStore = create<WallState>((set, get) => ({
|
|||||||
setFormDialogData: (data) => set({ formDialogData: data }),
|
setFormDialogData: (data) => set({ formDialogData: data }),
|
||||||
clear: async () => {
|
clear: async () => {
|
||||||
if (get().id) {
|
if (get().id) {
|
||||||
set({ nodes: initialNodes, id: null, selectedNode: null, editValue: '', data: null });
|
set({ nodes: [], selectedNode: null, editValue: '', data: null });
|
||||||
await useUserWallStore.getState().saveWall({
|
await useUserWallStore.getState().saveWall({
|
||||||
id: get().id!,
|
id: get().id!,
|
||||||
data: {
|
data: {
|
||||||
@ -143,10 +177,13 @@ export const useWallStore = create<WallState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
set({ nodes: initialNodes, id: null, selectedNode: null, editValue: '', data: null });
|
set({ nodes: [], id: null, selectedNode: null, editValue: '', data: null });
|
||||||
await setWallData({ nodes: [] });
|
await setWallData({ nodes: [] });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
clearId: async () => {
|
||||||
|
set({ id: null, data: null });
|
||||||
|
},
|
||||||
exportWall: async (nodes: NodeData[]) => {
|
exportWall: async (nodes: NodeData[]) => {
|
||||||
const covertData = getNodeData(nodes);
|
const covertData = getNodeData(nodes);
|
||||||
setWallData({ nodes: covertData });
|
setWallData({ nodes: covertData });
|
||||||
@ -160,6 +197,6 @@ export const useWallStore = create<WallState>((set, get) => ({
|
|||||||
a.click();
|
a.click();
|
||||||
},
|
},
|
||||||
clearQueryWall: async () => {
|
clearQueryWall: async () => {
|
||||||
set({ nodes: initialNodes, id: null, selectedNode: null, editValue: '', data: null, toolbarOpen: false, loaded: false });
|
set({ nodes: [], id: null, selectedNode: null, editValue: '', data: null, toolbarOpen: false, loaded: false });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
@ -29,3 +29,49 @@ export const getImageWidthHeightByBase64 = async (
|
|||||||
img.src = b64str;
|
img.src = b64str;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我有一个字符串,在在宽度为width的元素当中,自动换行,需要知道最后有多少行。
|
||||||
|
* 不使用canvas,通过文本font-size=16px,计算有多少行
|
||||||
|
* @param str
|
||||||
|
* @param width
|
||||||
|
*/
|
||||||
|
export const getTextWidthHeight = async ({
|
||||||
|
str,
|
||||||
|
width,
|
||||||
|
fontSize = 16,
|
||||||
|
maxHeight = 600,
|
||||||
|
minHeight = 100,
|
||||||
|
}: {
|
||||||
|
str: string;
|
||||||
|
width: number;
|
||||||
|
fontSize?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
}) => {
|
||||||
|
function calculateTextHeight(text: string, width: number, fontSize: number = 16): number {
|
||||||
|
// 创建一个隐藏的 DOM 元素来测量文本高度
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.style.position = 'absolute';
|
||||||
|
element.style.visibility = 'hidden';
|
||||||
|
element.style.width = `${width}px`;
|
||||||
|
element.style.fontSize = `${fontSize}px`;
|
||||||
|
element.style.lineHeight = '1.2'; // 假设行高为 1.2 倍字体大小
|
||||||
|
element.style.whiteSpace = 'pre-wrap'; // 保留空白并允许换行
|
||||||
|
element.style.wordWrap = 'break-word'; // 允许长单词换行
|
||||||
|
element.innerText = text;
|
||||||
|
|
||||||
|
document.body.appendChild(element);
|
||||||
|
const height = element.offsetHeight;
|
||||||
|
document.body.removeChild(element);
|
||||||
|
|
||||||
|
return height;
|
||||||
|
}
|
||||||
|
const height = calculateTextHeight(str, width, fontSize);
|
||||||
|
if (height > maxHeight) {
|
||||||
|
return { width, height: maxHeight };
|
||||||
|
} else if (height < minHeight) {
|
||||||
|
return { width, height: minHeight };
|
||||||
|
}
|
||||||
|
return { width, height };
|
||||||
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user