1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-10-21 19:00:12 +00:00

Add edit mode for exdraw (#8312)

* update backend api code

* optimize exdraw editor code

* add shared edit mode

* fix code wraning

---------

Co-authored-by: 小强 <shuntian@Mac.lan>
This commit is contained in:
杨顺强
2025-10-16 15:59:32 +08:00
committed by GitHub
parent 00b8b20c13
commit 7fdfce6c40
15 changed files with 456 additions and 47 deletions

View File

@@ -0,0 +1,87 @@
#wrapper {
height: 100%;
width: 100%;
display: flex;
}
.exdraw-editable-viewer-wrapper {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
}
.exdraw-editable-viewer-wrapper .exdraw-editable-viewer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e5e6e8;
flex-shrink: 0;
height: 56px;
position: relative;
background-color: #fff;
}
.exdraw-editable-viewer-wrapper .exdraw-editable-viewer-content {
flex: 1;
display: flex
}
.exdraw-editable-viewer-header .doc-info {
display: flex;
align-items: center;
}
.exdraw-editable-viewer-header .doc-info .doc-name {
font-size: 18px;
font-weight: 700;
color: #212529;
}
.exdraw-editable-viewer-header .doc-ops .collaborator-name {
margin-right: 10px;
}
.exdraw-editable-viewer-header .doc-ops .sdocfont {
font-size: 14px;
display: inline-block;
color: #6e7687;
}
.exdraw-editable-viewer-header .doc-ops .sdocfont:hover {
color: #333;
cursor: pointer;
}
.exdraw-editable-viewer-content .excali-container {
position: relative;
height: 100%;
width: 100%;
}
.exdraw-editable-viewer-content .excali-container .excali-tip-message {
position: absolute;
top: 16px;
left: 60px;
z-index: 2;
color: #999;
padding: 8px 12px;
border-radius: 4px;
font-size: 14px;
}
.excalidraw .popover {
filter: none;
--bs-popover-border-color: transparent;
}
.excalidraw .dropdown-menu {
display: block;
border: unset;
--bs-dropdown-border-radius: 10px;
--bs-dropdown-padding-x: auto;
}

View File

@@ -0,0 +1,106 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import context from '../excalidraw-editor/context';
import SimpleEditor from '../excalidraw-editor/editor';
import SocketManager from '../excalidraw-editor/socket/socket-manager';
import { Utils } from '../../utils/utils';
import { gettext } from '../../utils/constants';
import Rename from './rename';
import './index.css';
const { avatarURL, serviceURL } = window.app.config;
const { repoID, filePerm, docUuid, docName, docPath, exdrawServerUrl, exdrawAccessToken, name, shareLinkUsername, sharedToken } = window.shared.pageOptions;
const userInfo = {
name: name || '',
username: shareLinkUsername,
contact_email: '',
};
// used for support lib
window.name = `${docUuid}`;
window.excalidraw = {
serviceURL,
userInfo,
avatarURL,
repoID,
filePerm,
docUuid,
docName,
docPath,
excalidrawServerUrl: exdrawServerUrl,
accessToken: exdrawAccessToken,
sharedToken: sharedToken,
};
context.initSettings();
const updateAppIcon = () => {
const { docName } = window.excalidraw;
const fileIcon = Utils.getFileIconUrl(docName);
document.getElementById('favicon').href = fileIcon;
};
function ExcalidrawEdiableViewer() {
const canEditNameRef = useRef(name === 'Anonymous');
const [isEditName, setIsEditName] = useState(false);
const [username, setUsername] = useState(userInfo.name);
useEffect(() => {
updateAppIcon();
}, []);
const onEditNameToggle = useCallback(() => {
setIsEditName(true);
}, []);
const onRenameConfirm = useCallback((value) => {
setUsername(value);
const newUser = {
...userInfo,
_username: userInfo.username,
username: value,
avatarURL: avatarURL,
};
const socketManager = SocketManager.getInstance();
socketManager.updateUserInfo(newUser);
setIsEditName(false);
}, []);
const onRenameCancel = useCallback(() => {
setIsEditName(false);
}, []);
return (
<div className="exdraw-editable-viewer-wrapper">
<div className="exdraw-editable-viewer-header">
<div className='doc-info'>
<div className="doc-name">{docName}</div>
</div>
<div className='doc-ops'>
<span className="collaborator-name">{gettext('Username')}:</span>
{!isEditName && (
<span className="collaborator-name">{username}</span>
)}
{isEditName && (
<Rename
name={username}
onRenameConfirm={onRenameConfirm}
onRenameCancel={onRenameCancel}
/>
)}
{canEditNameRef.current && (
<i className="sdocfont sdoc-rename" onClick={onEditNameToggle}></i>
)}
</div>
</div>
<div className="exdraw-editable-viewer-content">
<SimpleEditor isSharedView={true} />
</div>
</div>
);
}
export default ExcalidrawEdiableViewer;

View File

@@ -0,0 +1,3 @@
.excalidraw-rename-container {
display: inline-block;
}

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { toaster } from '@seafile/sdoc-editor';
import PropTypes from 'prop-types';
import './index.css';
const propTypes = {
name: PropTypes.string.isRequired,
onRenameConfirm: PropTypes.func.isRequired,
onRenameCancel: PropTypes.func.isRequired,
};
class Rename extends React.Component {
constructor(props) {
super(props);
this.state = {
name: props.name
};
this.inputRef = React.createRef();
}
componentDidMount() {
this.inputRef.current.focus();
this.inputRef.current.setSelectionRange(0, -1);
}
onChange = (e) => {
this.setState({ name: e.target.value });
};
onKeyDown = (e) => {
if (e.keyCode === 13) {
this.onRenameConfirm(e);
} else if (e.keyCode === 27) {
this.onRenameCancel(e);
}
e.nativeEvent.stopImmediatePropagation();
};
onRenameConfirm = (e) => {
e && e.nativeEvent.stopImmediatePropagation();
let newName = this.state.name.trim();
if (newName === this.props.name) {
this.props.onRenameCancel();
return;
}
let { isValid, errMessage } = this.validateInput();
if (!isValid) {
toaster.danger(errMessage);
this.props.onRenameCancel();
} else {
this.props.onRenameConfirm(newName);
}
};
onRenameCancel = (e) => {
e.nativeEvent.stopImmediatePropagation();
this.props.onRenameCancel();
};
validateInput = () => {
let newName = this.state.name.trim();
const { t } = this.props;
let isValid = true;
let errMessage = '';
if (!newName) {
isValid = false;
errMessage = t('Name_is_required');
return { isValid, errMessage };
}
return { isValid, errMessage };
};
render() {
return (
<div className="excalidraw-rename-container">
<input
ref={this.inputRef}
value={this.state.name}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onBlur={this.onRenameConfirm}
/>
</div>
);
}
}
Rename.propTypes = propTypes;
export default Rename;

View File

@@ -1,13 +1,7 @@
import Url from 'url-parse';
import ExcalidrawServerApi from './api';
import editorApi from './api/editor-api';
import axios from 'axios';
const { avatarURL } = window.app.config;
const { docUuid, excalidrawServerUrl, server } = window.app.pageOptions;
const userInfo = window.app.userInfo;
class Context {
constructor() {
this.docUuid = '';
@@ -16,12 +10,14 @@ class Context {
this.accessToken = '';
}
initSettings = async () => {
this.docUuid = docUuid;
initSettings = () => {
this.settings = window.excalidraw;
const { serviceURL, excalidrawServerUrl, docUuid, userInfo, accessToken, avatarURL } = this.settings;
this.serviceURL = serviceURL;
this.exdrawServer = excalidrawServerUrl;
this.docUuid = docUuid;
this.user = { ...userInfo, _username: userInfo.username, username: userInfo.name, avatarUrl: avatarURL };
const resResult = await editorApi.getExdrawToken();
const accessToken = resResult;
this.accessToken = accessToken;
this.exdrawApi = new ExcalidrawServerApi({ exdrawUuid: docUuid, exdrawServer: excalidrawServerUrl, accessToken });
};
@@ -30,7 +26,11 @@ class Context {
return this.docUuid;
};
getSettings = () => {
getSetting = (key) => {
return this.settings[key];
};
getExdrawConfig = () => {
return {
docUuid: this.docUuid,
exdrawServer: new Url(this.exdrawServer).origin,
@@ -50,8 +50,8 @@ class Context {
uploadExdrawImage = (fileUuid, fileItem) => {
const docUuid = this.getDocUuid();
const accessToken = this.accessToken;
const url = `${server}/api/v2.1/exdraw/upload-image/${docUuid}/`;
const serviceURL = this.serviceURL;
const url = `${serviceURL}/api/v2.1/exdraw/upload-image/${docUuid}/`;
const form = new FormData();
form.append('image_data', fileItem.dataURL);
form.append('image_id', fileUuid);
@@ -62,8 +62,8 @@ class Context {
downloadExdrawImage = (fileUuid) => {
const docUuid = this.getDocUuid();
const accessToken = this.accessToken;
const url = `${server}/api/v2.1/exdraw/download-image/${docUuid}/${fileUuid}`;
const serviceURL = this.serviceURL;
const url = `${serviceURL}/api/v2.1/exdraw/download-image/${docUuid}/${fileUuid}`;
return axios.get(url, { headers: { Authorization: `Token ${accessToken}` } });
};
@@ -71,14 +71,16 @@ class Context {
getLocalFiles(p, type) {
const docUuid = this.getDocUuid();
const accessToken = this.accessToken;
const url = `${server}/api/v2.1/seadoc/dir/${docUuid}/?p=${p}&type=${type}&doc_uuid=${docUuid}`;
const serviceURL = this.serviceURL;
const url = `${serviceURL}/api/v2.1/seadoc/dir/${docUuid}/?p=${p}&type=${type}&doc_uuid=${docUuid}`;
return axios.get(url, { headers: { Authorization: `Token ${accessToken}` } });
}
getSearchFilesByFilename(query, page, per_page, search_type) {
const docUuid = this.getDocUuid();
const accessToken = this.accessToken;
const url = server + '/api/v2.1/seadoc/search-filename/' + docUuid + '/?query=' + query + '&page=' + page + '&per_page=' + per_page + '&search_type=' + search_type;
const serviceURL = this.serviceURL;
const url = serviceURL + '/api/v2.1/seadoc/search-filename/' + docUuid + '/?query=' + query + '&page=' + page + '&per_page=' + per_page + '&search_type=' + search_type;
return axios.get(url, { headers: { Authorization: `Token ${accessToken}` } });
}

View File

@@ -6,6 +6,7 @@ import {
import { getSyncableElements } from '.';
import isUrl from 'is-url';
import context from '../context';
import { formatImageUrlFromExternalLink } from '../utils/common-utils';
class ServerScreenCache {
static cache = new Map();
@@ -103,20 +104,24 @@ export const saveFilesToServer = async (addedFiles) => {
const getImageUrl = (fileName) => {
const docUuid = context.getDocUuid();
const { server } = window.app.pageOptions;
const url = `${server}/api/v2.1/exdraw/download-image/${docUuid}/${fileName}`;
const serviceURL = context.getSetting('serviceURL');
const url = `${serviceURL}/api/v2.1/exdraw/download-image/${docUuid}/${fileName}`;
return url;
};
export const loadFilesFromServer = async (elements) => {
const loadedFiles = [];
const erroredFiles = new Map();
const sharedToken = context.getSetting('sharedToken');
await Promise.all(elements.map(async (element) => {
try {
const { fileId, filename, dataURL } = element;
let imageUrl = getImageUrl(filename);
if (dataURL && isUrl(imageUrl)) {
imageUrl = element.dataURL;
if (sharedToken) { // from external edit mode link
imageUrl = formatImageUrlFromExternalLink(imageUrl, sharedToken);
}
}
loadedFiles.push({

View File

@@ -18,8 +18,6 @@ import isHotkey from 'is-hotkey';
import '@excalidraw/excalidraw/index.css';
const { docUuid, filePerm } = window.app.pageOptions;
window.name = `${docUuid}`;
const UIOptions = {
canvasActions: {
saveToActiveFile: false,
@@ -54,8 +52,9 @@ const initializeScene = async () => {
};
};
const SimpleEditor = () => {
const SimpleEditor = ({ isSharedView = false }) => {
const filePermRef = useRef(null);
const initialStatePromiseRef = useRef({ promise: null });
if (!initialStatePromiseRef.current.promise) {
initialStatePromiseRef.current.promise = resolvablePromise();
@@ -113,19 +112,18 @@ const SimpleEditor = () => {
}
};
context.initSettings().then(() => {
const config = context.getSettings();
initializeScene().then(async (data) => {
// init socket
SocketManager.getInstance(excalidrawAPI, data.scene, config);
loadImages(data, /* isInitialLoad */true);
initialStatePromiseRef.current.promise.resolve(data.scene);
});
const config = context.getExdrawConfig();
initializeScene().then(async (data) => {
// init socket
SocketManager.getInstance(excalidrawAPI, data.scene, config);
loadImages(data, /* isInitialLoad */true);
initialStatePromiseRef.current.promise.resolve(data.scene);
});
}, [excalidrawAPI]);
useEffect(() => {
filePermRef.current = context.getSetting('filePerm');
const handleHotkeySave = (event) => {
if (isHotkey('mod+s', event)) {
// delete cmd+s
@@ -139,7 +137,7 @@ const SimpleEditor = () => {
}, []);
const handleChange = useCallback((elements, appState, files) => {
if (filePerm === 'r') return;
if (filePermRef.current === 'r') return;
const socketManager = SocketManager.getInstance();
socketManager.syncLocalElementsToOthers(elements);
@@ -172,7 +170,7 @@ const SimpleEditor = () => {
}, [excalidrawAPI]);
const handlePointerUpdate = useCallback((payload) => {
if (filePerm === 'r') return;
if (filePermRef.current === 'r') return;
const socketManager = SocketManager.getInstance();
socketManager.syncMouseLocationToOthers(payload);
}, []);
@@ -229,13 +227,15 @@ const SimpleEditor = () => {
onPointerUpdate={handlePointerUpdate}
UIOptions={UIOptions}
langCode={langList[window.app.config.lang] || 'en'}
viewModeEnabled={filePerm === 'r'}
viewModeEnabled={filePermRef.current === 'r'}
>
<MainMenu>
<MainMenu.DefaultItems.SaveAsImage />
<MainMenu.Item className='sf3-font-upload-files sf3-font' onClick={onCustomImageDialogToggle}>
{gettext('Link image')}
</MainMenu.Item>
{!isSharedView && (
<MainMenu.Item className='sf3-font-upload-files sf3-font' onClick={onCustomImageDialogToggle}>
{gettext('Link image')}
</MainMenu.Item>
)}
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.DefaultItems.ToggleTheme />

View File

@@ -1,24 +1,56 @@
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import SimpleEditor from './editor';
import Loading from '../../components/loading';
import { Utils } from '../../utils/utils';
import context from './context';
import editorApi from './api/editor-api';
import './index.css';
const { avatarURL, serviceURL } = window.app.config;
const { repoID, filePerm, docUuid, docName, docPath, excalidrawServerUrl } = window.app.pageOptions;
const userInfo = window.app.userInfo;
// used for support lib
window.name = `${docUuid}`;
window.excalidraw = {
serviceURL,
userInfo,
avatarURL,
repoID,
filePerm,
docUuid,
docName,
docPath,
excalidrawServerUrl,
};
const updateAppIcon = () => {
const { docName } = window.app.pageOptions;
const { docName } = window.excalidraw;
const fileIcon = Utils.getFileIconUrl(docName);
document.getElementById('favicon').href = fileIcon;
};
const ExcaliEditor = () => {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
updateAppIcon();
const initExcalidraw = async () => {
const accessToken = await editorApi.getExdrawToken();
window.excalidraw = { ...window.excalidraw, accessToken: accessToken };
setIsLoading(false);
updateAppIcon();
context.initSettings();
};
initExcalidraw();
}, []);
return (
<div className="file-view-content flex-1 p-0 border-0">
<SimpleEditor />
{isLoading ? <Loading /> : <SimpleEditor />}
</div>
);
};

View File

@@ -65,6 +65,15 @@ class SocketManager {
});
}
updateUserInfo = (newUser) => {
const collaborators = new Map(this.collaborators);
this.config.user = newUser;
collaborators.set(newUser._username, newUser, { isCurrentUser: true });
this.collaborators = collaborators;
this.excalidrawAPI.updateScene({ collaborators });
};
static getInstance = (excalidrawAPI, document, socketConfig) => {
if (this.instance) {
return this.instance;
@@ -254,7 +263,7 @@ class SocketManager {
const { user, ...updates } = params;
if (!collaborators.get(user._username)) return;
const newUser = Object.assign({}, collaborators.get(user._username), updates);
const newUser = Object.assign({}, collaborators.get(user._username), { ...updates, username: user.username });
collaborators.set(newUser._username, newUser);
this.collaborators = collaborators;

View File

@@ -1,3 +1,5 @@
import context from '../context';
export const getErrorMsg = (error) => {
let errorMsg = '';
if (error.response) {
@@ -14,3 +16,18 @@ export const getErrorMsg = (error) => {
}
return errorMsg;
};
export const formatImageUrlFromExternalLink = (imageUrl, sharedToken) => {
let newImageUrl = imageUrl;
const serviceURL = context.getSetting('serviceURL');
const repoID = context.getSetting('repoID');
const re = new RegExp(serviceURL + '/lib/' + repoID + '/file.*raw=1');
if (re.test(newImageUrl)) {
// get image path
let index = newImageUrl.indexOf('/file');
let index2 = newImageUrl.indexOf('?');
newImageUrl = newImageUrl.substring(index + 5, index2);
}
newImageUrl = serviceURL + '/view-image-via-share-link/?token=' + sharedToken + '&path=' + newImageUrl;
return newImageUrl;
};

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { createRoot } from 'react-dom/client';
import { Utils } from './utils/utils';
import ExcaliViewer from './pages/excalidraw-viewer';
import ExcalidrawEdiableViewer from './pages/excalidraw-editable-viewer';
const { siteRoot, avatarURL } = window.app.config;
const { username } = window.app.pageOptions;
@@ -36,4 +37,4 @@ window.seafile = {
const root = createRoot(document.getElementById('wrapper'));
root.render(<ExcaliViewer />);
root.render(canEdit ? <ExcalidrawEdiableViewer /> : <ExcaliViewer />);

View File

@@ -177,7 +177,7 @@ export const Utils = {
permissionOptions.push('download_upload');
}
} else {
if ((this.isEditableOfficeFile(path) || this.isEditableSdocFile(path)) && (permission == 'rw' || permission == 'admin') && canEdit) {
if ((this.isEditableOfficeFile(path) || this.isEditableSdocFile(path) || this.isEditableExdrawFile(path)) && (permission == 'rw' || permission == 'admin') && canEdit) {
permissionOptions.push('edit_download');
}
@@ -238,6 +238,20 @@ export const Utils = {
}
},
isEditableExdrawFile: function (filename) {
// no file ext
if (filename.lastIndexOf('.') == -1) {
return false;
}
const file_ext = filename.substr(filename.lastIndexOf('.') + 1).toLowerCase();
if (enableSeadoc && file_ext == 'exdraw') {
return true;
} else {
return false;
}
},
// check if a file is a video
videoCheck: function (filename) {
// no file ext