feature: 增加全屏演示功能

pull/159/head
lixiaoming 2022-08-14 23:24:46 +08:00
parent 5d328c61fc
commit 9959750bb2
10 changed files with 419 additions and 4 deletions

View File

@ -89,6 +89,7 @@
"react": "17.0.2", "react": "17.0.2",
"react-countdown": "^2.3.2", "react-countdown": "^2.3.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-full-screen": "^1.1.1",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-lazy-load-image-component": "^1.5.4", "react-lazy-load-image-component": "^1.5.4",
"react-pdf": "^5.7.2", "react-pdf": "^5.7.2",

View File

@ -0,0 +1,123 @@
.wrap {
width: 100vw;
height: 100vh;
overflow: auto;
background-color: var(--semi-color-bg-0);
}
.fullscreenContainer {
position: fixed;
background: var(--semi-color-bg-0);
.show {
z-index: 2;
opacity: 1;
}
.hidden {
z-index: -1;
opacity: 0;
}
.fullscreenContent {
position: relative;
min-width: 100%;
min-height: 100%;
.header {
position: fixed;
z-index: 2;
display: flex;
width: 100%;
height: 60px;
padding: 0 24px;
background-color: var(--semi-color-bg-0);
opacity: 0.9;
align-items: center;
}
.content {
position: relative;
z-index: 1;
width: auto;
height: 100vh;
min-height: 680px;
padding: 0 120px;
margin: 0 auto;
overflow: auto;
letter-spacing: 0.05em;
background: var(--semi-color-bg-1);
transition: width 0.3s linear, padding 0.3s linear;
.title {
display: table-cell;
height: 100vh;
font-size: 4rem;
line-height: 4.6rem;
color: var(--semi-color-text-0);
text-align: left;
word-wrap: break-word;
vertical-align: middle;
overflow-wrap: break-word;
}
.node-title.is-empty {
display: none !important;
}
}
}
}
.fullScreenToolbar {
position: fixed;
bottom: 40px;
left: 50%;
z-index: 2;
padding: 12px;
background-color: rgb(var(--semi-grey-9));
border-radius: var(--semi-border-radius-medium);
opacity: 0;
transform: translateX(-50%);
transition: opacity 0.3s ease-in-out;
&:hover {
opacity: 1;
transition: opacity 0.3s ease-in-out;
}
.selected {
background-color: rgb(var(--semi-grey-8)) !important;
}
.customButton {
display: flex;
height: 32px;
min-width: 32px;
padding: 0 8px;
font-size: 14px;
color: rgb(var(--semi-grey-0));
cursor: pointer;
background: none;
border: none;
border-radius: 2px;
outline: none;
align-items: center;
&:hover {
background-color: rgb(var(--semi-grey-8));
}
}
.divider {
width: 1px;
height: 1em;
background: rgb(var(--semi-grey-0));
}
}
.drawingCursor {
position: fixed;
top: 0;
left: 0;
z-index: 999999;
}

View File

@ -0,0 +1,122 @@
import { IconShrinkScreenStroked } from '@douyinfe/semi-icons';
import { Button, Space, Tooltip, Typography } from '@douyinfe/semi-ui';
import { EditorContent, useEditor } from '@tiptap/react';
import cls from 'classnames';
import { IconFullscreen } from 'components/icons/IconFullscreen';
import { IconPencil } from 'components/icons/IconPencil';
import { safeJSONParse } from 'helpers/json';
import { useDrawingCursor } from 'hooks/use-cursor';
import { useToggle } from 'hooks/use-toggle';
import React, { useCallback, useEffect } from 'react';
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
import { CollaborationKit } from 'tiptap/editor';
import styles from './index.module.scss';
// 控制器
const FullscreenController = ({ handle: fullscreenHandler, isDrawing, toggleDrawing }) => {
const startDrawing = useCallback(() => {
toggleDrawing(!isDrawing);
}, [isDrawing, toggleDrawing]);
const close = useCallback(() => {
fullscreenHandler.exit();
toggleDrawing(false);
}, [fullscreenHandler, toggleDrawing]);
return (
<div className={styles.fullScreenToolbar}>
<Space>
<button type="button" className={cls(styles.customButton, isDrawing && styles.selected)} onClick={startDrawing}>
<IconPencil />
</button>
<div className={styles.divider}></div>
<button type="button" className={styles.customButton} onClick={close}>
<IconShrinkScreenStroked style={{ rotate: '90deg' }} />
</button>
</Space>
</div>
);
};
// 画笔
const DrawingCursor = ({ isDrawing }) => {
useDrawingCursor(isDrawing);
return isDrawing && <div className={cls(styles.drawingCursor, 'drawing-cursor')}></div>;
};
interface IProps {
data?: any;
}
// 全屏按钮
export const DocumentFullscreen: React.FC<IProps> = ({ data }) => {
const fullscreenHandler = useFullScreenHandle();
const [visible, toggleVisible] = useToggle(false);
const [isDrawing, toggleDrawing] = useToggle(false);
const editor = useEditor({
editable: false,
extensions: CollaborationKit,
content: { type: 'doc', content: [] },
});
const startPowerpoint = () => {
toggleVisible(true);
fullscreenHandler.enter();
};
const fullscreenChange = useCallback(
(state) => {
if (!state) {
toggleVisible(false);
toggleDrawing(false);
}
},
[toggleVisible, toggleDrawing]
);
useEffect(() => {
if (!editor) return;
const docJSON = safeJSONParse(data.content, { default: {} }).default;
docJSON.content = docJSON.content.filter((item) => item.type !== 'title');
editor.commands.setContent(docJSON);
}, [editor, data]);
const { Title } = Typography;
return (
<Tooltip content="演示文档" position="bottom">
<Button
type="tertiary"
icon={<IconFullscreen />}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
startPowerpoint();
}}
/>
<FullScreen
handle={fullscreenHandler}
onChange={fullscreenChange}
className={cls(styles.fullscreenContainer, visible ? styles.show : styles.hidden)}
>
{visible && (
<div className={styles.fullscreenContent}>
<div className={styles.header}>
<Title heading={4} type="tertiary">
{data.title}
</Title>
</div>
<div className={styles.content}>
<div className={styles.title}>{data.title || '未命名文档'}</div>
<EditorContent editor={editor} />
</div>
<DrawingCursor isDrawing={isDrawing} />
<FullscreenController handle={fullscreenHandler} isDrawing={isDrawing} toggleDrawing={toggleDrawing} />
</div>
)}
</FullScreen>
</Tooltip>
);
};

View File

@ -19,6 +19,7 @@ import { createPortal } from 'react-dom';
import { CollaborationEditor } from 'tiptap/editor'; import { CollaborationEditor } from 'tiptap/editor';
import { DocumentActions } from '../actions'; import { DocumentActions } from '../actions';
import { DocumentFullscreen } from '../fullscreen';
import { Author } from './author'; import { Author } from './author';
import styles from './index.module.scss'; import styles from './index.module.scss';
@ -73,6 +74,7 @@ export const DocumentReader: React.FC<IProps> = ({ documentId }) => {
documentId={documentId} documentId={documentId}
/> />
)} )}
{document && !isMobile && <DocumentFullscreen data={document} />}
{document && ( {document && (
<DocumentStar <DocumentStar
disabled={!readable} disabled={!readable}
@ -96,7 +98,7 @@ export const DocumentReader: React.FC<IProps> = ({ documentId }) => {
<DocumentVersion documentId={documentId} /> <DocumentVersion documentId={documentId} />
</Space> </Space>
); );
}, [document, documentId, readable, editable, gotoEdit]); }, [document, documentId, readable, editable, gotoEdit, isMobile]);
return ( return (
<div className={styles.wrap}> <div className={styles.wrap}>

View File

@ -23,6 +23,7 @@ import styles from './index.module.scss';
import Router from 'next/router'; import Router from 'next/router';
import { useRouterQuery } from 'hooks/use-router-query'; import { useRouterQuery } from 'hooks/use-router-query';
import { IDocument, IWiki } from '@think/domains'; import { IDocument, IWiki } from '@think/domains';
import { DocumentFullscreen } from 'components/document/fullscreen';
const { Header } = Layout; const { Header } = Layout;
const { Text } = Typography; const { Text } = Typography;
@ -148,6 +149,7 @@ export const DocumentPublicReader: React.FC<IProps> = ({ documentId, hideLogo =
} }
footer={ footer={
<Space> <Space>
{!isMobile && <DocumentFullscreen data={data}/>}
<Tooltip content={currentWikiId ? '独立模式' : '嵌入模式'}> <Tooltip content={currentWikiId ? '独立模式' : '嵌入模式'}>
<Button theme="borderless" type="tertiary" icon={<IconRoute />} onClick={toPublicWikiOrDocumentURL} /> <Button theme="borderless" type="tertiary" icon={<IconRoute />} onClick={toPublicWikiOrDocumentURL} />
</Tooltip> </Tooltip>

View File

@ -0,0 +1,18 @@
import { Icon } from '@douyinfe/semi-ui';
export const IconFullscreen: React.FC<{ style?: React.CSSProperties }> = ({ style = {} }) => {
return (
<Icon
style={style}
svg={
<svg xmlns="http://www.w3.org/2000/svg" width="1.2em" height="1.2em" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11 4a1 1 0 10-2 0v4a1 1 0 102 0V7zm-3 1a1 1 0 10-2 0v3a1 1 0 102 0V8zM8 9a1 1 0 00-2 0v2a1 1 0 102 0V9z"
clipRule="evenodd"
/>
</svg>
}
/>
);
};

View File

@ -0,0 +1,26 @@
import { Icon } from '@douyinfe/semi-ui';
export const IconPencil: React.FC<{ style?: React.CSSProperties }> = ({ style = {} }) => {
return (
<Icon
style={style}
svg={
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
}
/>
);
};

View File

@ -0,0 +1,107 @@
import { useEffect } from 'react';
let timer;
let cursor = { x: 0, y: 0 };
let particles = [];
const applyProperties = (target, properties) => {
for (const key in properties) {
target.style[key] = properties[key];
}
};
// Particles
class Particle {
lifeSpan: number;
initialStyles: any;
velocity: { x: number; y: number };
position: { x: number; y: number };
element: HTMLElement;
constructor() {
this.lifeSpan = 20; //ms
this.initialStyles = {
'position': 'fixed',
'display': 'block',
'width': '12px',
'height': '12px',
'border-radius': '50%',
'pointer-events': 'none',
'backgroundColor': '#D61C11',
'will-change': 'transform',
'z-index': '9999999',
};
}
init(x, y) {
this.position = { x: x - 6, y: y - 6 };
this.element = document.createElement('span');
applyProperties(this.element, this.initialStyles);
this.update();
document.querySelector('.drawing-cursor').appendChild(this.element);
}
update() {
this.lifeSpan--;
this.element.style.transform =
'translate3d(' + this.position.x + 'px,' + this.position.y + 'px, 0) scale(' + this.lifeSpan / 20 + ')';
this.element.style.opacity = '0.5';
}
destroy() {
this.element.parentNode.removeChild(this.element);
}
}
const addParticle = (x, y) => {
const particle = new Particle();
particle.init(x, y);
particles.push(particle);
};
const onMousemove = (e) => {
cursor.x = e.clientX;
cursor.y = e.clientY;
addParticle(cursor.x, cursor.y);
};
const updateParticles = () => {
// Updated particles
for (let i = 0; i < particles.length; i++) {
particles[i].update();
}
// Remove dead particles
for (let i = particles.length - 1; i >= 0; i--) {
if (particles[i].lifeSpan < 0) {
particles[i].destroy();
particles.splice(i, 1);
}
}
};
const loop = () => {
timer = requestAnimationFrame(loop);
updateParticles();
};
const destroyDrawingCursor = () => {
document.removeEventListener('mousemove', onMousemove);
cancelAnimationFrame(timer);
cursor = { x: 0, y: 0 };
particles = [];
};
const mountDrawingCursor = () => {
document.querySelector('.drawing-cursor').innerHTML = '';
document.addEventListener('mousemove', onMousemove);
loop();
};
export const useDrawingCursor = (isDrawing) => {
useEffect(() => {
if (isDrawing) {
mountDrawingCursor();
}
return () => {
destroyDrawingCursor();
};
}, [isDrawing]);
};

View File

@ -9,18 +9,16 @@
&::before { &::before {
position: absolute; position: absolute;
bottom: 0; top: 0;
height: 0; height: 0;
color: var(--semi-color-text-0); color: var(--semi-color-text-0);
pointer-events: none; pointer-events: none;
content: attr(data-placeholder); content: attr(data-placeholder);
transform: translateY(-4.2em);
} }
&.is-editable { &.is-editable {
&::before { &::before {
color: #aaa; color: #aaa;
transform: translateY(-1.7em);
} }
} }
} }

View File

@ -135,6 +135,7 @@ importers:
react: 17.0.2 react: 17.0.2
react-countdown: ^2.3.2 react-countdown: ^2.3.2
react-dom: 17.0.2 react-dom: 17.0.2
react-full-screen: ^1.1.1
react-helmet: ^6.1.0 react-helmet: ^6.1.0
react-lazy-load-image-component: ^1.5.4 react-lazy-load-image-component: ^1.5.4
react-pdf: ^5.7.2 react-pdf: ^5.7.2
@ -234,6 +235,7 @@ importers:
react: 17.0.2 react: 17.0.2
react-countdown: 2.3.2_sfoxds7t5ydpegc3knd667wn6m react-countdown: 2.3.2_sfoxds7t5ydpegc3knd667wn6m
react-dom: 17.0.2_react@17.0.2 react-dom: 17.0.2_react@17.0.2
react-full-screen: 1.1.1_react@17.0.2
react-helmet: 6.1.0_react@17.0.2 react-helmet: 6.1.0_react@17.0.2
react-lazy-load-image-component: 1.5.4_sfoxds7t5ydpegc3knd667wn6m react-lazy-load-image-component: 1.5.4_sfoxds7t5ydpegc3knd667wn6m
react-pdf: 5.7.2_sfoxds7t5ydpegc3knd667wn6m react-pdf: 5.7.2_sfoxds7t5ydpegc3knd667wn6m
@ -6392,6 +6394,10 @@ packages:
/fs.realpath/1.0.0: /fs.realpath/1.0.0:
resolution: {integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=} resolution: {integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=}
/fscreen/1.2.0:
resolution: {integrity: sha512-hlq4+BU0hlPmwsFjwGGzZ+OZ9N/wq9Ljg/sq3pX+2CD7hrJsX9tJgWWK/wiNTFM212CLHWhicOoqwXyZGGetJg==}
dev: false
/fsevents/2.3.2: /fsevents/2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -9740,6 +9746,16 @@ packages:
resolution: {integrity: sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==} resolution: {integrity: sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==}
dev: false dev: false
/react-full-screen/1.1.1_react@17.0.2:
resolution: {integrity: sha512-xoEgkoTiN0dw9cjYYGViiMCBYbkS97BYb4bHPhQVWXj1UnOs8PZ1rPzpX+2HMhuvQV1jA5AF9GaRbO3fA5aZtg==}
engines: {node: '>=10'}
peerDependencies:
react: '>= 16.8.0'
dependencies:
fscreen: 1.2.0
react: 17.0.2
dev: false
/react-helmet/6.1.0_react@17.0.2: /react-helmet/6.1.0_react@17.0.2:
resolution: {integrity: sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==} resolution: {integrity: sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==}
peerDependencies: peerDependencies: