├── public
└── .gitkeep
├── src
├── context
│ └── AppContext.js
├── core
│ ├── index.js
│ ├── utils
│ │ ├── Time.js
│ │ ├── Sizes.js
│ │ └── EventEmitter.js
│ ├── WhiteBoard.js
│ ├── Loader.js
│ ├── core
│ │ ├── ImageShader.js
│ │ ├── CopyShader.js
│ │ ├── FragmentShader.js
│ │ ├── Card.js
│ │ └── Image.js
│ ├── Camera.js
│ ├── Application.js
│ ├── GUIPanel.js
│ ├── Controls.js
│ ├── CardSet.js
│ └── World.js
├── main.jsx
├── components
│ ├── Version
│ │ └── Version.jsx
│ ├── About
│ │ └── About.jsx
│ ├── Info
│ │ └── Info.jsx
│ ├── Social
│ │ └── Social.jsx
│ ├── Cards
│ │ ├── Cards.jsx
│ │ └── Card
│ │ │ └── Card.jsx
│ ├── UrlCards
│ │ ├── hooks
│ │ │ └── useGenerateUrlCard.js
│ │ ├── UrlCards.jsx
│ │ └── UrlCard
│ │ │ └── UrlCard.jsx
│ ├── TextCards
│ │ ├── TextCards.tsx
│ │ └── TextCard
│ │ │ └── TextCard.tsx
│ ├── ui
│ │ └── Slider.tsx
│ ├── Hint
│ │ └── Hint.jsx
│ ├── .deprecated
│ │ └── Card
│ │ │ └── Card.jsx
│ └── FileSystem
│ │ ├── FileSystem.tsx
│ │ └── FileTree
│ │ └── FileTree.tsx
├── utils
│ ├── cn.ts
│ ├── formatBytes.ts
│ └── readDirectory.ts
├── hooks
│ ├── useWhiteboardApp.js
│ ├── useWhiteboardUpdate.js
│ ├── deprecated
│ │ ├── useCardList.js
│ │ └── useCardRender.js
│ ├── useHover.js
│ └── useRightClick.js
├── global
│ └── style.css
└── App.jsx
├── vite.config.js
├── tailwind.config.js
├── index.html
├── .gitignore
├── .eslintrc.cjs
├── postcss.config.js
├── README.md
└── package.json
/public/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/context/AppContext.js:
--------------------------------------------------------------------------------
1 | import { createContext } from 'react'
2 |
3 | export default createContext();
4 |
--------------------------------------------------------------------------------
/src/core/index.js:
--------------------------------------------------------------------------------
1 | import Application from './Application.js'
2 |
3 | window.application = new Application({
4 | $canvas: document.querySelector('.webgl'),
5 | })
--------------------------------------------------------------------------------
/src/main.jsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from "react-dom/client";
2 | import App from "./App.jsx";
3 |
4 | import "./global/style.css"
5 |
6 | ReactDOM.createRoot(document.getElementById("root")).render();
7 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | });
8 |
--------------------------------------------------------------------------------
/src/components/Version/Version.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function Version() {
4 | return (
5 |
0.2.2
6 | )
7 | }
8 |
--------------------------------------------------------------------------------
/src/utils/cn.ts:
--------------------------------------------------------------------------------
1 | import { cx } from "@emotion/css";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | const cn = (...args: string[]): string => {
5 | return cx(twMerge(...args));
6 | };
7 |
8 | export { cn };
9 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | };
9 |
--------------------------------------------------------------------------------
/src/core/utils/Time.js:
--------------------------------------------------------------------------------
1 | import EventEmitter from './EventEmitter';
2 |
3 | export default class Time extends EventEmitter {
4 | constructor() {
5 | super()
6 | }
7 |
8 | tick() {
9 | this.trigger('tick')
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/About/About.jsx:
--------------------------------------------------------------------------------
1 | import { cn } from '../../utils/cn'
2 |
3 | export default function About() {
4 | return (
5 |
6 |
7 | Created by Yao Hsiao & contributers.
8 |
9 |
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/src/utils/formatBytes.ts:
--------------------------------------------------------------------------------
1 | export default function formatBytes(a, b = 2) {
2 | if (!+a) return "0 Bytes";
3 | const c = 0 > b ? 0 : b,
4 | d = Math.floor(Math.log(a) / Math.log(1024));
5 | return `${parseFloat((a / Math.pow(1024, d)).toFixed(c))} ${
6 | ["Bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"][d]
7 | }`;
8 | }
9 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | VC Whiteboard
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/components/Info/Info.jsx:
--------------------------------------------------------------------------------
1 | import { cn } from '../../utils/cn'
2 |
3 | export default function Info() {
4 | return (
5 |
6 |
VC Whiteboard
7 | Make sense of complex topics in Vesuvius Challenge
8 |
9 | )
10 | }
11 |
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | /public/*
16 | !/public/.gitkeep
17 |
18 | # Editor directories and files
19 | .vscode/*
20 | !.vscode/extensions.json
21 | .idea
22 | .DS_Store
23 | *.suo
24 | *.ntvs*
25 | *.njsproj
26 | *.sln
27 | *.sw?
28 |
--------------------------------------------------------------------------------
/src/hooks/useWhiteboardApp.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import Application from "../core/Application";
3 |
4 | const useWhiteboardApp = () => {
5 | const [app, setApp] = useState(null);
6 | useEffect(() => {
7 | const app = new Application({
8 | $canvas: document.querySelector(".webgl"),
9 | });
10 | setApp(app);
11 | }, []);
12 | return app;
13 | };
14 |
15 |
16 | export default useWhiteboardApp
--------------------------------------------------------------------------------
/src/hooks/useWhiteboardUpdate.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react"
2 | import PubSub from "pubsub-js"
3 |
4 | const useWhiteboardUpdate = () => {
5 |
6 | const [whiteboard, setWhiteboard] = useState(null)
7 |
8 | useEffect(() => {
9 | PubSub.subscribe("onWhiteboardUpdate", (eventName, whiteboard) => {
10 | setWhiteboard(whiteboard)
11 | })
12 | }, [])
13 |
14 | return whiteboard
15 |
16 | }
17 |
18 | export default useWhiteboardUpdate
--------------------------------------------------------------------------------
/src/components/Social/Social.jsx:
--------------------------------------------------------------------------------
1 | import { AiOutlineGithub } from "react-icons/ai"
2 | import { cn } from '../../utils/cn'
3 |
4 | export default function Social() {
5 | return (
6 |
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/Cards/Cards.jsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react'
2 | import AppContext from "../../context/AppContext"
3 | import { filter, map } from "lodash"
4 | import Card from "./Card/Card"
5 |
6 | export default function Cards() {
7 |
8 | const { whiteboard } = useContext(AppContext)
9 | const cards = whiteboard ? filter(whiteboard.cards, (card) => card.type.split("/")[0] === "image") : null
10 |
11 | return (
12 | map(cards, card => )
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/core/WhiteBoard.js:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three'
2 |
3 | export default class WhiteBoard {
4 | constructor(_option) {
5 | this.container = new THREE.Object3D();
6 | this.container.matrixAutoUpdate = false;
7 |
8 | this.setWhiteBoard()
9 | }
10 |
11 | setWhiteBoard() {
12 | const geometry = new THREE.PlaneGeometry(60, 30)
13 | const material = new THREE.MeshBasicMaterial({ color: '#262626' })
14 | const mesh = new THREE.Mesh(geometry, material)
15 |
16 | this.container.add(mesh)
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 |
6 | 'plugin:react/jsx-runtime',
7 | 'plugin:react-hooks/recommended',
8 | ],
9 | ignorePatterns: ['dist', '.eslintrc.cjs'],
10 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
11 | settings: { react: { version: '18.2' } },
12 | plugins: ['react-refresh'],
13 | rules: {
14 | 'react-refresh/only-export-components': [
15 | 'warn',
16 | { allowConstantExport: true },
17 | ],
18 | },
19 | }
20 |
--------------------------------------------------------------------------------
/src/hooks/deprecated/useCardList.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | // generate cards, and return card list that react app need.
4 |
5 | export default (app) => {
6 | const [cardList, setCardList] = useState([]);
7 |
8 | useEffect(() => {
9 | if (app) {
10 | // when card generate
11 | app.API.on("cardGenerate", (data) => {
12 | setCardList([data, ...cardList]);
13 | });
14 | }
15 | }, [app, cardList]);
16 |
17 | useEffect(() => {
18 | // console.log(cardList);
19 | }, [cardList]);
20 |
21 | return cardList;
22 | };
23 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | /** The css files in this project is based on postcss. */
2 | /** View postcss.config.js & https://www.postcss.parts/ to see more. */
3 | /*
4 | * avilable features:
5 | * css nesting
6 | * tailwindcss features
7 | * auto prefixing
8 | * cssnano
9 | */
10 |
11 | export default {
12 | plugins: {
13 | // postcss integration pack
14 | "tailwindcss/nesting": {},
15 | // code compression for css
16 | cssnano: { preset: "default" },
17 | // tailwindcss features
18 | tailwindcss: {},
19 | // browser compatibility
20 | autoprefixer: {},
21 | },
22 | };
23 |
--------------------------------------------------------------------------------
/src/hooks/useHover.js:
--------------------------------------------------------------------------------
1 | import React, { useRef, useState, useEffect } from "react";
2 |
3 | // Hook
4 | const useHover = () => {
5 | const [value, setValue] = useState(false);
6 |
7 | const ref = useRef(null);
8 |
9 | const handleMouseOver = () => setValue(true);
10 | const handleMouseOut = () => setValue(false);
11 |
12 | useEffect(() => {
13 | const node = ref.current;
14 | if (node) {
15 | node.addEventListener("mouseover", handleMouseOver);
16 | node.addEventListener("mouseout", handleMouseOut);
17 | }
18 | });
19 |
20 | return [ref, value];
21 | };
22 |
23 | export { useHover };
24 |
--------------------------------------------------------------------------------
/src/core/Loader.js:
--------------------------------------------------------------------------------
1 | import { NRRDLoader } from 'three/examples/jsm/loaders/NRRDLoader'
2 | import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'
3 |
4 | export default class Loader {
5 | constructor() {
6 | }
7 |
8 | static getVolumeMeta() { return fetch('volume/meta.json').then((res) => res.json()) }
9 |
10 | static getSegmentMeta() { return fetch('segment/meta.json').then((res) => res.json()) }
11 |
12 | static getVolumeData(filename) { return new NRRDLoader().loadAsync('volume/' + filename) }
13 |
14 | static getSegmentData(filename) { return new OBJLoader().loadAsync('segment/' + filename) }
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/UrlCards/hooks/useGenerateUrlCard.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import useRightClick from "../../../hooks/useRightClick";
3 | import PubSub from "pubsub-js";
4 | import { nanoid } from "nanoid";
5 |
6 | const useGenerateUrlCard = () => {
7 | const { clicked, position } = useRightClick();
8 |
9 | useEffect(() => {
10 | if (clicked) {
11 | PubSub.publish("onUrlCardGenerated", {
12 | id: nanoid(),
13 | x: position[0],
14 | y: position[1],
15 | width: 800,
16 | height: 525,
17 | });
18 | }
19 | }, [clicked, position]);
20 | };
21 |
22 | export default useGenerateUrlCard;
23 |
--------------------------------------------------------------------------------
/src/utils/readDirectory.ts:
--------------------------------------------------------------------------------
1 | export default async function readDirectory(
2 | directoryHandle: FileSystemDirectoryHandle,
3 | path = ""
4 | ) {
5 | const files: any = {};
6 |
7 | for await (const item of directoryHandle.values()) {
8 | if (item.kind === "directory") {
9 | const subDirectoryHandle = await directoryHandle.getDirectoryHandle(
10 | item.name
11 | );
12 | files[item.name] = await readDirectory(
13 | subDirectoryHandle,
14 | path + item.name + "/"
15 | );
16 | } else {
17 | const file = await item.getFile();
18 | files[item.name] = file;
19 | }
20 | }
21 |
22 | return files;
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/TextCards/TextCards.tsx:
--------------------------------------------------------------------------------
1 | import { filter, map } from "lodash";
2 | import React, { useContext, useEffect, useState } from "react";
3 | import TextCard from "./TextCard/TextCard";
4 | import PubSub from "pubsub-js";
5 | import AppContext from "../../context/AppContext";
6 |
7 | export default function TextCards() {
8 | const { whiteboard } = useContext(AppContext);
9 | const cards = whiteboard
10 | ? filter(
11 | whiteboard.cards,
12 | (card) =>
13 | card.type.split("/")[0] === "text" ||
14 | card.type.split("/")[0] === "application"
15 | )
16 | : null;
17 |
18 | return map(cards, (card) => );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/UrlCards/UrlCards.jsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react'
2 | import AppContext from "../../context/AppContext"
3 | import useGenerateUrlCard from "./hooks/useGenerateUrlCard"
4 | import { filter, map } from "lodash";
5 | import { cn } from "../../utils/cn"
6 | import { css } from "@emotion/css";
7 | import UrlCard from "./UrlCard/UrlCard";
8 |
9 | export default function UrlCards() {
10 |
11 | useGenerateUrlCard();
12 |
13 | const { whiteboard } = useContext(AppContext)
14 | const urlCards = whiteboard ? filter(whiteboard.cards, (card) => card.type === "iframe") : null
15 |
16 | return (
17 | map(urlCards, card => )
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/src/core/core/ImageShader.js:
--------------------------------------------------------------------------------
1 | import { ShaderMaterial, DoubleSide } from "three"
2 |
3 | export class ImageShader extends ShaderMaterial {
4 | constructor(params) {
5 | super({
6 | transparent: true,
7 |
8 | uniforms: {
9 | tDiffuse: { value: null },
10 | opacity: { value: 1.0 }
11 | },
12 |
13 | vertexShader: /* glsl */ `
14 | varying vec2 vUv;
15 | void main() {
16 | vUv = uv;
17 | gl_Position = vec4( position, 1.0 );
18 | }
19 | `,
20 |
21 | fragmentShader: /* glsl */ `
22 | uniform float opacity;
23 | uniform sampler2D tDiffuse;
24 | varying vec2 vUv;
25 |
26 | void main() {
27 | vec4 color = texture2D( tDiffuse, vUv );
28 | gl_FragColor = color;
29 | }
30 | `
31 | });
32 |
33 | this.setValues(params);
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/core/core/CopyShader.js:
--------------------------------------------------------------------------------
1 | import { ShaderMaterial } from "three"
2 |
3 | // https://github.com/mrdoob/three.js/blob/master/examples/jsm/shaders/CopyShader.js
4 |
5 | export class CopyShader extends ShaderMaterial {
6 | constructor(params) {
7 | super({
8 | transparent: true,
9 |
10 | uniforms: {
11 | tDiffuse: { value: null },
12 | opacity: { value: 1.0 }
13 | },
14 |
15 | vertexShader: /* glsl */ `
16 | varying vec2 vUv;
17 | void main() {
18 | vUv = uv;
19 | gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
20 | }
21 | `,
22 |
23 | fragmentShader: /* glsl */ `
24 | uniform float opacity;
25 | uniform sampler2D tDiffuse;
26 | varying vec2 vUv;
27 |
28 | void main() {
29 | vec4 texel = texture2D( tDiffuse, vUv );
30 | gl_FragColor = opacity * texel;
31 | }
32 | `
33 | });
34 |
35 | this.setValues(params);
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | VC WhiteBoard
2 |
3 |
4 | Try to make sense of complex topics in Vesuvius Challenge
5 |
6 |
7 |
8 |
9 |
10 |
11 | ## Introduction
12 |
13 | This is a WIP tool that try to visualize scrolls and fragments data information on a endless whiteboard.
14 |
15 | ## Usage
16 |
17 | Download [this folder](https://www.kaggle.com/datasets/yaohsiao123/vc-whiteboard) and then run a localhost server on it (e.g. python server). Currently has some camera control issue on Windows, so it's more recommended to use Mac for now. Once you open the application, you can press `Enter` + `Click` to generate a card on the whiteboard. In the current application, we try to visualize the region along segment `20230509182749`.
18 |
19 | ## Notes
20 |
21 | There are still many bugs in this version, so if you encounter difficulties in using it, feel free to contact me directly. Will see is there anywhere that I can help, thanks!
22 |
--------------------------------------------------------------------------------
/src/global/style.css:
--------------------------------------------------------------------------------
1 | /** The css files in this project is based on postcss. */
2 | /** View postcss.config.js & https://www.postcss.parts/ to see more. */
3 | /*
4 | * avilable features:
5 | * css nesting
6 | * tailwindcss features
7 | * auto prefixing
8 | * cssnano
9 | */
10 |
11 | @tailwind base;
12 | @tailwind components;
13 | @tailwind utilities;
14 |
15 | * {
16 | margin: 0;
17 | padding: 0;
18 | user-select: none;
19 | box-sizing: border-box;
20 | }
21 |
22 | body {
23 | overflow: hidden;
24 | color: white;
25 | background-color: black;
26 | font-family: monospace;
27 |
28 | option {
29 | background-color: black;
30 | }
31 |
32 | .webgl {
33 | background-color: black;
34 | }
35 |
36 | .cardDOM {
37 | position: relative;
38 |
39 | .loadingCard {
40 | position: absolute;
41 | top: 50%;
42 | left: 50%;
43 | transform: translate(-50%, -50%);
44 | background-color: rgba(0, 0, 0, 0.3);
45 | color: white;
46 | font-size: 20px;
47 | padding: 5px 10px;
48 | border-radius: 5px;
49 | user-select: none;
50 | white-space: nowrap;
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/core/utils/Sizes.js:
--------------------------------------------------------------------------------
1 | import EventEmitter from './EventEmitter'
2 |
3 | export default class Sizes extends EventEmitter {
4 | constructor() {
5 | super();
6 |
7 | this.viewport = {};
8 | this.$sizeViewport = document.createElement('div');
9 | this.$sizeViewport.style.width = '100vw';
10 | this.$sizeViewport.style.height = '100vh';
11 | this.$sizeViewport.style.position = 'absolute';
12 | this.$sizeViewport.style.top = 0;
13 | this.$sizeViewport.style.left = 0;
14 | this.$sizeViewport.style.pointerEvents = 'none';
15 |
16 | this.resize = this.resize.bind(this);
17 | window.addEventListener('resize', this.resize);
18 |
19 | this.resize();
20 | }
21 |
22 | resize() {
23 | document.body.appendChild(this.$sizeViewport);
24 | this.viewport.width = this.$sizeViewport.offsetWidth;
25 | this.viewport.height = this.$sizeViewport.offsetHeight;
26 | document.body.removeChild(this.$sizeViewport);
27 |
28 | this.width = window.innerWidth;
29 | this.height = window.innerHeight;
30 |
31 | this.trigger('resize');
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/hooks/useRightClick.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, useCallback } from "react";
2 |
3 | const useRightClick = () => {
4 | const [isRightClick, setIsRightClick] = useState(false);
5 | const [rightClickPosition, setRightClickPosition] = useState([0, 0]);
6 |
7 | const handleRightClick = useCallback((e) => {
8 | e.preventDefault();
9 | setIsRightClick(!isRightClick);
10 | setRightClickPosition([e.clientX, e.clientY]);
11 | }, [isRightClick]);
12 |
13 | const handleLeftClick = useCallback((e) => {
14 | if (e.button === 0) {
15 | setIsRightClick(false);
16 | }
17 | }, []);
18 |
19 | useEffect(() => {
20 | window.addEventListener("mousedown", handleLeftClick);
21 | window.addEventListener("contextmenu", handleRightClick);
22 |
23 | return () => {
24 | window.removeEventListener("contextmenu", handleRightClick);
25 | window.removeEventListener("mousedown", handleLeftClick);
26 | };
27 | }, [handleLeftClick, handleRightClick]);
28 |
29 | return { clicked: isRightClick, position: rightClickPosition };
30 | };
31 |
32 | export default useRightClick;
33 |
--------------------------------------------------------------------------------
/src/components/ui/Slider.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as SliderPrimitive from "@radix-ui/react-slider";
3 |
4 | import { cn } from "../../utils/cn";
5 |
6 | const Slider = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
19 |
20 |
21 |
22 |
23 | ));
24 | Slider.displayName = SliderPrimitive.Root.displayName;
25 |
26 | export { Slider };
27 |
--------------------------------------------------------------------------------
/src/core/core/FragmentShader.js:
--------------------------------------------------------------------------------
1 | import { ShaderMaterial, DoubleSide } from "three"
2 |
3 | export class FragmentShader extends ShaderMaterial {
4 | constructor(params) {
5 | super({
6 | side: DoubleSide,
7 | transparent: true,
8 |
9 | uniforms: {
10 | tDiffuse: { value: null },
11 | uMask: { value: null },
12 | opacity: { value: 1.0 }
13 | },
14 |
15 | vertexShader: /* glsl */ `
16 | varying vec2 vUv;
17 | void main() {
18 | vUv = uv;
19 | gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
20 | }
21 | `,
22 |
23 | fragmentShader: /* glsl */ `
24 | uniform float opacity;
25 | uniform sampler2D uMask;
26 | uniform sampler2D tDiffuse;
27 | varying vec2 vUv;
28 |
29 | void main() {
30 | float intensity = texture2D( tDiffuse, vUv ).r;
31 | vec4 mask = texture2D( uMask, vUv );
32 | float maskI = mask.a;
33 | if (intensity < 0.0001) { gl_FragColor = vec4(0.0); return; }
34 |
35 | vec3 color = intensity * 0.88 * vec3(0.93, 0.80, 0.70);
36 |
37 | if (maskI < 0.1) { gl_FragColor = vec4(color, 1.0); return; }
38 | gl_FragColor = vec4(color, 1.0) * (1.0 - maskI * opacity);
39 | // gl_FragColor = vec4(color, opacity);
40 | }
41 | `
42 | });
43 |
44 | this.setValues(params);
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "volume-viewer",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@emotion/css": "^11.11.2",
14 | "@radix-ui/react-slider": "^1.1.2",
15 | "@types/lodash": "^4.14.201",
16 | "@types/pubsub-js": "^1.8.6",
17 | "@types/wicg-file-system-access": "^2023.10.3",
18 | "@vercel/analytics": "^1.1.1",
19 | "cssnano": "^6.0.1",
20 | "lodash": "^4.17.21",
21 | "nanoid": "^5.0.1",
22 | "postcss-nested": "^6.0.1",
23 | "postcss-preset-env": "^9.1.1",
24 | "pubsub-js": "^1.9.4",
25 | "re-resizable": "^6.9.11",
26 | "react": "^18.2.0",
27 | "react-dom": "^18.2.0",
28 | "react-icons": "^4.12.0",
29 | "tailwind-merge": "^1.14.0",
30 | "three": "^0.156.1",
31 | "three-mesh-bvh": "^0.6.3"
32 | },
33 | "devDependencies": {
34 | "@types/react": "^18.2.15",
35 | "@types/react-dom": "^18.2.7",
36 | "@vitejs/plugin-react": "^4.0.3",
37 | "autoprefixer": "^10.4.15",
38 | "eslint": "^8.45.0",
39 | "eslint-plugin-react": "^7.32.2",
40 | "eslint-plugin-react-hooks": "^4.6.0",
41 | "eslint-plugin-react-refresh": "^0.4.3",
42 | "postcss": "^8.4.28",
43 | "tailwindcss": "^3.3.3",
44 | "vite": "^4.4.5"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/hooks/deprecated/useCardRender.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | // render card (threejs part) via id, and return data that react app need.
4 |
5 | export default (app) => {
6 | /**
7 | * $ renderer object
8 | * * x: number | undefined
9 | * * y: number | undefined
10 | * * z: number | undefined
11 | * * isLoad: boolean
12 | */
13 | const [renderer, setRenderer] = useState({});
14 |
15 | useEffect(() => {
16 |
17 | if (app) {
18 | // yao's code (may be a function call)
19 | // do the threejs rendering work, and provide data that react app need.
20 | // call setRenderer to update State
21 | // e.g. setRenderer({x: 1, y: 2, z: 3, isLoad: true})
22 | app.API.on("cardInit", ({ id, x, y, width, height }) => {
23 | setRenderer({ id, x, y, width, height });
24 | });
25 | app.API.on("cardMove", ({ id, x, y, width, height }) => {
26 | // WB.API.cardMove({ x, y, width, height, id });
27 | setRenderer({ id, x, y, width, height });
28 | });
29 |
30 | app.API.on("cardLoad", (id) => {
31 | // WB.API.cardLoad(id);
32 | setRenderer({ id, isLoadId: id });
33 | })
34 |
35 | app.API.on("cardSelect", ({ id, x, y, width, height }) => {
36 | // WB.API.cardSelect(x, y, width, height);
37 | setRenderer({ id, x, y, width, height });
38 | })
39 |
40 | app.API.on("cardLeave", ({ id }) => {
41 | // WB.API.cardLeave(id);
42 | setRenderer({ id });
43 | })
44 | }
45 |
46 | }, [app]);
47 |
48 | return renderer;
49 | };
50 |
--------------------------------------------------------------------------------
/src/core/Camera.js:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three'
2 | import { MOUSE, TOUCH } from 'three'
3 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
4 |
5 | export default class Camera {
6 | constructor(_option) {
7 | this.time = _option.time
8 | this.sizes = _option.sizes
9 | this.renderer = _option.renderer
10 |
11 | this.container = new THREE.Object3D()
12 | this.container.matrixAutoUpdate = false
13 |
14 | this.setInstance()
15 | this.setOrbitControls()
16 | }
17 |
18 | setInstance() {
19 | const scope = 1.5
20 | const { width, height } = this.sizes.viewport
21 | this.instance = new THREE.OrthographicCamera(-scope * width / height, scope * width / height, scope, -scope, 0.1, 100)
22 | this.instance.position.z = 2
23 | this.container.add(this.instance)
24 |
25 | this.sizes.on('resize', () => {
26 | const { width, height } = this.sizes.viewport
27 | this.instance.aspect = width / height
28 | this.instance.updateProjectionMatrix()
29 | })
30 | }
31 |
32 | setOrbitControls() {
33 | this.controls = new OrbitControls(this.instance, this.renderer.domElement)
34 | this.controls.enableDamping = false
35 | this.controls.screenSpacePanning = true // pan orthogonal to world-space direction camera.up
36 | this.controls.mouseButtons = { LEFT: MOUSE.PAN, MIDDLE: MOUSE.PAN, RIGHT: MOUSE.PAN }
37 | // this.controls.mouseButtons = { LEFT: MOUSE.PAN, MIDDLE: MOUSE.DOLLY, RIGHT: MOUSE.ROTATE }
38 | this.controls.touches = { ONE: TOUCH.PAN, TWO: TOUCH.PAN }
39 | // this.controls.touches = { ONE: TOUCH.PAN, TWO: TOUCH.DOLLY_PAN }
40 |
41 | this.controls.addEventListener('change', () => this.time.trigger('tick'))
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/Hint/Hint.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 | import { cn } from '../../utils/cn'
3 |
4 | export default function Hint(props) {
5 |
6 | const [show, setShow] = useState(true);
7 |
8 | useEffect(() => {
9 | // disable
10 | const handleDisable = () => {
11 | setShow(false)
12 | }
13 | window.addEventListener("mousedown", handleDisable)
14 |
15 | return () => {
16 | window.removeEventListener("mousedown", handleDisable)
17 | }
18 | }, [show]);
19 |
20 | return (
21 | show ?
22 |
23 |
24 |
Hot Key
25 |
26 |
27 | {props.children}
28 |
29 |
: <>>
30 | )
31 | }
32 |
33 | const hintStyles = {
34 | hint: cn(
35 | 'fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
36 | "bg-[#111] opacity-80",
37 | "p-8",
38 | "flex flex-col gap-8")
39 | }
40 |
41 | Hint.HotKey = function HotKey(props) {
42 | return (
43 |
44 |
45 | { /*eslint-disable-next-line react/prop-types*/}
46 | {props.hotkey.map((h, i) =>
47 |
48 | { /*eslint-disable-next-line react/prop-types*/}
49 | {h}{i !== props.hotkey.length - 1 ? " +" : ""}
50 |
51 | )}
52 |
53 | { /*eslint-disable-next-line react/prop-types*/}
54 | {props.children}
55 |
56 | )
57 |
58 | }
59 |
60 | const hintHotKeyStyles = {
61 | hintHotKey: cn('flex w-[450px] justify-between')
62 | }
--------------------------------------------------------------------------------
/src/components/TextCards/TextCard/TextCard.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { cn } from "../../../utils/cn";
3 | import { css } from "@emotion/css";
4 | import { useHover } from "../../../hooks/useHover";
5 |
6 | export default function TextCard({ card }) {
7 | const [hover, isHover] = useHover();
8 | const [fontSize, setFontSize] = useState(16);
9 | return (
10 |
25 | {isHover && (
26 |
30 |
{card.name}
31 |
32 |
font size
33 |
{
37 | setFontSize(Number(e.target.value));
38 | }}
39 | type="text"
40 | />
41 |
42 |
43 | )}
44 |
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/src/core/Application.js:
--------------------------------------------------------------------------------
1 | import * as THREE from "three";
2 |
3 | import Time from "./utils/Time";
4 | import Sizes from "./utils/Sizes";
5 |
6 | import Camera from "./Camera";
7 | import World from "./World";
8 |
9 | export default class Application {
10 | constructor(_options) {
11 | this.$canvas = _options.$canvas;
12 |
13 | this.time = new Time();
14 | this.sizes = new Sizes();
15 |
16 | this.setRenderer();
17 | this.setCamera();
18 | this.setWorld();
19 | }
20 |
21 | API = {
22 | on: (eventName, cb) => {
23 | this.API[eventName] = cb;
24 | },
25 | };
26 |
27 | setRenderer() {
28 | this.scene = new THREE.Scene();
29 |
30 | this.renderer = new THREE.WebGLRenderer({
31 | antialias: true,
32 | canvas: this.$canvas,
33 | });
34 |
35 | const { width, height } = this.sizes.viewport;
36 | this.renderer.setSize(width, height);
37 | this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
38 | this.renderer.setClearColor(0, 0);
39 | this.renderer.outputColorSpace = THREE.SRGBColorSpace;
40 |
41 | this.sizes.on("resize", () => {
42 | const { width, height } = this.sizes.viewport;
43 | this.renderer.setSize(width, height);
44 | this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
45 | });
46 | }
47 |
48 | setCamera() {
49 | this.camera = new Camera({
50 | time: this.time,
51 | sizes: this.sizes,
52 | renderer: this.renderer,
53 | });
54 |
55 | this.scene.add(this.camera.container);
56 |
57 | this.time.on("tick", () => {
58 | this.renderer.render(this.scene, this.camera.instance);
59 | // console.log('render')
60 | });
61 | }
62 |
63 | setWorld() {
64 | this.world = new World({
65 | app: this,
66 | time: this.time,
67 | sizes: this.sizes,
68 | camera: this.camera,
69 | renderer: this.renderer,
70 | });
71 | this.scene.add(this.world.container);
72 |
73 | // render once
74 | this.renderer.render(this.scene, this.camera.instance);
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/core/GUIPanel.js:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three'
2 | import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min'
3 |
4 | export default class GUIPanel {
5 | constructor(_option) {
6 | this.mode = _option.mode
7 | this.cardSet = _option.cardSet
8 | this.cardUnwrap = _option.cardUnwrap
9 |
10 | this.cardMode = null
11 | this.onCard = false
12 | this.gui = new GUI()
13 | this.gui.add(this, 'mode', ['segment', 'layer', 'volume', 'volume-segment'])
14 | this.gui.add(this.cardUnwrap.viewer.params, 'flatten', 0.0, 1.0).onChange(() => this.cardUnwrap.updateAllBuffer())
15 | }
16 |
17 | currentCard() {
18 | if (this.onCard && this.cardSet.focusCard.userData.mode == this.cardMode) return
19 | this.cardMode = this.cardSet.focusCard.userData.mode
20 | this.onCard = true
21 | this.reset()
22 |
23 | const mode = this.cardMode
24 | const viewer = this.cardSet.viewer
25 |
26 | if (mode === 'segment') {
27 | const id = viewer.params.layers.select
28 | const clip = viewer.volumeMeta.nrrd[id].clip
29 | this.gui.add(viewer.params, 'alpha', 0.0, 1.0).onChange(() => this.cardSet.updateAllBuffer())
30 | this.gui.add(viewer.params, 'layer', clip.z, clip.z + clip.d, 1).onChange(() => this.cardSet.updateAllBuffer())
31 | }
32 | if (mode === 'volume') { return }
33 | if (mode === 'volume-segment') {
34 | this.gui.add(viewer.params, 'surface', 0.001, 0.5).onChange(() => this.cardSet.updateAllBuffer())
35 | }
36 | if (mode === 'layer') {
37 | const id = viewer.params.layers.select
38 | const clip = viewer.volumeMeta.nrrd[id].clip
39 |
40 | viewer.params.layer = clip.z
41 | this.gui.add(viewer.params, 'inverse').onChange(() => this.cardSet.updateAllBuffer())
42 | this.gui.add(viewer.params, 'surface', 0.001, 0.5).onChange(() => this.cardSet.updateAllBuffer())
43 | this.gui.add(viewer.params, 'layer', clip.z, clip.z + clip.d, 1).onChange(() => this.cardSet.updateAllBuffer())
44 | }
45 | }
46 |
47 | newCard() {
48 | if (!this.onCard) return
49 | this.onCard = false
50 |
51 | this.reset()
52 | this.gui.add(this, 'mode', ['segment', 'layer', 'volume', 'volume-segment'])
53 | }
54 |
55 | reset() {
56 | if (this.gui) { this.gui.destroy() }
57 | this.gui = new GUI()
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/core/core/Card.js:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three'
2 | import PubSub from "pubsub-js";
3 | import { TextureLoader } from 'three'
4 | import { FragmentShader } from './FragmentShader'
5 | import { TIFFLoader } from 'three/addons/loaders/TIFFLoader.js'
6 | import { ArcballControls } from 'three/addons/controls/ArcballControls.js'
7 |
8 | export default class Card {
9 | constructor(_option) {
10 | this.scene = null
11 | this.camera = null
12 | this.controls = null
13 | this.renderer = null
14 | this.segmentID = null
15 |
16 | this.time = _option.time
17 | this.app = _option.app
18 | this.canvas = _option.canvas
19 | this.renderer = _option.renderer
20 | this.width = _option.info.w
21 | this.height = _option.info.h
22 | this.buffer = new THREE.WebGLRenderTarget(this.width, this.height)
23 |
24 | this.init()
25 | }
26 |
27 | init() {
28 | // scene setup
29 | this.scene = new THREE.Scene()
30 |
31 | // camera setup
32 | this.camera = new THREE.PerspectiveCamera(75, this.width / this.height, 0.1, 50)
33 | this.camera.position.copy(new THREE.Vector3(0.4, -0.4, -1.0).multiplyScalar(1.0))
34 | this.camera.up.set(0, -1, 0)
35 | this.camera.far = 5
36 | this.camera.updateProjectionMatrix()
37 |
38 | // camera controls
39 | this.controls = new ArcballControls(this.camera, this.canvas, this.scene)
40 | }
41 |
42 | async create(segmentID, uuid, info) {
43 | const texture = await new TIFFLoader().loadAsync(`${segmentID}.tif`)
44 |
45 | let mtexture = null
46 | if (segmentID === '20230509182749') { mtexture = await new TextureLoader().loadAsync('20230509182749-mask.png') }
47 |
48 | const material = new FragmentShader()
49 | material.uniforms.tDiffuse.value = texture
50 | material.uniforms.uMask.value = mtexture
51 |
52 | const mesh = new THREE.Mesh(new THREE.PlaneGeometry(info.w / info.h, 1), material)
53 | this.scene.add(mesh)
54 |
55 | this.segmentID = segmentID
56 |
57 | this.render()
58 | this.time.trigger('tick')
59 | this.app.API.cardLoad(uuid)
60 |
61 | PubSub.publish("onFinishLoad", { id: uuid })
62 | }
63 |
64 | render() {
65 | if (!this.renderer) return
66 |
67 | this.renderer.setRenderTarget(this.buffer)
68 | this.renderer.clear()
69 |
70 | this.renderer.render(this.scene, this.camera)
71 | this.renderer.setRenderTarget(null)
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/components/.deprecated/Card/Card.jsx:
--------------------------------------------------------------------------------
1 | import React, { useContext, useState } from "react";
2 | import AppContext from "../../../context/AppContext";
3 | import useCardRender from "../../../hooks/need-refactor/useCardRender";
4 | import { css } from "@emotion/css";
5 | import { cn } from "../../../utils/cn";
6 |
7 | export default function Card(props) {
8 |
9 | const card = props.options.card;
10 | const renderer = props.options.renderer;
11 | const rendererId = renderer?.id;
12 | const id = card?.id;
13 |
14 | const [cardLoad, setCardLoad] = useState(false);
15 | const [cardSelected, setCardSelected] = useState(false);
16 |
17 | if (!cardLoad && renderer.isLoadId === id) {
18 | setCardLoad(true);
19 | }
20 |
21 | if (!cardSelected && id === rendererId) {
22 | setCardSelected(true)
23 | }
24 | if (cardSelected && id !== rendererId) {
25 | setCardSelected(false)
26 | }
27 |
28 | console.log(renderer)
29 |
30 | if (!cardLoad) {
31 | return
42 |
43 | Loading...
44 |
45 |
46 | }
47 |
48 | if (cardSelected) {
49 | return (
50 |
62 |
63 | {card?.name}
64 |
65 |
66 | );
67 | } else {
68 | return <>>
69 | }
70 |
71 |
72 |
73 |
74 |
75 | }
76 |
77 | const styles = {
78 | card: cn("fixed translate-x-[-50%] translate-y-[-50%]"),
79 | };
80 |
--------------------------------------------------------------------------------
/src/core/Controls.js:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three'
2 |
3 | export default class Controls {
4 | constructor(_option) {
5 | this.time = _option.time
6 | this.sizes = _option.sizes
7 | this.camera = _option.camera
8 |
9 | this.mousePress = false
10 | this.spacePress = false
11 | this.numKeyPress = [false, false, false, false]
12 |
13 | this.setMouse()
14 | }
15 |
16 | setMouse() {
17 | this.mouse = new THREE.Vector2()
18 |
19 | // triggered when the mouse moves
20 | window.addEventListener('mousemove', (e) => {
21 | this.mouse.x = e.clientX / this.sizes.width * 2 - 1
22 | this.mouse.y = -(e.clientY / this.sizes.height) * 2 + 1
23 | this.time.trigger('mouseMove')
24 | })
25 |
26 | // after pressing down the mouse button (for left click only)
27 | window.addEventListener('pointerdown', (e) => {
28 | if (e.button !== 0) return
29 |
30 | const name = e.srcElement.className
31 | // console.log(name)
32 | // if (name !== 'webgl' && name !== 'cardDOM') return
33 |
34 | this.mousePress = true
35 | this.time.trigger('mouseDown')
36 | })
37 | // can't use 'mousedown' event because of the OrbitControls library
38 |
39 | // after releasing the mouse button
40 | window.addEventListener('click', () => {
41 | this.mousePress = false
42 | this.time.trigger('mouseUp')
43 | })
44 |
45 | // whether space key is pressed or not
46 | window.addEventListener('keydown', (e) => {
47 | this.spacePress = (e.code === 'Space')
48 | this.numKeyPress[0] = (e.code === 'Digit1')
49 | this.numKeyPress[1] = (e.code === 'Digit2')
50 | this.numKeyPress[2] = (e.code === 'Digit3')
51 | this.numKeyPress[3] = (e.code === 'Digit4')
52 |
53 | if (this.spacePress) this.time.trigger('spaceDown')
54 | if (this.spacePress && !e.repeat) this.time.trigger('spaceDownStart')
55 | })
56 | window.addEventListener('keyup', (e) => {
57 | if (this.spacePress) this.time.trigger('spaceUp')
58 | if (this.numKeyPress[4]) this.time.trigger('dragUp')
59 |
60 | this.spacePress = false
61 | this.numKeyPress[0] = false
62 | this.numKeyPress[1] = false
63 | this.numKeyPress[2] = false
64 | this.numKeyPress[3] = false
65 | })
66 | }
67 |
68 | getRayCast(meshes) {
69 | const raycaster = new THREE.Raycaster()
70 | raycaster.setFromCamera(this.mouse, this.camera.instance)
71 | const intersects = raycaster.intersectObjects(meshes)
72 |
73 | return intersects
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/core/core/Image.js:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three'
2 | import PubSub from "pubsub-js";
3 | import { TextureLoader } from 'three'
4 | import { ImageShader } from './ImageShader'
5 |
6 | export default class Image {
7 | constructor(_option) {
8 | this.scene = null
9 | this.camera = null
10 | this.renderer = null
11 | this.width = null
12 | this.height = null
13 | this.buffer = null
14 |
15 | this.time = _option.time
16 | this.renderer = _option.renderer
17 |
18 | this.init()
19 | }
20 |
21 | init() {
22 | // scene setup
23 | this.scene = new THREE.Scene()
24 |
25 | // camera setup
26 | this.camera = new THREE.PerspectiveCamera(75, this.width / this.height, 0.1, 50)
27 | this.camera.updateProjectionMatrix()
28 | }
29 |
30 | async create(blob, uuid, card) {
31 | // create a full screen texture image for rendering
32 | const blobUrl = URL.createObjectURL(blob)
33 | const texture = await new TextureLoader().loadAsync(blobUrl)
34 | texture.minFilter = THREE.NearestFilter
35 | texture.magFilter = THREE.NearestFilter
36 |
37 | const material = new ImageShader()
38 | material.uniforms.tDiffuse.value = texture
39 |
40 | const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2, 1, 1), material)
41 | this.scene.add(mesh)
42 |
43 | // change card size via texture w, h info
44 | const tWidth = texture.image.width
45 | const tHeight = texture.image.height
46 | const lMax = Math.max(tWidth, tHeight)
47 | const s = (lMax < 10000) ? 1 : 10000 / lMax
48 |
49 | this.width = tWidth * s
50 | this.height = tHeight * s
51 | this.buffer = new THREE.WebGLRenderTarget(this.width, this.height)
52 |
53 | const size = 2
54 | const fw = (lMax === tWidth) ? 1 : tWidth / lMax
55 | const fh = (lMax === tHeight) ? 1 : tHeight / lMax
56 |
57 | card.userData.wo = size * fw
58 | card.userData.ho = size * fh
59 |
60 | card.userData.w = card.userData.wo
61 | card.userData.h = card.userData.ho
62 | card.scale.x = card.userData.wo
63 | card.scale.y = card.userData.ho
64 | card.material.uniforms.tDiffuse.value = this.buffer.texture
65 |
66 | this.render()
67 | this.time.trigger('tick')
68 |
69 | PubSub.publish("onFinishLoad", { id: uuid })
70 | }
71 |
72 | render() {
73 | if (!this.renderer || !this.buffer) return
74 |
75 | this.renderer.setRenderTarget(this.buffer)
76 | this.renderer.clear()
77 |
78 | this.renderer.render(this.scene, this.camera)
79 | this.renderer.setRenderTarget(null)
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/App.jsx:
--------------------------------------------------------------------------------
1 |
2 | /**
3 | * * this is the index.js file of Volume Viewer Core
4 | * * ===============================================
5 | * * It will load when the App component is mounted.
6 | * *
7 | */
8 | import Info from './components/Info/Info';
9 | import Hint from './components/Hint/Hint';
10 | import Social from './components/Social/Social';
11 | import AppContext from './context/AppContext';
12 | import FileSystem from "./components/FileSystem/FileSystem";
13 | import useWhiteboardUpdate from "./hooks/useWhiteboardUpdate";
14 | import useWhiteboardApp from "./hooks/useWhiteboardApp";
15 | import UrlCards from "./components/UrlCards/UrlCards";
16 | import Cards from "./components/Cards/Cards";
17 | import useCardRender from "./hooks/deprecated/useCardRender";
18 | import Version from './components/Version/Version';
19 | import TextCards from "./components/TextCards/TextCards"
20 | import { Analytics } from '@vercel/analytics/react';
21 |
22 |
23 | export default function App() {
24 |
25 | // 白板本身
26 | const app = useWhiteboardApp();
27 | // 白板狀態
28 | const whiteboard = useWhiteboardUpdate();
29 |
30 | // 白板控制 (舊, 會重構)
31 | useCardRender(app);
32 |
33 |
34 | return (
35 |
39 |
40 |
41 |
42 |
43 | {/*
44 | {""}
45 | */}
46 |
47 | {""}
48 |
49 |
50 | {""}
51 |
52 |
53 | {""}
54 |
55 | {/*
56 | {""}
57 | */}
58 |
59 | {/*
*/}
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | )
71 | }
72 |
--------------------------------------------------------------------------------
/src/components/FileSystem/FileSystem.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import readDirectory from "../../utils/readDirectory";
3 | import FileTree from "./FileTree/FileTree";
4 | import { isEmpty } from "lodash";
5 | import { Resizable } from "re-resizable";
6 | import { cn } from "../../utils/cn";
7 | import PubSub from "pubsub-js";
8 | import { nanoid } from "nanoid";
9 | import { VscFiles } from "react-icons/vsc";
10 |
11 | export default function FileSystem() {
12 | const [dir, setDir] = useState({});
13 | const [isResize, setIsResize] = useState(false);
14 | const [isOpenFileSystem, setIsOpenFileSystem] = useState(true);
15 |
16 | const handleFileBtnOnClick = async () => {
17 | const directoryHandle = await window.showDirectoryPicker();
18 | const dir = await readDirectory(directoryHandle);
19 | setDir(dir);
20 | };
21 |
22 | const handleFileOnClick = async (file: File) => {
23 | const arraybuffer = await file.arrayBuffer();
24 | const blob = new Blob([arraybuffer], { type: file.name });
25 | const text = await file.text();
26 | PubSub.publish("onFileSelect", {
27 | id: nanoid(),
28 | fileType: file.type,
29 | fileName: file.name,
30 | blob,
31 | text,
32 | });
33 | };
34 |
35 | const handleFolderOnClick = async () => {};
36 |
37 | return (
38 |
39 | {!isEmpty(dir) ? (
40 |
41 |
{
43 | setIsOpenFileSystem(!isOpenFileSystem);
44 | }}
45 | className="p-4 text-xl cursor-pointer"
46 | title={
47 | isOpenFileSystem
48 | ? "close file system"
49 | : "open file system"
50 | }
51 | >
52 |
53 |
54 | {
55 |
{
57 | setIsResize(true);
58 | }}
59 | onResizeStop={() => {
60 | setIsResize(false);
61 | }}
62 | className={cn(
63 | isOpenFileSystem ? "visible" : "invisible",
64 | "text-lg bg-[#111] opacity-80 py-2 pr-4 overflow-hidden border-4 transition-[border]",
65 | isResize ? "" : "border-[#111]"
66 | )}
67 | >
68 |
73 |
74 | }
75 |
76 | ) : (
77 |
83 | )}
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/src/components/FileSystem/FileTree/FileTree.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useState } from "react";
2 | import { AiOutlineCaretDown, AiOutlineCaretRight } from "react-icons/ai";
3 | import formatBytes from "../../../utils/formatBytes";
4 | import {
5 | LuScroll,
6 | LuFolder,
7 | LuFolderOpen,
8 | LuImage,
9 | LuFile,
10 | LuFileText,
11 | } from "react-icons/lu";
12 |
13 | const Dir = ({ name, item, fileOnClick, folderOnClick }) => {
14 | const [open, setOpen] = useState(false);
15 |
16 | const distinguishFileType = useCallback((name: string, type: string) => {
17 | const spl = type.split("/")[0];
18 | if (spl === "image") {
19 | return (
20 | <>
21 |
22 | {name}
23 | >
24 | );
25 | } else if (spl === "text" || spl === "application") {
26 | return (
27 | <>
28 |
29 | {name}
30 | >
31 | );
32 | } else {
33 | return (
34 | <>
35 |
36 | {name}
37 | >
38 | );
39 | }
40 | }, []);
41 |
42 | return (
43 |
44 | {item instanceof File ? (
45 | // file
46 | {
48 | fileOnClick && fileOnClick(item);
49 | }}
50 | className="flex items-center pl-4 hover:underline"
51 | title={"file | " + formatBytes(item.size)}
52 | >
53 |
54 | {distinguishFileType(name, item.type)}
55 |
56 |
57 | ) : (
58 | // folder
59 | {
61 | setOpen(!open);
62 | folderOnClick && folderOnClick(item);
63 | }}
64 | className="flex items-center gap-1 hover:underline"
65 | title="segment"
66 | >
67 | {open ? : }
68 | {name.match(/202[34]\d+/gu) ? (
69 |
70 |
71 | {name}
72 |
73 | ) : (
74 |
75 | {open ? (
76 |
77 | ) : (
78 |
79 | )}
80 | {name}
81 |
82 | )}
83 |
84 | )}
85 | {item instanceof Object && open && (
86 |
91 | )}
92 |
93 | );
94 | };
95 |
96 | const FileTree = ({ data, fileOnClick, folderOnClick }) => {
97 | return (
98 |
99 | {Object.entries(data).map(([name, item]) => (
100 |
107 | ))}
108 |
109 | );
110 | };
111 |
112 | export default FileTree;
113 |
--------------------------------------------------------------------------------
/src/components/Cards/Card/Card.jsx:
--------------------------------------------------------------------------------
1 | import { css } from "@emotion/css"
2 | import { cn } from "../../../utils/cn"
3 | import { useContext, useEffect, useState } from "react"
4 | import AppContext from "../../../context/AppContext"
5 | import { useHover } from "../../../hooks/useHover"
6 | import { Slider } from "../../ui/Slider"
7 |
8 | export default function Card({ card }) {
9 |
10 | const { app } = useContext(AppContext)
11 |
12 | const [hover, isHover] = useHover();
13 | const [isLoad, setIsLoad] = useState(false)
14 |
15 | useEffect(() => {
16 | PubSub.subscribe("onFinishLoad", (_, { id }) => {
17 | id === card.id && setIsLoad(true)
18 | })
19 | setTimeout(() => {
20 | setIsLoad(true)
21 | }, 4000)
22 | }, [card.id])
23 |
24 |
25 | const [opacity, setOpacity] = useState(1)
26 | useEffect(() => {
27 | PubSub.publish("onCardOpacityChange", { id: card.id, opacity })
28 | }, [opacity, card.id])
29 |
30 | const [rotation, setRotation] = useState(180);
31 | useEffect(() => {
32 | PubSub.publish("onCardRotationChange", { id: card.id, rotation: rotation - 180 })
33 | }, [rotation, card.id])
34 |
35 | const [scale, setScale] = useState(1);
36 | useEffect(() => {
37 | PubSub.publish("onCardScaleChange", { id: card.id, scale })
38 | }, [scale, card.id])
39 |
40 | return
49 | {
52 |
{card.name}
53 |
54 |
55 |
rotation
56 |
57 | {
60 | setRotation(v[0])
61 | }}
62 | className="w-full" max={360} step={1} />
63 |
64 |
65 |
66 |
scale
67 |
68 | {
71 | setScale(v[0])
72 | }}
73 | className="w-full" max={2} step={0.01} />
74 |
75 |
76 |
77 |
opacity
78 |
79 | {
82 | setOpacity(v[0] / 100)
83 | }}
84 | className="w-full" max={100} step={1} />
85 |
86 |
87 |
88 |
}
89 | {isLoad ||
loading...
}
90 |
91 | }
92 |
--------------------------------------------------------------------------------
/src/core/CardSet.js:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three'
2 | import Card from './core/Card'
3 | import Image from './core/Image'
4 | import PubSub from "pubsub-js";
5 | import { CopyShader } from './core/CopyShader'
6 |
7 | export default class CardSet {
8 | constructor(_option) {
9 | this.app = _option.app
10 | this.time = _option.time
11 | this.sizes = _option.sizes
12 | this.camera = _option.camera
13 | this.renderer = _option.renderer
14 |
15 | this.list = []
16 | this.targetCard = null
17 | }
18 |
19 | create(name, dom, mouse, center) {
20 | const info = {}
21 | if (name === '20230522181603') { info.w = 2912; info.h = 1060; }
22 | if (name === '20230509182749') { info.w = 3278; info.h = 1090; }
23 | if (name === '20230702185752') { info.w = 1746; info.h = 1726; }
24 |
25 | const viewer = new Card({
26 | info,
27 | canvas: dom,
28 | renderer: this.renderer,
29 | time: this.time,
30 | app: this.app,
31 | })
32 |
33 | viewer.controls.addEventListener('change', () => {
34 | this.render()
35 | this.time.trigger('tick')
36 | })
37 |
38 | const w = parseFloat((info.w / 1500).toFixed(2))
39 | const h = parseFloat((info.h / 1500).toFixed(2))
40 |
41 | const geometry = new THREE.PlaneGeometry(w, h)
42 | const material = new CopyShader()
43 | material.uniforms.tDiffuse.value = viewer.buffer.texture
44 |
45 | const card = new THREE.Mesh(geometry, material)
46 | const id = card.uuid
47 | const type = 'card'
48 | card.position.copy(center)
49 | card.userData = { id, name, type, center, w, h, viewer, dom }
50 |
51 | viewer.create(name, id, info)
52 | this.list.push(card)
53 |
54 | return card
55 | }
56 |
57 | createImage(id, fileType, fileName, blob, center) {
58 | const viewer = new Image({
59 | renderer: this.renderer,
60 | time: this.time,
61 | })
62 |
63 | const geometry = new THREE.PlaneGeometry(1, 1)
64 | const material = new CopyShader()
65 |
66 | const name = fileName
67 | const type = fileType
68 |
69 | const card = new THREE.Mesh(geometry, material)
70 | card.position.copy(center)
71 | card.userData = { id, name, type, center, w: 1, h: 1 }
72 | this.list.push(card)
73 |
74 | viewer.create(blob, id, card)
75 |
76 | return card
77 | }
78 |
79 | createText(id, fileType, fileName, text, center, width, height) {
80 | const geometry = new THREE.PlaneGeometry(width, height)
81 | const material = new THREE.MeshBasicMaterial()
82 |
83 | const name = fileName
84 | const type = fileType
85 |
86 | const card = new THREE.Mesh(geometry, material)
87 | card.position.copy(center)
88 | card.userData = { id, name, type, center, content: text, w: width, h: height }
89 | this.list.push(card)
90 |
91 | PubSub.publish("onFinishLoad", { id })
92 |
93 | return card
94 | }
95 |
96 | createIframe(id, center, width, height) {
97 | const geometry = new THREE.PlaneGeometry(width, height)
98 | const material = new THREE.MeshBasicMaterial()
99 |
100 | const name = ''
101 | const type = 'iframe'
102 |
103 | const card = new THREE.Mesh(geometry, material)
104 | card.position.copy(center)
105 | card.userData = { id, name, type, center, w: width, h: height, wo: width, ho: height }
106 | this.list.push(card)
107 |
108 | PubSub.publish("onFinishLoad", { id })
109 |
110 | return card
111 | }
112 |
113 | updateCanvas(card) {
114 | if (!card) return
115 |
116 | const { center, dom, w, h } = card.userData
117 |
118 | const bl = new THREE.Vector3(center.x - w / 2, center.y - h / 2, 0)
119 | const tr = new THREE.Vector3(center.x + w / 2, center.y + h / 2, 0)
120 | // bottom-left (-1, -1) top-right (1, 1)
121 | const pbl = bl.clone().project(this.camera.instance)
122 | const ptr = tr.clone().project(this.camera.instance)
123 |
124 | return [ pbl, ptr ]
125 | }
126 |
127 | render() {
128 | if (!this.targetCard) return
129 |
130 | const { viewer } = this.targetCard.userData
131 | viewer.render()
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/src/core/utils/EventEmitter.js:
--------------------------------------------------------------------------------
1 | export default class EventEmiter {
2 | constructor() {
3 | this.callbacks = {};
4 | this.callbacks.base = {};
5 | }
6 |
7 | // input : this.on('xx/yy/zz.alpha', callback)
8 | // result: this.callbacks['base'] = {'xx':[..., callback], 'yy':[..., callback]}
9 | // this.callbacks['alpha'] = {'zz':[..., callback]}
10 | on(_names, _callback, _unsubsrcibe) {
11 | if (typeof _names === 'undefined' || _names === '') {
12 | console.warn('wrong names');
13 | return false;
14 | }
15 | if (typeof _callback === 'undefined') {
16 | console.warn('wrong callback');
17 | return false;
18 | }
19 |
20 | const names = EventEmiter.resolveNames(_names);
21 |
22 | names.forEach((_name) => {
23 | const name = EventEmiter.resolveName(_name);
24 |
25 | if (!(this.callbacks[name.namespace] instanceof Object)) {
26 | this.callbacks[name.namespace] = {};
27 | }
28 |
29 | if (!(this.callbacks[name.namespace][name.value] instanceof Array)) {
30 | this.callbacks[name.namespace][name.value] = [];
31 | }
32 |
33 | this.callbacks[name.namespace][name.value].push(_callback);
34 | });
35 |
36 | return { _names, _callback, _unsubsrcibe };
37 | }
38 |
39 | // input : this.trigger('xx', [5,3])
40 | // result: this.callbacks['base']['xx'] -> [c1, c2, ...]
41 | // execute c1(5,3), c2(5,3)
42 | trigger(_name, _args) {
43 | if (typeof _name === 'undefined' || _name === '') {
44 | console.warn('wrong name');
45 | return false;
46 | }
47 |
48 | let finalResult = null;
49 | let result = null;
50 |
51 | // Default args []
52 | const args = !(_args instanceof Array) ? [] : _args;
53 |
54 | const names = EventEmiter.resolveNames(_name);
55 | const name = EventEmiter.resolveName(names[0]);
56 | const { namespace, value } = name;
57 |
58 | if (namespace === 'base') {
59 | const callbacks = this.callbacks[namespace];
60 | const callback = this.callbacks[namespace][value];
61 |
62 | if (callbacks instanceof Object && callback instanceof Array) {
63 | callback.forEach((callback) => {
64 | // execute callback
65 | result = callback.apply(this, args);
66 |
67 | if (typeof finalResult === 'undefined') { finalResult = result; }
68 | });
69 | }
70 | } else if (this.callbacks[namespace] instanceof Object) {
71 | if (value === '') {
72 | console.warn('wrong name');
73 | return this;
74 | }
75 |
76 | this.callbacks[namespace][value].forEach((callback) => {
77 | // execute callback
78 | result = callback.apply(this, args);
79 |
80 | if (typeof finalResult === 'undefined') { finalResult = result; }
81 | });
82 | }
83 |
84 | return finalResult;
85 | }
86 |
87 | // ex: this.remove({_names: 'xx', _callback: ...})
88 | // ex: this.remove({_names: 'xx/yy/zz.alpha', _callback: ...})
89 | remove(_target) {
90 | const { _names, _callback, _unsubsrcibe } = _target;
91 |
92 | if (typeof _names === 'undefined' || _names === '') {
93 | console.warn('wrong names');
94 | return false;
95 | }
96 | if (typeof _callback === 'undefined') {
97 | console.warn('wrong callback');
98 | return false;
99 | }
100 |
101 | const names = EventEmiter.resolveNames(_names);
102 |
103 | names.forEach((_name) => {
104 | const name = EventEmiter.resolveName(_name);
105 | const { namespace, value } = name;
106 |
107 | if (!(this.callbacks[namespace] instanceof Object)) return;
108 | if (!(this.callbacks[namespace][value] instanceof Array)) return;
109 |
110 | // remove the callback
111 | this.callbacks[namespace][value] = this.callbacks[namespace][value].filter((callback) => callback !== _callback);
112 | // execute unsubsrcibe function
113 | if (_unsubsrcibe instanceof Function) _unsubsrcibe();
114 |
115 | if (!this.callbacks[namespace][value].length) {
116 | delete this.callbacks[namespace][value];
117 | }
118 | });
119 |
120 | return this;
121 | }
122 |
123 | // ex: 'xx/yy/zz' -> ['xx', 'yy', 'zz']
124 | static resolveNames(_names) {
125 | let names = _names;
126 | names = names.replace(/[^a-zA-Z0-9 ,/.]/g, '');
127 | names = names.replace(/[,/]+/g, ' ');
128 | names = names.split(' ');
129 |
130 | return names;
131 | }
132 |
133 | // ex: 'xx' -> {original: 'xx', value: 'xx', namespace: 'base'}
134 | // ex: 'xx.yy' -> {original: 'xx.yy', value: 'xx', namespace: 'yy'}
135 | static resolveName(name) {
136 | const newName = {};
137 | const [value, namespace] = name.split('.');
138 |
139 | newName.original = name;
140 | newName.value = value;
141 | newName.namespace = 'base';
142 |
143 | if (namespace) { newName.namespace = namespace; }
144 |
145 | return newName;
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/src/components/UrlCards/UrlCard/UrlCard.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react"
2 | import { cn } from "../../../utils/cn"
3 | import { css } from "@emotion/css"
4 | import { Slider } from "../../ui/Slider"
5 | import { useHover } from "../../../hooks/useHover"
6 |
7 | export default function UrlCard({ card }) {
8 |
9 | const inputRef = useRef(null)
10 |
11 | const [inupt, setInput] = useState("");
12 | const [url, setUrl] = useState("");
13 |
14 | const [hover, isHover] = useHover();
15 | const [isVisable, setIsVisable] = useState(true);
16 |
17 | const handleClose = () => {
18 | PubSub.publish("onUrlCardDelete", { id: card.id })
19 | }
20 |
21 | const handleEnter = () => {
22 | setIsVisable(true)
23 | }
24 |
25 | const handleLeave = () => {
26 | if (url) {
27 | setIsVisable(false)
28 | }
29 | }
30 |
31 | const handleFlip = () => {
32 | setFlip(!flip)
33 | }
34 |
35 | useEffect(() => {
36 | inputRef.current?.focus()
37 | }, [])
38 |
39 | const [flip, setFlip] = useState(false);
40 | useEffect(() => {
41 | PubSub.publish("onCardFlipChange", { id: card.id, flip })
42 | }, [flip, card.id])
43 |
44 | const [rotation, setRotation] = useState(180);
45 | useEffect(() => {
46 | PubSub.publish("onCardRotationChange", { id: card.id, rotation: rotation - 180 })
47 | }, [rotation, card.id])
48 |
49 | const [scale, setScale] = useState(1);
50 | useEffect(() => {
51 | PubSub.publish("onCardScaleChange", { id: card.id, scale })
52 | }, [scale, card.id])
53 |
54 | return
66 |
68 |
69 |
flip
70 |
71 |
79 |
80 |
81 |
82 |
84 |
85 |
scale
86 |
87 | {
90 | setScale(v[0])
91 | }}
92 | className="w-full" max={2} step={0.01} />
93 |
94 |
95 |
96 |
98 |
99 |
rotation
100 |
101 | {
104 | setRotation(v[0])
105 | }}
106 | className="w-full" max={360} step={1} />
107 |
108 |
109 |
110 |
113 | {card.heightScreen < 150 ? <>> :
114 |
115 |
From the web
116 |
[X]
117 |
}
118 | {card.heightScreen < 280 ? <>> :
{ setInput(e.target.value) }}
122 | onKeyDown={(e) => {
123 | if (e.key === "Enter") {
124 | setUrl(inupt)
125 | }
126 | }}
127 | className={cn("w-full", "text-lg text-[#111]", "p-1")}
128 | type="text" />}
129 |
130 |
138 |
139 | }
140 |
--------------------------------------------------------------------------------
/src/core/World.js:
--------------------------------------------------------------------------------
1 | import * as THREE from "three";
2 |
3 | import WhiteBoard from "./WhiteBoard";
4 | import CardSet from "./CardSet";
5 | // import GUIPanel from './GUIPanel'
6 | import Controls from "./Controls";
7 | import Application from "./Application";
8 |
9 | import PubSub from "pubsub-js";
10 | // import { TIFFLoader } from 'three/addons/loaders/TIFFLoader.js';
11 | // import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'
12 |
13 | export default class World {
14 | constructor(_option) {
15 | this.app = _option.app;
16 | this.time = _option.time;
17 | this.sizes = _option.sizes;
18 | this.camera = _option.camera;
19 | this.renderer = _option.renderer;
20 |
21 | this.container = new THREE.Object3D();
22 | this.container.matrixAutoUpdate = false;
23 |
24 | this.start();
25 | }
26 |
27 | start() {
28 | this.setControls();
29 | this.setWhiteBoard();
30 | this.setCard();
31 | }
32 |
33 | setControls() {
34 | this.controls = new Controls({
35 | time: this.time,
36 | sizes: this.sizes,
37 | camera: this.camera,
38 | });
39 | }
40 |
41 | setWhiteBoard() {
42 | this.whiteBoard = new WhiteBoard({});
43 | this.container.add(this.whiteBoard.container);
44 |
45 | this.time.trigger("tick");
46 | }
47 |
48 | setCard() {
49 | this.cardSet = new CardSet({
50 | app: this.app,
51 | time: this.time,
52 | sizes: this.sizes,
53 | camera: this.camera,
54 | renderer: this.renderer,
55 | });
56 |
57 | // update whiteboard config info
58 | this.time.on('tick', () => { PubSub.publish("onWhiteboardUpdate", this.getConfig()) })
59 | this.sizes.on("resize", () => { PubSub.publish("onWhiteboardUpdate", this.getConfig()) });
60 |
61 | // iframe generate
62 | PubSub.subscribe("onUrlCardGenerated", (eventName, { id, x, y }) => {
63 | // I don't use the last two params (random numbers)
64 | const scenePos = this.getScenePosition(x, y, 100, 100)
65 | const card = this.cardSet.createIframe(id, scenePos.center, 800 / 300, 525 / 300)
66 | card.visible = false
67 | this.container.add(card)
68 | this.time.trigger("tick")
69 | })
70 |
71 | // image card opacity
72 | PubSub.subscribe("onCardOpacityChange", (eventName, { id, opacity }) => {
73 | this.cardSet.list.forEach((card) => {
74 | if (id !== card.userData.id) return
75 | if (!card.material.uniforms.opacity) return
76 | card.material.uniforms.opacity.value = opacity
77 | this.time.trigger("tick")
78 | })
79 | })
80 |
81 | // image card rotation
82 | PubSub.subscribe("onCardRotationChange", (eventName, { id, rotation }) => {
83 | this.cardSet.list.forEach((card) => {
84 | if (id !== card.userData.id) return
85 | card.rotation.z = 2 * Math.PI * (rotation / 360)
86 | this.time.trigger("tick")
87 | })
88 | })
89 |
90 | // image card scale
91 | PubSub.subscribe("onCardScaleChange", (eventName, { id, scale }) => {
92 | this.cardSet.list.forEach((card) => {
93 | if (id !== card.userData.id) return
94 |
95 | const { wo, ho } = card.userData
96 | const { width, height } = card.geometry.parameters
97 | card.userData.w = wo * scale
98 | card.userData.h = ho * scale
99 | card.scale.x = (wo / width) * scale
100 | card.scale.y = (ho / height) * scale
101 |
102 | this.time.trigger("tick")
103 | })
104 | })
105 |
106 | PubSub.subscribe("onFileSelect", async (eventName, data) => {
107 | const spl = data.fileType.split('/')[0]
108 |
109 | // find out whiteboard position on screen center
110 | const raycaster = new THREE.Raycaster()
111 | raycaster.setFromCamera(new THREE.Vector3(), this.camera.instance)
112 | const intersects = raycaster.intersectObjects([this.whiteBoard.container])
113 | if (!intersects.length) return;
114 |
115 | if (spl === 'image') {
116 | const { id, fileType, fileName, blob } = data
117 | const center = intersects[0].point
118 | const card = this.cardSet.createImage(id, fileType, fileName, blob, center)
119 |
120 | this.container.add(card)
121 | this.time.trigger("tick")
122 | return
123 | }
124 |
125 | if (spl === 'text' || spl === 'application') {
126 | const { id, fileType, fileName, text } = data
127 | const width = 9 / 3
128 | const height = 16 / 8
129 | const center = intersects[0].point
130 | const card = this.cardSet.createText(id, fileType, fileName, text, center, width, height)
131 | card.visible = false
132 |
133 | this.container.add(card)
134 | this.time.trigger("tick")
135 |
136 | return
137 | }
138 | })
139 |
140 | // delete the card
141 | PubSub.subscribe("onUrlCardDelete", (evnetName, { id }) => {
142 | const index = this.cardSet.list.findIndex(card => card.userData.id === id)
143 | const card = this.cardSet.list[index]
144 |
145 | card.geometry.dispose()
146 | card.material.dispose()
147 | this.container.remove(card)
148 |
149 | this.cardSet.list.splice(index, 1)
150 | this.time.trigger("tick")
151 | })
152 |
153 | // generate a card when clicking
154 | this.time.on("mouseDown", () => {
155 | let name;
156 | if (this.controls.numKeyPress[0]) name = "20230522181603";
157 | if (this.controls.numKeyPress[1]) name = "20230509182749";
158 | if (this.controls.numKeyPress[2]) name = "20230702185752";
159 | if (this.controls.numKeyPress[3]) name = " ";
160 | if (!name) return;
161 |
162 | const intersects = this.controls.getRayCast([this.whiteBoard.container]);
163 | if (!intersects.length) return;
164 |
165 | const pos = intersects[0].point;
166 | const center = new THREE.Vector3(pos.x, pos.y, 0);
167 | const dom = this.setDOM();
168 | const card = this.cardSet.create(name, dom, this.controls.mouse, center);
169 | this.container.add(card);
170 |
171 | this.time.trigger("tick");
172 |
173 | // this api is the bridge from Whiteboard Engine to React App.
174 | const id = card.uuid;
175 | const { w, h } = card.userData;
176 | const c = card.position.clone();
177 | const { x, y, width, height } = this.getScreenPosition(c, w, h);
178 | // this.app.API.cardGenerate({ id, name, x, y, width, height });
179 | // this.app.API.cardInit({ id, name, x, y, width, height });
180 | });
181 |
182 | // mouse pointer
183 | // this.time.on('mouseMove', () => {
184 | // document.body.style.cursor = 'auto';
185 |
186 | // const intersects = this.controls.getRayCast(this.cardSet.list);
187 | // if (!intersects.length) return;
188 | // document.body.style.cursor = 'pointer';
189 | // });
190 |
191 | // drag the card
192 | this.cardDonwPos = null
193 | this.mouseDownPos = null
194 | this.mouseNowPos = null
195 |
196 | this.time.on('mouseDown', () => {
197 | const intersects = this.controls.getRayCast(this.cardSet.list);
198 | if (!intersects.length) return;
199 |
200 | const card = intersects[intersects.length - 1].object;
201 | this.cardSet.targetCard = card;
202 | this.mouseDownPos = intersects[intersects.length - 1].point;
203 | this.cardDownPos = card.position.clone();
204 | this.camera.controls.enabled = false;
205 | });
206 | this.time.on('mouseMove', () => {
207 | if (!this.controls.mousePress) { this.cardDonwPos = null; this.mouseDownPos = null; this.mouseNowPos = null; return; }
208 | if (!this.mouseDownPos || !this.cardSet.targetCard || this.controls.spacePress) { return; }
209 |
210 | const intersects = this.controls.getRayCast([this.whiteBoard.container]);
211 | if (!intersects.length) return;
212 |
213 | const { dom } = this.cardSet.targetCard.userData;
214 |
215 | this.mouseNowPos = intersects[0].point;
216 | const pos = this.cardDownPos.clone().add(this.mouseNowPos).sub(this.mouseDownPos);
217 | this.cardSet.targetCard.position.copy(pos);
218 | this.cardSet.targetCard.userData.center = pos;
219 |
220 | const [pbl, ptr] = this.cardSet.updateCanvas(this.cardSet.targetCard);
221 | const { width, height } = this.sizes.viewport;
222 |
223 | if (dom) {
224 | dom.style.left = `${(pbl.x + 1) * width * 0.5}px`;
225 | dom.style.bottom = `${(pbl.y + 1) * height * 0.5}px`;
226 | dom.style.width = `${(ptr.x - pbl.x) * width * 0.5}px`;
227 | dom.style.height = `${(ptr.y - pbl.y) * height * 0.5}px`;
228 | dom.style.display = "none";
229 | }
230 |
231 | const { w, h } = this.cardSet.targetCard.userData;
232 | const center = this.cardSet.targetCard.position.clone();
233 | const info = this.getScreenPosition(center, w, h, '');
234 | info.id = this.cardSet.targetCard.uuid;
235 | // this.app.API.cardMove(info);
236 |
237 | this.time.trigger("tick");
238 | });
239 | this.time.on('mouseUp', () => {
240 | this.camera.controls.enabled = true;
241 | if (!this.cardSet.targetCard) return;
242 |
243 | const { dom } = this.cardSet.targetCard.userData;
244 | if (!dom) return;
245 |
246 | dom.style.display = "none";
247 | this.cardSet.targetCard = null;
248 | });
249 |
250 | // make the whiteboard controllable (all scene in cards remains unchanged)
251 | this.time.on("spaceUp", () => {
252 | if (!this.cardSet.targetCard) return
253 | // this.app.API.cardLeave(!this.cardSet.targetCard.uuid)
254 |
255 | document.body.style.cursor = "auto";
256 | this.camera.controls.enabled = true;
257 | this.cardSet.targetCard = null;
258 |
259 | this.cardSet.list.forEach((card) => {
260 | const { dom } = card.userData;
261 | if (!dom) return
262 | dom.style.display = "none";
263 | });
264 | });
265 |
266 | // fix the whiteboard (scene in selected card is controllable)
267 | this.time.on("spaceDown", () => {
268 | this.camera.controls.enabled = false;
269 | const intersects = this.controls.getRayCast(this.cardSet.list);
270 |
271 | // if (!intersects.length && this.cardSet.targetCard) { this.app.API.cardLeave(this.cardSet.targetCard.uuid); }
272 | // if (intersects.length && this.cardSet.targetCard && this.cardSet.targetCard.uuid !== intersects[0].object.uuid) { this.app.API.cardLeave(this.cardSet.targetCard.uuid); }
273 | if (!intersects.length) { this.cardSet.targetCard = null; return; }
274 |
275 | const card = intersects[0].object;
276 | const { dom, viewer, w, h } = card.userData;
277 |
278 | if (!this.cardSet.targetCard || (this.cardSet.targetCard && this.cardSet.targetCard.uuid !== card.uuid)) {
279 | const u = card.userData;
280 | const center = card.position.clone();
281 | const info = this.getScreenPosition(center, u.w, u.h);
282 | info.id = card.uuid;
283 | // this.app.API.cardSelect(info);
284 | }
285 | this.cardSet.targetCard = card;
286 |
287 | this.cardSet.list.forEach((c) => {
288 | const v = c.userData.viewer;
289 | if (v) v.controls.enabled = false;
290 | });
291 | if (viewer) viewer.controls.enabled = true;
292 |
293 | const [pbl, ptr] = this.cardSet.updateCanvas(card);
294 | const { width, height } = this.sizes.viewport;
295 |
296 | if (!dom) return
297 | dom.style.left = `${(pbl.x + 1) * width * 0.5}px`;
298 | dom.style.bottom = `${(pbl.y + 1) * height * 0.5}px`;
299 | dom.style.width = `${(ptr.x - pbl.x) * width * 0.5}px`;
300 | dom.style.height = `${(ptr.y - pbl.y) * height * 0.5}px`;
301 | dom.style.display = "inline";
302 | });
303 | }
304 |
305 | getConfig() {
306 | const cameraInfo = {}
307 | cameraInfo.x = parseFloat(this.camera.instance.position.x.toFixed(5))
308 | cameraInfo.y = parseFloat(this.camera.instance.position.y.toFixed(5))
309 | cameraInfo.z = parseFloat(this.camera.instance.position.z.toFixed(5))
310 | cameraInfo.zoom = parseFloat(this.camera.instance.zoom.toFixed(3))
311 |
312 | const cardSetInfo = []
313 | this.cardSet.list.forEach((card) => {
314 | const cardInfo = {}
315 |
316 | const position = {}
317 | position.x = parseFloat(card.userData.center.x.toFixed(5))
318 | position.y = parseFloat(card.userData.center.y.toFixed(5))
319 | position.z = parseFloat(card.userData.center.z.toFixed(5))
320 |
321 | const { w, h } = card.userData;
322 | const center = card.position.clone()
323 | const info = this.getScreenPosition(center, w, h)
324 |
325 | const positionScreen = {}
326 | positionScreen.x = parseInt(info.x)
327 | positionScreen.y = parseInt(info.y)
328 |
329 | cardInfo.id = card.userData.id
330 | cardInfo.name = card.userData.name
331 | cardInfo.type = card.userData.type
332 | cardInfo.content = card.userData.content
333 | cardInfo.position = position
334 | cardInfo.positionScreen = positionScreen
335 | cardInfo.width = parseFloat(card.userData.w.toFixed(5))
336 | cardInfo.height = parseFloat(card.userData.h.toFixed(5))
337 | cardInfo.widthScreen = parseInt(info.width)
338 | cardInfo.heightScreen = parseInt(info.height)
339 | cardInfo.rotation = parseFloat((360 * card.rotation.z / (2 * Math.PI)).toFixed(1))
340 |
341 | if (card.material && card.material.uniforms && card.material.uniforms.opacity) {
342 | cardInfo.opacity = parseFloat(card.material.uniforms.opacity.value.toFixed(2))
343 | }
344 |
345 | cardSetInfo.push(cardInfo)
346 | })
347 |
348 | const config = {}
349 | config.camera = cameraInfo
350 | config.cards = cardSetInfo
351 |
352 | return config
353 | }
354 |
355 | getScreenPosition(center, w, h) {
356 | const corner = new THREE.Vector3(center.x + w / 2, center.y + h / 2, center.z);
357 | center.project(this.camera.instance);
358 | corner.project(this.camera.instance);
359 |
360 | const x = this.sizes.width * (1 + center.x) / 2;
361 | const y = this.sizes.height * (1 - center.y) / 2;
362 | const wScreen = this.sizes.width * Math.abs(corner.x - center.x);
363 | const hScreen = this.sizes.height * Math.abs(corner.y - center.y);
364 |
365 | return { x, y, width: wScreen, height: hScreen }
366 | }
367 |
368 | getScenePosition(x, y, w, h) {
369 | const mc = new THREE.Vector2()
370 | mc.x = x / this.sizes.width * 2 - 1
371 | mc.y = -(y / this.sizes.height) * 2 + 1
372 |
373 | const me = new THREE.Vector2()
374 | me.x = (x + w / 2) / this.sizes.width * 2 - 1
375 | me.y = -((y + h / 2) / this.sizes.height) * 2 + 1
376 |
377 | const raycaster = new THREE.Raycaster()
378 | raycaster.setFromCamera(mc, this.camera.instance)
379 | const intersectsC = raycaster.intersectObjects([this.whiteBoard.container])
380 | raycaster.setFromCamera(me, this.camera.instance)
381 | const intersectsE = raycaster.intersectObjects([this.whiteBoard.container])
382 | if (!intersectsC.length || !intersectsE.length) return
383 |
384 | const center = intersectsC[0].point
385 | const corner = intersectsE[0].point
386 | const wScene = 2 * Math.abs(corner.x - center.x)
387 | const hScene = 2 * Math.abs(corner.y - center.y)
388 |
389 | return { center, width: wScene, height: hScene }
390 | }
391 |
392 | setDOM() {
393 | const cardDOM = document.createElement("div");
394 |
395 | cardDOM.className = "cardDOM";
396 | cardDOM.style.backgroundColor = "rgba(0, 0, 0, 0.0)";
397 | // cardDOM.style.border = "1px solid white";
398 | cardDOM.style.display = "none";
399 | cardDOM.style.position = "absolute";
400 | document.body.appendChild(cardDOM);
401 |
402 | return cardDOM;
403 | }
404 | }
405 |
--------------------------------------------------------------------------------