mirror of
https://github.com/ruilisi/fortune-sheet.git
synced 2025-01-09 04:07:33 +08:00
feat: add presence
This commit is contained in:
parent
9175d24685
commit
f8dcd0d36a
@ -80,10 +80,23 @@ wss.on("connection", (ws) => {
|
||||
} else if (msg.req === "op") {
|
||||
await applyOp(client.db(dbName).collection(collectionName), msg.data);
|
||||
broadcastToOthers(ws.id, data.toString());
|
||||
} else if (msg.req === "addPresence") {
|
||||
ws.userId = msg.data.userId;
|
||||
ws.username = msg.data.username;
|
||||
broadcastToOthers(ws.id, data.toString());
|
||||
} else if (msg.req === "removePresence") {
|
||||
broadcastToOthers(ws.id, data.toString());
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
broadcastToOthers(
|
||||
ws.id,
|
||||
JSON.stringify({
|
||||
req: "removePresence",
|
||||
data: { userId: ws.userId, username: ws.username },
|
||||
})
|
||||
);
|
||||
delete connections[ws.id];
|
||||
});
|
||||
});
|
||||
|
@ -2,7 +2,15 @@ import _ from "lodash";
|
||||
import { SheetConfig } from ".";
|
||||
import { FormulaCache } from "./modules";
|
||||
import { normalizeSelection } from "./modules/selection";
|
||||
import { Sheet, Selection, Cell, CommentBox, Rect, Image } from "./types";
|
||||
import {
|
||||
Sheet,
|
||||
Selection,
|
||||
Cell,
|
||||
CommentBox,
|
||||
Rect,
|
||||
Image,
|
||||
Presence,
|
||||
} from "./types";
|
||||
import { getSheetIndex } from "./utils";
|
||||
|
||||
export type Context = {
|
||||
@ -17,6 +25,7 @@ export type Context = {
|
||||
insertedImgs?: Image[];
|
||||
editingInsertedImgs?: Image;
|
||||
activeImg?: Image;
|
||||
presences?: Presence[];
|
||||
|
||||
contextMenu: any;
|
||||
sheetTabContextMenu: {
|
||||
|
@ -83,6 +83,17 @@ export type Selection = {
|
||||
column_select?: boolean;
|
||||
};
|
||||
|
||||
export type Presence = {
|
||||
sheetId: string;
|
||||
username: string;
|
||||
userId?: string;
|
||||
color: string;
|
||||
selection: {
|
||||
r: number;
|
||||
c: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type SheetConfig = {
|
||||
merge?: Record<string, { r: number; c: number; rs: number; cs: number }>; // 合并单元格
|
||||
rowlen?: Record<string, number>; // 表格行高
|
||||
|
@ -256,7 +256,7 @@
|
||||
}
|
||||
|
||||
.luckysheet-cs-touchhandle:before {
|
||||
content: "";
|
||||
content: '';
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@ -383,7 +383,6 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
.luckysheet-bottom-controll-row {
|
||||
position: absolute;
|
||||
height: 30px;
|
||||
@ -643,7 +642,7 @@
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border: 2px dashed #12A5FF;
|
||||
border: 2px dashed #12a5ff;
|
||||
z-index: 8;
|
||||
}
|
||||
.luckysheet-modal-dialog-resize {
|
||||
@ -784,7 +783,6 @@
|
||||
cursor: se-resize;
|
||||
}
|
||||
|
||||
|
||||
.fortune-formula-functionrange-highlight .luckysheet-highlight {
|
||||
position: absolute;
|
||||
z-index: 19;
|
||||
@ -792,4 +790,25 @@
|
||||
background: #0188fb;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.fortune-presence-username {
|
||||
position: absolute;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
left: -2px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fortune-presence-selection {
|
||||
position: absolute;
|
||||
border-style: solid;
|
||||
border-width: 1;
|
||||
}
|
||||
|
@ -421,6 +421,49 @@ const SheetOverlay: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(context.presences?.length ?? 0) > 0 &&
|
||||
context.presences!.map((presence) => {
|
||||
if (presence.sheetId !== context.currentSheetId) {
|
||||
return null;
|
||||
}
|
||||
const {
|
||||
selection: { r, c },
|
||||
color,
|
||||
} = presence;
|
||||
const row_pre = r - 1 === -1 ? 0 : context.visibledatarow[r - 1];
|
||||
const col_pre =
|
||||
c - 1 === -1 ? 0 : context.visibledatacolumn[c - 1];
|
||||
const row = context.visibledatarow[r];
|
||||
const col = context.visibledatacolumn[c];
|
||||
const width = col - col_pre - 1;
|
||||
const height = row - row_pre - 1;
|
||||
const usernameStyle = {
|
||||
maxWidth: width + 1,
|
||||
backgroundColor: color,
|
||||
};
|
||||
_.set(usernameStyle, r === 0 ? "top" : "bottom", height);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={presence.userId}
|
||||
className="fortune-presence-selection"
|
||||
style={{
|
||||
left: col_pre,
|
||||
top: row_pre - 2,
|
||||
width,
|
||||
height,
|
||||
borderColor: color,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="fortune-presence-username"
|
||||
style={usernameStyle}
|
||||
>
|
||||
{presence.username}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<InputBox />
|
||||
<div id="luckysheet-postil-showBoxs">
|
||||
{_.concat(
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
opToPatch,
|
||||
Range,
|
||||
Selection,
|
||||
Presence,
|
||||
Settings,
|
||||
SingleRange,
|
||||
} from "@fortune-sheet/core";
|
||||
@ -207,5 +208,34 @@ export function generateAPIs(
|
||||
targetRow?: number;
|
||||
targetColumn?: number;
|
||||
}) => api.scroll(context, scrollbarX, scrollbarY, options),
|
||||
|
||||
addPresence: (presence: Presence) => {
|
||||
setContext((draftCtx) => {
|
||||
const presences = (draftCtx.presences || [])
|
||||
.filter(
|
||||
(v) =>
|
||||
(presence.userId != null && presence.userId !== v.userId) ||
|
||||
presence.username !== v.username
|
||||
)
|
||||
.concat(presence);
|
||||
draftCtx.presences = presences;
|
||||
});
|
||||
},
|
||||
|
||||
removePresence: ({
|
||||
username,
|
||||
userId,
|
||||
}: {
|
||||
username: string;
|
||||
userId?: string;
|
||||
}) => {
|
||||
setContext((draftCtx) => {
|
||||
const presences = (draftCtx.presences || []).filter(
|
||||
(v) =>
|
||||
(userId != null && userId !== v.userId) || username !== v.username
|
||||
);
|
||||
draftCtx.presences = presences;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ import {
|
||||
filterPatch,
|
||||
patchToOp,
|
||||
Op,
|
||||
Selection,
|
||||
inverseRowColOptions,
|
||||
ensureSheetIndex,
|
||||
} from "@fortune-sheet/core";
|
||||
@ -51,10 +52,13 @@ export type WorkbookInstance = ReturnType<typeof generateAPIs>;
|
||||
type AdditionalProps = {
|
||||
onChange?: (data: SheetType[]) => void;
|
||||
onOp?: (op: Op[]) => void;
|
||||
hooks?: {
|
||||
onSelectionChange?: (sheetId: string, selection: Selection) => void;
|
||||
};
|
||||
};
|
||||
|
||||
const Workbook = React.forwardRef<WorkbookInstance, Settings & AdditionalProps>(
|
||||
({ onChange, onOp, data: originalData, ...props }, ref) => {
|
||||
({ onChange, onOp, hooks, data: originalData, ...props }, ref) => {
|
||||
const [context, setContext] = useState(defaultContext());
|
||||
const cellInput = useRef<HTMLDivElement>(null);
|
||||
const fxInput = useRef<HTMLDivElement>(null);
|
||||
@ -143,6 +147,15 @@ const Workbook = React.forwardRef<WorkbookInstance, Settings & AdditionalProps>(
|
||||
}
|
||||
}, [emitOp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (context.luckysheet_select_save != null) {
|
||||
hooks?.onSelectionChange?.(
|
||||
context.currentSheetId,
|
||||
context.luckysheet_select_save[0]
|
||||
);
|
||||
}
|
||||
}, [hooks, context.currentSheetId, context.luckysheet_select_save]);
|
||||
|
||||
const providerValue = useMemo(
|
||||
() => ({
|
||||
context,
|
||||
|
@ -1,7 +1,15 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||
import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { ComponentMeta, ComponentStory } from "@storybook/react";
|
||||
import { Sheet, Op } from "@fortune-sheet/core";
|
||||
import { Sheet, Op, Selection, colors } from "@fortune-sheet/core";
|
||||
import { Workbook, WorkbookInstance } from "@fortune-sheet/react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { hashCode } from "./utils";
|
||||
|
||||
export default {
|
||||
component: Workbook,
|
||||
@ -12,6 +20,10 @@ const Template: ComponentStory<typeof Workbook> = ({ ...args }) => {
|
||||
const [error, setError] = useState(false);
|
||||
const wsRef = useRef<WebSocket>();
|
||||
const workbookRef = useRef<WorkbookInstance>(null);
|
||||
const { username, userId } = useMemo(() => {
|
||||
const _userId = uuidv4();
|
||||
return { username: `Guest-${_userId.slice(0, 3)}`, userId: _userId };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = new WebSocket("ws://localhost:8081/ws");
|
||||
@ -26,6 +38,10 @@ const Template: ComponentStory<typeof Workbook> = ({ ...args }) => {
|
||||
setData(msg.data);
|
||||
} else if (msg.req === "op") {
|
||||
workbookRef.current?.applyOp(msg.data);
|
||||
} else if (msg.req === "addPresence") {
|
||||
workbookRef.current?.addPresence(msg.data);
|
||||
} else if (msg.req === "removePresence") {
|
||||
workbookRef.current?.removePresence(msg.data);
|
||||
}
|
||||
};
|
||||
socket.onerror = () => {
|
||||
@ -43,6 +59,29 @@ const Template: ComponentStory<typeof Workbook> = ({ ...args }) => {
|
||||
setData(d);
|
||||
}, []);
|
||||
|
||||
const onSelectionChange = useCallback(
|
||||
(sheetId: string, selection: Selection) => {
|
||||
const socket = wsRef.current;
|
||||
if (!socket) return;
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
req: "addPresence",
|
||||
data: {
|
||||
sheetId,
|
||||
username,
|
||||
userId,
|
||||
color: colors[Math.abs(hashCode(userId)) % colors.length],
|
||||
selection: {
|
||||
r: selection.row[0],
|
||||
c: selection.column[0],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
@ -73,6 +112,9 @@ const Template: ComponentStory<typeof Workbook> = ({ ...args }) => {
|
||||
data={data}
|
||||
onChange={onChange}
|
||||
onOp={onOp}
|
||||
hooks={{
|
||||
onSelectionChange,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
12
stories/utils.ts
Normal file
12
stories/utils.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export function hashCode(str: string) {
|
||||
let hash = 0;
|
||||
let i;
|
||||
let chr;
|
||||
if (str.length === 0) return hash;
|
||||
for (i = 0; i < str.length; i += 1) {
|
||||
chr = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + chr;
|
||||
hash |= 0; // Convert to 32bit integer
|
||||
}
|
||||
return hash;
|
||||
}
|
Loading…
Reference in New Issue
Block a user