snapshot of https://github.com/raimohanska/ourboard.git (master, 2026-09-23; blobs > 131072B omitted)
This commit is contained in:
3
frontend/.sassrc
Normal file
3
frontend/.sassrc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"includePaths": ["node_modules"]
|
||||
}
|
||||
106
frontend/esbuild.js
Normal file
106
frontend/esbuild.js
Normal file
@@ -0,0 +1,106 @@
|
||||
require("dotenv").config()
|
||||
|
||||
const sass = require("sass")
|
||||
const path = require("path")
|
||||
const fs = require("fs")
|
||||
const esbuild = require("esbuild")
|
||||
const rimraf = require("rimraf")
|
||||
const chokidar = require("chokidar")
|
||||
|
||||
const mode = process.argv[2]
|
||||
|
||||
if (!mode) {
|
||||
throw Error("Specify 'build' or 'watch' as argument")
|
||||
}
|
||||
const stubImportsPlugin = (paths) => {
|
||||
return {
|
||||
name: "stub-imports",
|
||||
setup(build) {
|
||||
const regex = new RegExp(`^(${paths.join("|")})\$`)
|
||||
build.onResolve({ filter: regex }, (args) => ({
|
||||
path: args.path,
|
||||
namespace: "stub-imports-namespace",
|
||||
}))
|
||||
build.onLoad({ filter: /.*/, namespace: "stub-imports-namespace" }, () => ({
|
||||
contents: "{}",
|
||||
loader: "json",
|
||||
}))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const sassPlugin = {
|
||||
name: "sass",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /(\.svg|\.png)$/ }, (args) => {
|
||||
return {
|
||||
path: path.resolve(CWD, "src", args.path),
|
||||
}
|
||||
})
|
||||
build.onResolve({ filter: /\.scss$/ }, (args) => {
|
||||
return {
|
||||
path: path.resolve(args.resolveDir, args.path),
|
||||
namespace: "sass",
|
||||
}
|
||||
})
|
||||
build.onLoad({ filter: /.*/, namespace: "sass" }, (args) => {
|
||||
let compiled = sass.renderSync({ file: args.path })
|
||||
return {
|
||||
contents: compiled.css.toString(),
|
||||
loader: "css",
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const CWD = process.cwd()
|
||||
const DIST_FOLDER = path.resolve(CWD, "dist")
|
||||
|
||||
const envFallback = (envVar, fb = null) => (envVar ? `"${envVar}"` : `${fb}`)
|
||||
|
||||
async function build() {
|
||||
if (fs.existsSync(DIST_FOLDER)) rimraf.sync(DIST_FOLDER)
|
||||
const randomString = Math.random().toString(36).slice(2)
|
||||
const outfile = path.resolve(CWD, `dist/bundle.${randomString}.js`)
|
||||
const metafile = path.resolve(CWD, `dist/bundle.${randomString}.meta.json`)
|
||||
const now = Date.now()
|
||||
await esbuild.build({
|
||||
entryPoints: [path.resolve(CWD, "src", "index.tsx")],
|
||||
bundle: true,
|
||||
minify: mode !== "watch",
|
||||
outfile,
|
||||
metafile,
|
||||
sourcemap: true,
|
||||
platform: "browser",
|
||||
plugins: [sassPlugin, stubImportsPlugin(["path"])],
|
||||
loader: { ".png": "file", ".svg": "file" },
|
||||
define: {
|
||||
"process.env.NODE_ENV": envFallback(process.env.NODE_ENV, `"development"`),
|
||||
},
|
||||
})
|
||||
|
||||
fs.writeFileSync(
|
||||
path.resolve(CWD, "dist/index.html"),
|
||||
fs
|
||||
.readFileSync(path.resolve(CWD, "index.tmpl.html"), "utf8")
|
||||
.replace("JAVASCRIPT_BUNDLE", `/bundle.${randomString}.js`)
|
||||
.replace("CSS_BUNDLE", `/bundle.${randomString}.css`),
|
||||
)
|
||||
console.log(`Frontend build done, took ${Date.now() - now} ms`)
|
||||
}
|
||||
|
||||
if (mode === "build") {
|
||||
build().catch((e) => !console.error(e) && process.exit(1))
|
||||
} else if (mode === "watch") {
|
||||
build()
|
||||
.catch((e) => console.error(e))
|
||||
.then(() => {
|
||||
chokidar
|
||||
.watch([path.resolve(CWD, "src"), path.resolve(CWD, "../common/src")], { ignoreInitial: true })
|
||||
.on("all", (...arg) => {
|
||||
build().catch((e) => console.error(e))
|
||||
})
|
||||
})
|
||||
} else {
|
||||
throw Error("Unknown mode: " + mode)
|
||||
}
|
||||
16
frontend/index.tmpl.html
Normal file
16
frontend/index.tmpl.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>OurBoard</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Raleway:wght@500&display=swap" rel="stylesheet" />
|
||||
<link href="https://cdn.jsdelivr.net/npm/quill@1.3.7/dist/quill.core.css" rel="stylesheet" />
|
||||
<script src="https://apis.google.com/js/api.js"></script>
|
||||
<script defer="defer" src="JAVASCRIPT_BUNDLE"></script>
|
||||
<link href="CSS_BUNDLE" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
66
frontend/package.json
Normal file
66
frontend/package.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "rboard-frontend",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cookie": "^0.4.0",
|
||||
"@types/email-validator": "^1.0.6",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/lodash": "^4.14.161",
|
||||
"@types/md5": "^2.2.1",
|
||||
"@types/path-to-regexp": "^1.7.0",
|
||||
"@types/pretty-ms": "^5.0.1",
|
||||
"@types/quill": "^2.0.14",
|
||||
"@types/ramda": "^0.27.40",
|
||||
"@types/sanitize-html": "^1.27.1",
|
||||
"bezier-js": "^4.0.3",
|
||||
"binpackingjs": "^3.0.2",
|
||||
"cookie": "^0.4.1",
|
||||
"email-validator": "^2.0.4",
|
||||
"esbuild": "^0.8.57",
|
||||
"fp-ts": "^2.9.5",
|
||||
"harmaja": "^0.24",
|
||||
"harmaja-router": "^0.3.3",
|
||||
"io-ts": "^2.2.15",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"localforage": "^1.9.0",
|
||||
"lodash": "^4.17.20",
|
||||
"lonna": "^0.12.2",
|
||||
"md5": "^2.3.0",
|
||||
"path-to-regexp": "^6.2.0",
|
||||
"pretty-ms": "^7.0.1",
|
||||
"quill": "^1.3.7",
|
||||
"quill-cursors": "^4.0.2",
|
||||
"ramda": "^0.27.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"sanitize-html": "^2.3.2",
|
||||
"sass": "^1.32.8",
|
||||
"uuid": "^8.3.0",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"y-quill": "^0.1.5",
|
||||
"y-websocket": "^1.5.3",
|
||||
"yjs": "^13.6.12"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node esbuild.js build",
|
||||
"watch": "node esbuild.js watch",
|
||||
"tsc:watch": "tsc --watch --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsonwebtoken": "^8.5.0",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"chokidar": "^3.5.1",
|
||||
"core-js": "^3.8.3",
|
||||
"cssnano": "^4.1.10",
|
||||
"dotenv": "^8.2.0",
|
||||
"nodemon": "^2.0.4",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"typescript": "^5.3"
|
||||
},
|
||||
"browserslist": [
|
||||
"since 2017-06"
|
||||
]
|
||||
}
|
||||
BIN
frontend/public/img/reaktor-logo.jpg
Normal file
BIN
frontend/public/img/reaktor-logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
10
frontend/src/app.scss
Normal file
10
frontend/src/app.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
@import "style/variables.scss";
|
||||
@import "style/global.scss";
|
||||
@import "style/dashboard.scss";
|
||||
@import "style/utils.scss";
|
||||
@import "style/board.scss";
|
||||
@import "style/tool-layer.scss";
|
||||
@import "style/header.scss";
|
||||
@import "style/modal.scss";
|
||||
@import "style/sharing-modal.scss";
|
||||
@import "style/user-info-modal.scss";
|
||||
46
frontend/src/board-navigation.ts
Normal file
46
frontend/src/board-navigation.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { HarmajaRouter, Navigator } from "harmaja-router"
|
||||
import * as L from "lonna"
|
||||
import { Board, BoardStub, EventFromServer } from "../../common/src/domain"
|
||||
import "./app.scss"
|
||||
import { Dispatch } from "./store/server-connection"
|
||||
|
||||
export const BOARD_PATH = "/b/:boardId"
|
||||
export const ROOT_PATH = "/"
|
||||
|
||||
export const Routes = {
|
||||
[ROOT_PATH]: () => ({ page: "Dashboard" as const }),
|
||||
[BOARD_PATH]: ({ boardId }: { boardId: string }) => ({ page: "Board" as const, boardId }),
|
||||
"": () => ({ page: "NotFound" as const }),
|
||||
}
|
||||
export type Routes = typeof Routes
|
||||
|
||||
export function BoardNavigation() {
|
||||
const result = HarmajaRouter(Routes)
|
||||
|
||||
const nicknameFromURL = new URLSearchParams(location.search).get("nickname")
|
||||
if (nicknameFromURL) {
|
||||
localStorage.nickname = nicknameFromURL
|
||||
const search = new URLSearchParams(location.search)
|
||||
search.delete("nickname")
|
||||
document.location.search = search.toString()
|
||||
}
|
||||
|
||||
const boardId = L.view(result, (r) => (r.page === "Board" ? r.boardId : undefined))
|
||||
|
||||
return {
|
||||
boardId,
|
||||
page: result,
|
||||
}
|
||||
}
|
||||
|
||||
export function createBoardAndNavigate(
|
||||
newBoard: Board | BoardStub,
|
||||
dispatch: Dispatch,
|
||||
navigator: Navigator<Routes>,
|
||||
serverEvents: L.EventStream<EventFromServer>,
|
||||
) {
|
||||
dispatch({ action: "board.add", payload: newBoard })
|
||||
serverEvents.forEach(
|
||||
(e) => e.action === "board.add.ack" && navigator.navigateByParams(BOARD_PATH, { boardId: newBoard.id }),
|
||||
)
|
||||
}
|
||||
434
frontend/src/board/BoardView.tsx
Normal file
434
frontend/src/board/BoardView.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
import * as H from "harmaja"
|
||||
import { componentScope, h, ListView } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import {
|
||||
Board,
|
||||
canWrite,
|
||||
findConnection,
|
||||
findItem,
|
||||
getConnection,
|
||||
Id,
|
||||
Image,
|
||||
Item,
|
||||
newNote,
|
||||
Note,
|
||||
Video,
|
||||
} from "../../../common/src/domain"
|
||||
import { isFirefox } from "../components/browser"
|
||||
import { ModalContainer } from "../components/ModalContainer"
|
||||
import { onClickOutside } from "../components/onClickOutside"
|
||||
import { isEmbedded } from "../embedding"
|
||||
import { AssetStore } from "../store/asset-store"
|
||||
import { BoardState, BoardStore, Dispatch } from "../store/board-store"
|
||||
import { CursorsStore } from "../store/cursors-store"
|
||||
import { UserSessionState } from "../store/user-session-store"
|
||||
import { boardCoordinateHelper } from "./board-coordinates"
|
||||
import { boardDragHandler } from "./board-drag"
|
||||
import {
|
||||
BoardFocus,
|
||||
getSelectedItemIds,
|
||||
getSelectedItem,
|
||||
getSelectedItems,
|
||||
noFocus,
|
||||
getSelectedConnectionIds,
|
||||
} from "./board-focus"
|
||||
import { boardScrollAndZoomHandler } from "./board-scroll-and-zoom"
|
||||
import { BoardToolLayer } from "./toolbars/BoardToolLayer"
|
||||
import { ConnectionsView } from "./ConnectionsView"
|
||||
import { ContextMenuView } from "./contextmenu/ContextMenuView"
|
||||
import { CursorsView } from "./CursorsView"
|
||||
import * as G from "../../../common/src/geometry"
|
||||
import { imageUploadHandler, imageDropHandler } from "./image-upload"
|
||||
import { ImageView } from "./ImageView"
|
||||
import { itemCreateHandler } from "./item-create"
|
||||
import { cutCopyPasteHandler } from "./item-cut-copy-paste"
|
||||
import { itemDeleteHandler } from "./item-delete"
|
||||
import { itemDuplicateHandler } from "./item-duplicate"
|
||||
import { itemMoveWithArrowKeysHandler } from "./item-move-with-arrow-keys"
|
||||
import { itemSelectAllHandler } from "./item-select-all"
|
||||
import { withCurrentContainer } from "./item-setcontainer"
|
||||
import { itemUndoHandler } from "./item-undo-redo"
|
||||
import { ItemView } from "./ItemView"
|
||||
import { installKeyboardShortcut, plainKey } from "./keyboard-shortcuts"
|
||||
import { RectangularDragSelection } from "./RectangularDragSelection"
|
||||
import { SelectionBorder } from "./SelectionBorder"
|
||||
import { synchronizeFocusWithServer } from "./synchronize-focus-with-server"
|
||||
import { ToolController } from "./tool-selection"
|
||||
import { BoardViewHeader } from "./header/BoardViewHeader"
|
||||
import { VideoView } from "./VideoView"
|
||||
import { startConnecting } from "./item-connect"
|
||||
import { emptySet } from "../../../common/src/sets"
|
||||
import { installZoomKeyboardShortcuts } from "./zoom-shortcuts"
|
||||
import { itemHideContentsHandler } from "./item-hide-contents"
|
||||
|
||||
const emptyNote = newNote("")
|
||||
|
||||
export const BoardView = ({
|
||||
boardId,
|
||||
cursors,
|
||||
boardStore,
|
||||
sessionState,
|
||||
assets,
|
||||
dispatch,
|
||||
}: {
|
||||
boardId: string
|
||||
cursors: CursorsStore
|
||||
boardStore: BoardStore
|
||||
sessionState: L.Property<UserSessionState>
|
||||
assets: AssetStore
|
||||
dispatch: Dispatch
|
||||
}) => {
|
||||
const boardState = boardStore.state
|
||||
const board = boardState.pipe(
|
||||
L.map((s: BoardState) => s.board!),
|
||||
L.filter((b: Board) => !!b, componentScope()),
|
||||
)
|
||||
const accessLevel = L.view(boardState, "accessLevel")
|
||||
const locks = L.view(boardState, (s) => s.locks)
|
||||
const sessionId = L.view(sessionState, (s) => s.sessionId)
|
||||
const sessions = L.view(boardState, (s) => s.users)
|
||||
const zoom = L.atom({ zoom: 1, quickZoom: 1 })
|
||||
|
||||
const containerElement = L.atom<HTMLElement | null>(null)
|
||||
const scrollElement = L.atom<HTMLElement | null>(null)
|
||||
const boardElement = L.atom<HTMLElement | null>(null)
|
||||
const latestNoteId = L.atom<Id | null>(null)
|
||||
const latestNote = L.view(latestNoteId, board, (id, b) => {
|
||||
const note = id ? findItem(b)(id) : null
|
||||
return (note as Note) || emptyNote
|
||||
})
|
||||
const latestConnectionId = L.atom<Id | null>(null)
|
||||
const latestConnection = L.view(latestConnectionId, board, (id, b) => {
|
||||
return id ? findConnection(b)(id) : null
|
||||
})
|
||||
const focus = synchronizeFocusWithServer(board, locks, sessionId, dispatch)
|
||||
const coordinateHelper = boardCoordinateHelper(containerElement, scrollElement, boardElement, zoom)
|
||||
const toolController = ToolController()
|
||||
const tool = toolController.tool
|
||||
|
||||
let previousFocus: BoardFocus | null = null
|
||||
focus.forEach((f) => {
|
||||
const previousIDs = previousFocus && getSelectedItemIds(previousFocus)
|
||||
const itemIds = [...getSelectedItemIds(f)].filter((id) => !previousIDs || !previousIDs.has(id))
|
||||
previousFocus = f
|
||||
if (itemIds.length > 0) {
|
||||
dispatch({ action: "item.front", boardId: board.get().id, itemIds })
|
||||
const item = getSelectedItem(board.get())(f)
|
||||
if (item && item.type === "note") {
|
||||
latestNoteId.set(item.id)
|
||||
}
|
||||
}
|
||||
const connectionId = [...getSelectedConnectionIds(f)][0]
|
||||
if (connectionId && getConnection(board.get())(connectionId)?.action === "connect") {
|
||||
latestConnectionId.set(connectionId)
|
||||
}
|
||||
})
|
||||
|
||||
tool.pipe(L.changes).forEach((tool) => {
|
||||
if (tool !== "note" && tool !== "container" && tool !== "text" && focus.get().status === "adding") {
|
||||
focus.set(noFocus)
|
||||
}
|
||||
})
|
||||
|
||||
const doOnUnmount: Function[] = []
|
||||
|
||||
const itemsList = L.view(L.view(board, "items"), Object.values)
|
||||
|
||||
function onURL(assetId: string, url: string) {
|
||||
itemsList.get().forEach((i) => {
|
||||
if ((i.type === "image" || i.type === "video") && i.assetId === assetId && i.src != url) {
|
||||
dispatch({ action: "item.update", boardId, items: [{ id: i.id, src: url }] })
|
||||
}
|
||||
})
|
||||
}
|
||||
const uploadImageFile = imageUploadHandler(assets, coordinateHelper, onAdd, onURL)
|
||||
|
||||
doOnUnmount.push(
|
||||
cutCopyPasteHandler(board, boardStore.crdtStore, focus, coordinateHelper, dispatch, uploadImageFile),
|
||||
)
|
||||
|
||||
const zoomControls = boardScrollAndZoomHandler(
|
||||
board,
|
||||
boardElement,
|
||||
scrollElement,
|
||||
zoom,
|
||||
coordinateHelper,
|
||||
toolController,
|
||||
)
|
||||
const { viewRect } = zoomControls
|
||||
|
||||
boardStore.eventsFromServer.forEach((e) => {
|
||||
if (e.action === "user.bringAllToMe") {
|
||||
console.log(`Following user ${e.nickname}`, e)
|
||||
viewRect.set(e.viewRect)
|
||||
}
|
||||
})
|
||||
|
||||
imageDropHandler(boardElement, assets, focus, uploadImageFile)
|
||||
itemCreateHandler(board, focus, latestNote, boardElement, onAdd)
|
||||
itemDeleteHandler(boardId, dispatch, focus)
|
||||
itemDuplicateHandler(board, boardStore.crdtStore, dispatch, focus)
|
||||
itemHideContentsHandler(board, focus, dispatch)
|
||||
itemMoveWithArrowKeysHandler(board, dispatch, focus)
|
||||
itemUndoHandler(dispatch)
|
||||
itemSelectAllHandler(board, focus)
|
||||
installZoomKeyboardShortcuts(zoomControls)
|
||||
installKeyboardShortcut(
|
||||
(e) => e.key === "Escape",
|
||||
() => {
|
||||
toolController.useDefaultTool()
|
||||
focus.set(noFocus)
|
||||
},
|
||||
)
|
||||
installKeyboardShortcut(plainKey("c"), () => toolController.tool.set("connect"))
|
||||
installKeyboardShortcut(plainKey("l"), () => toolController.tool.set("line"))
|
||||
L.fromEvent<JSX.KeyboardEvent>(window, "click")
|
||||
.pipe(L.applyScope(componentScope()))
|
||||
.forEach((event) => {
|
||||
if (!boardElement.get()!.contains(event.target as Node)) {
|
||||
// Click outside => reset selection
|
||||
focus.set(noFocus)
|
||||
}
|
||||
})
|
||||
|
||||
onClickOutside(boardElement, () => {
|
||||
focus.set(noFocus)
|
||||
})
|
||||
|
||||
function onClick(e: JSX.UIEvent) {
|
||||
const f = focus.get()
|
||||
if (f.status === "connection-adding") {
|
||||
toolController.useDefaultTool()
|
||||
} else if (f.status === "adding") {
|
||||
onAdd(f.item)
|
||||
} else {
|
||||
if (e.target === boardElement.get()) {
|
||||
if (tool.get() === "connect") {
|
||||
startConnecting(
|
||||
board,
|
||||
coordinateHelper,
|
||||
latestConnection,
|
||||
dispatch,
|
||||
toolController,
|
||||
focus,
|
||||
coordinateHelper.currentBoardCoordinates.get(),
|
||||
)
|
||||
} else {
|
||||
focus.set(noFocus)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onAdd(item: Item) {
|
||||
toolController.useDefaultTool()
|
||||
const point = coordinateHelper.currentBoardCoordinates.get()
|
||||
const { x, y } = item.type !== "container" ? G.add(point, { x: -item.width / 2, y: -item.height / 2 }) : point
|
||||
item = withCurrentContainer({ ...item, x, y }, board.get())
|
||||
|
||||
dispatch({ action: "item.add", boardId, items: [item], connections: [] })
|
||||
|
||||
if (item.type === "note" || item.type === "text") {
|
||||
focus.set({ status: "editing", itemId: item.id })
|
||||
} else {
|
||||
focus.set({ status: "selected", itemIds: new Set([item.id]), connectionIds: emptySet() })
|
||||
}
|
||||
}
|
||||
|
||||
coordinateHelper.currentBoardCoordinates.pipe(L.throttle(30)).forEach((position) => {
|
||||
dispatch({ action: "cursor.move", position, boardId })
|
||||
})
|
||||
|
||||
const { selectionRect } = boardDragHandler({
|
||||
...{
|
||||
board,
|
||||
boardElem: boardElement,
|
||||
coordinateHelper,
|
||||
latestConnection,
|
||||
focus,
|
||||
toolController,
|
||||
dispatch,
|
||||
},
|
||||
})
|
||||
|
||||
H.onUnmount(() => {
|
||||
doOnUnmount.forEach((fn) => fn())
|
||||
})
|
||||
|
||||
const boardAccessStatus = L.view(boardState, (s) => s.status)
|
||||
const quickZoom = L.view(zoom, "quickZoom")
|
||||
const mainZoom = L.view(zoom, "zoom")
|
||||
const borderContainerStyle = L.combineTemplate({
|
||||
width: L.view(board, quickZoom, (b) => b.width + "em"),
|
||||
height: L.view(board, quickZoom, (b) => b.height + "em"),
|
||||
fontSize: L.view(mainZoom, (z) => z + "em"),
|
||||
transform: L.view(quickZoom, (z) => {
|
||||
const percentTranslate = ((z - 1) / 2) * 100
|
||||
return `translate(${percentTranslate}%, ${percentTranslate}%) scale(${z})`
|
||||
}),
|
||||
"will-change": "transform, fontSize",
|
||||
})
|
||||
|
||||
const className = L.view(
|
||||
boardAccessStatus,
|
||||
(status) => `board-container ${isEmbedded() ? "embedded" : ""} ${status}`,
|
||||
)
|
||||
|
||||
const items = L.view(L.view(board, "items"), Object.values, (items) => items.filter((i) => !i.hidden))
|
||||
const selectedItems = L.view(board, focus, (b, f) => getSelectedItems(b)(f))
|
||||
const modalContent = L.atom<any>(null)
|
||||
|
||||
L.interval(100, null, componentScope()).forEach(() => {
|
||||
if (window.scrollY !== 0 || window.scrollX !== 0) {
|
||||
if (focus.get().status !== "editing") {
|
||||
// Reset scroll position when not editing. At least iOS seems to need this. It's vital that our scroll pos stays at origin.
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
return (
|
||||
<div id="root" className={className}>
|
||||
<ModalContainer content={modalContent} />
|
||||
<BoardViewHeader
|
||||
{...{
|
||||
board,
|
||||
usersOnBoard: L.view(boardState, "users"),
|
||||
sessionState,
|
||||
dispatch,
|
||||
accessLevel,
|
||||
modalContent,
|
||||
eventsFromServer: boardStore.eventsFromServer,
|
||||
viewRect,
|
||||
online: L.view(boardStore.state, (s) => s.status === "online"),
|
||||
crdtStore: boardStore.crdtStore,
|
||||
}}
|
||||
/>
|
||||
<div className="content-container" ref={containerElement.set}>
|
||||
<div className="scroll-container" ref={scrollElement.set}>
|
||||
<div className="border-container" style={borderContainerStyle}>
|
||||
<div
|
||||
className={L.view(tool, (t) => "board " + t)}
|
||||
draggable={isFirefox ? L.view(focus, (f) => f.status !== "editing") : true}
|
||||
ref={boardElement.set}
|
||||
onClick={onClick}
|
||||
onTouchEnd={onClick}
|
||||
>
|
||||
<ListView observable={items} renderObservable={renderItem} getKey={(i) => i.id} />
|
||||
|
||||
{L.view(tool, (t) =>
|
||||
t === "connect" ? null : (
|
||||
<ListView
|
||||
observable={selectedItems}
|
||||
renderObservable={renderSelectionBorder}
|
||||
getKey={(i) => i.id}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<RectangularDragSelection {...{ rect: selectionRect }} />
|
||||
<CursorsView {...{ cursors, sessions, viewRect }} />
|
||||
<ContextMenuView
|
||||
{...{
|
||||
latestNote,
|
||||
dispatch,
|
||||
board,
|
||||
focus,
|
||||
viewRect,
|
||||
}}
|
||||
/>
|
||||
<ConnectionsView {...{ board, zoom, dispatch, focus, coordinateHelper }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BoardToolLayer
|
||||
{...{
|
||||
board,
|
||||
accessLevel,
|
||||
boardStore,
|
||||
containerElement,
|
||||
coordinateHelper,
|
||||
dispatch,
|
||||
focus,
|
||||
latestNote,
|
||||
onAdd,
|
||||
toolController,
|
||||
sessionState,
|
||||
...zoomControls,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
function renderSelectionBorder(id: string, item: L.Property<Item>) {
|
||||
return <SelectionBorder {...{ id, tool, item: item, coordinateHelper, board, focus, dispatch }} />
|
||||
}
|
||||
|
||||
function renderItem(id: string, item: L.Property<Item>) {
|
||||
const isLocked = L.combineTemplate({ locks, sessionId }).pipe(
|
||||
L.map(({ locks, sessionId }) => !!locks[id] && locks[id] !== sessionId),
|
||||
)
|
||||
|
||||
return L.view(L.view(item, "type"), (t) => {
|
||||
switch (t) {
|
||||
case "container":
|
||||
case "text":
|
||||
case "note":
|
||||
return (
|
||||
<ItemView
|
||||
{...{
|
||||
board,
|
||||
history,
|
||||
id,
|
||||
type: t,
|
||||
item: item as L.Property<Note>,
|
||||
isLocked,
|
||||
focus,
|
||||
coordinateHelper,
|
||||
latestConnection,
|
||||
dispatch,
|
||||
toolController,
|
||||
accessLevel,
|
||||
boardStore,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
case "image":
|
||||
return (
|
||||
<ImageView
|
||||
{...{
|
||||
id,
|
||||
image: item as L.Property<Image>,
|
||||
assets,
|
||||
board,
|
||||
isLocked,
|
||||
focus,
|
||||
toolController,
|
||||
coordinateHelper,
|
||||
latestConnection,
|
||||
dispatch,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
case "video":
|
||||
return (
|
||||
<VideoView
|
||||
{...{
|
||||
id,
|
||||
video: item as L.Property<Video>,
|
||||
assets,
|
||||
board,
|
||||
isLocked,
|
||||
focus,
|
||||
toolController,
|
||||
coordinateHelper,
|
||||
latestConnection,
|
||||
dispatch,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
throw Error("Unsupported item: " + t)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
60
frontend/src/board/BoardViewMessage.tsx
Normal file
60
frontend/src/board/BoardViewMessage.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { Board } from "../../../common/src/domain"
|
||||
import { signIn } from "../google-auth"
|
||||
import { BoardAccessStatus } from "../store/board-store"
|
||||
import { UserSessionState } from "../store/user-session-store"
|
||||
|
||||
export const BoardViewMessage = ({
|
||||
boardAccessStatus,
|
||||
sessionState,
|
||||
board,
|
||||
}: {
|
||||
boardAccessStatus: L.Property<BoardAccessStatus>
|
||||
sessionState: L.Property<UserSessionState>
|
||||
board: L.Property<Board>
|
||||
}) => {
|
||||
// TODO: login may be disabled due to Incognito mode or other reasons
|
||||
return L.combine(boardAccessStatus, L.view(sessionState, "status"), (s: BoardAccessStatus, sessionStatus) => {
|
||||
if (s === "not-found") {
|
||||
return (
|
||||
<div className="board-status-message">
|
||||
<div>
|
||||
<p>Board not found. A typo, maybe?</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (s === "denied-permanently") {
|
||||
return (
|
||||
<div className="board-status-message">
|
||||
<div>
|
||||
<p>
|
||||
Sorry, access denied. Click <a onClick={signIn}>here</a> to sign in with another account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (s === "login-required") {
|
||||
if (sessionStatus === "login-failed") {
|
||||
return (
|
||||
<div className="board-status-message">
|
||||
<div>
|
||||
Something went wrong with logging in. Click <a onClick={signIn}>here</a> to try again.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="board-status-message">
|
||||
<div>
|
||||
This board is for authorized users only. Click <a onClick={signIn}>here</a> to sign in.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})
|
||||
}
|
||||
166
frontend/src/board/CollaborativeTextView.tsx
Normal file
166
frontend/src/board/CollaborativeTextView.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { componentScope, h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import Quill from "quill"
|
||||
import QuillCursors from "quill-cursors"
|
||||
import { QuillBinding } from "y-quill"
|
||||
import {
|
||||
AccessLevel,
|
||||
Board,
|
||||
LocalUIEvent,
|
||||
TextItem,
|
||||
canWrite,
|
||||
getAlign,
|
||||
getHorizontalAlign,
|
||||
getItemBackground,
|
||||
} from "../../../common/src/domain"
|
||||
import { CRDTStore } from "../store/crdt-store"
|
||||
import { getAlignItems } from "./ItemView"
|
||||
import { BoardFocus } from "./board-focus"
|
||||
import { contrastingColor } from "./contrasting-color"
|
||||
import { preventDoubleClick } from "./double-click"
|
||||
import PasteLinkOverText from "./quillPasteLinkOverText"
|
||||
import ClickableLink from "./quillClickableLink"
|
||||
|
||||
Quill.register("modules/cursors", QuillCursors)
|
||||
Quill.register("modules/pasteLinkOverText", PasteLinkOverText)
|
||||
Quill.register(ClickableLink)
|
||||
|
||||
interface CollaborativeTextViewProps {
|
||||
item: L.Property<TextItem>
|
||||
board: L.Property<Board>
|
||||
id: string
|
||||
accessLevel: L.Property<AccessLevel>
|
||||
focus: L.Atom<BoardFocus>
|
||||
itemFocus: L.Property<"none" | "selected" | "dragging" | "editing">
|
||||
crdtStore: CRDTStore
|
||||
isLocked: L.Property<boolean>
|
||||
uiEvents: L.EventStream<LocalUIEvent>
|
||||
}
|
||||
export function CollaborativeTextView({
|
||||
id,
|
||||
item,
|
||||
board,
|
||||
accessLevel,
|
||||
focus,
|
||||
itemFocus,
|
||||
isLocked,
|
||||
crdtStore,
|
||||
uiEvents,
|
||||
}: CollaborativeTextViewProps) {
|
||||
const fontSize = L.view(item, (i) => `${i.fontSize ? i.fontSize : 1}em`)
|
||||
const color = L.view(item, getItemBackground, contrastingColor)
|
||||
|
||||
const quillEditor = L.atom<Quill | null>(null)
|
||||
|
||||
accessLevel.applyScope(componentScope()).forEach((al) => {
|
||||
quillEditor.get()?.enable(canWrite(al))
|
||||
})
|
||||
|
||||
function initQuill(el: HTMLElement) {
|
||||
const quill = new Quill(el, {
|
||||
modules: {
|
||||
cursors: true,
|
||||
toolbar: false,
|
||||
pasteLinkOverText: true,
|
||||
history: {
|
||||
userOnly: true, // Local undo shouldn't undo changes from remote users
|
||||
},
|
||||
},
|
||||
theme: "snow",
|
||||
readOnly: !canWrite(accessLevel.get()),
|
||||
})
|
||||
const crdt = crdtStore.getBoardCrdt(board.get().id)
|
||||
const ytext = crdt.getField(id, "text")
|
||||
new QuillBinding(ytext, quill, crdt.awareness)
|
||||
quillEditor.set(quill)
|
||||
}
|
||||
|
||||
const editingThis = L.view(itemFocus, (f) => f === "editing")
|
||||
|
||||
editingThis.forEach((e) => {
|
||||
const q = quillEditor.get()
|
||||
if (q) {
|
||||
if (e) {
|
||||
const multipleLines =
|
||||
q
|
||||
.getText()
|
||||
.split("\n")
|
||||
.filter((x) => x).length > 1
|
||||
if (!multipleLines) {
|
||||
// For one-liners, select the whole text on double click
|
||||
q.setSelection(0, 1000000)
|
||||
}
|
||||
} else {
|
||||
// Clear text selecting when not editing
|
||||
q.setSelection(null as any)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
if (itemFocus.get() === "selected") {
|
||||
focus.set({ status: "editing", itemId: id })
|
||||
}
|
||||
}
|
||||
|
||||
const pointerEvents = L.view(itemFocus, isLocked, (f, l) =>
|
||||
f === "editing" || f === "selected" || l ? "auto" : "none",
|
||||
)
|
||||
const hAlign = L.view(item, getAlign, getHorizontalAlign).applyScope(componentScope())
|
||||
hAlign.onChange((align) => {
|
||||
quillEditor.get()?.formatText(0, 10000000, "align", align === "left" ? "" : align)
|
||||
})
|
||||
|
||||
uiEvents.applyScope(componentScope()).forEach((e) => {
|
||||
if (e.action === "ui.text.format" && e.itemIds.includes(id)) {
|
||||
const quill = quillEditor.get()
|
||||
const selection = quill?.getSelection()
|
||||
const format = selection && quill?.getFormat(selection)
|
||||
const newValue = !(format && format[e.format])
|
||||
quill?.format(e.format, newValue)
|
||||
}
|
||||
})
|
||||
|
||||
let touchMoves = 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className="quill-wrapper text"
|
||||
onKeyUp={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === "Escape") {
|
||||
focus.set({ status: "selected", itemIds: new Set([id]), connectionIds: new Set() })
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onKeyPress={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
quillEditor.get()?.focus()
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
preventDoubleClick(e)
|
||||
touchMoves = 0
|
||||
}}
|
||||
onTouchMove={() => touchMoves++}
|
||||
onTouchEnd={() => {
|
||||
if (touchMoves === 0) {
|
||||
// This is a way to detect a tap (vs swipe)
|
||||
quillEditor.get()?.focus()
|
||||
}
|
||||
}}
|
||||
onClick={handleClick}
|
||||
style={L.combineTemplate({ alignItems: L.view(item, getAlignItems) })}
|
||||
>
|
||||
<div
|
||||
className="quill-editor"
|
||||
style={L.combineTemplate({ fontSize, color, pointerEvents })}
|
||||
ref={initQuill}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
256
frontend/src/board/ConnectionsView.tsx
Normal file
256
frontend/src/board/ConnectionsView.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { Fragment, h, ListView } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { findAttachmentLocation, resolveItemEndpoint } from "../../../common/src/connection-utils"
|
||||
import {
|
||||
AttachmentLocation,
|
||||
Board,
|
||||
ConnectionEndPoint,
|
||||
ConnectionEndStyle,
|
||||
isDirectedItemEndPoint,
|
||||
Item,
|
||||
Point,
|
||||
} from "../../../common/src/domain"
|
||||
import { Dispatch } from "../store/board-store"
|
||||
import { BoardCoordinateHelper } from "./board-coordinates"
|
||||
import { BoardFocus, getSelectedConnectionIds } from "./board-focus"
|
||||
import { existingConnectionHandler } from "./item-connect"
|
||||
import { Z_CONNECTIONS } from "./zIndices"
|
||||
|
||||
export const ConnectionsView = ({
|
||||
board,
|
||||
dispatch,
|
||||
zoom,
|
||||
coordinateHelper,
|
||||
focus,
|
||||
}: {
|
||||
board: L.Property<Board>
|
||||
dispatch: Dispatch
|
||||
zoom: L.Property<BoardZoom>
|
||||
coordinateHelper: BoardCoordinateHelper
|
||||
focus: L.Atom<BoardFocus>
|
||||
}) => {
|
||||
// Item position might change but connection doesn't -- need to rerender connections anyway
|
||||
// Connection objects normally only hold the ID to the "from" and "to" items
|
||||
// This populates the actual object in place of the ID
|
||||
|
||||
function determineAttachmenLocation(
|
||||
e: ConnectionEndPoint,
|
||||
control: Point,
|
||||
is: Record<string, Item>,
|
||||
): AttachmentLocation {
|
||||
if (isDirectedItemEndPoint(e)) {
|
||||
return findAttachmentLocation(resolveItemEndpoint(e, is), e.side)
|
||||
}
|
||||
const fromItem: Point = resolveEndpoint(e, is)
|
||||
// Support legacy routing (side not fixed)
|
||||
return findNearestAttachmentLocationForConnectionNode(fromItem, control)
|
||||
}
|
||||
const connectionsWithItemsPopulated = L.view(
|
||||
L.view(board, (b) => ({ is: b.items, cs: b.connections })),
|
||||
focus,
|
||||
L.view(zoom, "zoom"),
|
||||
({ is, cs }, f, z) => {
|
||||
return cs
|
||||
.filter((c) => !c.hidden)
|
||||
.map((c) => {
|
||||
const fromItem: Point = resolveEndpoint(c.from, is)
|
||||
const toItemOrPoint = resolveEndpoint(c.to, is)
|
||||
const firstControlPoint = c.controlPoints[0] || fromItem
|
||||
const lastControlPoint = c.controlPoints[c.controlPoints.length - 1] || toItemOrPoint
|
||||
return {
|
||||
...c,
|
||||
from: determineAttachmenLocation(c.from, firstControlPoint, is),
|
||||
to: determineAttachmenLocation(c.to, lastControlPoint, is),
|
||||
selected: getSelectedConnectionIds(f).has(c.id),
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
// We want to render round draggable nodes at the end of edges (paths),
|
||||
// But SVG elements are not draggable by default, so get a flat list of
|
||||
// nodes and render them as regular HTML elements
|
||||
const connectionNodes = L.view(connectionsWithItemsPopulated, (cs) =>
|
||||
cs.flatMap((c) => [
|
||||
{ id: c.id, type: "from" as const, node: c.from, selected: c.selected, style: c.fromStyle },
|
||||
{ id: c.id, type: "to" as const, node: c.to, selected: c.selected, style: c.toStyle },
|
||||
...c.controlPoints.map((cp) => ({
|
||||
id: c.id,
|
||||
type: "control" as const,
|
||||
node: { point: cp, side: "none" as const },
|
||||
selected: c.selected,
|
||||
style: c.pointStyle,
|
||||
})),
|
||||
]),
|
||||
)
|
||||
|
||||
const svgElementStyle = L.combineTemplate({
|
||||
width: L.view(board, (b) => b.width + "em"),
|
||||
height: L.view(board, (b) => b.height + "em"),
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
pointerEvents: "none",
|
||||
zIndex: Z_CONNECTIONS,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListView<ConnectionNodeProps, string>
|
||||
observable={connectionNodes}
|
||||
renderObservable={ConnectionNode}
|
||||
getKey={(c) => c.id + c.type}
|
||||
/>
|
||||
<svg className="connections" style={svgElementStyle}>
|
||||
<ListView
|
||||
observable={connectionsWithItemsPopulated}
|
||||
renderObservable={(key, conn) => {
|
||||
const curve = L.combine(
|
||||
L.view(conn, "from"),
|
||||
L.view(conn, "to"),
|
||||
L.view(conn, "controlPoints"),
|
||||
(from, to, cps) => {
|
||||
return quadraticCurveSVGPath(
|
||||
{
|
||||
x: coordinateHelper.emToBoardPx(from.point.x),
|
||||
y: coordinateHelper.emToBoardPx(from.point.y),
|
||||
},
|
||||
{
|
||||
x: coordinateHelper.emToBoardPx(to.point.x),
|
||||
y: coordinateHelper.emToBoardPx(to.point.y),
|
||||
},
|
||||
cps.map((cp) => ({
|
||||
x: coordinateHelper.emToBoardPx(cp.x),
|
||||
y: coordinateHelper.emToBoardPx(cp.y),
|
||||
})),
|
||||
)
|
||||
},
|
||||
)
|
||||
return (
|
||||
<g>
|
||||
<path
|
||||
className={L.view(conn, (c) => (c.selected ? "connection selected" : "connection"))}
|
||||
d={curve}
|
||||
></path>
|
||||
</g>
|
||||
)
|
||||
}}
|
||||
getKey={(c) => c.id}
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
)
|
||||
|
||||
type ConnectionNodeProps = {
|
||||
id: string
|
||||
node: AttachmentLocation
|
||||
type: "to" | "from" | "control"
|
||||
style: ConnectionEndStyle
|
||||
selected: boolean
|
||||
}
|
||||
function ConnectionNode(key: string, cNode: L.Property<ConnectionNodeProps>) {
|
||||
function onRef(el: HTMLDivElement) {
|
||||
const { id, type } = cNode.get()
|
||||
existingConnectionHandler(el, id, type, coordinateHelper, board, dispatch)
|
||||
}
|
||||
|
||||
const id = L.view(cNode, (cn) => `connection-${cn.id}-${cn.type}`)
|
||||
|
||||
const angle = L.view(cNode, (cn) => {
|
||||
if (cn.style !== "arrow" || cn.type === "control") return null
|
||||
const conn = connectionsWithItemsPopulated.get().find((c) => c.id === cn.id)
|
||||
if (!conn) {
|
||||
return null
|
||||
}
|
||||
const [thisEnd, otherEnd] = cn.type === "from" ? [conn.from, conn.to] : [conn.to, conn.from]
|
||||
const bez = bezierCurveFromPoints(
|
||||
otherEnd.point,
|
||||
getControlPoint(otherEnd.point, thisEnd.point, conn.controlPoints),
|
||||
thisEnd.point,
|
||||
)
|
||||
const derivative = bez.derivative(1) // tangent vector at the very end of the curve
|
||||
const angleInDegrees =
|
||||
((Math.atan2(derivative.y, derivative.x) - Math.atan2(0, Math.abs(derivative.x))) * 180) / Math.PI
|
||||
return Math.round(angleInDegrees)
|
||||
})
|
||||
|
||||
const wrapperStyle = L.view(cNode, (cn) => ({
|
||||
top: `${cn.node.point.y}em`,
|
||||
left: `${cn.node.point.x}em`,
|
||||
zIndex: Z_CONNECTIONS + 1,
|
||||
}))
|
||||
|
||||
const nodeStyle = L.view(angle, (ang) => ({
|
||||
transform: ang !== null ? `rotate(${ang}deg)` : undefined,
|
||||
}))
|
||||
|
||||
const selectThisConnection = (e: JSX.MouseEvent) => {
|
||||
const id = cNode.get().id
|
||||
const f = focus.get()
|
||||
if (e.shiftKey && f.status === "selected") {
|
||||
focus.set({ ...f, connectionIds: toggleInSet(id, f.connectionIds) })
|
||||
} else {
|
||||
focus.set({ status: "selected", connectionIds: new Set([id]), itemIds: emptySet() })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={onRef}
|
||||
draggable={true}
|
||||
onClick={selectThisConnection}
|
||||
onDragStart={selectThisConnection}
|
||||
style={wrapperStyle}
|
||||
className="connection-node-grabber-helper"
|
||||
>
|
||||
<div
|
||||
id={id}
|
||||
className={L.view(cNode, (cn) => {
|
||||
let cls = `connection-node ${cn.type}-node ${cn.style}-style `
|
||||
|
||||
if (cn.selected) cls += "highlight "
|
||||
|
||||
cls += cn.node.side === "none" ? "unattached" : "attached"
|
||||
|
||||
return cls
|
||||
})}
|
||||
style={nodeStyle}
|
||||
></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
import { Bezier } from "bezier-js"
|
||||
import { findNearestAttachmentLocationForConnectionNode, resolveEndpoint } from "../../../common/src/connection-utils"
|
||||
import { emptySet, toggleInSet } from "../../../common/src/sets"
|
||||
import { BoardZoom } from "./board-scroll-and-zoom"
|
||||
|
||||
function quadraticCurveSVGPath(from: Point, to: Point, controlPoints: Point[]) {
|
||||
if (!controlPoints.length) {
|
||||
// fallback if no control points: straight line
|
||||
const midPoint = getControlPoint(from, to, controlPoints)
|
||||
return "M" + from.x + " " + from.y + " Q " + midPoint.x + " " + midPoint.y + " " + to.x + " " + to.y
|
||||
} else {
|
||||
const peakPointOfCurve = controlPoints[0]
|
||||
const bez = bezierCurveFromPoints(from, peakPointOfCurve, to)
|
||||
return bez
|
||||
.getLUT()
|
||||
.reduce(
|
||||
(acc: string, p: Point, i: number) =>
|
||||
i === 0 ? (acc += `M ${p.x} ${p.y}`) : (acc += `L ${p.x} ${p.y}`),
|
||||
"",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getControlPoint(from: Point, to: Point, controlPoints: Point[]) {
|
||||
if (controlPoints.length > 0) return controlPoints[0]
|
||||
// fallback if no control points: midpoint
|
||||
return { x: (to.x + from.x) * 0.5, y: (to.y + from.y) * 0.5 }
|
||||
}
|
||||
|
||||
function bezierCurveFromPoints(from: Point, middle: Point, to: Point): any {
|
||||
return Bezier.quadraticFromPoints(from, middle, to)
|
||||
}
|
||||
80
frontend/src/board/CursorsView.tsx
Normal file
80
frontend/src/board/CursorsView.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { componentScope, h, ListView } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { UserCursorPosition, UserSessionInfo } from "../../../common/src/domain"
|
||||
import { CursorsStore } from "../store/cursors-store"
|
||||
import { BoardZoom } from "./board-scroll-and-zoom"
|
||||
import { Rect } from "../../../common/src/geometry"
|
||||
import _ from "lodash"
|
||||
|
||||
export const CursorsView = ({
|
||||
sessions,
|
||||
cursors,
|
||||
viewRect,
|
||||
}: {
|
||||
cursors: CursorsStore
|
||||
sessions: L.Property<UserSessionInfo[]>
|
||||
viewRect: L.Property<Rect>
|
||||
}) => {
|
||||
const transitionFromCursorDelay = cursors.cursorDelay.pipe(
|
||||
L.changes,
|
||||
L.throttle(2000, componentScope()),
|
||||
L.map((d) => {
|
||||
const t = (Math.min(d, 1000) / 1000).toFixed(1)
|
||||
return `all ${t}s, top ${t}s`
|
||||
}),
|
||||
)
|
||||
const transitionFromZoom = viewRect.pipe(
|
||||
L.changes,
|
||||
L.map(() => "none"),
|
||||
)
|
||||
const transition = L.merge(transitionFromCursorDelay, transitionFromZoom).pipe(
|
||||
L.toProperty("none", componentScope()),
|
||||
)
|
||||
|
||||
const scope = componentScope()
|
||||
|
||||
return (
|
||||
<ListView<UserCursorPosition, string>
|
||||
observable={cursors.cursors}
|
||||
renderObservable={(sessionId: string, pos_: L.Property<UserCursorPosition>) => {
|
||||
const pos = pos_.pipe(L.skipDuplicates(_.isEqual), L.applyScope(scope))
|
||||
const changes = pos.pipe(L.changes)
|
||||
const stale = L.merge(
|
||||
changes.pipe(
|
||||
L.debounce(1000),
|
||||
L.map(() => true),
|
||||
),
|
||||
changes.pipe(L.map(() => false)),
|
||||
).pipe(L.toProperty(false, scope))
|
||||
const className = L.view(stale, (s) => (s ? "cursor stale" : "cursor"))
|
||||
const style = L.view(pos, transition, viewRect, (p, t, vr) => {
|
||||
const x = _.clamp(p.x, vr.x, vr.x + vr.width - 1)
|
||||
const y = _.clamp(p.y, vr.y, vr.y + vr.height - 1)
|
||||
return {
|
||||
transition: t,
|
||||
left: x + "em",
|
||||
top: y + "em",
|
||||
}
|
||||
})
|
||||
const userInfo = L.view(sessions, (sessions) => {
|
||||
const session = sessions.find((s) => s.sessionId === sessionId)
|
||||
return {
|
||||
name: session ? session.nickname : null,
|
||||
picture: session && session.userType === "authenticated" ? <img src={session.picture} /> : null,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<span className={className} style={style}>
|
||||
<span className="arrow" />
|
||||
<span className="userInfo">
|
||||
{L.view(userInfo, "picture")}
|
||||
<span className="text">{L.view(userInfo, "name")}</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}}
|
||||
getKey={(c: UserCursorPosition) => c.sessionId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
44
frontend/src/board/DragBorder.tsx
Normal file
44
frontend/src/board/DragBorder.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { h, Fragment } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { Board, Connection } from "../../../common/src/domain"
|
||||
import { BoardCoordinateHelper } from "./board-coordinates"
|
||||
import { BoardFocus } from "./board-focus"
|
||||
import { Dispatch } from "../store/board-store"
|
||||
import { itemDragToMove } from "./item-dragmove"
|
||||
import { Tool, ToolController } from "./tool-selection"
|
||||
|
||||
type Position = "left" | "right" | "top" | "bottom"
|
||||
|
||||
export const DragBorder = ({
|
||||
id,
|
||||
board,
|
||||
coordinateHelper,
|
||||
latestConnection,
|
||||
focus,
|
||||
toolController,
|
||||
dispatch,
|
||||
}: {
|
||||
id: string
|
||||
coordinateHelper: BoardCoordinateHelper
|
||||
latestConnection: L.Property<Connection | null>
|
||||
focus: L.Atom<BoardFocus>
|
||||
board: L.Property<Board>
|
||||
toolController: ToolController
|
||||
dispatch: Dispatch
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<DragHandle {...{ position: "left" }} />
|
||||
<DragHandle {...{ position: "right" }} />
|
||||
<DragHandle {...{ position: "top" }} />
|
||||
<DragHandle {...{ position: "bottom" }} />
|
||||
</>
|
||||
)
|
||||
|
||||
function DragHandle({ position }: { position: Position }) {
|
||||
const ref = (e: HTMLElement) =>
|
||||
itemDragToMove(id, board, focus, toolController, coordinateHelper, latestConnection, dispatch, false)(e)
|
||||
|
||||
return <span ref={ref} draggable={true} className={`edge-drag ${position}`} />
|
||||
}
|
||||
}
|
||||
82
frontend/src/board/ImageView.tsx
Normal file
82
frontend/src/board/ImageView.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { Board, Connection, Image } from "../../../common/src/domain"
|
||||
import { AssetStore } from "../store/asset-store"
|
||||
import { Dispatch } from "../store/board-store"
|
||||
import { BoardCoordinateHelper } from "./board-coordinates"
|
||||
import { BoardFocus } from "./board-focus"
|
||||
import { itemDragToMove } from "./item-dragmove"
|
||||
import { itemSelectionHandler } from "./item-selection"
|
||||
import { ToolController } from "./tool-selection"
|
||||
import { itemZIndex } from "./zIndices"
|
||||
|
||||
export const ImageView = ({
|
||||
id,
|
||||
image,
|
||||
assets,
|
||||
board,
|
||||
isLocked,
|
||||
focus,
|
||||
toolController,
|
||||
coordinateHelper,
|
||||
latestConnection,
|
||||
dispatch,
|
||||
}: {
|
||||
board: L.Property<Board>
|
||||
id: string
|
||||
image: L.Property<Image>
|
||||
isLocked: L.Property<boolean>
|
||||
focus: L.Atom<BoardFocus>
|
||||
toolController: ToolController
|
||||
coordinateHelper: BoardCoordinateHelper
|
||||
latestConnection: L.Property<Connection | null>
|
||||
dispatch: Dispatch
|
||||
assets: AssetStore
|
||||
}) => {
|
||||
const { selected, onClick, onTouchStart } = itemSelectionHandler(
|
||||
id,
|
||||
"image",
|
||||
focus,
|
||||
toolController,
|
||||
board,
|
||||
coordinateHelper,
|
||||
latestConnection,
|
||||
dispatch,
|
||||
)
|
||||
const tool = toolController.tool
|
||||
return (
|
||||
<span
|
||||
className="image"
|
||||
onClick={onClick}
|
||||
onTouchStart={onTouchStart}
|
||||
ref={
|
||||
itemDragToMove(
|
||||
id,
|
||||
board,
|
||||
focus,
|
||||
toolController,
|
||||
coordinateHelper,
|
||||
latestConnection,
|
||||
dispatch,
|
||||
false,
|
||||
) as any
|
||||
}
|
||||
style={L.view(
|
||||
image,
|
||||
(p: Image) =>
|
||||
({
|
||||
top: 0,
|
||||
left: 0,
|
||||
transform: `translate(${p.x}em, ${p.y}em)`,
|
||||
height: p.height + "em",
|
||||
width: p.width + "em",
|
||||
zIndex: itemZIndex(p),
|
||||
position: "absolute",
|
||||
} as any),
|
||||
)}
|
||||
>
|
||||
<img loading="lazy" src={L.view(image, (i) => assets.getAsset(i.assetId, i.src))} />
|
||||
{L.view(isLocked, (l) => l && <span className="lock">🔒</span>)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
221
frontend/src/board/ItemView.tsx
Normal file
221
frontend/src/board/ItemView.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { componentScope, h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import {
|
||||
AccessLevel,
|
||||
Board,
|
||||
Connection,
|
||||
getAlign,
|
||||
getHorizontalAlign,
|
||||
getItemBackground,
|
||||
getItemShape,
|
||||
getVerticalAlign,
|
||||
isContainer,
|
||||
isLocalUIEvent,
|
||||
isTextItem,
|
||||
Item,
|
||||
ItemType,
|
||||
TextItem,
|
||||
} from "../../../common/src/domain"
|
||||
import { BoardStore, Dispatch } from "../store/board-store"
|
||||
import { BoardCoordinateHelper } from "./board-coordinates"
|
||||
import { BoardFocus } from "./board-focus"
|
||||
import { CollaborativeTextView } from "./CollaborativeTextView"
|
||||
import { DragBorder } from "./DragBorder"
|
||||
import { itemDragToMove } from "./item-dragmove"
|
||||
import { itemSelectionHandler } from "./item-selection"
|
||||
import { TextView } from "./TextView"
|
||||
import { ToolController } from "./tool-selection"
|
||||
import { itemZIndex } from "./zIndices"
|
||||
import { VisibilityOffIcon } from "../components/Icons"
|
||||
|
||||
export const ItemView = ({
|
||||
board,
|
||||
accessLevel,
|
||||
id,
|
||||
type,
|
||||
item,
|
||||
isLocked,
|
||||
focus,
|
||||
coordinateHelper,
|
||||
latestConnection,
|
||||
dispatch,
|
||||
toolController,
|
||||
boardStore,
|
||||
}: {
|
||||
board: L.Property<Board>
|
||||
accessLevel: L.Property<AccessLevel>
|
||||
id: string
|
||||
type: ItemType
|
||||
item: L.Property<Item>
|
||||
isLocked: L.Property<boolean>
|
||||
focus: L.Atom<BoardFocus>
|
||||
coordinateHelper: BoardCoordinateHelper
|
||||
latestConnection: L.Property<Connection | null>
|
||||
dispatch: Dispatch
|
||||
toolController: ToolController
|
||||
boardStore: BoardStore
|
||||
}) => {
|
||||
const element = L.atom<HTMLElement | null>(null)
|
||||
|
||||
const ref = (el: HTMLElement) => {
|
||||
itemDragToMove(
|
||||
id,
|
||||
board,
|
||||
focus,
|
||||
toolController,
|
||||
coordinateHelper,
|
||||
latestConnection,
|
||||
dispatch,
|
||||
type === "container",
|
||||
)(el)
|
||||
element.set(el)
|
||||
}
|
||||
|
||||
const { itemFocus, selected, onClick, onTouchStart } = itemSelectionHandler(
|
||||
id,
|
||||
type,
|
||||
focus,
|
||||
toolController,
|
||||
board,
|
||||
coordinateHelper,
|
||||
latestConnection,
|
||||
dispatch,
|
||||
)
|
||||
|
||||
function itemPadding(i: Item) {
|
||||
if (i.type != "note") return undefined
|
||||
|
||||
const shape = getItemShape(i)
|
||||
return shape == "diamond"
|
||||
? `${i.width / 4}em`
|
||||
: shape == "round"
|
||||
? `${i.width / 8}em`
|
||||
: shape == "square" || shape == "rect"
|
||||
? `${(i.fontSize || 1) / 3}em`
|
||||
: undefined
|
||||
}
|
||||
const shape = L.view(item, getItemShape)
|
||||
const itemNow = item.get()
|
||||
|
||||
return (
|
||||
<span
|
||||
title={L.view(isLocked, (l) => (l ? "Item is selected by another user" : ""))}
|
||||
ref={ref}
|
||||
data-itemid={id}
|
||||
draggable={L.view(itemFocus, (f) => f !== "editing")}
|
||||
onClick={onClick}
|
||||
onTouchStart={onTouchStart}
|
||||
className={L.view(
|
||||
selected,
|
||||
L.view(item, getItemBackground),
|
||||
isLocked,
|
||||
(s, b, l) =>
|
||||
`${type} ${"color-" + b.replace("#", "").toLowerCase()} ${s ? "selected" : ""} ${
|
||||
l ? "locked" : ""
|
||||
}`,
|
||||
)}
|
||||
style={L.view(item, (i) => {
|
||||
return {
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: i.height + "em",
|
||||
width: i.width + "em",
|
||||
transform: `translate(${i.x}em, ${i.y}em)`,
|
||||
zIndex: itemZIndex(i),
|
||||
position: "absolute",
|
||||
padding: itemPadding(i),
|
||||
justifyContent: getJustifyContent(i),
|
||||
alignItems: getAlignItems(i),
|
||||
textAlign: getTextAlign(i),
|
||||
}
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={L.view(shape, (s) => "shape " + s)}
|
||||
style={L.view(item, (i) => {
|
||||
return {
|
||||
background: getItemBackground(i),
|
||||
}
|
||||
})}
|
||||
/>
|
||||
|
||||
{isTextItem(itemNow) && itemNow.crdt ? (
|
||||
<CollaborativeTextView
|
||||
item={item as L.Property<TextItem>}
|
||||
board={board}
|
||||
id={id}
|
||||
accessLevel={accessLevel}
|
||||
focus={focus}
|
||||
itemFocus={itemFocus}
|
||||
crdtStore={boardStore.crdtStore}
|
||||
isLocked={isLocked}
|
||||
uiEvents={boardStore.events.pipe(L.filter(isLocalUIEvent), L.applyScope(componentScope()))}
|
||||
/>
|
||||
) : (
|
||||
<TextView
|
||||
id={id}
|
||||
item={item as L.Property<TextItem>}
|
||||
dispatch={dispatch}
|
||||
board={board}
|
||||
toolController={toolController}
|
||||
accessLevel={accessLevel}
|
||||
focus={focus}
|
||||
itemFocus={itemFocus}
|
||||
coordinateHelper={coordinateHelper}
|
||||
element={element}
|
||||
/>
|
||||
)}
|
||||
|
||||
{L.view(
|
||||
item,
|
||||
(i) => isContainer(i) && i.contentsHidden,
|
||||
(hidden) =>
|
||||
(hidden && (
|
||||
<div className="hidden-contents-indicator">
|
||||
<VisibilityOffIcon />
|
||||
</div>
|
||||
)) ??
|
||||
null,
|
||||
)}
|
||||
|
||||
{type === "container" && (
|
||||
<DragBorder {...{ id, board, toolController, coordinateHelper, latestConnection, focus, dispatch }} />
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function getJustifyContent(item: Item) {
|
||||
if (isTextItem(item)) {
|
||||
switch (getHorizontalAlign(getAlign(item))) {
|
||||
case "left":
|
||||
return "flex-start"
|
||||
case "center":
|
||||
return "center"
|
||||
case "right":
|
||||
return "flex-end"
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function getAlignItems(item: Item) {
|
||||
if (isTextItem(item)) {
|
||||
switch (getVerticalAlign(getAlign(item))) {
|
||||
case "top":
|
||||
return "flex-start"
|
||||
case "middle":
|
||||
return "center"
|
||||
case "bottom":
|
||||
return "flex-end"
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getTextAlign(item: Item) {
|
||||
if (isTextItem(item)) {
|
||||
return getHorizontalAlign(getAlign(item))
|
||||
}
|
||||
return null
|
||||
}
|
||||
21
frontend/src/board/RectangularDragSelection.tsx
Normal file
21
frontend/src/board/RectangularDragSelection.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { Rect } from "../../../common/src/geometry"
|
||||
|
||||
export const RectangularDragSelection = ({ rect }: { rect: L.Property<Rect | null> }) => {
|
||||
return L.view(
|
||||
rect,
|
||||
(r) =>
|
||||
r && (
|
||||
<span
|
||||
className="rectangular-selection"
|
||||
style={{
|
||||
left: r.x + "em",
|
||||
top: r.y + "em",
|
||||
width: r.width + "em",
|
||||
height: r.height + "em",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)
|
||||
}
|
||||
32
frontend/src/board/SaveAsTemplate.tsx
Normal file
32
frontend/src/board/SaveAsTemplate.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { Board } from "../../../common/src/domain"
|
||||
|
||||
export const SaveAsTemplate = ({ board }: { board: L.Property<Board | undefined> }) => {
|
||||
const currSavedBoard = L.atom<Board | null>(null)
|
||||
|
||||
function handleLocalTemplateSave() {
|
||||
const b = board.get()
|
||||
if (!b) return
|
||||
const saved = localStorage.getItem("rboard_templates")
|
||||
const templates = saved ? (JSON.parse(saved) as Record<string, Board>) : {}
|
||||
|
||||
templates[b.name] = b
|
||||
localStorage.setItem("rboard_templates", JSON.stringify(templates))
|
||||
currSavedBoard.set(b)
|
||||
}
|
||||
|
||||
const changed = L.combineTemplate({
|
||||
curr: currSavedBoard,
|
||||
next: board,
|
||||
}).pipe(L.map((c) => c.curr !== c.next))
|
||||
return (
|
||||
<li
|
||||
className={L.view(changed, (c) => (c ? "" : "disabled"))}
|
||||
data-test="palette-save-as-template"
|
||||
onClick={() => handleLocalTemplateSave()}
|
||||
>
|
||||
Save as template
|
||||
</li>
|
||||
)
|
||||
}
|
||||
118
frontend/src/board/SelectionBorder.tsx
Normal file
118
frontend/src/board/SelectionBorder.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { BoardCoordinateHelper } from "./board-coordinates"
|
||||
import { Board, Container } from "../../../common/src/domain"
|
||||
import { BoardFocus } from "./board-focus"
|
||||
import { onBoardItemDrag } from "./item-drag"
|
||||
import { Dispatch } from "../store/board-store"
|
||||
import { canMove } from "./board-permissions"
|
||||
|
||||
type Horizontal = "left" | "right"
|
||||
type Vertical = "top" | "bottom"
|
||||
const borderOffset = 0.25
|
||||
|
||||
export const SelectionBorder = ({
|
||||
id,
|
||||
board,
|
||||
coordinateHelper,
|
||||
focus,
|
||||
dispatch,
|
||||
}: {
|
||||
id: string
|
||||
coordinateHelper: BoardCoordinateHelper
|
||||
focus: L.Atom<BoardFocus>
|
||||
board: L.Property<Board>
|
||||
dispatch: Dispatch
|
||||
}) => {
|
||||
const item = L.view(board, (b) => b.items[id])
|
||||
const style = L.view(item, (i) => {
|
||||
return {
|
||||
top: -borderOffset + "rem",
|
||||
left: -borderOffset + "rem",
|
||||
height: `calc(${i.height}em + 2 * ${borderOffset}rem)`,
|
||||
width: `calc(${i.width}em + 2 * ${borderOffset}rem)`,
|
||||
transform: `translate(${i.x}em, ${i.y}em)`,
|
||||
}
|
||||
})
|
||||
|
||||
return L.view(
|
||||
item,
|
||||
(i) => !i.hidden && canMove(i),
|
||||
(m) =>
|
||||
m ? (
|
||||
<span className="selection-control" style={style}>
|
||||
<span className="corner-resize-drag top left"></span>
|
||||
<DragCorner {...{ horizontal: "left", vertical: "top" }} />
|
||||
<DragCorner {...{ horizontal: "left", vertical: "bottom" }} />
|
||||
<DragCorner {...{ horizontal: "right", vertical: "top" }} />
|
||||
<DragCorner {...{ horizontal: "right", vertical: "bottom" }} />
|
||||
</span>
|
||||
) : null,
|
||||
)
|
||||
|
||||
function DragCorner({ vertical, horizontal }: { vertical: Vertical; horizontal: Horizontal }) {
|
||||
const ref = (e: HTMLElement) =>
|
||||
onBoardItemDrag(
|
||||
e,
|
||||
id,
|
||||
board,
|
||||
focus,
|
||||
coordinateHelper,
|
||||
false,
|
||||
(b, startPos, items, connections, xDiff, yDiff) => {
|
||||
const updatedItems = items.map(({ current, dragStartPosition }) => {
|
||||
const maintainAspectRatio =
|
||||
current.type === "image" || (current.type === "note" && current.shape !== "rect")
|
||||
if (maintainAspectRatio) {
|
||||
let minDiff = Math.min(Math.abs(xDiff), Math.abs(yDiff))
|
||||
if (minDiff < 0.1) {
|
||||
xDiff = 0
|
||||
yDiff = 0
|
||||
} else {
|
||||
const aspectRatio = dragStartPosition.width / dragStartPosition.height
|
||||
const invert =
|
||||
(horizontal == "left" && vertical == "bottom") ||
|
||||
(horizontal == "right" && vertical == "top")
|
||||
const factor = invert ? -1 : 1
|
||||
|
||||
if (Math.abs(xDiff) == minDiff) {
|
||||
// x is the smaller adjustment, use that as basis
|
||||
yDiff = (minDiff / aspectRatio) * factor * sign(xDiff)
|
||||
} else {
|
||||
xDiff = minDiff * aspectRatio * factor * sign(yDiff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const x = horizontal === "left" ? dragStartPosition.x + xDiff : dragStartPosition.x
|
||||
const y = vertical === "top" ? dragStartPosition.y + yDiff : dragStartPosition.y
|
||||
const width = Math.max(
|
||||
0.5,
|
||||
horizontal === "left" ? dragStartPosition.width - xDiff : dragStartPosition.width + xDiff,
|
||||
)
|
||||
|
||||
const height = Math.max(
|
||||
0.5,
|
||||
vertical === "top" ? dragStartPosition.height - yDiff : dragStartPosition.height + yDiff,
|
||||
)
|
||||
const updatedItem = {
|
||||
id: current.id,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
return updatedItem
|
||||
})
|
||||
|
||||
dispatch({ action: "item.update", boardId: b.id, items: updatedItems })
|
||||
|
||||
function sign(x: number) {
|
||||
return x / Math.abs(x)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return <span ref={ref} draggable={true} className={`corner-resize-drag ${horizontal} ${vertical}`} />
|
||||
}
|
||||
}
|
||||
81
frontend/src/board/TextView.tsx
Normal file
81
frontend/src/board/TextView.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { AccessLevel, Board, canWrite, getItemBackground, TextItem } from "../../../common/src/domain"
|
||||
import { emptySet } from "../../../common/src/sets"
|
||||
import { HTMLEditableSpan } from "../components/HTMLEditableSpan"
|
||||
import { Dispatch } from "../store/board-store"
|
||||
import { autoFontSize } from "./autoFontSize"
|
||||
import { BoardCoordinateHelper } from "./board-coordinates"
|
||||
import { BoardFocus, getSelectedItemIds } from "./board-focus"
|
||||
import { contrastingColor } from "./contrasting-color"
|
||||
import { ToolController } from "./tool-selection"
|
||||
|
||||
interface TextViewProps {
|
||||
id: string
|
||||
item: L.Property<TextItem>
|
||||
dispatch: Dispatch
|
||||
board: L.Property<Board>
|
||||
toolController: ToolController
|
||||
accessLevel: L.Property<AccessLevel>
|
||||
focus: L.Atom<BoardFocus>
|
||||
itemFocus: L.Property<"none" | "selected" | "dragging" | "editing">
|
||||
coordinateHelper: BoardCoordinateHelper
|
||||
element: L.Property<HTMLElement | null>
|
||||
}
|
||||
|
||||
export function TextView({
|
||||
id,
|
||||
item,
|
||||
dispatch,
|
||||
board,
|
||||
toolController,
|
||||
focus,
|
||||
coordinateHelper,
|
||||
itemFocus,
|
||||
accessLevel,
|
||||
element,
|
||||
}: TextViewProps) {
|
||||
const textAtom = L.atom(L.view(item, "text"), (text) =>
|
||||
dispatch({ action: "item.update", boardId: board.get().id, items: [{ id, text }] }),
|
||||
)
|
||||
const showCoords = false
|
||||
const focused = L.view(focus, (f) => getSelectedItemIds(f).has(id))
|
||||
|
||||
const setEditing = (e: boolean) => {
|
||||
if (toolController.tool.get() === "connect") return // Don't switch to editing in middle of connecting
|
||||
dispatch({ action: "item.front", boardId: board.get().id, itemIds: [id] })
|
||||
focus.set(
|
||||
e
|
||||
? { status: "editing", itemId: id }
|
||||
: { status: "selected", itemIds: new Set([id]), connectionIds: emptySet() },
|
||||
)
|
||||
}
|
||||
const color = L.view(item, getItemBackground, contrastingColor)
|
||||
const fontSize = autoFontSize(
|
||||
item,
|
||||
L.view(item, (i) => (i.fontSize ? i.fontSize : 1)),
|
||||
L.view(item, "text"),
|
||||
focused,
|
||||
coordinateHelper,
|
||||
element,
|
||||
)
|
||||
return (
|
||||
<span
|
||||
className="text"
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
style={L.combineTemplate({ fontSize, color })}
|
||||
>
|
||||
<HTMLEditableSpan
|
||||
{...{
|
||||
value: textAtom,
|
||||
editingThis: L.atom(
|
||||
L.view(itemFocus, (f) => f === "editing"),
|
||||
setEditing,
|
||||
),
|
||||
editable: L.view(accessLevel, canWrite),
|
||||
}}
|
||||
/>
|
||||
{showCoords && <small>{L.view(item, (p) => Math.floor(p.x) + ", " + Math.floor(p.y))}</small>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
87
frontend/src/board/VideoView.tsx
Normal file
87
frontend/src/board/VideoView.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { BoardCoordinateHelper } from "./board-coordinates"
|
||||
import { Board, Connection, Image, Video } from "../../../common/src/domain"
|
||||
import { BoardFocus } from "./board-focus"
|
||||
import { AssetStore } from "../store/asset-store"
|
||||
import { itemDragToMove } from "./item-dragmove"
|
||||
import { itemSelectionHandler } from "./item-selection"
|
||||
import { Dispatch } from "../store/board-store"
|
||||
import { Tool, ToolController } from "./tool-selection"
|
||||
import { DragBorder } from "./DragBorder"
|
||||
import { itemZIndex } from "./zIndices"
|
||||
|
||||
export const VideoView = ({
|
||||
id,
|
||||
video,
|
||||
assets,
|
||||
board,
|
||||
isLocked,
|
||||
focus,
|
||||
toolController,
|
||||
coordinateHelper,
|
||||
latestConnection,
|
||||
dispatch,
|
||||
}: {
|
||||
board: L.Property<Board>
|
||||
id: string
|
||||
video: L.Property<Video>
|
||||
isLocked: L.Property<boolean>
|
||||
focus: L.Atom<BoardFocus>
|
||||
toolController: ToolController
|
||||
coordinateHelper: BoardCoordinateHelper
|
||||
latestConnection: L.Property<Connection | null>
|
||||
dispatch: Dispatch
|
||||
assets: AssetStore
|
||||
}) => {
|
||||
const { selected, onClick, onTouchStart } = itemSelectionHandler(
|
||||
id,
|
||||
"video",
|
||||
focus,
|
||||
toolController,
|
||||
board,
|
||||
coordinateHelper,
|
||||
latestConnection,
|
||||
dispatch,
|
||||
)
|
||||
const tool = toolController.tool
|
||||
return (
|
||||
<span
|
||||
className="video"
|
||||
onClick={onClick}
|
||||
onTouchStart={onTouchStart}
|
||||
ref={
|
||||
itemDragToMove(
|
||||
id,
|
||||
board,
|
||||
focus,
|
||||
toolController,
|
||||
coordinateHelper,
|
||||
latestConnection,
|
||||
dispatch,
|
||||
false,
|
||||
) as any
|
||||
}
|
||||
style={L.view(
|
||||
video,
|
||||
(p: Video) =>
|
||||
({
|
||||
top: 0,
|
||||
left: 0,
|
||||
transform: `translate(${p.x}em, ${p.y}em)`,
|
||||
height: p.height + "em",
|
||||
width: p.width + "em",
|
||||
zIndex: itemZIndex(p),
|
||||
position: "absolute",
|
||||
} as any),
|
||||
)}
|
||||
>
|
||||
<video id="video" controls={true} preload="none">
|
||||
<source id="mp4" src={L.view(video, (i) => assets.getAsset(i.assetId, i.src))} type="video/mp4" />
|
||||
<p>Your user agent does not support the HTML5 Video element.</p>
|
||||
</video>
|
||||
{L.view(isLocked, (l) => l && <span className="lock">🔒</span>)}
|
||||
<DragBorder {...{ id, board, toolController, coordinateHelper, latestConnection, focus, dispatch }} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
165
frontend/src/board/autoFontSize.ts
Normal file
165
frontend/src/board/autoFontSize.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { isUndefined } from "lodash"
|
||||
import * as L from "lonna"
|
||||
import { getItemShape, Item, TextItem } from "../../../common/src/domain"
|
||||
import { toPlainText } from "../components/sanitizeHTML"
|
||||
import { BoardCoordinateHelper } from "./board-coordinates"
|
||||
import { Dimensions } from "../../../common/src/geometry"
|
||||
|
||||
export type AutoFontSizeOptions = {
|
||||
maxFontSize: number
|
||||
maxLines: number
|
||||
hideIfNoFit: boolean
|
||||
minFontSize: number
|
||||
widthTarget: number
|
||||
heightTarget: number
|
||||
}
|
||||
|
||||
function getElementFont(e: HTMLElement | null) {
|
||||
if (!e) return "10px arial"
|
||||
const { fontFamily, fontSize } = getComputedStyle(e)
|
||||
return `${fontSize} ${fontFamily}` // Firefox returns these properties separately, so can't just use computedStyle.font
|
||||
}
|
||||
|
||||
const defaultOptions = {
|
||||
maxFontSize: Number.MAX_VALUE,
|
||||
minFontSize: 0,
|
||||
maxLines: Number.MAX_VALUE,
|
||||
hideIfNoFit: false,
|
||||
widthTarget: 0.65, // TODO: something fishy here, why these number need to be so low?
|
||||
heightTarget: 0.6,
|
||||
}
|
||||
|
||||
export function autoFontSize(
|
||||
item: L.Property<TextItem>,
|
||||
fontSize: L.Property<number>,
|
||||
text: L.Property<string>,
|
||||
focused: L.Property<boolean>,
|
||||
coordinateHelper: BoardCoordinateHelper,
|
||||
element: L.Property<HTMLElement | null>,
|
||||
options: Partial<AutoFontSizeOptions> = {},
|
||||
): L.Property<string> {
|
||||
let fullOptions = { ...defaultOptions, ...options }
|
||||
|
||||
return L.view(
|
||||
L.view(item, "type"),
|
||||
L.view(item, getItemShape),
|
||||
L.view(item, "width"),
|
||||
L.view(item, "height"),
|
||||
fontSize,
|
||||
text,
|
||||
(t, shape, w, h, fs, text) => {
|
||||
if (t !== "note") return fs + "em"
|
||||
|
||||
const width = shape === "round" ? Math.sqrt(w ** 2 / 2) : shape === "diamond" ? w / 2 : w
|
||||
const height = shape === "round" ? Math.sqrt(h ** 2 / 2) : shape === "diamond" ? h / 2 : h
|
||||
|
||||
const referenceFont = getElementFont(element.get())
|
||||
const plainText = toPlainText(text)
|
||||
const split = plainText.split(/\s/)
|
||||
const words = split
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s)
|
||||
.map((s) => getTextDimensions(s, referenceFont))
|
||||
const spaceCharSize = getTextDimensions("", referenceFont)
|
||||
const widthTarget = coordinateHelper.emToPagePx(width) * fullOptions.widthTarget
|
||||
const heightTarget = coordinateHelper.emToPagePx(height) * fullOptions.heightTarget
|
||||
|
||||
const maxWidth = widthTarget
|
||||
const lineSpacingEm = 0.4
|
||||
|
||||
let lowerBound = Math.max(0, fullOptions.minFontSize)
|
||||
let upperBound = Math.min(10, fullOptions.maxFontSize)
|
||||
let sizeEm = Math.min(1, upperBound)
|
||||
let fit = false
|
||||
if (words.length > 0) {
|
||||
let iterations = 1
|
||||
while (iterations < 10) {
|
||||
// Limited binary search
|
||||
const fitInfo = tryFit(sizeEm)
|
||||
const fitFactor = fitInfo.fitFactor
|
||||
|
||||
//if (f) console.log(text, "Try size", sizeEm, "Total lines", fitInfo.lines.length, "V-Fit", fitInfo.heightFitFactor, "H-fit", fitInfo.widthFitFactor, "limited by", fitFactor === fitInfo.heightFitFactor ? "height" : "width")
|
||||
if (!fit && fitFactor <= 1) fit = true
|
||||
if (lowerBound >= upperBound) break
|
||||
if (fitFactor < 0.95) {
|
||||
// too small
|
||||
lowerBound = sizeEm
|
||||
sizeEm = (sizeEm + upperBound) / 2
|
||||
} else if (fitFactor > 1) {
|
||||
// too big
|
||||
upperBound = sizeEm
|
||||
sizeEm = (sizeEm + lowerBound) / 2
|
||||
} else {
|
||||
// Good enough
|
||||
break
|
||||
}
|
||||
iterations++
|
||||
}
|
||||
}
|
||||
|
||||
if (!fit && fullOptions.hideIfNoFit) return "0"
|
||||
|
||||
return Math.min(sizeEm, fullOptions.maxFontSize) * fs + "em"
|
||||
|
||||
// Try to fit text using given font size. Return fit factor (text size / max size)
|
||||
function tryFit(sizeEm: number) {
|
||||
let index = 0
|
||||
let lines: Dimensions[] = []
|
||||
let maxWordWidth = 0
|
||||
let lineWidth = 0
|
||||
|
||||
while (true) {
|
||||
// loop through lines
|
||||
let nextWord = words[index]
|
||||
let nextWordWidth = nextWord.width * sizeEm
|
||||
maxWordWidth = Math.max(nextWordWidth, maxWordWidth)
|
||||
let nextWordWidthWithSpacing =
|
||||
(lineWidth == 0 ? nextWord.width : nextWord.width + spaceCharSize.width) * sizeEm
|
||||
let fitFactor = (lineWidth + nextWordWidthWithSpacing) / maxWidth
|
||||
if (fitFactor > 1) {
|
||||
// no more words for this line
|
||||
if (lines.length >= fullOptions.maxLines) {
|
||||
return { lines: [], fitFactor, widthFitFactor: fitFactor, heightFitFactor: 0 }
|
||||
} else if (lineWidth === 0) {
|
||||
//if (f) console.log("couldn't fit a single word, return factor based on width")
|
||||
return { lines: [], fitFactor, widthFitFactor: fitFactor, heightFitFactor: 0 }
|
||||
} else {
|
||||
lines.push({ width: lineWidth, height: nextWord.height * sizeEm })
|
||||
lineWidth = 0
|
||||
}
|
||||
} else {
|
||||
// add this word on the line
|
||||
lineWidth = lineWidth + nextWordWidthWithSpacing
|
||||
if (++index >= words.length) {
|
||||
//if (f) console.log("All words added", words)
|
||||
lines.push({ width: lineWidth, height: nextWord.height * sizeEm })
|
||||
lineWidth = 0
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// At this point the text was horizontally fit. Return fit factor based on height
|
||||
const totalHeight =
|
||||
lines.reduce((h, l) => h + l.height, 0) + (lines.length - 1) * lineSpacingEm * sizeEm
|
||||
const heightFitFactor = totalHeight / heightTarget
|
||||
const widthFitFactor = maxWordWidth / widthTarget
|
||||
const fitFactor = Math.max(heightFitFactor, widthFitFactor)
|
||||
return { lines, fitFactor, heightFitFactor, widthFitFactor, totalHeight, lineCount: lines.length }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function getTextDimensions(text: string, font: string): Dimensions {
|
||||
// if given, use cached canvas for better performance
|
||||
// else, create new canvas
|
||||
var gtw: any = getTextDimensions
|
||||
var canvas: HTMLCanvasElement = gtw.canvas || (gtw.canvas = document.createElement("canvas"))
|
||||
var context = canvas.getContext("2d")!
|
||||
context.font = font
|
||||
var metrics = context.measureText(text)
|
||||
const height = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent
|
||||
const width = metrics.width
|
||||
|
||||
return { height, width }
|
||||
}
|
||||
175
frontend/src/board/board-coordinates.ts
Normal file
175
frontend/src/board/board-coordinates.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { componentScope } from "harmaja"
|
||||
import * as _ from "lodash"
|
||||
import * as L from "lonna"
|
||||
import { add, Coordinates, origin, subtract } from "../../../common/src/geometry"
|
||||
import { BoardZoom } from "./board-scroll-and-zoom"
|
||||
import { onSingleTouch } from "./touchScreen"
|
||||
|
||||
const newCoordinates = (x: number, y: number): Coordinates => {
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
// HTML client coordinates: relative to viewport
|
||||
export type PageCoordinates = Coordinates
|
||||
// Board coordinates used in the domain objects: in em unit, from board top left corner.
|
||||
export type BoardCoordinates = Coordinates
|
||||
|
||||
export type BoardCoordinateHelper = ReturnType<typeof boardCoordinateHelper>
|
||||
|
||||
export function boardCoordinateHelper(
|
||||
containerElem: L.Property<HTMLElement | null>,
|
||||
scrollElem: L.Property<HTMLElement | null>,
|
||||
boardElem: L.Property<HTMLElement | null>,
|
||||
zoom: L.Property<BoardZoom>,
|
||||
) {
|
||||
const quickZoom = L.view(zoom, "quickZoom")
|
||||
|
||||
function pxToEm(px: number) {
|
||||
return px / baseFontSize() / quickZoom.get()
|
||||
}
|
||||
|
||||
function emToPagePx(em: number) {
|
||||
return em * baseFontSize() * quickZoom.get()
|
||||
}
|
||||
|
||||
function emToBoardPx(em: number) {
|
||||
return em * baseFontSize()
|
||||
}
|
||||
|
||||
function baseFontSize() {
|
||||
const e = boardElem.get()
|
||||
return e ? parseFloat(getComputedStyle(e).fontSize) : 10
|
||||
}
|
||||
|
||||
function coordDiff(a: Coordinates, b: Coordinates) {
|
||||
return newCoordinates(a.x - b.x, a.y - b.y)
|
||||
}
|
||||
|
||||
function getBoardAbsolutePosition() {
|
||||
const b = boardElem.get()
|
||||
return b ? offset(b) : origin
|
||||
}
|
||||
|
||||
function offset(el: HTMLElement): Coordinates {
|
||||
let o = { x: el.offsetLeft, y: el.offsetTop }
|
||||
|
||||
if (el.parentElement) {
|
||||
return add(o, offset(el.parentElement))
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
function pageToPixelCoordinates(pp: PageCoordinates) {
|
||||
return subtract(pp, getBoardAbsolutePosition())
|
||||
}
|
||||
|
||||
function pageToBoardCoordinates(pageCoords: PageCoordinates): Coordinates {
|
||||
const pixelCoords = pageToPixelCoordinates(pageCoords)
|
||||
const scrollEl = scrollElem.get()
|
||||
if (scrollEl === null) {
|
||||
return origin // Not the smartest move
|
||||
}
|
||||
|
||||
// Use offsetLeft/offsetTop instead of getBoundingClientRect for getting board position
|
||||
// because drag-to-scroll uses CSS translate while dragging and we don't want that to affect the calculation.
|
||||
|
||||
return newCoordinates(pxToEm(pixelCoords.x + scrollEl.scrollLeft), pxToEm(pixelCoords.y + scrollEl.scrollTop))
|
||||
}
|
||||
|
||||
// Page coordinates of mouse pointer
|
||||
let currentPageCoordinates = L.atom(origin)
|
||||
|
||||
// Position on the viewport, with relation to the top-left corner of the board area.
|
||||
// If board is scrolled to top left corner, the top-left corner of actual board area (excluding borders) will be at (0, 0)
|
||||
let currentBoardViewPortCoordinates = L.view(currentPageCoordinates, (pp) =>
|
||||
subtract(pp, getBoardAbsolutePosition()),
|
||||
)
|
||||
|
||||
function pageCoordDiffToThisPoint(coords: PageCoordinates) {
|
||||
return coordDiff(currentPageCoordinates.get(), coords)
|
||||
}
|
||||
|
||||
L.view(boardElem, containerElem, (b, c) => [b, c]).forEach(([elem, container]) => {
|
||||
if (!elem || !container) {
|
||||
return
|
||||
}
|
||||
|
||||
elem.addEventListener(
|
||||
"gesturestart",
|
||||
_.throttle(
|
||||
(e) => {
|
||||
currentPageCoordinates.set({ x: e.pageX, y: e.pageY })
|
||||
},
|
||||
16,
|
||||
{ leading: true, trailing: true },
|
||||
),
|
||||
)
|
||||
elem.addEventListener(
|
||||
"dragover",
|
||||
_.throttle(
|
||||
(e) => {
|
||||
currentPageCoordinates.set({ x: e.pageX, y: e.pageY })
|
||||
e.preventDefault() // To disable Safari slow animation
|
||||
},
|
||||
16,
|
||||
{ leading: true, trailing: true },
|
||||
),
|
||||
)
|
||||
container.addEventListener(
|
||||
"mousemove",
|
||||
_.throttle(
|
||||
(e) => {
|
||||
currentPageCoordinates.set({ x: e.pageX, y: e.pageY })
|
||||
},
|
||||
16,
|
||||
{ leading: true, trailing: true },
|
||||
),
|
||||
)
|
||||
container.addEventListener("touchstart", (e) => {
|
||||
onSingleTouch(e, (touch) => currentPageCoordinates.set({ x: touch.pageX, y: touch.pageY }))
|
||||
})
|
||||
container.addEventListener("touchmove", (e) => {
|
||||
onSingleTouch(e, (touch) => currentPageCoordinates.set({ x: touch.pageX, y: touch.pageY }))
|
||||
})
|
||||
})
|
||||
|
||||
const scrollEvent = scrollElem.pipe(
|
||||
L.changes,
|
||||
L.flatMapLatest((el) => L.fromEvent(el, "scroll"), componentScope()),
|
||||
)
|
||||
const updateEvent = L.merge(scrollEvent, L.changes(zoom), L.changes(currentPageCoordinates))
|
||||
|
||||
// Mouse position in board coordinates
|
||||
const currentBoardCoordinates = updateEvent.pipe(
|
||||
L.toStatelessProperty(() => {
|
||||
return pageToBoardCoordinates(currentPageCoordinates.get())
|
||||
}),
|
||||
)
|
||||
|
||||
function scrollByPixels(diffPixels: { x: number; y: number }) {
|
||||
scrollElem.get()!.scrollLeft += diffPixels.x
|
||||
scrollElem.get()!.scrollTop += diffPixels.y
|
||||
}
|
||||
|
||||
function scrollByBoardCoordinates(diffEm: { x: number; y: number }) {
|
||||
const diffPx = {
|
||||
x: emToPagePx(diffEm.x),
|
||||
y: emToPagePx(diffEm.y),
|
||||
}
|
||||
scrollByPixels(diffPx)
|
||||
}
|
||||
|
||||
return {
|
||||
pageToBoardCoordinates,
|
||||
pageCoordDiffToThisPoint,
|
||||
currentBoardViewPortCoordinates,
|
||||
currentPageCoordinates,
|
||||
currentBoardCoordinates,
|
||||
boardCoordDiffFromThisPageCoordinate: (coords: PageCoordinates) =>
|
||||
coordDiff(currentBoardCoordinates.get(), pageToBoardCoordinates(coords)),
|
||||
emToPagePx,
|
||||
emToBoardPx,
|
||||
pxToEm,
|
||||
scrollByBoardCoordinates,
|
||||
}
|
||||
}
|
||||
192
frontend/src/board/board-drag.ts
Normal file
192
frontend/src/board/board-drag.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { componentScope } from "harmaja"
|
||||
import * as _ from "lodash"
|
||||
import * as L from "lonna"
|
||||
import { connectionRect } from "../../../common/src/connection-utils"
|
||||
import { Board, Connection, Point } from "../../../common/src/domain"
|
||||
import { containedBy, overlaps, Rect, rectFromPoints } from "../../../common/src/geometry"
|
||||
import { Dispatch } from "../store/server-connection"
|
||||
import { BoardCoordinateHelper, BoardCoordinates } from "./board-coordinates"
|
||||
import { BoardFocus, getSelectedConnectionIds, getSelectedItemIds, isAnythingSelected, noFocus } from "./board-focus"
|
||||
import { newConnectionCreator } from "./item-connect"
|
||||
import { DND_GHOST_HIDING_IMAGE } from "./item-drag"
|
||||
import { ToolController } from "./tool-selection"
|
||||
import { onSingleTouch } from "./touchScreen"
|
||||
|
||||
export type DragAction =
|
||||
| { action: "select"; selectedAtStart: BoardFocus }
|
||||
| { action: "pan" }
|
||||
| { action: "none" }
|
||||
| { action: "connect" | "line"; startPos: Point }
|
||||
|
||||
export function boardDragHandler({
|
||||
boardElem,
|
||||
coordinateHelper,
|
||||
latestConnection,
|
||||
board,
|
||||
toolController,
|
||||
focus,
|
||||
dispatch,
|
||||
}: {
|
||||
boardElem: L.Property<HTMLElement | null>
|
||||
coordinateHelper: BoardCoordinateHelper
|
||||
latestConnection: L.Property<Connection | null>
|
||||
board: L.Property<Board>
|
||||
toolController: ToolController
|
||||
focus: L.Atom<BoardFocus>
|
||||
dispatch: Dispatch
|
||||
}) {
|
||||
let start: L.Atom<BoardCoordinates | null> = L.atom(null)
|
||||
let current: L.Atom<BoardCoordinates | null> = L.atom(null)
|
||||
let rect: L.Property<Rect | null> = L.view(start, current, (s, c) => {
|
||||
if (!s || !c) return null
|
||||
return rectFromPoints(s, c)
|
||||
})
|
||||
const tool = toolController.tool
|
||||
|
||||
const connector = newConnectionCreator(board, focus, latestConnection, dispatch)
|
||||
|
||||
const dragAction = L.atom<DragAction>({ action: "none" })
|
||||
|
||||
boardElem.forEach((el) => {
|
||||
if (!el) return
|
||||
el.addEventListener("dragstart", (e) => {
|
||||
const t = tool.get()
|
||||
const shouldDragSelect = t === "pan" ? !!e.altKey || !!e.shiftKey : !e.altKey
|
||||
e.dataTransfer?.setDragImage(DND_GHOST_HIDING_IMAGE, 0, 0)
|
||||
const pos = coordinateHelper.pageToBoardCoordinates({ x: e.pageX, y: e.pageY })
|
||||
start.set(pos)
|
||||
current.set(pos)
|
||||
if (t === "connect") {
|
||||
dragAction.set({ action: "connect", startPos: pos })
|
||||
} else if (t === "line") {
|
||||
dragAction.set({ action: "line", startPos: pos })
|
||||
} else if (!shouldDragSelect) {
|
||||
dragAction.set({ action: "pan" })
|
||||
} else {
|
||||
const f = focus.get()
|
||||
const selectedAtStart = e.shiftKey ? f : noFocus
|
||||
const anySelected = isAnythingSelected(selectedAtStart)
|
||||
focus.set(
|
||||
anySelected
|
||||
? {
|
||||
status: "selected",
|
||||
itemIds: getSelectedItemIds(selectedAtStart),
|
||||
connectionIds: getSelectedConnectionIds(selectedAtStart),
|
||||
}
|
||||
: noFocus,
|
||||
)
|
||||
dragAction.set({ action: "select", selectedAtStart })
|
||||
}
|
||||
})
|
||||
|
||||
el.addEventListener(
|
||||
"drag",
|
||||
_.throttle(
|
||||
(e: DragEvent) => {
|
||||
const coords = coordinateHelper.currentBoardCoordinates.get()
|
||||
current.set(coords)
|
||||
const da = dragAction.get()
|
||||
if (da.action === "select") {
|
||||
const bounds = rect.get()!
|
||||
const startPoint = start.get()!
|
||||
const b = board.get()
|
||||
|
||||
const itemIds = new Set([
|
||||
...Object.values(b.items)
|
||||
.filter((i) => overlaps(i, bounds) && !containedBy(startPoint, i) && !i.hidden) // Do not select container if drag originates from within container
|
||||
.map((i) => i.id),
|
||||
...getSelectedItemIds(da.selectedAtStart),
|
||||
])
|
||||
|
||||
const connectionIds = new Set([
|
||||
...b.connections.filter((c) => overlaps(connectionRect(b)(c), bounds)).map((i) => i.id),
|
||||
...getSelectedConnectionIds(da.selectedAtStart),
|
||||
])
|
||||
|
||||
itemIds.size + connectionIds.size > 0
|
||||
? focus.set({ status: "selected", itemIds, connectionIds })
|
||||
: focus.set(noFocus)
|
||||
} else if (da.action === "pan") {
|
||||
const s = start.get()
|
||||
const c = current.get()
|
||||
s &&
|
||||
c &&
|
||||
(el.style.transform = `translate(${coordinateHelper.emToPagePx(
|
||||
c.x - s.x,
|
||||
)}px, ${coordinateHelper.emToPagePx(c.y - s.y)}px)`)
|
||||
} else if (da.action === "connect") {
|
||||
connector.whileDragging(da.startPos, coords, "connect")
|
||||
} else if (da.action === "line") {
|
||||
connector.whileDragging(da.startPos, coords, "line")
|
||||
}
|
||||
},
|
||||
15,
|
||||
{ leading: true, trailing: true },
|
||||
),
|
||||
)
|
||||
|
||||
el.addEventListener("drop", end)
|
||||
|
||||
el.addEventListener("dragend", reset)
|
||||
|
||||
function reset() {
|
||||
const da = dragAction.get()
|
||||
if (da.action === "pan") {
|
||||
boardElem.get()!.style.transform = "translate(0, 0)"
|
||||
if (!start.get() || !current.get()) return
|
||||
const s = document.querySelector(".scroll-container")!
|
||||
const { x: startX, y: startY } = start.get()!
|
||||
const { x, y } = current.get()!
|
||||
const xDiff = coordinateHelper.emToPagePx(startX - x)
|
||||
const yDiff = coordinateHelper.emToPagePx(startY - y)
|
||||
s.scrollBy(xDiff, yDiff)
|
||||
} else if (da.action === "connect" || da.action === "line") {
|
||||
toolController.useDefaultTool()
|
||||
connector.endDrag()
|
||||
}
|
||||
dragAction.set({ action: "none" })
|
||||
}
|
||||
|
||||
function end() {
|
||||
reset()
|
||||
if (start.get()) {
|
||||
start.set(null)
|
||||
current.set(null)
|
||||
}
|
||||
}
|
||||
|
||||
let touchStart: Touch | null = null
|
||||
function preventDefaultTouch(e: TouchEvent) {
|
||||
if (e.target === boardElem.get()) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
const onTouch = (e: TouchEvent) => {
|
||||
preventDefaultTouch(e)
|
||||
onSingleTouch(e, (touch) => {
|
||||
if (touchStart) {
|
||||
const xDiff = touchStart.pageX - touch.pageX
|
||||
const yDiff = touchStart.pageY - touch.pageY
|
||||
const s = document.querySelector(".scroll-container")!
|
||||
s.scrollBy(xDiff, yDiff)
|
||||
}
|
||||
touchStart = touch
|
||||
})
|
||||
}
|
||||
el.addEventListener("touchmove", onTouch)
|
||||
el.addEventListener("touchend", (e) => {
|
||||
preventDefaultTouch(e)
|
||||
touchStart = null
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
selectionRect: L.pipe(
|
||||
L.combine(rect, dragAction, (rect: Rect | null, dragAction: DragAction) => {
|
||||
if (!rect || dragAction.action !== "select") return null
|
||||
return rect
|
||||
}),
|
||||
L.skipDuplicates<Rect | null>(_.isEqual, componentScope()),
|
||||
),
|
||||
}
|
||||
}
|
||||
93
frontend/src/board/board-focus.ts
Normal file
93
frontend/src/board/board-focus.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { HarmajaChild } from "harmaja"
|
||||
import { Board, Connection, findConnection, findItem, Id, Item } from "../../../common/src/domain"
|
||||
import { difference, emptySet } from "../../../common/src/sets"
|
||||
|
||||
export type BoardFocus =
|
||||
| { status: "none" }
|
||||
| { status: "selected"; itemIds: Set<Id>; connectionIds: Set<Id> }
|
||||
| { status: "dragging"; itemIds: Set<Id>; connectionIds: Set<Id> }
|
||||
| { status: "editing"; itemId: Id }
|
||||
| { status: "adding"; element: HarmajaChild; item: Item }
|
||||
| { status: "connection-adding" }
|
||||
|
||||
export function getSelectedItemIds(f: BoardFocus): Set<Id> {
|
||||
switch (f.status) {
|
||||
case "none":
|
||||
case "adding":
|
||||
case "connection-adding":
|
||||
return emptySet()
|
||||
case "editing":
|
||||
return new Set([f.itemId])
|
||||
case "selected":
|
||||
case "dragging":
|
||||
return f.itemIds
|
||||
}
|
||||
}
|
||||
|
||||
export function getSelectedConnectionIds(f: BoardFocus): Set<Id> {
|
||||
switch (f.status) {
|
||||
case "none":
|
||||
case "adding":
|
||||
case "editing":
|
||||
case "connection-adding":
|
||||
return emptySet()
|
||||
case "dragging":
|
||||
case "selected":
|
||||
return f.connectionIds
|
||||
}
|
||||
}
|
||||
|
||||
export const getSelectedItems = (b: Board) => (f: BoardFocus): Item[] => {
|
||||
return [...getSelectedItemIds(f)].flatMap((id) => findItem(b)(id) || [])
|
||||
}
|
||||
|
||||
export const getSelectedConnections = (b: Board) => (f: BoardFocus): Connection[] => {
|
||||
return [...getSelectedConnectionIds(f)].flatMap((id) => findConnection(b)(id) || [])
|
||||
}
|
||||
|
||||
export const getSelectedItem = (b: Board) => (f: BoardFocus): Item | null => {
|
||||
return getSelectedItems(b)(f)[0] || null
|
||||
}
|
||||
|
||||
export const isAnythingSelected = (f: BoardFocus) =>
|
||||
getSelectedConnectionIds(f).size > 0 || getSelectedItemIds(f).size > 0
|
||||
|
||||
export function removeFromSelection(
|
||||
selection: BoardFocus,
|
||||
toRemoveItems: Set<Id>,
|
||||
toRemoveConnections: Set<Id>,
|
||||
): BoardFocus {
|
||||
switch (selection.status) {
|
||||
case "adding":
|
||||
case "none":
|
||||
case "connection-adding":
|
||||
return selection
|
||||
case "editing":
|
||||
return toRemoveItems.has(selection.itemId) ? noFocus : selection
|
||||
case "dragging":
|
||||
case "selected":
|
||||
selection = {
|
||||
...selection,
|
||||
itemIds: difference(selection.itemIds, toRemoveItems),
|
||||
connectionIds: difference(selection.connectionIds, toRemoveConnections),
|
||||
}
|
||||
return selection.itemIds.size + selection.connectionIds.size > 0 ? selection : noFocus
|
||||
}
|
||||
}
|
||||
|
||||
export function removeNonExistingFromSelection(selection: BoardFocus, board: Board): BoardFocus {
|
||||
const toRemoveItems = new Set(
|
||||
[...getSelectedItemIds(selection)].filter((id) => {
|
||||
if (!board.items[id]) return true
|
||||
if (board.items[id].hidden) return true
|
||||
}),
|
||||
)
|
||||
const selectedConnectionIds = getSelectedConnectionIds(selection)
|
||||
const toRemoveConnections =
|
||||
selectedConnectionIds.size > 0
|
||||
? difference(selectedConnectionIds, new Set(board.connections.filter((c) => !c.hidden).map((c) => c.id)))
|
||||
: emptySet<Id>()
|
||||
return removeFromSelection(selection, toRemoveItems, toRemoveConnections)
|
||||
}
|
||||
|
||||
export const noFocus: BoardFocus = { status: "none" }
|
||||
16
frontend/src/board/board-permissions.ts
Normal file
16
frontend/src/board/board-permissions.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Connection, Item } from "../../../common/src/domain"
|
||||
|
||||
export const canChangeFont: BoardPermission = (item) => !item.locked
|
||||
export const canChangeShapeAndColor: BoardPermission = (item): boolean => !item.locked
|
||||
export const canChangeTextAlign: BoardPermission = (item): boolean => !item.locked
|
||||
export const canChangeTextFormat: BoardPermission = (item): boolean => !item.locked
|
||||
export const canChangeVisibility: BoardPermission = (item): boolean => !item.locked
|
||||
export const canChangeText: BoardPermission = (item): boolean => true
|
||||
export const canMove: BoardPermission = (item): boolean => !item.locked
|
||||
export const canLock: BoardPermission = (item): boolean => !item.locked
|
||||
export const canUnlock: BoardPermission = (item): boolean => item.locked === "locked"
|
||||
export const canDelete: BoardPermission = (item): boolean => !item.locked
|
||||
|
||||
export type BoardPermission = (item: Item | Connection) => boolean
|
||||
export const nullablePermission = (permission: BoardPermission) => (item: Item | Connection | null) =>
|
||||
item === null ? false : permission(item)
|
||||
240
frontend/src/board/board-scroll-and-zoom.ts
Normal file
240
frontend/src/board/board-scroll-and-zoom.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import * as H from "harmaja"
|
||||
import { componentScope } from "harmaja"
|
||||
import _, { clamp } from "lodash"
|
||||
import * as L from "lonna"
|
||||
import { Board } from "../../../common/src/domain"
|
||||
import * as G from "../../../common/src/geometry"
|
||||
import { BoardCoordinateHelper } from "./board-coordinates"
|
||||
import { ToolController } from "./tool-selection"
|
||||
import { boardContentArea } from "./boardContentArea"
|
||||
|
||||
export type BoardZoom = { zoom: number; quickZoom: number }
|
||||
export type ZoomAdjustMode = "preserveCursor" | "preserveCenter"
|
||||
|
||||
export function nonNull<A>(x: A | null | undefined): x is A {
|
||||
return !!x
|
||||
}
|
||||
|
||||
export type ZoomAndScrollControls = ReturnType<typeof boardScrollAndZoomHandler>
|
||||
|
||||
export function boardScrollAndZoomHandler(
|
||||
board: L.Property<Board>,
|
||||
boardElement: L.Property<HTMLElement | null>,
|
||||
scrollElement: L.Property<HTMLElement | null>,
|
||||
zoom: L.Atom<BoardZoom>,
|
||||
coordinateHelper: BoardCoordinateHelper,
|
||||
toolController: ToolController,
|
||||
) {
|
||||
const scrollPos = scrollElement.pipe(
|
||||
L.changes,
|
||||
L.filter(nonNull),
|
||||
L.flatMapLatest(
|
||||
(el) => L.fromEvent(el, "scroll").pipe(L.map(() => ({ x: el.scrollLeft, y: el.scrollTop }))),
|
||||
componentScope(),
|
||||
),
|
||||
L.toProperty(G.origin as { x: number; y: number }, componentScope()),
|
||||
)
|
||||
|
||||
const scrollAndZoom = L.combine(scrollPos, zoom, (s, zoom) => ({ ...s, zoom }))
|
||||
|
||||
const localStorageKey = L.view(
|
||||
board,
|
||||
(b) => b.id,
|
||||
(id) => "scrollAndZoom." + id,
|
||||
)
|
||||
|
||||
const boardIsNonEmpty = board.pipe(L.map((b) => b.name !== ""))
|
||||
L.view(scrollElement, boardElement, localStorageKey, boardIsNonEmpty, (el, be, key, neb) => ({ el, be, key, neb }))
|
||||
.pipe(L.applyScope(componentScope()))
|
||||
.forEach(({ el, be, key, neb }) => {
|
||||
if (el && be && key && neb) {
|
||||
const storedScrollAndZoom = localStorage[key]
|
||||
setTimeout(() => {
|
||||
if (storedScrollAndZoom) {
|
||||
const parsed = JSON.parse(storedScrollAndZoom)
|
||||
console.log("Restoring scroll and zoom for board from localStorage", parsed.x, parsed.x)
|
||||
zoom.set({ zoom: parsed.zoom, quickZoom: 1 })
|
||||
el.scrollTop = parsed.y
|
||||
el.scrollLeft = parsed.x
|
||||
} else {
|
||||
viewRect.set(boardContentArea(board.get(), viewRect.get()))
|
||||
}
|
||||
}, 0) // Need to wait for first render to have correct size. Causes a little flicker.
|
||||
}
|
||||
})
|
||||
|
||||
scrollAndZoom.pipe(L.changes, L.debounce(100), L.applyScope(componentScope())).forEach((s) => {
|
||||
//console.log("Store position for board", localStorageKey.get())
|
||||
localStorage[localStorageKey.get()] = JSON.stringify({ ...s, zoom: s.zoom.zoom * s.zoom.quickZoom })
|
||||
})
|
||||
|
||||
const changes = L.merge(
|
||||
L.fromEvent(window, "resize"),
|
||||
scrollPos.pipe(L.changes),
|
||||
L.changes(boardElement),
|
||||
L.changes(zoom),
|
||||
)
|
||||
|
||||
const viewRectProp = changes.pipe(
|
||||
L.throttle(0, componentScope()), // without the throttle/delay the rects below are not set correctly yet
|
||||
L.toStatelessProperty(() => {
|
||||
const boardRect = boardElement.get()?.getBoundingClientRect()
|
||||
const viewRect = scrollElement.get()?.getBoundingClientRect()!
|
||||
|
||||
if (!boardRect || !viewRect) return G.ZERO_RECT
|
||||
|
||||
return {
|
||||
x: coordinateHelper.pxToEm(viewRect.x - boardRect.x),
|
||||
y: coordinateHelper.pxToEm(viewRect.y - boardRect.y),
|
||||
width: coordinateHelper.pxToEm(viewRect.width),
|
||||
height: coordinateHelper.pxToEm(viewRect.height),
|
||||
}
|
||||
}),
|
||||
L.cached(componentScope()),
|
||||
)
|
||||
|
||||
const viewRect = L.atom(viewRectProp, (newRect) => {
|
||||
const currentRect = viewRectProp.get()
|
||||
const factor = newRect.width / currentRect.width
|
||||
zoom.modify((z) => ({ ...z, quickZoom: z.quickZoom / factor }))
|
||||
|
||||
const newX = coordinateHelper.emToBoardPx(newRect.x) / factor
|
||||
const newY = coordinateHelper.emToBoardPx(newRect.y) / factor
|
||||
|
||||
scrollElement.get()!.scrollLeft = newX
|
||||
scrollElement.get()!.scrollTop = newY
|
||||
})
|
||||
|
||||
function wheelZoomHandler(event: WheelEvent) {
|
||||
const ctrlOrCmd = event.ctrlKey || event.metaKey
|
||||
|
||||
// Wheel-zoom, or two finger zoom gesture on trackpad
|
||||
if (ctrlOrCmd && event.deltaY !== 0) {
|
||||
event.preventDefault()
|
||||
const clampedStep = clamp(event.deltaY, -8, 8)
|
||||
const step = Math.pow(1.01, -clampedStep)
|
||||
adjustZoom({ scaleBy: step }, "preserveCursor")
|
||||
} else {
|
||||
// If the user seems to be using a trackpad, and they haven't manually selected a tool yet,
|
||||
// Let's set the mode to 'select' as a best-effort "works like you'd expect" UX thing
|
||||
const settings = toolController.controlSettings.get()
|
||||
if (settings.defaultTool || settings.tool === "select") {
|
||||
// Don't automatically make decisions for user if they have already set tool manually,
|
||||
// Or if the select tool is already on
|
||||
return
|
||||
}
|
||||
|
||||
// On Firefox event.deltaMode is 0 on trackpad, 1 on mouse. Other browsers always 0.
|
||||
// So we guess that user using trackpad if deltaMode == 0 and both deltaY/deltaX are sufficiently small (mousewheel is more coarse)
|
||||
const isTrackpad = event.deltaMode === 0 && Math.max(Math.abs(event.deltaX), Math.abs(event.deltaY)) <= 3
|
||||
|
||||
if (isTrackpad) {
|
||||
toolController.tool.set("select")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_ZOOM = 10
|
||||
const MIN_ZOOM = 0.1
|
||||
|
||||
zoom.pipe(L.changes, L.debounce(50, componentScope())).forEach((z) => {
|
||||
if (z.quickZoom !== 1 && !scaleStart) {
|
||||
const newZoom = clamp(z.zoom * z.quickZoom, MIN_ZOOM, MAX_ZOOM)
|
||||
zoom.set({ zoom: newZoom, quickZoom: 1 })
|
||||
}
|
||||
})
|
||||
|
||||
function getViewRectCenter() {
|
||||
const vr = viewRect.get()
|
||||
return { x: vr.x + vr.width / 2, y: vr.y + vr.height / 2 }
|
||||
}
|
||||
|
||||
type ZoomAdjustment = { scaleBy: number } | { setZoom: number }
|
||||
|
||||
function adjustZoom(change: ZoomAdjustment, mode: ZoomAdjustMode) {
|
||||
if (mode === "preserveCursor") {
|
||||
const prevCursor = coordinateHelper.currentBoardCoordinates.get()
|
||||
justAdjustZoom(change)
|
||||
const diffEm = G.subtract(prevCursor, coordinateHelper.currentBoardCoordinates.get())
|
||||
coordinateHelper.scrollByBoardCoordinates(diffEm)
|
||||
} else {
|
||||
const prevCenterEm = getViewRectCenter()
|
||||
const prevZoom = zoom.get()
|
||||
justAdjustZoom(change)
|
||||
const newZoom = zoom.get()
|
||||
const ratio = (newZoom.zoom * newZoom.quickZoom) / (prevZoom.zoom * prevZoom.quickZoom)
|
||||
const newCenterEm = G.multiply(prevCenterEm, 1 / ratio)
|
||||
const diffEm = G.subtract(prevCenterEm, newCenterEm)
|
||||
coordinateHelper.scrollByBoardCoordinates(diffEm)
|
||||
}
|
||||
}
|
||||
|
||||
function justAdjustZoom(change: ZoomAdjustment) {
|
||||
if ("scaleBy" in change) {
|
||||
zoom.modify((z) => {
|
||||
return {
|
||||
quickZoom: _.clamp(z.quickZoom * change.scaleBy, MIN_ZOOM / z.zoom, MAX_ZOOM / z.zoom),
|
||||
zoom: z.zoom,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
zoom.set({ quickZoom: 1, zoom: change.setZoom })
|
||||
}
|
||||
}
|
||||
|
||||
let scaleStart: number | null = null
|
||||
|
||||
function onGestureStart(e: any) {
|
||||
e.preventDefault()
|
||||
const scale = typeof e.scale === "number" && (e.scale as number)
|
||||
if (scale) {
|
||||
scaleStart = (zoom.get().quickZoom * zoom.get().zoom) / scale
|
||||
}
|
||||
}
|
||||
|
||||
function onGestureChange(e: any) {
|
||||
e.preventDefault()
|
||||
const scale = typeof e.scale === "number" && (e.scale as number)
|
||||
if (scale && scaleStart) {
|
||||
adjustZoom({ setZoom: scale * scaleStart }, "preserveCursor")
|
||||
}
|
||||
}
|
||||
|
||||
function onGestureEnd(e: any) {
|
||||
onGestureChange(e)
|
||||
scaleStart = null
|
||||
}
|
||||
|
||||
function increaseZoom(adjustMode: ZoomAdjustMode) {
|
||||
adjustZoom({ scaleBy: 1.2 }, adjustMode)
|
||||
}
|
||||
|
||||
function decreaseZoom(adjustMode: ZoomAdjustMode) {
|
||||
adjustZoom({ scaleBy: 1 / 1.2 }, adjustMode)
|
||||
}
|
||||
|
||||
function resetZoom(adjustMode: ZoomAdjustMode) {
|
||||
adjustZoom({ setZoom: 1 }, adjustMode)
|
||||
}
|
||||
|
||||
H.onMount(() => {
|
||||
// have to use this for chrome: https://stackoverflow.com/questions/42101723/unable-to-preventdefault-inside-passive-event-listener
|
||||
document.addEventListener("wheel", wheelZoomHandler, { passive: false })
|
||||
document.addEventListener("gesturestart", onGestureStart)
|
||||
document.addEventListener("gesturechange", onGestureChange)
|
||||
document.addEventListener("gestureend", onGestureEnd)
|
||||
})
|
||||
H.onUnmount(() => {
|
||||
document.removeEventListener("wheel", wheelZoomHandler)
|
||||
document.removeEventListener("gesturestart", onGestureStart)
|
||||
document.removeEventListener("gesturechange", onGestureChange)
|
||||
document.removeEventListener("gestureend", onGestureChange)
|
||||
})
|
||||
return {
|
||||
viewRect,
|
||||
adjustZoom,
|
||||
increaseZoom,
|
||||
decreaseZoom,
|
||||
resetZoom,
|
||||
}
|
||||
}
|
||||
93
frontend/src/board/boardContentArea.ts
Normal file
93
frontend/src/board/boardContentArea.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import _ from "lodash"
|
||||
import { Board } from "../../../common/src/domain"
|
||||
import { Rect } from "../../../common/src/geometry"
|
||||
|
||||
function combineRects(r1: Rect, r2: Rect): Rect {
|
||||
const left = Math.min(r1.x, r2.x)
|
||||
const top = Math.min(r1.y, r2.y)
|
||||
const right = Math.max(r1.x + r1.width, r2.x + r2.width)
|
||||
const bottom = Math.max(r1.y + r1.height, r2.y + r2.height)
|
||||
return { x: left, y: top, width: right - left, height: bottom - top }
|
||||
}
|
||||
|
||||
function itemToRect(item: Rect): Rect {
|
||||
return { x: item.x, y: item.y, width: item.width, height: item.height }
|
||||
}
|
||||
|
||||
function addMargin(rect: Rect, margin: number): Rect {
|
||||
return {
|
||||
x: rect.x - margin,
|
||||
y: rect.y - margin,
|
||||
width: rect.width + 2 * margin,
|
||||
height: rect.height + 2 * margin,
|
||||
}
|
||||
}
|
||||
|
||||
function setMinimumSizeKeepingCenter(rect: Rect, minimumSize: { width: number; height: number }) {
|
||||
const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }
|
||||
const width = Math.max(rect.width, minimumSize.width)
|
||||
const height = Math.max(rect.height, minimumSize.height)
|
||||
return {
|
||||
x: center.x - width / 2,
|
||||
y: center.y - height / 2,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
function clampIntoKeepingSize(rect: Rect, bounds: Rect) {
|
||||
const minLeft = bounds.x
|
||||
const minTop = bounds.y
|
||||
const maxLeft = bounds.x + bounds.width - rect.width
|
||||
const maxTop = bounds.y + bounds.height - rect.height
|
||||
const x = _.clamp(rect.x, minLeft, maxLeft)
|
||||
const y = _.clamp(rect.y, minTop, maxTop)
|
||||
return { x, y, width: rect.width, height: rect.height }
|
||||
}
|
||||
|
||||
function growToProportions(rect: Rect, proportions: { width: number; height: number }) {
|
||||
const width = rect.width
|
||||
const height = rect.height
|
||||
const targetWidth = rect.height * (proportions.width / proportions.height)
|
||||
const targetHeight = rect.width * (proportions.height / proportions.width)
|
||||
if (targetWidth > rect.width) {
|
||||
const diff = targetWidth - rect.width
|
||||
rect.x -= diff / 2
|
||||
rect.width = targetWidth
|
||||
} else {
|
||||
const diff = targetHeight - rect.height
|
||||
rect.y -= diff / 2
|
||||
rect.height = targetHeight
|
||||
}
|
||||
return rect
|
||||
}
|
||||
|
||||
export function boardContentArea(b: Board, viewPortDimensions: { width: number; height: number }) {
|
||||
// Default / minimum size for initial view
|
||||
const width = b.width / 10
|
||||
const height = b.height / 10
|
||||
|
||||
const items = Object.values(b.items)
|
||||
|
||||
if (!items.length) {
|
||||
console.log("No items in board, centering view")
|
||||
return addMargin({ x: b.width / 2 - width / 2, y: b.height / 2 - height / 2, width, height }, height * 0.1)
|
||||
}
|
||||
console.log(`${items.length} items on board, calculating view area`)
|
||||
let itemsArea = itemToRect(items[0])
|
||||
items.forEach((item) => {
|
||||
itemsArea = combineRects(itemsArea, itemToRect(item))
|
||||
})
|
||||
|
||||
// Now we have the area of all items, let's add some margin
|
||||
itemsArea = addMargin(itemsArea, width * 0.2)
|
||||
|
||||
itemsArea = growToProportions(itemsArea, viewPortDimensions)
|
||||
|
||||
// Grow to at least the default size keeping center point
|
||||
itemsArea = setMinimumSizeKeepingCenter(itemsArea, { width, height })
|
||||
|
||||
// Clamp to board limits
|
||||
itemsArea = clampIntoKeepingSize(itemsArea, { x: 0, y: 0, width: b.width, height: b.height })
|
||||
return itemsArea
|
||||
}
|
||||
139
frontend/src/board/contextmenu/ContextMenuView.tsx
Normal file
139
frontend/src/board/contextmenu/ContextMenuView.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { h, HarmajaOutput, ListView } from "harmaja"
|
||||
import _ from "lodash"
|
||||
import * as L from "lonna"
|
||||
import { Board, Connection, findItem, Id, Item } from "../../../../common/src/domain"
|
||||
import { Dispatch } from "../../store/board-store"
|
||||
import { BoardFocus, getSelectedConnections, getSelectedItems } from "../board-focus"
|
||||
import { Rect } from "../../../../common/src/geometry"
|
||||
import { alignmentsMenu } from "./alignments"
|
||||
import { areaTilingMenu } from "./areaTiling"
|
||||
import { colorsAndShapesMenu } from "./colorsAndShapes"
|
||||
import { fontSizesMenu } from "./fontSizes"
|
||||
import { resolveEndpoint } from "../../../../common/src/connection-utils"
|
||||
import { connectionEndsMenu } from "./connection-ends"
|
||||
import { textAlignmentsMenu } from "./textAlignments"
|
||||
import { lockMenu } from "./lock"
|
||||
import { hideContentsMenu } from "./hideContents"
|
||||
import { textFormatsMenu } from "./textFormats"
|
||||
import { nonNull } from "../board-scroll-and-zoom"
|
||||
|
||||
export type SubmenuProps = {
|
||||
focusedItems: L.Property<{ items: Item[]; connections: Connection[] }>
|
||||
board: L.Property<Board>
|
||||
dispatch: Dispatch
|
||||
submenu: L.Atom<SubMenuCreator | null>
|
||||
}
|
||||
|
||||
export type SubMenuCreator = (props: SubmenuProps) => HarmajaOutput
|
||||
|
||||
export const connectionPos = (b: Board | Record<string, Item>) => (c: Connection): Rect => {
|
||||
if (c.controlPoints.length === 0) {
|
||||
const start = resolveEndpoint(c.from, b)
|
||||
const end = resolveEndpoint(c.to, b)
|
||||
return { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2, width: 0, height: 0 }
|
||||
}
|
||||
const p = c.controlPoints[0]
|
||||
return { ...p, width: 0, height: 0 }
|
||||
}
|
||||
|
||||
type ItemsAndConnections = { items: Item[]; connections: Connection[] }
|
||||
type Bounds = { minX: number; maxX: number; minY: number; maxY: number } | null
|
||||
|
||||
const getSelectionBounds = (items: ItemsAndConnections, b: Board): Bounds => {
|
||||
if (items.items.length === 0 && items.connections.length === 0) {
|
||||
return null
|
||||
}
|
||||
const rects = [...items.items, ...items.connections.map(connectionPos(b))]
|
||||
const minY = _.min(rects.map((i) => i.y)) || 0
|
||||
const minX = _.min(rects.map((i) => i.x)) || 0
|
||||
const maxY = _.max(rects.map((i) => i.y + i.height)) || 0
|
||||
const maxX = _.max(rects.map((i) => i.x + i.width)) || 0
|
||||
return { minX, maxX, minY, maxY }
|
||||
}
|
||||
|
||||
const getStyleAndClass = (bounds: Bounds) => (vr: Rect) => {
|
||||
const cn = "context-menu-positioner"
|
||||
if (bounds === null) {
|
||||
return {
|
||||
style: null,
|
||||
className: cn,
|
||||
}
|
||||
}
|
||||
const { minX, maxX, minY, maxY } = bounds
|
||||
const alignRight = minX > vr.x + vr.width / 2
|
||||
const topOfItem = minY - vr.y > vr.height / 3
|
||||
return {
|
||||
style: {
|
||||
left: alignRight ? undefined : `max(${minX}em, ${vr.x}em)`,
|
||||
right: alignRight ? `calc(100% - min(${maxX}em, ${vr.x + vr.width}em))` : undefined,
|
||||
top: topOfItem ? `${minY}em` : `${maxY}em`,
|
||||
},
|
||||
className: cn + (topOfItem ? " item-top" : " item-bottom"),
|
||||
}
|
||||
}
|
||||
|
||||
export const ContextMenuView = ({
|
||||
dispatch,
|
||||
board,
|
||||
focus,
|
||||
viewRect,
|
||||
}: {
|
||||
dispatch: Dispatch
|
||||
board: L.Property<Board>
|
||||
focus: L.Property<BoardFocus>
|
||||
viewRect: L.Property<Rect>
|
||||
}) => {
|
||||
const focusedItems = L.view(focus, board, (f, b) => {
|
||||
if (f.status === "dragging" || f.status === "connection-adding" || f.status === "adding")
|
||||
return { items: [], connections: [] }
|
||||
return { items: getSelectedItems(b)(f), connections: getSelectedConnections(b)(f) }
|
||||
})
|
||||
|
||||
const submenu = L.atom<SubMenuCreator | null>(null)
|
||||
L.view(
|
||||
focusedItems,
|
||||
(items) => items.items[0],
|
||||
(i) => i?.id,
|
||||
).forEach(() => submenu.set(null))
|
||||
|
||||
const props = { board, focusedItems, dispatch, submenu }
|
||||
const widgetCreators = [
|
||||
alignmentsMenu("x", props),
|
||||
alignmentsMenu("y", props),
|
||||
colorsAndShapesMenu(props),
|
||||
fontSizesMenu(props),
|
||||
textFormatsMenu(props),
|
||||
textAlignmentsMenu(props),
|
||||
areaTilingMenu(props),
|
||||
connectionEndsMenu(props),
|
||||
hideContentsMenu(props),
|
||||
lockMenu(props),
|
||||
]
|
||||
const activeWidgets = L.view(L.combineAsArray(widgetCreators), (arrays) => arrays.flat().filter(nonNull))
|
||||
const captureEvents = (e: JSX.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
return L.view(
|
||||
activeWidgets,
|
||||
(ws) => ws.length === 0,
|
||||
(hide) => {
|
||||
if (hide) return null
|
||||
const bounds = L.view(focus, () => getSelectionBounds(focusedItems.get(), board.get()))
|
||||
const styleAndClass = L.view(viewRect, bounds, (vr, b) => getStyleAndClass(b)(vr))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={L.view(styleAndClass, "className")}
|
||||
style={L.view(styleAndClass, "style")}
|
||||
onDoubleClick={captureEvents}
|
||||
onClick={captureEvents}
|
||||
>
|
||||
<div className="context-menu">
|
||||
<ListView observable={activeWidgets} renderItem={(x) => x} getKey={(x) => x} />
|
||||
</div>
|
||||
{L.view(submenu, (show) => (show ? show(props) : null))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
212
frontend/src/board/contextmenu/alignments.tsx
Normal file
212
frontend/src/board/contextmenu/alignments.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { componentScope, h } from "harmaja"
|
||||
import _ from "lodash"
|
||||
import * as L from "lonna"
|
||||
import { Item } from "../../../../common/src/domain"
|
||||
import {
|
||||
AlignHorizontalCenterIcon,
|
||||
AlignHorizontalLeftIcon,
|
||||
AlignHorizontalRightIcon,
|
||||
AlignVerticalBottomIcon,
|
||||
AlignVerticalCenterIcon,
|
||||
AlignVerticalTopIcon,
|
||||
HorizontalDistributeIcon,
|
||||
VerticalDistributeIcon,
|
||||
} from "../../components/Icons"
|
||||
import { SubmenuProps } from "./ContextMenuView"
|
||||
import { canMove } from "../board-permissions"
|
||||
|
||||
const createSubMenuByAxis = (axis: Axis) => (props: SubmenuProps) => {
|
||||
return <div className={`submenu alignment ${axis}`}>{alignmentsSubMenu(axis, props)}</div>
|
||||
}
|
||||
|
||||
export function alignmentsMenu(axis: Axis, props: SubmenuProps) {
|
||||
const hasItemsToAlign = L.view(props.focusedItems, (items) => items.items.length > 1)
|
||||
const hasItemsToDistribute = L.view(props.focusedItems, (items) => items.items.length > 2)
|
||||
const createSubmenu = createSubMenuByAxis(axis)
|
||||
const enabled = L.view(props.focusedItems, (items) => items.items.some(canMove))
|
||||
|
||||
return L.combine(hasItemsToAlign, hasItemsToDistribute, (hasItemsToAlign, hasItemsToDistribute) => {
|
||||
return !hasItemsToAlign && !hasItemsToDistribute
|
||||
? []
|
||||
: [
|
||||
<div className="icon-group align">
|
||||
{hasItemsToAlign && (
|
||||
<span
|
||||
className={L.view(enabled, (e) => (e ? "icon" : "icon disabled"))}
|
||||
onClick={() => props.submenu.modify((v) => (v === createSubmenu ? null : createSubmenu))}
|
||||
title={axis === "x" ? "Align left" : "Align top"}
|
||||
>
|
||||
{axis == "x" ? <AlignHorizontalLeftIcon /> : <AlignVerticalTopIcon />}
|
||||
</span>
|
||||
)}
|
||||
</div>,
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
type Axis = "x" | "y"
|
||||
type GetCoordinate = (
|
||||
item: Item,
|
||||
min: number,
|
||||
max: number,
|
||||
axis: Axis,
|
||||
index: number,
|
||||
numberOfItems: number,
|
||||
sumOfPreviousSizes: number,
|
||||
totalSumOfSizes: number,
|
||||
) => number
|
||||
|
||||
function getItemSize(item: Item, axis: Axis) {
|
||||
return axis === "x" ? item.width : item.height
|
||||
}
|
||||
|
||||
function moveFocusedItems(
|
||||
axis: Axis,
|
||||
getCoordinateToSetToItem: GetCoordinate,
|
||||
{ board, focusedItems, dispatch }: SubmenuProps,
|
||||
) {
|
||||
const b = board.get()
|
||||
|
||||
const itemsToMove = focusedItems.get().items
|
||||
const min = _.min(itemsToMove.map((i) => i[axis])) || 0
|
||||
const max = _.max(itemsToMove.map((i) => i[axis] + getItemSize(i, axis))) || 0
|
||||
const totalSumOfSizes = _.sum(itemsToMove.map((i) => getItemSize(i, axis), 0))
|
||||
|
||||
let sumOfPreviousSizes = 0
|
||||
const updatedItems = focusedItems
|
||||
.get()
|
||||
.items.sort((item1, item2) => item1[axis] - item2[axis])
|
||||
.map((item, index) => {
|
||||
const newItem = {
|
||||
id: item.id,
|
||||
[axis]: getCoordinateToSetToItem(
|
||||
item,
|
||||
min,
|
||||
max,
|
||||
axis,
|
||||
index,
|
||||
itemsToMove.length,
|
||||
sumOfPreviousSizes,
|
||||
totalSumOfSizes,
|
||||
),
|
||||
}
|
||||
sumOfPreviousSizes += getItemSize(item, axis)
|
||||
return newItem
|
||||
})
|
||||
dispatch({ action: "item.update", boardId: b.id, items: updatedItems })
|
||||
}
|
||||
|
||||
export function alignmentsSubMenu(axis: Axis, props: SubmenuProps) {
|
||||
const hasItemsToAlign = L.view(props.focusedItems, (items) => items.items.length > 1)
|
||||
const hasItemsToDistribute = L.view(props.focusedItems, (items) => items.items.length > 2)
|
||||
|
||||
const getMinCoordinate: GetCoordinate = (_, min) => min
|
||||
|
||||
const getCenterCoordinate: GetCoordinate = (item, min, max, axis) => (min + max - getItemSize(item, axis)) / 2
|
||||
|
||||
const getMaxCoordinate: GetCoordinate = (item, min, max, axis) => max - getItemSize(item, axis)
|
||||
|
||||
const getDistributedCoordinate: GetCoordinate = (
|
||||
item,
|
||||
min,
|
||||
max,
|
||||
_,
|
||||
index,
|
||||
numberOfItems,
|
||||
sumOfPreviousSizes,
|
||||
totalSumOfSizes,
|
||||
) => {
|
||||
const spaceBetweenItems = (max - min - totalSumOfSizes) / (numberOfItems - 1)
|
||||
return min + sumOfPreviousSizes + index * spaceBetweenItems
|
||||
}
|
||||
return L.combine(hasItemsToAlign, hasItemsToDistribute, (hasItemsToAlign, hasItemsToDistribute) => {
|
||||
return !hasItemsToAlign
|
||||
? []
|
||||
: axis == "x"
|
||||
? [
|
||||
<div className="icon-group align">
|
||||
{hasItemsToAlign && (
|
||||
<span
|
||||
className="icon"
|
||||
onClick={() => moveFocusedItems("x", getMinCoordinate, props)}
|
||||
title="Align left"
|
||||
>
|
||||
<AlignHorizontalLeftIcon />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{hasItemsToAlign && (
|
||||
<span
|
||||
className="icon"
|
||||
onClick={() => moveFocusedItems("x", getCenterCoordinate, props)}
|
||||
title="Align center"
|
||||
>
|
||||
<AlignHorizontalCenterIcon />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{hasItemsToAlign && (
|
||||
<span
|
||||
className="icon"
|
||||
onClick={() => moveFocusedItems("x", getMaxCoordinate, props)}
|
||||
title="Align right"
|
||||
>
|
||||
<AlignHorizontalRightIcon />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{hasItemsToDistribute && (
|
||||
<span
|
||||
className="icon"
|
||||
title="Distribute evenly"
|
||||
onClick={() => moveFocusedItems("x", getDistributedCoordinate, props)}
|
||||
>
|
||||
<HorizontalDistributeIcon />
|
||||
</span>
|
||||
)}
|
||||
</div>,
|
||||
]
|
||||
: [
|
||||
<div className="icon-group align">
|
||||
{hasItemsToAlign && (
|
||||
<span
|
||||
className="icon"
|
||||
title="Align top"
|
||||
onClick={() => moveFocusedItems("y", getMinCoordinate, props)}
|
||||
>
|
||||
<AlignVerticalTopIcon />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{hasItemsToAlign && (
|
||||
<span
|
||||
className="icon"
|
||||
title="Align middle"
|
||||
onClick={() => moveFocusedItems("y", getCenterCoordinate, props)}
|
||||
>
|
||||
<AlignVerticalCenterIcon />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{hasItemsToAlign && (
|
||||
<span
|
||||
className="icon"
|
||||
title="Align bottom"
|
||||
onClick={() => moveFocusedItems("y", getMaxCoordinate, props)}
|
||||
>
|
||||
<AlignVerticalBottomIcon />
|
||||
</span>
|
||||
)}
|
||||
{hasItemsToDistribute && (
|
||||
<span
|
||||
className="icon"
|
||||
title="Distribute evenly vertically"
|
||||
onClick={() => moveFocusedItems("y", getDistributedCoordinate, props)}
|
||||
>
|
||||
<VerticalDistributeIcon />
|
||||
</span>
|
||||
)}
|
||||
</div>,
|
||||
]
|
||||
})
|
||||
}
|
||||
69
frontend/src/board/contextmenu/areaTiling.tsx
Normal file
69
frontend/src/board/contextmenu/areaTiling.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { Board, Container, Item, findItem, isContainer } from "../../../../common/src/domain"
|
||||
import { TileIcon } from "../../components/Icons"
|
||||
import { contentRect, organizeItems, packableItems } from "../item-organizer"
|
||||
import { packItems } from "../item-packer"
|
||||
import { SubmenuProps } from "./ContextMenuView"
|
||||
import { canMove } from "../board-permissions"
|
||||
|
||||
export function areaTilingMenu({ board, focusedItems, dispatch }: SubmenuProps) {
|
||||
const packables = L.view(focusedItems, (items) => {
|
||||
if (items.items.length === 1) {
|
||||
if (isContainer(items.items[0])) return items.items
|
||||
}
|
||||
if (items.items.length >= 1) {
|
||||
const containerIds = new Set(items.items.map((i) => i.containerId))
|
||||
if (containerIds.size === 1 && [...containerIds][0]) return items.items
|
||||
}
|
||||
return []
|
||||
})
|
||||
const enabled = L.view(packables, (items) => items.some(canMove))
|
||||
const className = enabled.pipe(L.map((e) => (e ? "icon" : "icon disabled")))
|
||||
|
||||
return L.view(
|
||||
packables,
|
||||
(ps) => ps.length > 0,
|
||||
(show) =>
|
||||
show
|
||||
? [
|
||||
<div className="icon-group area-options">
|
||||
<span
|
||||
className={className}
|
||||
title="Organize contents"
|
||||
onClick={() => packArbitraryItems(packables.get())}
|
||||
>
|
||||
<TileIcon />
|
||||
</span>
|
||||
</div>,
|
||||
]
|
||||
: [],
|
||||
)
|
||||
|
||||
function packArbitraryItems(items: Item[]) {
|
||||
const b = board.get()
|
||||
if (items.length === 1 && isContainer(items[0])) {
|
||||
packItemsInsideContainer(items[0], b)
|
||||
} else {
|
||||
packItemsInsideContainer(findItem(b)(items[0].containerId!) as Container, b)
|
||||
}
|
||||
}
|
||||
function packItemsInsideContainer(container: Container, b: Board) {
|
||||
const targetRect = contentRect(container)
|
||||
const itemsToPack = packableItems(container, b)
|
||||
let organizedItems = organizeItems(itemsToPack, [], targetRect)
|
||||
if (organizedItems.length === 0) {
|
||||
console.log("Packing")
|
||||
// Already organized -> Pack into equal size to fit
|
||||
const packResult = packItems(targetRect, itemsToPack)
|
||||
|
||||
if (!packResult.ok) {
|
||||
console.error("Packing container failed: " + packResult.error)
|
||||
return
|
||||
}
|
||||
organizedItems = packResult.packedItems
|
||||
}
|
||||
|
||||
dispatch({ action: "item.update", boardId: board.get().id, items: organizedItems })
|
||||
}
|
||||
}
|
||||
47
frontend/src/board/contextmenu/colors.tsx
Normal file
47
frontend/src/board/contextmenu/colors.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { DEFAULT_NOTE_COLOR, NOTE_COLORS, TRANSPARENT } from "../../../../common/src/colors"
|
||||
import { Color, Item, isColoredItem } from "../../../../common/src/domain"
|
||||
import { SubmenuProps } from "./ContextMenuView"
|
||||
|
||||
export function colorsSubMenu({ board, focusedItems, dispatch }: SubmenuProps) {
|
||||
const coloredItems = L.view(focusedItems, (items) => items.items.filter(isColoredItem))
|
||||
const anyColored = L.view(coloredItems, (items) => items.length > 0)
|
||||
|
||||
return L.view(anyColored, (anyColored) => {
|
||||
return !anyColored
|
||||
? []
|
||||
: [
|
||||
<div className="colors icon-group">
|
||||
{NOTE_COLORS.map((color) => {
|
||||
return (
|
||||
<span
|
||||
className={`icon color ${color.name}`}
|
||||
style={{ background: color.color === TRANSPARENT ? undefined : color.color }}
|
||||
onClick={() => setColor(color.color)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<span className={"icon color new-color"}>
|
||||
<input
|
||||
type="color"
|
||||
onInput={(e) => setColor(e.target.value)}
|
||||
value={itemColorOrDefault(focusedItems.get().items)}
|
||||
/>
|
||||
</span>
|
||||
</div>,
|
||||
]
|
||||
})
|
||||
|
||||
function setColor(color: Color) {
|
||||
const b = board.get()
|
||||
const updated = coloredItems.get().map((item) => ({ id: item.id, color }))
|
||||
dispatch({ action: "item.update", boardId: b.id, items: updated })
|
||||
}
|
||||
}
|
||||
|
||||
function itemColorOrDefault(items: Item[]) {
|
||||
const firstNoteWithColor = items.find(isColoredItem)
|
||||
if (!firstNoteWithColor) return DEFAULT_NOTE_COLOR
|
||||
return firstNoteWithColor.color
|
||||
}
|
||||
42
frontend/src/board/contextmenu/colorsAndShapes.tsx
Normal file
42
frontend/src/board/contextmenu/colorsAndShapes.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { componentScope, h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { NOTE_COLORS } from "../../../../common/src/colors"
|
||||
import { ColoredItem, isColoredItem } from "../../../../common/src/domain"
|
||||
import { colorsSubMenu } from "./colors"
|
||||
import { SubmenuProps } from "./ContextMenuView"
|
||||
import { getShapeIcon, shapesSubMenu } from "./shapes"
|
||||
import { disabledColor } from "../../components/UIColors"
|
||||
import { canChangeShapeAndColor } from "../board-permissions"
|
||||
|
||||
function createSubMenu(props: SubmenuProps) {
|
||||
return (
|
||||
<div className="submenu">
|
||||
{colorsSubMenu(props)}
|
||||
{shapesSubMenu(props)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function colorsAndShapesMenu(props: SubmenuProps) {
|
||||
const coloredItems = L.view(props.focusedItems, (items) => items.items.filter(isColoredItem))
|
||||
const representativeColoredItem: L.Property<ColoredItem | null> = L.view(coloredItems, (items) => items[0] || null)
|
||||
const enabled = L.view(coloredItems, (items) => items.some(canChangeShapeAndColor))
|
||||
return L.view(representativeColoredItem, enabled, (item, enabled) => {
|
||||
if (!item) return []
|
||||
const color = NOTE_COLORS.find((c) => c.color === item.color) || { name: "custom", color: item.color }
|
||||
const shapeIcon = getShapeIcon(item)
|
||||
|
||||
return !item
|
||||
? []
|
||||
: [
|
||||
<div className="colors-shapes icon-group">
|
||||
<span
|
||||
className={`icon color ${color.name} ${enabled ? "" : "disabled"}`}
|
||||
onClick={() => props.submenu.modify((v) => (v == createSubMenu ? null : createSubMenu))}
|
||||
>
|
||||
{shapeIcon(enabled ? color.color : disabledColor, enabled ? color.color : undefined)}
|
||||
</span>
|
||||
</div>,
|
||||
]
|
||||
})
|
||||
}
|
||||
108
frontend/src/board/contextmenu/connection-ends.tsx
Normal file
108
frontend/src/board/contextmenu/connection-ends.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { componentScope, h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { rerouteConnection } from "../../../../common/src/connection-utils"
|
||||
import { ConnectionEndStyle } from "../../../../common/src/domain"
|
||||
import {
|
||||
ConnectionCenterCurveDotIcon,
|
||||
ConnectionCenterCurveIcon,
|
||||
ConnectionCenterLineIcon,
|
||||
ConnectionEndLineIcon,
|
||||
ConnectionLeftArrowIcon,
|
||||
ConnectionLeftDotIcon,
|
||||
ConnectionRightArrowIcon,
|
||||
ConnectionRightDotIcon,
|
||||
} from "../../components/Icons"
|
||||
import { SubmenuProps } from "./ContextMenuView"
|
||||
import { canChangeShapeAndColor } from "../board-permissions"
|
||||
|
||||
const styles: ConnectionEndStyle[] = ["arrow", "black-dot", "none"]
|
||||
function nextStyle(style: ConnectionEndStyle) {
|
||||
const i = styles.indexOf(style)
|
||||
return styles[(i + 1) % styles.length]
|
||||
}
|
||||
|
||||
export function connectionEndsMenu({ board, focusedItems, dispatch }: SubmenuProps) {
|
||||
const connections = L.view(focusedItems, (items) => items.connections)
|
||||
const singleConnection = L.view(connections, (connections) =>
|
||||
connections.length === 1 && connections[0].action === "connect" ? connections[0] : null,
|
||||
)
|
||||
const enabled = L.view(connections, (connections) => connections.some(canChangeShapeAndColor))
|
||||
const className = enabled.pipe(L.map((e) => (e ? "icon" : "icon disabled")))
|
||||
|
||||
return L.view(singleConnection, (connection) => {
|
||||
if (!connection) return []
|
||||
return !connection
|
||||
? []
|
||||
: [
|
||||
<div className="connection-ends icon-group">
|
||||
<span
|
||||
className={className}
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
action: "connection.modify",
|
||||
boardId: board.get().id,
|
||||
connections: [{ ...connection, fromStyle: nextStyle(connection.fromStyle) }],
|
||||
})
|
||||
}
|
||||
>
|
||||
{connection.fromStyle === "arrow" ? (
|
||||
<ConnectionLeftArrowIcon />
|
||||
) : connection.fromStyle === "black-dot" ? (
|
||||
<ConnectionLeftDotIcon />
|
||||
) : (
|
||||
<ConnectionEndLineIcon />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={className}
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
action: "connection.modify",
|
||||
boardId: board.get().id,
|
||||
connections: [
|
||||
connection.controlPoints.length === 0
|
||||
? rerouteConnection(
|
||||
{
|
||||
...connection,
|
||||
controlPoints: [{ x: 0, y: 0 }],
|
||||
pointStyle: "black-dot",
|
||||
},
|
||||
board.get(),
|
||||
)
|
||||
: connection.pointStyle === "black-dot"
|
||||
? { ...connection, pointStyle: "none" }
|
||||
: { ...connection, controlPoints: [] },
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
{connection.controlPoints.length === 0 ? (
|
||||
<ConnectionCenterLineIcon />
|
||||
) : connection.pointStyle === "black-dot" ? (
|
||||
<ConnectionCenterCurveDotIcon />
|
||||
) : (
|
||||
<ConnectionCenterCurveIcon />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={className}
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
action: "connection.modify",
|
||||
boardId: board.get().id,
|
||||
connections: [{ ...connection, toStyle: nextStyle(connection.toStyle) }],
|
||||
})
|
||||
}
|
||||
>
|
||||
{connection.toStyle === "arrow" ? (
|
||||
<ConnectionRightArrowIcon />
|
||||
) : connection.toStyle === "black-dot" ? (
|
||||
<ConnectionRightDotIcon />
|
||||
) : (
|
||||
<ConnectionEndLineIcon />
|
||||
)}
|
||||
</span>
|
||||
</div>,
|
||||
]
|
||||
})
|
||||
}
|
||||
65
frontend/src/board/contextmenu/fontSizes.tsx
Normal file
65
frontend/src/board/contextmenu/fontSizes.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { HarmajaOutput, componentScope, h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { isTextItem } from "../../../../common/src/domain"
|
||||
import { DecreaseFontSizeIcon, IncreaseFontSizeIcon } from "../../components/Icons"
|
||||
import { SubmenuProps } from "./ContextMenuView"
|
||||
import { black, disabledColor } from "../../components/UIColors"
|
||||
import { canChangeFont } from "../board-permissions"
|
||||
|
||||
type MenuIconProps = {
|
||||
onClick: () => void
|
||||
title: string
|
||||
icon: HarmajaOutput
|
||||
enabled: L.Property<boolean>
|
||||
}
|
||||
export const MenuIcon = (props: MenuIconProps) => {
|
||||
return (
|
||||
<span
|
||||
className="icon"
|
||||
style={props.enabled.pipe(L.map((e) => ({ color: e ? black : disabledColor })))}
|
||||
onClick={L.view(props.enabled, (e) => (e ? props.onClick : undefined))}
|
||||
title={props.title}
|
||||
>
|
||||
{props.icon}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function fontSizesMenu({ board, focusedItems, dispatch }: SubmenuProps) {
|
||||
const textItems = L.view(focusedItems, (items) => items.items.filter(isTextItem))
|
||||
const anyText = L.view(textItems, (items) => items.length > 0)
|
||||
const enabled = L.view(textItems, (items) => items.some(canChangeFont))
|
||||
const className = enabled.pipe(L.map((e) => (e ? "icon" : "icon disabled")))
|
||||
|
||||
return L.view(anyText, (any) =>
|
||||
!any
|
||||
? []
|
||||
: [
|
||||
<div className="font-size icon-group">
|
||||
<span className={className} onClick={increaseFont} title="Bigger font">
|
||||
<IncreaseFontSizeIcon />
|
||||
</span>
|
||||
<span className={className} onClick={decreaseFont} title="Smaller font">
|
||||
<DecreaseFontSizeIcon />
|
||||
</span>
|
||||
</div>,
|
||||
],
|
||||
)
|
||||
|
||||
function increaseFont() {
|
||||
if (!enabled.get()) return
|
||||
dispatch({
|
||||
action: "item.font.increase",
|
||||
boardId: board.get().id,
|
||||
itemIds: textItems.get().map((i) => i.id),
|
||||
})
|
||||
}
|
||||
function decreaseFont() {
|
||||
if (!enabled.get()) return
|
||||
dispatch({
|
||||
action: "item.font.decrease",
|
||||
boardId: board.get().id,
|
||||
itemIds: textItems.get().map((i) => i.id),
|
||||
})
|
||||
}
|
||||
}
|
||||
38
frontend/src/board/contextmenu/hideContents.tsx
Normal file
38
frontend/src/board/contextmenu/hideContents.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { isContainer } from "../../../../common/src/domain"
|
||||
import { VisibilityIcon, VisibilityOffIcon } from "../../components/Icons"
|
||||
import { canChangeVisibility } from "../board-permissions"
|
||||
import { hasContentHidden, toggleContentsHidden } from "../item-hide-contents"
|
||||
import { SubmenuProps } from "./ContextMenuView"
|
||||
|
||||
export function hideContentsMenu({ board, focusedItems, dispatch }: SubmenuProps) {
|
||||
const containers = L.view(focusedItems, (items) => items.items.filter(isContainer))
|
||||
|
||||
const containersOrContained = L.view(focusedItems, (items) =>
|
||||
items.items.filter((i) => isContainer(i) || !!i.containerId),
|
||||
)
|
||||
const hasContainers = L.view(containersOrContained, (cs) => cs.length > 0)
|
||||
const enabled = L.view(containersOrContained, (items) => items.some(canChangeVisibility))
|
||||
|
||||
const className = enabled.pipe(L.map((e) => (e ? "icon" : "icon disabled")))
|
||||
const currentlyHidden = L.view(containers, hasContentHidden)
|
||||
|
||||
return L.view(hasContainers, currentlyHidden, (hasContainers, hidden) => {
|
||||
return !hasContainers
|
||||
? []
|
||||
: [
|
||||
<div className="icon-group visibility">
|
||||
<span
|
||||
className={className}
|
||||
onClick={() => {
|
||||
toggleContentsHidden(focusedItems.get().items, board.get(), dispatch)
|
||||
}}
|
||||
title={hidden ? "Show contents" : "Hide contents"}
|
||||
>
|
||||
{hidden ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
||||
</span>
|
||||
</div>,
|
||||
]
|
||||
})
|
||||
}
|
||||
49
frontend/src/board/contextmenu/lock.tsx
Normal file
49
frontend/src/board/contextmenu/lock.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { LockIcon, UnlockIcon } from "../../components/Icons"
|
||||
import * as P from "../board-permissions"
|
||||
import { SubmenuProps } from "./ContextMenuView"
|
||||
import { LockState } from "../../../../common/src/domain"
|
||||
|
||||
export function lockMenu({ board, focusedItems, dispatch }: SubmenuProps) {
|
||||
const canLock = L.view(focusedItems, (items) => items.items.some(P.canLock) || items.connections.some(P.canLock))
|
||||
|
||||
const canUnlock = L.view(
|
||||
focusedItems,
|
||||
(items) => items.items.some(P.canUnlock) || items.connections.some(P.canUnlock),
|
||||
)
|
||||
|
||||
const showUnlock = L.view(canLock, canUnlock, (l, u) => !l && u)
|
||||
const enabled = L.view(canLock, canUnlock, (l, u) => l || u)
|
||||
|
||||
const hasItems = L.view(focusedItems, (ps) => ps.connections.length > 0 || ps.items.length > 0)
|
||||
const nextLockState: L.Property<"locked" | false> = L.view(showUnlock, (unlock): "locked" | false =>
|
||||
unlock ? false : "locked",
|
||||
)
|
||||
|
||||
function setLocked() {
|
||||
const b = board.get()
|
||||
if (!enabled.get()) return
|
||||
const all = focusedItems.get()
|
||||
const locked = nextLockState.get()
|
||||
const items = all.items.map((item) => ({ id: item.id, locked }))
|
||||
const connections = all.connections.map((connection) => ({ id: connection.id, locked }))
|
||||
dispatch({ action: "item.update", boardId: b.id, items, connections })
|
||||
}
|
||||
|
||||
return L.view(hasItems, nextLockState, (hasItems, nextState) => {
|
||||
return hasItems
|
||||
? [
|
||||
<div className="icon-group lock">
|
||||
<span
|
||||
className={L.view(enabled, (e) => (e ? "icon" : "icon disabled"))}
|
||||
title={nextState === "locked" ? "Lock item(s)" : "Unlock item(s)"}
|
||||
onClick={setLocked}
|
||||
>
|
||||
{nextState === "locked" ? <LockIcon /> : <UnlockIcon />}
|
||||
</span>
|
||||
</div>,
|
||||
]
|
||||
: []
|
||||
})
|
||||
}
|
||||
66
frontend/src/board/contextmenu/shapes.tsx
Normal file
66
frontend/src/board/contextmenu/shapes.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { h, HarmajaOutput } from "harmaja"
|
||||
import * as _ from "lodash"
|
||||
import * as L from "lonna"
|
||||
import { Color, isShapedItem, Item, NoteShape, ShapedItem } from "../../../../common/src/domain"
|
||||
import { ShapeDiamondIcon, ShapeRectIcon, ShapeRoundIcon, ShapeSquareIcon } from "../../components/Icons"
|
||||
import { black, selectedColor } from "../../components/UIColors"
|
||||
import { SubmenuProps } from "./ContextMenuView"
|
||||
|
||||
const shapes = {
|
||||
square: ShapeSquareIcon,
|
||||
round: ShapeRoundIcon,
|
||||
rect: ShapeRectIcon,
|
||||
diamond: ShapeDiamondIcon,
|
||||
}
|
||||
|
||||
type ShapeIcon = (c: Color, f?: Color) => HarmajaOutput
|
||||
type ShapeIconAndId = { id: NoteShape; svg: ShapeIcon }
|
||||
const shapeSymbols: ShapeIconAndId[] = Object.entries(shapes).map(([id, svg]) => ({ id: id as NoteShape, svg }))
|
||||
|
||||
export function getShapeIcon(item: Item): ShapeIcon {
|
||||
return shapes[isShapedItem(item) ? item.shape || "square" : "square"]
|
||||
}
|
||||
|
||||
export function shapesSubMenu({ board, focusedItems, dispatch }: SubmenuProps) {
|
||||
const shapedItems = L.view(focusedItems, (items) => items.items.filter(isShapedItem))
|
||||
const anyShaped = L.view(shapedItems, (items) => items.length > 0)
|
||||
const currentShape = L.view(shapedItems, (items) =>
|
||||
_.uniq(items.map((item) => item.shape)).length > 1 ? undefined : items[0]?.shape,
|
||||
)
|
||||
|
||||
return L.view(anyShaped, (anyShaped) => {
|
||||
return !anyShaped
|
||||
? []
|
||||
: [
|
||||
<div className="shapes icon-group">
|
||||
{shapeSymbols.map((shape) => {
|
||||
return (
|
||||
<span className="icon" onClick={changeShape(shape.id)}>
|
||||
{L.view(
|
||||
currentShape,
|
||||
(s) => s === shape.id,
|
||||
(selected) => shape.svg(selected ? selectedColor : black),
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>,
|
||||
]
|
||||
})
|
||||
|
||||
function changeShape(newShape: NoteShape) {
|
||||
return () => {
|
||||
const b = board.get()
|
||||
const items = shapedItems.get()
|
||||
const updated = items.map((item) => {
|
||||
const maxDim = Math.max(item.width, item.height)
|
||||
const dimensions =
|
||||
newShape === "rect"
|
||||
? { width: maxDim * 1.2, height: maxDim / 1.2 }
|
||||
: { width: maxDim, height: maxDim }
|
||||
return { id: item.id, shape: newShape, ...dimensions }
|
||||
}) as ShapedItem[]
|
||||
dispatch({ action: "item.update", boardId: b.id, items: updated })
|
||||
}
|
||||
}
|
||||
}
|
||||
115
frontend/src/board/contextmenu/textAlignments.tsx
Normal file
115
frontend/src/board/contextmenu/textAlignments.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { HarmajaOutput, componentScope, h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import {
|
||||
Align,
|
||||
Color,
|
||||
HorizontalAlign,
|
||||
Item,
|
||||
ItemUpdate,
|
||||
TextItem,
|
||||
VerticalAlign,
|
||||
getAlign,
|
||||
getHorizontalAlign,
|
||||
getVerticalAlign,
|
||||
isTextItem,
|
||||
setHorizontalAlign,
|
||||
setVerticalAlign,
|
||||
} from "../../../../common/src/domain"
|
||||
import {
|
||||
TextAlignHorizontalCenterIcon,
|
||||
TextAlignHorizontalLeftIcon,
|
||||
TextAlignHorizontalRightIcon,
|
||||
TextAlignVerticalBottomIcon,
|
||||
TextAlignVerticalMiddleIcon,
|
||||
TextAlignVerticalTopIcon,
|
||||
} from "../../components/Icons"
|
||||
import { black, disabledColor } from "../../components/UIColors"
|
||||
import { SubmenuProps } from "./ContextMenuView"
|
||||
import { canChangeTextAlign } from "../board-permissions"
|
||||
|
||||
export function textAlignmentsMenu({ board, focusedItems, dispatch }: SubmenuProps) {
|
||||
const textItems = L.view(focusedItems, (items) => items.items.filter(isTextItem))
|
||||
const allText = L.view(focusedItems, textItems, (f, t) => f.items.length > 0 && t.length === f.items.length)
|
||||
|
||||
const currentHAlign = L.view(focusedItems, (f) => {
|
||||
return getIfSame(f.items, (item) => (isTextItem(item) ? getHorizontalAlign(getAlign(item)) : null), "left")
|
||||
})
|
||||
|
||||
const currentVAlign = L.view(focusedItems, (f) => {
|
||||
return getIfSame(f.items, (item) => (isTextItem(item) ? getVerticalAlign(getAlign(item)) : null), "top")
|
||||
})
|
||||
|
||||
function setAlign(modifyAlign: (i: TextItem) => ItemUpdate<TextItem>) {
|
||||
focusedItems.get()
|
||||
const b = board.get()
|
||||
const updated = focusedItems
|
||||
.get()
|
||||
.items.filter(isTextItem)
|
||||
.map((i) => modifyAlign(i))
|
||||
dispatch({ action: "item.update", boardId: b.id, items: updated })
|
||||
}
|
||||
|
||||
const enabled = L.view(textItems, (items) => items.some(canChangeTextAlign))
|
||||
|
||||
const className = enabled.pipe(L.map((e) => (e ? "icon" : "icon disabled")))
|
||||
|
||||
return L.view(allText, currentHAlign, currentVAlign, (all, ha, va) => {
|
||||
return !all
|
||||
? []
|
||||
: [
|
||||
<div className="icon-group text-align">
|
||||
<span
|
||||
className={className}
|
||||
onClick={() => {
|
||||
const hAlign: HorizontalAlign = hAligns[(hAligns.indexOf(ha) + 1) % hAligns.length]
|
||||
setAlign((i) => setHorizontalAlign(i, hAlign))
|
||||
}}
|
||||
title="Horizontal align"
|
||||
>
|
||||
{enabled.pipe(L.map((e) => horizontalIcons[ha](e ? black : disabledColor)))}
|
||||
</span>
|
||||
</div>,
|
||||
<div className="icon-group text-align">
|
||||
<span
|
||||
className={className}
|
||||
onClick={() => {
|
||||
const vAlign: VerticalAlign = vAligns[(vAligns.indexOf(va) + 1) % vAligns.length]
|
||||
setAlign((i) => setVerticalAlign(i, vAlign))
|
||||
}}
|
||||
title="Vertical align"
|
||||
>
|
||||
{enabled.pipe(L.map((e) => verticalIcons[va](e ? black : disabledColor)))}
|
||||
</span>
|
||||
</div>,
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const horizontalIcons: Record<HorizontalAlign, (color: Color) => HarmajaOutput> = {
|
||||
left: () => <TextAlignHorizontalLeftIcon />,
|
||||
center: () => <TextAlignHorizontalCenterIcon />,
|
||||
right: () => <TextAlignHorizontalRightIcon />,
|
||||
}
|
||||
const verticalIcons: Record<VerticalAlign, (color: Color) => HarmajaOutput> = {
|
||||
top: () => <TextAlignVerticalTopIcon />,
|
||||
middle: () => <TextAlignVerticalMiddleIcon />,
|
||||
bottom: () => <TextAlignVerticalBottomIcon />,
|
||||
}
|
||||
const hAligns: HorizontalAlign[] = ["left", "center", "right"]
|
||||
const vAligns: VerticalAlign[] = ["top", "middle", "bottom"]
|
||||
|
||||
export function getIfSame<I, P>(items: I[], get: (item: I) => P | null, defaultValue: P) {
|
||||
let align: P | null = null
|
||||
items.forEach((item) => {})
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
const itemAlign = get(item)
|
||||
if (align != null && itemAlign !== align) {
|
||||
// If not all share the same, exit
|
||||
align = null
|
||||
break
|
||||
}
|
||||
align = itemAlign
|
||||
}
|
||||
return align ?? defaultValue
|
||||
}
|
||||
66
frontend/src/board/contextmenu/textFormats.tsx
Normal file
66
frontend/src/board/contextmenu/textFormats.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { CrdtEnabled, isTextItem } from "../../../../common/src/domain"
|
||||
import { BoldIcon, ItalicIcon, UnderlineIcon } from "../../components/Icons"
|
||||
import { canChangeTextFormat } from "../board-permissions"
|
||||
import { SubmenuProps } from "./ContextMenuView"
|
||||
|
||||
export function textFormatsMenu({ board, focusedItems, dispatch }: SubmenuProps) {
|
||||
const textItems = L.view(focusedItems, (items) =>
|
||||
items.items.filter((i) => isTextItem(i) && i.crdt === CrdtEnabled),
|
||||
)
|
||||
const singleText = L.view(focusedItems, textItems, (f, t) => f.items.length === 1 && t.length === f.items.length)
|
||||
|
||||
const enabled = L.view(textItems, (items) => items.some(canChangeTextFormat))
|
||||
|
||||
const className = enabled.pipe(L.map((e) => (e ? "icon" : "icon disabled")))
|
||||
|
||||
return L.view(singleText, (singleText) => {
|
||||
return !singleText
|
||||
? []
|
||||
: [
|
||||
<div className="icon-group text-format">
|
||||
<span
|
||||
className={className}
|
||||
onClick={() => {
|
||||
dispatch({
|
||||
action: "ui.text.format",
|
||||
itemIds: textItems.get().map((i) => i.id),
|
||||
format: "bold",
|
||||
})
|
||||
}}
|
||||
title="Bold"
|
||||
>
|
||||
<BoldIcon />
|
||||
</span>
|
||||
<span
|
||||
className={className}
|
||||
onClick={() => {
|
||||
dispatch({
|
||||
action: "ui.text.format",
|
||||
itemIds: textItems.get().map((i) => i.id),
|
||||
format: "italic",
|
||||
})
|
||||
}}
|
||||
title="Italic"
|
||||
>
|
||||
<ItalicIcon />
|
||||
</span>
|
||||
<span
|
||||
className={className}
|
||||
onClick={() => {
|
||||
dispatch({
|
||||
action: "ui.text.format",
|
||||
itemIds: textItems.get().map((i) => i.id),
|
||||
format: "underline",
|
||||
})
|
||||
}}
|
||||
title="Underline"
|
||||
>
|
||||
<UnderlineIcon />
|
||||
</span>
|
||||
</div>,
|
||||
,
|
||||
]
|
||||
})
|
||||
}
|
||||
189
frontend/src/board/contrasting-color.ts
Normal file
189
frontend/src/board/contrasting-color.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
interface RGB {
|
||||
b: number
|
||||
g: number
|
||||
r: number
|
||||
}
|
||||
function rgbToYIQ({ r, g, b }: RGB): number {
|
||||
return (r * 299 + g * 587 + b * 114) / 1000
|
||||
}
|
||||
function hexToRgb(hex: string): RGB | undefined {
|
||||
if (!hex || hex === undefined || hex === "") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const result: RegExpExecArray | null = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
// Source: https://medium.com/better-programming/generate-contrasting-text-for-your-random-background-color-ac302dc87b4
|
||||
export function contrastingColor(colorNameOrHex: string | undefined, threshold: number = 128): string {
|
||||
if (colorNameOrHex === undefined) {
|
||||
return "#000"
|
||||
}
|
||||
|
||||
const rgb: RGB | undefined = hexToRgb(colorNameToHex(colorNameOrHex))
|
||||
|
||||
if (rgb === undefined) {
|
||||
return "#000"
|
||||
}
|
||||
|
||||
return rgbToYIQ(rgb) >= threshold ? "#000" : "#fff"
|
||||
}
|
||||
|
||||
// Source: https://stackoverflow.com/questions/1573053/javascript-function-to-convert-color-names-to-hex-codes/24390910
|
||||
var colours = {
|
||||
aliceblue: "#f0f8ff",
|
||||
antiquewhite: "#faebd7",
|
||||
aqua: "#00ffff",
|
||||
aquamarine: "#7fffd4",
|
||||
azure: "#f0ffff",
|
||||
beige: "#f5f5dc",
|
||||
bisque: "#ffe4c4",
|
||||
black: "#000000",
|
||||
blanchedalmond: "#ffebcd",
|
||||
blue: "#0000ff",
|
||||
blueviolet: "#8a2be2",
|
||||
brown: "#a52a2a",
|
||||
burlywood: "#deb887",
|
||||
cadetblue: "#5f9ea0",
|
||||
chartreuse: "#7fff00",
|
||||
chocolate: "#d2691e",
|
||||
coral: "#ff7f50",
|
||||
cornflowerblue: "#6495ed",
|
||||
cornsilk: "#fff8dc",
|
||||
crimson: "#dc143c",
|
||||
cyan: "#00ffff",
|
||||
darkblue: "#00008b",
|
||||
darkcyan: "#008b8b",
|
||||
darkgoldenrod: "#b8860b",
|
||||
darkgray: "#a9a9a9",
|
||||
darkgreen: "#006400",
|
||||
darkkhaki: "#bdb76b",
|
||||
darkmagenta: "#8b008b",
|
||||
darkolivegreen: "#556b2f",
|
||||
darkorange: "#ff8c00",
|
||||
darkorchid: "#9932cc",
|
||||
darkred: "#8b0000",
|
||||
darksalmon: "#e9967a",
|
||||
darkseagreen: "#8fbc8f",
|
||||
darkslateblue: "#483d8b",
|
||||
darkslategray: "#2f4f4f",
|
||||
darkturquoise: "#00ced1",
|
||||
darkviolet: "#9400d3",
|
||||
deeppink: "#ff1493",
|
||||
deepskyblue: "#00bfff",
|
||||
dimgray: "#696969",
|
||||
dodgerblue: "#1e90ff",
|
||||
firebrick: "#b22222",
|
||||
floralwhite: "#fffaf0",
|
||||
forestgreen: "#228b22",
|
||||
fuchsia: "#ff00ff",
|
||||
gainsboro: "#dcdcdc",
|
||||
ghostwhite: "#f8f8ff",
|
||||
gold: "#ffd700",
|
||||
goldenrod: "#daa520",
|
||||
gray: "#808080",
|
||||
green: "#008000",
|
||||
greenyellow: "#adff2f",
|
||||
honeydew: "#f0fff0",
|
||||
hotpink: "#ff69b4",
|
||||
"indianred ": "#cd5c5c",
|
||||
indigo: "#4b0082",
|
||||
ivory: "#fffff0",
|
||||
khaki: "#f0e68c",
|
||||
lavender: "#e6e6fa",
|
||||
lavenderblush: "#fff0f5",
|
||||
lawngreen: "#7cfc00",
|
||||
lemonchiffon: "#fffacd",
|
||||
lightblue: "#add8e6",
|
||||
lightcoral: "#f08080",
|
||||
lightcyan: "#e0ffff",
|
||||
lightgoldenrodyellow: "#fafad2",
|
||||
lightgrey: "#d3d3d3",
|
||||
lightgreen: "#90ee90",
|
||||
lightpink: "#ffb6c1",
|
||||
lightsalmon: "#ffa07a",
|
||||
lightseagreen: "#20b2aa",
|
||||
lightskyblue: "#87cefa",
|
||||
lightslategray: "#778899",
|
||||
lightsteelblue: "#b0c4de",
|
||||
lightyellow: "#ffffe0",
|
||||
lime: "#00ff00",
|
||||
limegreen: "#32cd32",
|
||||
linen: "#faf0e6",
|
||||
magenta: "#ff00ff",
|
||||
maroon: "#800000",
|
||||
mediumaquamarine: "#66cdaa",
|
||||
mediumblue: "#0000cd",
|
||||
mediumorchid: "#ba55d3",
|
||||
mediumpurple: "#9370d8",
|
||||
mediumseagreen: "#3cb371",
|
||||
mediumslateblue: "#7b68ee",
|
||||
mediumspringgreen: "#00fa9a",
|
||||
mediumturquoise: "#48d1cc",
|
||||
mediumvioletred: "#c71585",
|
||||
midnightblue: "#191970",
|
||||
mintcream: "#f5fffa",
|
||||
mistyrose: "#ffe4e1",
|
||||
moccasin: "#ffe4b5",
|
||||
navajowhite: "#ffdead",
|
||||
navy: "#000080",
|
||||
oldlace: "#fdf5e6",
|
||||
olive: "#808000",
|
||||
olivedrab: "#6b8e23",
|
||||
orange: "#ffa500",
|
||||
orangered: "#ff4500",
|
||||
orchid: "#da70d6",
|
||||
palegoldenrod: "#eee8aa",
|
||||
palegreen: "#98fb98",
|
||||
paleturquoise: "#afeeee",
|
||||
palevioletred: "#d87093",
|
||||
papayawhip: "#ffefd5",
|
||||
peachpuff: "#ffdab9",
|
||||
peru: "#cd853f",
|
||||
pink: "#ffc0cb",
|
||||
plum: "#dda0dd",
|
||||
powderblue: "#b0e0e6",
|
||||
purple: "#800080",
|
||||
rebeccapurple: "#663399",
|
||||
red: "#ff0000",
|
||||
rosybrown: "#bc8f8f",
|
||||
royalblue: "#4169e1",
|
||||
saddlebrown: "#8b4513",
|
||||
salmon: "#fa8072",
|
||||
sandybrown: "#f4a460",
|
||||
seagreen: "#2e8b57",
|
||||
seashell: "#fff5ee",
|
||||
sienna: "#a0522d",
|
||||
silver: "#c0c0c0",
|
||||
skyblue: "#87ceeb",
|
||||
slateblue: "#6a5acd",
|
||||
slategray: "#708090",
|
||||
snow: "#fffafa",
|
||||
springgreen: "#00ff7f",
|
||||
steelblue: "#4682b4",
|
||||
tan: "#d2b48c",
|
||||
teal: "#008080",
|
||||
thistle: "#d8bfd8",
|
||||
tomato: "#ff6347",
|
||||
turquoise: "#40e0d0",
|
||||
violet: "#ee82ee",
|
||||
wheat: "#f5deb3",
|
||||
white: "#ffffff",
|
||||
whitesmoke: "#f5f5f5",
|
||||
yellow: "#ffff00",
|
||||
yellowgreen: "#9acd32",
|
||||
} as Record<string, string>
|
||||
|
||||
function colorNameToHex(c: string) {
|
||||
if (colours[c]) {
|
||||
return colours[c]
|
||||
}
|
||||
return c
|
||||
}
|
||||
34
frontend/src/board/double-click.ts
Normal file
34
frontend/src/board/double-click.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { componentScope } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { IS_TOUCHSCREEN, isSingleTouch } from "./touchScreen"
|
||||
|
||||
export function installDoubleClickHandler(action: (e: JSX.UIEvent) => void) {
|
||||
if (IS_TOUCHSCREEN) {
|
||||
let previousClick = 0
|
||||
L.fromEvent<JSX.TouchEvent>(window, "touchstart")
|
||||
.pipe(L.applyScope(componentScope()))
|
||||
.forEach((e) => {
|
||||
if (isSingleTouch(e)) {
|
||||
if (!!(e as any).PREVENT_DBL_CLICK) {
|
||||
return
|
||||
}
|
||||
const now = new Date().getTime()
|
||||
if (now - previousClick < 300) {
|
||||
action(e)
|
||||
}
|
||||
previousClick = now
|
||||
}
|
||||
})
|
||||
} else {
|
||||
L.fromEvent<JSX.MouseEvent>(window, "dblclick")
|
||||
.pipe(L.applyScope(componentScope()))
|
||||
.forEach((e) => {
|
||||
e.preventDefault()
|
||||
action(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function preventDoubleClick(e: JSX.TouchEvent) {
|
||||
;(e as any).PREVENT_DBL_CLICK = true
|
||||
}
|
||||
167
frontend/src/board/header/BoardViewHeader.tsx
Normal file
167
frontend/src/board/header/BoardViewHeader.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { Fragment, h } from "harmaja"
|
||||
import { getNavigator } from "harmaja-router"
|
||||
import * as L from "lonna"
|
||||
import { AccessLevel, Board, EventFromServer, UserSessionInfo } from "../../../../common/src/domain"
|
||||
import { Routes, createBoardAndNavigate } from "../../board-navigation"
|
||||
import { EditableSpan } from "../../components/EditableSpan"
|
||||
import { Dispatch } from "../../store/board-store"
|
||||
import { UserSessionState, defaultAccessPolicy } from "../../store/user-session-store"
|
||||
import { BackToAllBoardsLink } from "../toolbars/BackToAllBoardsLink"
|
||||
import { OtherUsersView } from "./OtherUsersView"
|
||||
import { SharingModalDialog } from "./SharingModalDialog"
|
||||
import { UserInfoView } from "./UserInfoView"
|
||||
import { Rect } from "../../../../common/src/geometry"
|
||||
import { CRDTStore } from "../../store/crdt-store"
|
||||
import * as uuid from "uuid"
|
||||
|
||||
export function BoardViewHeader({
|
||||
usersOnBoard,
|
||||
board,
|
||||
accessLevel,
|
||||
sessionState,
|
||||
dispatch,
|
||||
modalContent,
|
||||
eventsFromServer,
|
||||
viewRect,
|
||||
online,
|
||||
crdtStore,
|
||||
}: {
|
||||
usersOnBoard: L.Property<UserSessionInfo[]>
|
||||
board: L.Property<Board>
|
||||
accessLevel: L.Property<AccessLevel>
|
||||
sessionState: L.Property<UserSessionState>
|
||||
dispatch: Dispatch
|
||||
modalContent: L.Atom<any>
|
||||
eventsFromServer: L.EventStream<EventFromServer>
|
||||
viewRect: L.Property<Rect>
|
||||
online: L.Property<boolean>
|
||||
crdtStore: CRDTStore
|
||||
}) {
|
||||
const editingAtom = L.atom(false)
|
||||
const nameAtom = L.atom(
|
||||
L.view(board, (board) => board.name || ""),
|
||||
(newName) => dispatch({ action: "board.rename", boardId: board.get()!.id, name: newName }),
|
||||
)
|
||||
const navigator = getNavigator<Routes>()
|
||||
function makeCopy() {
|
||||
const newBoard = {
|
||||
id: uuid.v4(),
|
||||
name: `${nameAtom.get()} copy`,
|
||||
templateId: board.get()!.id,
|
||||
accessPolicy: defaultAccessPolicy(sessionState.get(), false),
|
||||
}
|
||||
createBoardAndNavigate(newBoard, dispatch, navigator, eventsFromServer)
|
||||
}
|
||||
|
||||
return (
|
||||
<header>
|
||||
<span className="logo-area">
|
||||
<BackToAllBoardsLink />
|
||||
</span>
|
||||
{L.view(
|
||||
board,
|
||||
(b) => !!b,
|
||||
(b) =>
|
||||
b && (
|
||||
<>
|
||||
<span data-test="board-name" id="board-info">
|
||||
<span id="board-name">
|
||||
{L.view(accessLevel, (l) =>
|
||||
l === "read-only" ? (
|
||||
<span>
|
||||
{nameAtom} <small>read-only</small>
|
||||
</span>
|
||||
) : (
|
||||
<EditableSpan value={nameAtom} editingThis={editingAtom} />
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
<ForkButton onClick={makeCopy} />
|
||||
<ShareButton
|
||||
onClick={() =>
|
||||
modalContent.set(
|
||||
<SharingModalDialog
|
||||
{...{
|
||||
board,
|
||||
sessionState,
|
||||
dismiss: () => modalContent.set(null),
|
||||
dispatch,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
)}
|
||||
<span className="right-panel">
|
||||
<OtherUsersView
|
||||
usersOnBoard={usersOnBoard}
|
||||
board={board}
|
||||
viewRect={viewRect}
|
||||
state={sessionState}
|
||||
dispatch={dispatch}
|
||||
online={online}
|
||||
/>
|
||||
<UserInfoView
|
||||
state={sessionState}
|
||||
usersOnBoard={usersOnBoard}
|
||||
dispatch={dispatch}
|
||||
modalContent={modalContent}
|
||||
/>
|
||||
</span>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
const ShareButton = ({ onClick }: { onClick: () => void }) => {
|
||||
return (
|
||||
<a className="board-button" title="Sharing and permissions" onClick={onClick}>
|
||||
<svg viewBox="0 0 25 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Sharing and permissions</title>
|
||||
<path
|
||||
d="M0.941645 6.11807L21.2054 1.47105C21.3809 1.4308 21.5161 1.62489 21.4174 1.77556L14.237 12.7407C14.2219 12.7638 14.2021 12.7836 14.1789 12.7987L8.77875 16.3188C8.63058 16.4154 8.43918 16.2863 8.47328 16.1127L9.6818 9.96216C9.70067 9.86617 9.64732 9.77062 9.55571 9.73631L0.916195 6.5003C0.73048 6.43074 0.748347 6.1624 0.941645 6.11807Z"
|
||||
fill="white"
|
||||
stroke="#0A5AF5"
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M15.1744 17.9751L9.85705 10.1208C9.79688 10.0319 9.81759 9.91137 9.90397 9.84767L21.1175 1.57863C21.277 1.46099 21.4921 1.62213 21.424 1.80829L15.5279 17.9317C15.4719 18.0849 15.2659 18.1102 15.1744 17.9751Z"
|
||||
fill="white"
|
||||
stroke="#0A5AF5"
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const ForkButton = ({ onClick }: { onClick: () => void }) => {
|
||||
return (
|
||||
<a className="board-button" title="Make a copy" onClick={onClick}>
|
||||
<svg viewBox="0 0 22 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Make a copy</title>
|
||||
<rect
|
||||
x="6.56582"
|
||||
y="5.86245"
|
||||
width="14.0013"
|
||||
height="14.0013"
|
||||
rx="1.4"
|
||||
fill="#0A5AF5"
|
||||
fill-opacity="0.1"
|
||||
stroke="#0A5AF5"
|
||||
stroke-width="1.2"
|
||||
/>
|
||||
<path
|
||||
d="M3.6805 15.7398H3.18848C2.08391 15.7398 1.18848 14.8444 1.18848 13.7398V3.18433C1.18848 2.07976 2.08391 1.18433 3.18848 1.18433H13.8317C14.9363 1.18433 15.8317 2.07976 15.8317 3.18433V3.76324"
|
||||
stroke="#0A5AF5"
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
73
frontend/src/board/header/OtherUsersView.tsx
Normal file
73
frontend/src/board/header/OtherUsersView.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ListView, h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { Board, UserSessionInfo } from "../../../../common/src/domain"
|
||||
import { Dispatch } from "../../store/board-store"
|
||||
import { UserSessionState } from "../../store/user-session-store"
|
||||
import { Rect } from "../../../../common/src/geometry"
|
||||
import { assertNotNull } from "../../../../common/src/assertNotNull"
|
||||
|
||||
type OtherUsersViewProps = {
|
||||
usersOnBoard: L.Property<UserSessionInfo[]>
|
||||
dispatch: Dispatch
|
||||
state: L.Property<UserSessionState>
|
||||
board: L.Property<Board>
|
||||
viewRect: L.Property<Rect>
|
||||
online: L.Property<boolean>
|
||||
}
|
||||
|
||||
export const OtherUsersView = ({ usersOnBoard, dispatch, state, online, board, viewRect }: OtherUsersViewProps) => {
|
||||
const status = L.view(online, usersOnBoard, (online, users) =>
|
||||
online ? (users.length > 1 ? "online-with-others" : "online-alone") : "offline",
|
||||
)
|
||||
return L.view(status, (status) => {
|
||||
if (status === "offline") {
|
||||
return (
|
||||
<div
|
||||
className="offline-status"
|
||||
title="Keep on working! Your work will be synchronized with others', once we are connected to the server again."
|
||||
>
|
||||
Offline
|
||||
</div>
|
||||
)
|
||||
} else if (status === "online-alone") {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="other-users">
|
||||
{L.view(usersOnBoard, (u) => u.length)} users
|
||||
<div className="pop-up">
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
action: "user.bringAllToMe",
|
||||
boardId: board.get().id,
|
||||
sessionId: assertNotNull(state.get().sessionId),
|
||||
viewRect: viewRect.get(),
|
||||
nickname: assertNotNull(state.get().nickname),
|
||||
})
|
||||
}
|
||||
>
|
||||
Bring all to me
|
||||
</a>
|
||||
</li>
|
||||
<ListView
|
||||
observable={L.view(usersOnBoard, (users) =>
|
||||
users.slice().sort((a, b) => a.nickname.localeCompare(b.nickname)),
|
||||
)}
|
||||
renderItem={(u) => (
|
||||
<li className="user">
|
||||
{u.nickname}
|
||||
<span className="youlink">
|
||||
{u.sessionId === state.get().sessionId ? " (you)" : ""}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
73
frontend/src/board/header/SharingModalDialog.tsx
Normal file
73
frontend/src/board/header/SharingModalDialog.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Fragment, h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { Board, checkBoardAccess } from "../../../../common/src/domain"
|
||||
import { BoardAccessPolicyEditor } from "../../components/BoardAccessPolicyEditor"
|
||||
import { Dispatch, sessionState2UserInfo } from "../../store/board-store"
|
||||
import { UserSessionState } from "../../store/user-session-store"
|
||||
|
||||
export const SharingModalDialog = ({
|
||||
board,
|
||||
sessionState,
|
||||
dismiss,
|
||||
dispatch,
|
||||
}: {
|
||||
board: L.Property<Board>
|
||||
sessionState: L.Property<UserSessionState>
|
||||
dismiss: () => void
|
||||
dispatch: Dispatch
|
||||
}) => {
|
||||
const originalAccessPolicy = board.get().accessPolicy
|
||||
const accessPolicy = L.atom(originalAccessPolicy)
|
||||
|
||||
const copied = L.atom(false)
|
||||
function copyToClipboard() {
|
||||
navigator.clipboard.writeText(document.location.toString())
|
||||
copied.set(true)
|
||||
setTimeout(() => copied.set(false), 3000)
|
||||
}
|
||||
function saveChanges() {
|
||||
dispatch({ action: "board.setAccessPolicy", boardId: board.get().id, accessPolicy: accessPolicy.get()! })
|
||||
dismiss()
|
||||
}
|
||||
const adminAccess = L.view(
|
||||
sessionState,
|
||||
(s) => checkBoardAccess(originalAccessPolicy, sessionState2UserInfo(s)) === "admin",
|
||||
)
|
||||
|
||||
return (
|
||||
<span className="sharing">
|
||||
<h2>Sharing</h2>
|
||||
<button onClick={copyToClipboard}>Copy board link</button>
|
||||
{L.view(copied, (c) => (c ? <span className="copied">Copied to clipboard.</span> : null))}
|
||||
{L.view(sessionState, adminAccess, (s, admin) =>
|
||||
s.status === "logged-in" && admin ? (
|
||||
<>
|
||||
<h2>Manage board permissions</h2>
|
||||
<BoardAccessPolicyEditor {...{ accessPolicy, user: s }} />
|
||||
<p>
|
||||
<button
|
||||
onClick={saveChanges}
|
||||
disabled={L.view(accessPolicy, (ap) => ap === originalAccessPolicy)}
|
||||
>
|
||||
Save changes
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
) : originalAccessPolicy ? (
|
||||
<>
|
||||
<h2>Board permissions</h2>
|
||||
<p>You don't have the privileges to change board permissions</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2>Board permissions</h2>
|
||||
<p>
|
||||
Anonymous boards are accessible to anyone with the link. To control board permissions,
|
||||
create a new board when logged in.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
87
frontend/src/board/header/UserInfoModal.tsx
Normal file
87
frontend/src/board/header/UserInfoModal.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Fragment, h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { TextInput } from "../../components/components"
|
||||
import { UserIcon } from "../../components/Icons"
|
||||
import { signIn, signOut } from "../../google-auth"
|
||||
import { Dispatch } from "../../store/board-store"
|
||||
import { canLogin, LoggingInServer, UserSessionState } from "../../store/user-session-store"
|
||||
|
||||
export const UserInfoModal = ({
|
||||
state,
|
||||
dispatch,
|
||||
dismiss,
|
||||
}: {
|
||||
state: L.Property<UserSessionState>
|
||||
dispatch: Dispatch
|
||||
dismiss: () => void
|
||||
}) => {
|
||||
const pictureURL = L.view(state, (s) => (s.status === "logged-in" ? s.picture : undefined))
|
||||
return (
|
||||
<span className="user-info">
|
||||
<h2>Welcome to OurBoard</h2>
|
||||
{L.view(
|
||||
state,
|
||||
(s) => s.status,
|
||||
(status) => {
|
||||
switch (status) {
|
||||
case "logging-in-server":
|
||||
return null
|
||||
case "logged-in":
|
||||
return (
|
||||
<div className="logged-in">
|
||||
<p>
|
||||
You're signed in as{" "}
|
||||
<span className="name">
|
||||
{L.view(state, (s) => (s as LoggingInServer).name)}
|
||||
</span>{" "}
|
||||
({L.view(state, (s) => (s as LoggingInServer).email)}).
|
||||
</p>
|
||||
<p>
|
||||
<a className="login" onClick={signOut}>
|
||||
Sign out
|
||||
</a>{" "}
|
||||
to access the board anonymously.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<div className="anonymous">
|
||||
<p className="nickname">
|
||||
<span>Select nickname to be shown to others</span>
|
||||
<NicknameEditor {...{ state, dispatch }} />
|
||||
</p>
|
||||
{L.view(
|
||||
state,
|
||||
canLogin,
|
||||
(show) =>
|
||||
show && (
|
||||
<p className="sign-in">
|
||||
Or{" "}
|
||||
<a className="login" onClick={signIn}>
|
||||
sign in
|
||||
</a>{" "}
|
||||
using your Google account.
|
||||
</p>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
)}
|
||||
<p>
|
||||
<button onClick={dismiss}>Done</button>
|
||||
</p>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const NicknameEditor = ({ state, dispatch }: { state: L.Property<UserSessionState>; dispatch: Dispatch }) => {
|
||||
const nicknameAtom = L.atom(L.view(state, "nickname"), (nickname) => {
|
||||
if (nickname === undefined) throw Error("Cannot set nickname to undefined")
|
||||
dispatch({ action: "nickname.set", nickname })
|
||||
})
|
||||
|
||||
return <TextInput value={nicknameAtom} />
|
||||
}
|
||||
56
frontend/src/board/header/UserInfoView.tsx
Normal file
56
frontend/src/board/header/UserInfoView.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { componentScope, h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { UserSessionInfo } from "../../../../common/src/domain"
|
||||
import { UserIcon } from "../../components/Icons"
|
||||
import { BoardState, Dispatch } from "../../store/board-store"
|
||||
import { UserSessionState } from "../../store/user-session-store"
|
||||
import { UserInfoModal } from "./UserInfoModal"
|
||||
|
||||
export const UserInfoView = ({
|
||||
state,
|
||||
dispatch,
|
||||
usersOnBoard,
|
||||
modalContent,
|
||||
}: {
|
||||
state: L.Property<UserSessionState>
|
||||
usersOnBoard: L.Property<UserSessionInfo[]>
|
||||
dispatch: Dispatch
|
||||
modalContent: L.Atom<any>
|
||||
}) => {
|
||||
const pictureURL = L.view(state, (s) => (s.status === "logged-in" ? s.picture : undefined))
|
||||
usersOnBoard
|
||||
.pipe(
|
||||
L.map((users) => users.length),
|
||||
L.changes,
|
||||
L.filter((l) => l > 1),
|
||||
L.takeUntil(L.later(5000, null)),
|
||||
L.take(1),
|
||||
L.applyScope(componentScope()),
|
||||
)
|
||||
.forEach(() => {
|
||||
if (!state.get().nicknameSetByUser) {
|
||||
showDialog()
|
||||
}
|
||||
})
|
||||
|
||||
function dismiss() {
|
||||
modalContent.set(null)
|
||||
}
|
||||
function showDialog() {
|
||||
modalContent.set(<UserInfoModal {...{ dispatch, state, dismiss }} />)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={L.view(
|
||||
state,
|
||||
(s) => s.status,
|
||||
(s) => `user-info ${s}`,
|
||||
)}
|
||||
>
|
||||
<span className="icon" onClick={showDialog}>
|
||||
{L.view(pictureURL, (p) => (p ? <img src={p} /> : <UserIcon />))}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
97
frontend/src/board/image-upload.ts
Normal file
97
frontend/src/board/image-upload.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import * as L from "lonna"
|
||||
import { Item, newImage, newVideo } from "../../../common/src/domain"
|
||||
import { AssetStore, AssetURL } from "../store/asset-store"
|
||||
import { BoardCoordinateHelper } from "./board-coordinates"
|
||||
import { BoardFocus } from "./board-focus"
|
||||
|
||||
export function imageDropHandler(
|
||||
boardElement: L.Atom<HTMLElement | null>,
|
||||
assets: AssetStore,
|
||||
focus: L.Atom<BoardFocus>,
|
||||
uploadImageFile: (file: File) => Promise<void>,
|
||||
) {
|
||||
boardElement.forEach((el) => {
|
||||
if (el) {
|
||||
function preventDefaults(e: any) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
;["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
|
||||
el.addEventListener(eventName, preventDefaults, false)
|
||||
})
|
||||
|
||||
el.addEventListener("drop", handleDrop, false)
|
||||
async function handleDrop(e: DragEvent) {
|
||||
if (focus.get().status === "dragging") {
|
||||
return // was dragging an item
|
||||
}
|
||||
|
||||
const url = e.dataTransfer?.getData("URL")
|
||||
if (url) {
|
||||
// Try direct fetch first, and on CORS error fall back to letting the server request it
|
||||
const res = await fetch(url).catch(() => assets.getExternalAssetAsBytes(url))
|
||||
const blob = await res.blob()
|
||||
await uploadImageFile(blob as any)
|
||||
} else {
|
||||
let dt = e.dataTransfer
|
||||
let files = dt!.files
|
||||
if (files.length === 0) {
|
||||
return
|
||||
}
|
||||
if (files.length != 1) {
|
||||
throw Error("Unexpected number of files: " + files.length)
|
||||
}
|
||||
const file = files[0]
|
||||
await uploadImageFile(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export type ImageUploadFunction = (file: File) => Promise<void>
|
||||
export function imageUploadHandler(
|
||||
assets: AssetStore,
|
||||
coordinateHelper: BoardCoordinateHelper,
|
||||
onAdd: (item: Item) => void,
|
||||
onURL: (id: string, url: AssetURL) => void,
|
||||
): ImageUploadFunction {
|
||||
return async (file: File): Promise<void> => {
|
||||
const info = await imageDimensions(file)
|
||||
if (!info) {
|
||||
console.log("File is not an image")
|
||||
} else if (info.type === "image") {
|
||||
const [assetId, urlPromise] = await assets.uploadAsset(file)
|
||||
const { width, height } = info
|
||||
const maxWidth = 10
|
||||
const w = Math.min(width, maxWidth)
|
||||
const h = (height * w) / width
|
||||
const { x, y } = coordinateHelper.currentBoardCoordinates.get()
|
||||
const image = newImage(assetId, x, y, w, h)
|
||||
onAdd(image)
|
||||
const finalUrl = await urlPromise
|
||||
onURL(assetId, finalUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ImageInfo = { type: "image"; width: number; height: number }
|
||||
|
||||
function imageDimensions(file: File): Promise<ImageInfo | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
|
||||
// the following handler will fire after the successful loading of the image
|
||||
img.onload = () => {
|
||||
const { naturalWidth: width, naturalHeight: height } = img
|
||||
resolve({ type: "image", width, height })
|
||||
}
|
||||
|
||||
// and this handler will fire if there was an error with the image (like if it's not really an image or a corrupted one)
|
||||
img.onerror = () => {
|
||||
resolve(null)
|
||||
}
|
||||
|
||||
img.src = URL.createObjectURL(file)
|
||||
})
|
||||
}
|
||||
298
frontend/src/board/item-connect.ts
Normal file
298
frontend/src/board/item-connect.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import _, { isEqual } from "lodash"
|
||||
import * as L from "lonna"
|
||||
import { globalScope } from "lonna"
|
||||
import * as uuid from "uuid"
|
||||
import { rerouteByNewControlPoints, rerouteConnection, resolveEndpoint } from "../../../common/src/connection-utils"
|
||||
import {
|
||||
Board,
|
||||
Connection,
|
||||
ConnectionEndPoint,
|
||||
getEndPointItemId,
|
||||
Id,
|
||||
isContainedBy,
|
||||
isItem,
|
||||
isItemEndPoint,
|
||||
isPoint,
|
||||
Item,
|
||||
Point,
|
||||
} from "../../../common/src/domain"
|
||||
import { Dispatch } from "../store/board-store"
|
||||
import { BoardCoordinateHelper } from "./board-coordinates"
|
||||
import { BoardFocus, noFocus } from "./board-focus"
|
||||
import { Coordinates, centerPoint, containedBy } from "../../../common/src/geometry"
|
||||
import { ToolController } from "./tool-selection"
|
||||
import { emptySet } from "../../../common/src/sets"
|
||||
import { IS_TOUCHSCREEN, onSingleTouch } from "./touchScreen"
|
||||
|
||||
export const DND_GHOST_HIDING_IMAGE = new Image()
|
||||
// https://png-pixel.com/
|
||||
DND_GHOST_HIDING_IMAGE.src =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
|
||||
|
||||
let currentConnectionHandler = L.atom<ConnectionHandler | null>(null)
|
||||
|
||||
export function startConnecting(
|
||||
board: L.Property<Board>,
|
||||
coordinateHelper: BoardCoordinateHelper,
|
||||
latestConnection: L.Property<Connection | null>,
|
||||
dispatch: Dispatch,
|
||||
toolController: ToolController,
|
||||
focus: L.Atom<BoardFocus>,
|
||||
from: Item | Point,
|
||||
) {
|
||||
const h = currentConnectionHandler.get()
|
||||
if (h) {
|
||||
endConnection()
|
||||
} else {
|
||||
const h = newConnectionCreator(board, focus, latestConnection, dispatch)
|
||||
currentConnectionHandler.set(h)
|
||||
focus.set({ status: "connection-adding" })
|
||||
const toWatch = [currentConnectionHandler, toolController.tool, focus] as L.Property<any>[]
|
||||
const stop = L.merge(toWatch.map((p) => p.pipe(L.changes))).pipe(L.take(1, globalScope))
|
||||
const action = toolController.tool.get() === "line" ? "line" : "connect"
|
||||
|
||||
h.whileDragging(from, coordinateHelper.currentBoardCoordinates.get(), action)
|
||||
coordinateHelper.currentBoardCoordinates.pipe(L.takeUntil(stop)).forEach((pos) => {
|
||||
h.whileDragging(from, pos, action)
|
||||
})
|
||||
stop.forEach(endConnection)
|
||||
}
|
||||
|
||||
function endConnection() {
|
||||
const h = currentConnectionHandler.get()
|
||||
if (h) {
|
||||
h.endDrag()
|
||||
toolController.useDefaultTool()
|
||||
currentConnectionHandler.set(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ConnectionHandler = ReturnType<typeof newConnectionCreator>
|
||||
|
||||
export function newConnectionCreator(
|
||||
board: L.Property<Board>,
|
||||
focus: L.Atom<BoardFocus>,
|
||||
latestConnection: L.Property<Connection | null>,
|
||||
dispatch: Dispatch,
|
||||
) {
|
||||
let localConnection: Connection | null = null
|
||||
|
||||
function whileDragging(from: Item | Point, currentBoardCoords: Point, action: "connect" | "line") {
|
||||
const b = board.get()
|
||||
const boardId = b.id
|
||||
const startPoint: ConnectionEndPoint = isItem(from) ? from.id : from
|
||||
const target = findTarget(b, startPoint, currentBoardCoords, localConnection, getFindTargetOptions(action))
|
||||
|
||||
if (target === null) {
|
||||
if (localConnection !== null) {
|
||||
// Remove current connection, because connect-to-self is not allowed at least for now
|
||||
if (!IS_TOUCHSCREEN)
|
||||
dispatch({ action: "connection.delete", boardId, connectionIds: [localConnection.id] })
|
||||
localConnection = null
|
||||
}
|
||||
} else {
|
||||
if (localConnection === null) {
|
||||
// Start new connection
|
||||
localConnection = newConnection(startPoint, target, action)
|
||||
if (!IS_TOUCHSCREEN) dispatch({ action: "connection.add", boardId, connections: [localConnection] })
|
||||
} else {
|
||||
// Change current connection endpoint
|
||||
// console.log({ item, midpoint, to: targetExistingItem ?? currentPos })
|
||||
|
||||
localConnection = rerouteConnection(
|
||||
{
|
||||
...localConnection,
|
||||
to: target,
|
||||
},
|
||||
b,
|
||||
)
|
||||
|
||||
if (!IS_TOUCHSCREEN)
|
||||
dispatch({ action: "connection.modify", boardId: b.id, connections: [localConnection] })
|
||||
}
|
||||
}
|
||||
|
||||
function newConnection(from: ConnectionEndPoint, target: Id | Point, action: "connect" | "line"): Connection {
|
||||
const l = latestConnection.get()
|
||||
|
||||
return rerouteConnection(
|
||||
{
|
||||
id: uuid.v4(),
|
||||
from: from,
|
||||
controlPoints: l && l.controlPoints.length === 0 ? [] : [{ x: 0, y: 0 }],
|
||||
to: target,
|
||||
action,
|
||||
locked: false,
|
||||
...(action === "connect"
|
||||
? {
|
||||
fromStyle: (l && l.fromStyle) ?? "none",
|
||||
toStyle: (l && l.toStyle) ?? "arrow",
|
||||
pointStyle: (l && l.pointStyle) ?? "black-dot",
|
||||
}
|
||||
: {
|
||||
fromStyle: "none",
|
||||
toStyle: "none",
|
||||
pointStyle: "none",
|
||||
}),
|
||||
},
|
||||
b,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const endDrag = () => {
|
||||
if (localConnection) {
|
||||
const addedConnection = localConnection
|
||||
localConnection = null
|
||||
if (IS_TOUCHSCREEN) {
|
||||
dispatch({ action: "connection.add", boardId: board.get().id, connections: [addedConnection] })
|
||||
}
|
||||
focus.set({ status: "selected", itemIds: emptySet(), connectionIds: new Set(addedConnection.id) })
|
||||
} else {
|
||||
focus.set(noFocus)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
endDrag,
|
||||
whileDragging,
|
||||
getCurrentConnection: () => localConnection,
|
||||
}
|
||||
}
|
||||
|
||||
function shouldPreventAttach(e: DragEvent) {
|
||||
return e.shiftKey || e.altKey || e.ctrlKey || e.metaKey
|
||||
}
|
||||
|
||||
export function existingConnectionHandler(
|
||||
endNode: HTMLElement,
|
||||
connectionId: string,
|
||||
type: "from" | "to" | "control",
|
||||
coordinateHelper: BoardCoordinateHelper,
|
||||
board: L.Property<Board>,
|
||||
dispatch: Dispatch,
|
||||
) {
|
||||
endNode.addEventListener("drag", (e) => e.stopPropagation())
|
||||
endNode.addEventListener("drag", (e) => modifyConnection(shouldPreventAttach(e)))
|
||||
|
||||
endNode.addEventListener("touchmove", (e: TouchEvent) => {
|
||||
onSingleTouch(e, (touch) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
coordinateHelper.currentPageCoordinates.set({ x: touch.pageX, y: touch.pageY })
|
||||
modifyConnection(false)
|
||||
})
|
||||
})
|
||||
|
||||
let prevCoords: Coordinates = coordinateHelper.currentBoardCoordinates.get()
|
||||
|
||||
function modifyConnection(preventAttach: boolean) {
|
||||
const coords = coordinateHelper.currentBoardCoordinates.get()
|
||||
if (isEqual(coords, prevCoords)) {
|
||||
return
|
||||
}
|
||||
prevCoords = coords
|
||||
const b = board.get()
|
||||
const connection = b.connections.find((c) => c.id === connectionId)!
|
||||
const options = getFindTargetOptions(connection.action, preventAttach)
|
||||
if (type === "to") {
|
||||
const target = findTarget(b, connection.from, coords, connection, options)
|
||||
if (target !== null) {
|
||||
const to = target
|
||||
dispatch({
|
||||
action: "connection.modify",
|
||||
boardId: b.id,
|
||||
connections: [rerouteConnection({ ...connection, to }, b)],
|
||||
})
|
||||
}
|
||||
} else if (type === "from") {
|
||||
const target = findTarget(b, connection.to, coords, connection, options)
|
||||
if (target != null) {
|
||||
const from = target
|
||||
dispatch({
|
||||
action: "connection.modify",
|
||||
boardId: b.id,
|
||||
connections: [rerouteConnection({ ...connection, from }, b)],
|
||||
})
|
||||
}
|
||||
} else {
|
||||
dispatch({
|
||||
action: "connection.modify",
|
||||
boardId: b.id,
|
||||
connections: [rerouteByNewControlPoints(connection, [coords], b)],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getFindTargetOptions(action: "line" | "connect", preventAttach = false): FindTargetOptions {
|
||||
return {
|
||||
allowConnect: action === "connect" && !preventAttach,
|
||||
allowSnap: !preventAttach,
|
||||
}
|
||||
}
|
||||
|
||||
type FindTargetOptions = { allowConnect: boolean; allowSnap: boolean }
|
||||
function findTarget(
|
||||
b: Board,
|
||||
from: Item | ConnectionEndPoint,
|
||||
currentPos: Point,
|
||||
currentConnection: Connection | null,
|
||||
options: FindTargetOptions,
|
||||
): Id | Point | null {
|
||||
const items = b.items
|
||||
const resolvedFromPoint = resolveEndpoint(from, items)
|
||||
const fromItem = isItem(resolvedFromPoint) ? resolvedFromPoint : null
|
||||
|
||||
if (fromItem && containedBy(currentPos, fromItem)) {
|
||||
// Target point inside fromItem => not acceptable
|
||||
return null
|
||||
}
|
||||
|
||||
const targetItem =
|
||||
options.allowConnect &&
|
||||
Object.values(items)
|
||||
.filter((i) => !i.hidden)
|
||||
.filter((i) => containedBy({ ...currentPos, width: 0, height: 0 }, i)) // match coordinates
|
||||
.filter((i) => isConnectionAttachmentPoint(currentPos, i))
|
||||
.filter((i) =>
|
||||
isItem(resolvedFromPoint)
|
||||
? !isConnected(b, i, resolvedFromPoint, currentConnection)
|
||||
: !containedBy(resolvedFromPoint, i),
|
||||
)
|
||||
.sort((a, b) => (isContainedBy(items, a)(b) ? 1 : -1)) // most innermost first (containers last)
|
||||
.find((i) => !fromItem || !isContainedBy(items, i)(fromItem)) // does not contain the "from" item
|
||||
|
||||
if (targetItem) return targetItem
|
||||
if (!isPoint(from)) return currentPos
|
||||
const xDiff = Math.abs(currentPos.x - from.x)
|
||||
const yDiff = Math.abs(currentPos.y - from.y)
|
||||
if (xDiff === 0 || yDiff === 0) return currentPos
|
||||
const threshold = 0.02
|
||||
if (xDiff / yDiff < threshold) return { x: from.x, y: currentPos.y }
|
||||
if (yDiff / xDiff < threshold) return { x: currentPos.x, y: from.y }
|
||||
return currentPos
|
||||
}
|
||||
|
||||
function isConnected(b: Board, x: Item, y: Item, connectionToIgnore: Connection | null) {
|
||||
return b.connections.some((c) => connectionToIgnore != c && isConnectionRelated(x, c) && isConnectionRelated(y, c))
|
||||
}
|
||||
|
||||
function isConnectionRelated(i: Item, c: Connection) {
|
||||
return isEndPointRelated(i, c.from) || isEndPointRelated(i, c.to)
|
||||
}
|
||||
|
||||
function isEndPointRelated(i: Item, c: ConnectionEndPoint) {
|
||||
return isItemEndPoint(c) && getEndPointItemId(c) === i.id
|
||||
}
|
||||
|
||||
export function isConnectionAttachmentPoint(point: Point, item: Item) {
|
||||
if (item.type !== "container") return true
|
||||
const center = centerPoint(item)
|
||||
const factor = 0.9
|
||||
return (
|
||||
Math.abs(point.x - center.x) > (item.width / 2) * factor ||
|
||||
Math.abs(point.y - center.y) > (item.height / 2) * factor
|
||||
)
|
||||
}
|
||||
32
frontend/src/board/item-create.ts
Normal file
32
frontend/src/board/item-create.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as L from "lonna"
|
||||
import { Board, Item, newContainer, newSimilarNote, newText, Note } from "../../../common/src/domain"
|
||||
import { BoardFocus, getSelectedItem } from "./board-focus"
|
||||
import { installDoubleClickHandler } from "./double-click"
|
||||
import { installKeyboardShortcut, plainKey } from "./keyboard-shortcuts"
|
||||
|
||||
export function itemCreateHandler(
|
||||
board: L.Property<Board>,
|
||||
focus: L.Property<BoardFocus>,
|
||||
latestNote: L.Property<Note>,
|
||||
boardElement: L.Property<HTMLElement | null>,
|
||||
onAdd: (item: Item) => void,
|
||||
) {
|
||||
installKeyboardShortcut(plainKey("n"), () => onAdd(newSimilarNote(latestNote.get())))
|
||||
installKeyboardShortcut(plainKey("a"), () => onAdd(newContainer(board.get().crdt)))
|
||||
installKeyboardShortcut(plainKey("t"), () => onAdd(newText(board.get().crdt)))
|
||||
|
||||
installDoubleClickHandler((e) => {
|
||||
shouldCreateOnDblClick(e) && onAdd(newSimilarNote(latestNote.get()))
|
||||
})
|
||||
|
||||
function shouldCreateOnDblClick(event: JSX.UIEvent) {
|
||||
if (event.target === boardElement.get()! || boardElement.get()!.contains(event.target as Node)) {
|
||||
const f = focus.get()
|
||||
const selectedElement = getSelectedItem(board.get())(focus.get())
|
||||
if (f.status === "none" || (selectedElement && selectedElement.type === "container")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
262
frontend/src/board/item-cut-copy-paste.ts
Normal file
262
frontend/src/board/item-cut-copy-paste.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import _ from "lodash"
|
||||
import * as L from "lonna"
|
||||
import * as uuid from "uuid"
|
||||
import { DEFAULT_NOTE_COLOR } from "../../../common/src/colors"
|
||||
import { connectionRect, resolveEndpoint } from "../../../common/src/connection-utils"
|
||||
import {
|
||||
BOARD_ITEM_BORDER_MARGIN,
|
||||
Board,
|
||||
Connection,
|
||||
ConnectionEndPoint,
|
||||
Id,
|
||||
Item,
|
||||
Point,
|
||||
findItemsRecursively,
|
||||
getEndPointItemId,
|
||||
isItemEndPoint,
|
||||
newNote,
|
||||
newText,
|
||||
} from "../../../common/src/domain"
|
||||
import * as G from "../../../common/src/geometry"
|
||||
import { emptySet } from "../../../common/src/sets"
|
||||
import { sanitizeHTML } from "../components/sanitizeHTML"
|
||||
import { Dispatch } from "../store/board-store"
|
||||
import { CRDTStore } from "../store/crdt-store"
|
||||
import { BoardCoordinateHelper } from "./board-coordinates"
|
||||
import { BoardFocus, getSelectedConnectionIds, getSelectedItemIds } from "./board-focus"
|
||||
|
||||
const CLIPBOARD_EVENTS = ["cut", "copy", "paste"] as const
|
||||
|
||||
export type ItemsAndConnections = {
|
||||
items: Item[]
|
||||
connections: Connection[]
|
||||
}
|
||||
|
||||
export function augmentWithCRDT(
|
||||
boardId: Id,
|
||||
itemsAndConnections: ItemsAndConnections,
|
||||
crdtStore: CRDTStore,
|
||||
): ItemsAndConnections {
|
||||
return {
|
||||
...itemsAndConnections,
|
||||
items: crdtStore.augmentItems(boardId, itemsAndConnections.items),
|
||||
}
|
||||
}
|
||||
|
||||
export function findSelectedItemsAndConnections(currentFocus: BoardFocus, currentBoard: Board): ItemsAndConnections {
|
||||
const selectedItemIds = getSelectedItemIds(currentFocus)
|
||||
const selectedConnectionIds = getSelectedConnectionIds(currentFocus)
|
||||
const items = findItemsRecursively([...selectedItemIds], currentBoard)
|
||||
const recursiveItemIds = new Set(items.map((i) => i.id))
|
||||
const connections = currentBoard.connections
|
||||
.filter((c) => {
|
||||
if (selectedConnectionIds.has(c.id)) {
|
||||
return true
|
||||
}
|
||||
// Include connections between these items and connections that have one end
|
||||
// in these items and the other end not connected.
|
||||
const ids = connectedIds(c)
|
||||
if (ids.length > 0 && !ids.some((id) => !recursiveItemIds.has(id))) {
|
||||
return true
|
||||
}
|
||||
if (c.containerId && recursiveItemIds.has(c.containerId)) {
|
||||
return true
|
||||
}
|
||||
})
|
||||
.map((c) => ({
|
||||
...c,
|
||||
from: detachEndPointIfItemNotFound(c.from, recursiveItemIds, currentBoard),
|
||||
to: detachEndPointIfItemNotFound(c.to, recursiveItemIds, currentBoard),
|
||||
}))
|
||||
return { items, connections }
|
||||
}
|
||||
|
||||
function detachEndPointIfItemNotFound(ep: ConnectionEndPoint, itemIds: Set<Id>, currentBoard: Board) {
|
||||
if (isItemEndPoint(ep) && !itemIds.has(getEndPointItemId(ep))) {
|
||||
const resolved = resolveEndpoint(ep, currentBoard)
|
||||
return Point(resolved.x, resolved.y)
|
||||
}
|
||||
return ep
|
||||
}
|
||||
|
||||
function connectedIds(connection: Connection) {
|
||||
const endpoints = [connection.to, connection.from]
|
||||
return endpoints.flatMap((ep) => (isItemEndPoint(ep) ? [getEndPointItemId(ep)] : []))
|
||||
}
|
||||
|
||||
export function makeCopies(
|
||||
itemsAndConnections: ItemsAndConnections,
|
||||
xDiff: number,
|
||||
yDiff: number,
|
||||
): { toCreate: Item[]; toSelect: Item[]; connections: Connection[] } {
|
||||
const items = itemsAndConnections.items
|
||||
const ids = new Set(items.map((i) => i.id))
|
||||
const contained = items.filter((i) => !!i.containerId && ids.has(i.containerId))
|
||||
const notContained = items.filter((i) => !contained.some((c) => c.id === i.id))
|
||||
const oldToNewId: Record<Id, Id> = {}
|
||||
let toCreate: Item[] = []
|
||||
// As a side effect makeCopy adds items to toCreate
|
||||
const toSelect = notContained.map(makeCopy)
|
||||
// As a result, the top-level (notContained) copied items will be selected
|
||||
const connections = itemsAndConnections.connections.map((c) => {
|
||||
return {
|
||||
...c,
|
||||
from: translateEndpoint(c.from),
|
||||
to: translateEndpoint(c.to),
|
||||
controlPoints: c.controlPoints.map(translateEndpoint) as Point[],
|
||||
id: uuid.v4(),
|
||||
containerId: c.containerId && oldToNewId[c.containerId],
|
||||
}
|
||||
})
|
||||
|
||||
return { toCreate, toSelect, connections }
|
||||
|
||||
function translateEndpoint(endPoint: ConnectionEndPoint) {
|
||||
if (isItemEndPoint(endPoint)) {
|
||||
const newId = oldToNewId[getEndPointItemId(endPoint)]
|
||||
if (!newId) {
|
||||
console.warn(`Target item ${endPoint} not found from pasted contents, assuming it exists on board`)
|
||||
return endPoint
|
||||
}
|
||||
return newId
|
||||
} else {
|
||||
return G.add(endPoint, { x: xDiff, y: yDiff })
|
||||
}
|
||||
}
|
||||
|
||||
function makeCopy(i: Item): Item {
|
||||
const containerId = i.id
|
||||
const newContainer = flatCopy(i)
|
||||
toCreate.push(newContainer)
|
||||
contained
|
||||
.filter((ctd) => ctd.containerId === containerId)
|
||||
.forEach((ctd) => {
|
||||
makeCopy({ ...ctd, containerId: newContainer.id })
|
||||
})
|
||||
return newContainer
|
||||
}
|
||||
|
||||
function flatCopy(i: Item) {
|
||||
const newId = uuid.v4()
|
||||
oldToNewId[i.id] = newId
|
||||
return { ...i, id: newId, x: i.x + xDiff, y: i.y + yDiff }
|
||||
}
|
||||
}
|
||||
|
||||
export function cutCopyPasteHandler(
|
||||
board: L.Property<Board>,
|
||||
crdtStore: CRDTStore,
|
||||
focus: L.Atom<BoardFocus>,
|
||||
coordinateHelper: BoardCoordinateHelper,
|
||||
dispatch: Dispatch,
|
||||
uploadImageFile: (file: File) => Promise<void>,
|
||||
) {
|
||||
const clipboardEventHandler = (e: ClipboardEvent) => {
|
||||
const currentFocus = focus.get()
|
||||
const currentBoard = board.get()
|
||||
switch (e.type) {
|
||||
case "cut": {
|
||||
if (currentFocus.status !== "selected") return
|
||||
const clipboard = augmentWithCRDT(
|
||||
board.get().id,
|
||||
findSelectedItemsAndConnections(currentFocus, currentBoard),
|
||||
crdtStore,
|
||||
)
|
||||
dispatch({
|
||||
action: "item.delete",
|
||||
boardId: currentBoard.id,
|
||||
itemIds: clipboard.items.map((i) => i.id),
|
||||
connectionIds: [...getSelectedConnectionIds(currentFocus)],
|
||||
})
|
||||
e.clipboardData!.setData("application/rboard", JSON.stringify(clipboard))
|
||||
e.preventDefault()
|
||||
break
|
||||
}
|
||||
case "copy": {
|
||||
if (currentFocus.status !== "selected") return
|
||||
console.log("Copying to clipboard", currentBoard)
|
||||
const clipboard = augmentWithCRDT(
|
||||
board.get().id,
|
||||
findSelectedItemsAndConnections(currentFocus, currentBoard),
|
||||
crdtStore,
|
||||
)
|
||||
e.clipboardData!.setData("application/rboard", JSON.stringify(clipboard))
|
||||
e.preventDefault()
|
||||
break
|
||||
}
|
||||
case "paste": {
|
||||
if (e.clipboardData) {
|
||||
const imageFile = [...e.clipboardData.files].find((file) => file.type.startsWith("image/"))
|
||||
if (imageFile) {
|
||||
uploadImageFile(imageFile)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (currentFocus.status === "editing") return
|
||||
const rboardData = e.clipboardData?.getData("application/rboard")
|
||||
if (!rboardData) {
|
||||
const html = e.clipboardData?.getData("text/html") || e.clipboardData?.getData("text/plain")
|
||||
if (html) {
|
||||
const sanitized = sanitizeHTML(html)
|
||||
const currentCenter = coordinateHelper.currentBoardCoordinates.get()
|
||||
let toCreate
|
||||
if (sanitized.length > 50) {
|
||||
toCreate = [newText(board.get().crdt, sanitized, currentCenter.x, currentCenter.y)]
|
||||
} else {
|
||||
toCreate = [newNote(sanitized, DEFAULT_NOTE_COLOR, currentCenter.x, currentCenter.y)]
|
||||
}
|
||||
dispatch({ action: "item.add", boardId: currentBoard.id, items: toCreate, connections: [] })
|
||||
} else if (e.clipboardData) {
|
||||
console.log(
|
||||
"Unsupported data from clipboard.",
|
||||
Object.fromEntries(e.clipboardData.types.map((t) => [t, e.clipboardData?.getData(t)])),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const clipboard = JSON.parse(rboardData) as ItemsAndConnections
|
||||
if (!("items" in clipboard)) {
|
||||
console.warn("Unexpected clipboard content", clipboard)
|
||||
return
|
||||
}
|
||||
const requiredMargin = BOARD_ITEM_BORDER_MARGIN
|
||||
const itemRecord = Object.fromEntries(clipboard.items.map((i) => [i.id, i]))
|
||||
const theItems = [...clipboard.items, ...clipboard.connections.map(connectionRect(itemRecord))]
|
||||
if (theItems.length === 0) {
|
||||
console.log("Empty clipboard")
|
||||
return
|
||||
}
|
||||
const xDiffMin = -_.min(theItems.map((i) => i.x))! + requiredMargin
|
||||
const yDiffMin = -_.min(theItems.map((i) => i.y))! + requiredMargin
|
||||
const xDiffMax = board.get().width - _.max(theItems.map((i) => i.x + i.width))! - requiredMargin
|
||||
const yDiffMax = board.get().height - _.max(theItems.map((i) => i.y + i.height))! - requiredMargin
|
||||
const xCenterOld = _.sum(theItems.map((i) => i.x + i.width / 2)) / theItems.length
|
||||
const yCenterOld = _.sum(theItems.map((i) => i.y + i.height / 2)) / theItems.length
|
||||
const currentCenter = coordinateHelper.currentBoardCoordinates.get()
|
||||
|
||||
const xDiff = Math.min(Math.max(currentCenter.x - xCenterOld, xDiffMin), xDiffMax)
|
||||
const yDiff = Math.min(Math.max(currentCenter.y - yCenterOld, yDiffMin), yDiffMax)
|
||||
const { toCreate, toSelect, connections } = makeCopies(clipboard, xDiff, yDiff)
|
||||
|
||||
dispatch({ action: "item.add", boardId: currentBoard.id, items: toCreate, connections })
|
||||
focus.set({
|
||||
status: "selected",
|
||||
itemIds: new Set(toSelect.map((it) => it.id)),
|
||||
connectionIds: emptySet(),
|
||||
})
|
||||
e.preventDefault()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CLIPBOARD_EVENTS.forEach((eventType) => {
|
||||
document.addEventListener(eventType, clipboardEventHandler)
|
||||
})
|
||||
|
||||
return () => {
|
||||
CLIPBOARD_EVENTS.forEach((eventType) => {
|
||||
document.removeEventListener(eventType, clipboardEventHandler)
|
||||
})
|
||||
}
|
||||
}
|
||||
20
frontend/src/board/item-delete.ts
Normal file
20
frontend/src/board/item-delete.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as L from "lonna"
|
||||
import { Id } from "../../../common/src/domain"
|
||||
import { Dispatch } from "../store/board-store"
|
||||
import { BoardFocus, getSelectedConnectionIds, getSelectedItemIds } from "./board-focus"
|
||||
import { installKeyboardShortcut, plainKey } from "./keyboard-shortcuts"
|
||||
|
||||
export function itemDeleteHandler(boardId: Id, dispatch: Dispatch, focus: L.Property<BoardFocus>) {
|
||||
installKeyboardShortcut(plainKey("Delete", "Backspace"), () => {
|
||||
const f = focus.get()
|
||||
dispatchDeletion(boardId, f, dispatch)
|
||||
})
|
||||
}
|
||||
|
||||
export function dispatchDeletion(boardId: Id, f: BoardFocus, dispatch: Dispatch) {
|
||||
const connectionIds = [...getSelectedConnectionIds(f)]
|
||||
const itemIds = [...getSelectedItemIds(f)]
|
||||
if (itemIds.length || connectionIds.length) {
|
||||
dispatch({ action: "item.delete", boardId, itemIds, connectionIds })
|
||||
}
|
||||
}
|
||||
172
frontend/src/board/item-drag.ts
Normal file
172
frontend/src/board/item-drag.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import * as L from "lonna"
|
||||
import { Board, Connection, getConnection, getItem, Item, Point } from "../../../common/src/domain"
|
||||
import { emptySet } from "../../../common/src/sets"
|
||||
import { BoardCoordinateHelper } from "./board-coordinates"
|
||||
import { BoardFocus, getSelectedItemIds } from "./board-focus"
|
||||
import { isSingleTouch } from "./touchScreen"
|
||||
|
||||
export const DND_GHOST_HIDING_IMAGE = new Image()
|
||||
// https://png-pixel.com/
|
||||
DND_GHOST_HIDING_IMAGE.src =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
|
||||
|
||||
export function onBoardItemDrag(
|
||||
elem: HTMLElement,
|
||||
id: string,
|
||||
board: L.Property<Board>,
|
||||
focus: L.Atom<BoardFocus>,
|
||||
coordinateHelper: BoardCoordinateHelper,
|
||||
onlyWhenSelected: boolean,
|
||||
doWhileDragging: (
|
||||
// TODO: this abstraction is leaking
|
||||
b: Board,
|
||||
dragStartPosition: Point,
|
||||
items: { current: Item; dragStartPosition: Item }[],
|
||||
connections: { current: Connection; dragStartPosition: Connection }[],
|
||||
xDiff: number,
|
||||
yDiff: number,
|
||||
) => void,
|
||||
doOnDrop?: (b: Board, current: Item[]) => void,
|
||||
) {
|
||||
type Drag = { pageX: number; pageY: number; preventDefault: () => void; stopPropagation: () => void }
|
||||
type DragEnd = { stopPropagation: () => void }
|
||||
|
||||
const touch2Drag = (e: TouchEvent): Drag => {
|
||||
return {
|
||||
pageX: e.touches[0].pageX,
|
||||
pageY: e.touches[0].pageY,
|
||||
stopPropagation: () => e.stopPropagation(),
|
||||
preventDefault: () => e.preventDefault(),
|
||||
}
|
||||
}
|
||||
const touch2DragEnd = (e: TouchEvent): DragEnd => {
|
||||
return {
|
||||
stopPropagation: () => {},
|
||||
}
|
||||
}
|
||||
|
||||
let dragStart: Drag | null = null
|
||||
let dragStartPositions: Board
|
||||
let currentPos: { x: number; y: number } | null = null
|
||||
|
||||
const dragEnabled = onlyWhenSelected ? L.view(focus, (f) => getSelectedItemIds(f).has(id)) : L.constant(true)
|
||||
|
||||
const onDragStart = (e: DragEvent) => {
|
||||
e.dataTransfer?.setDragImage(DND_GHOST_HIDING_IMAGE, 0, 0)
|
||||
startDrag(e)
|
||||
}
|
||||
const startDrag = (e: Drag) => {
|
||||
e.stopPropagation()
|
||||
const f = focus.get()
|
||||
if (f.status === "dragging") {
|
||||
if (!f.itemIds.has(id)) {
|
||||
focus.set({ status: "dragging", itemIds: new Set([id]), connectionIds: emptySet() })
|
||||
}
|
||||
} else if (f.status === "selected" && f.itemIds.has(id)) {
|
||||
focus.set({ status: "dragging", itemIds: f.itemIds, connectionIds: f.connectionIds })
|
||||
} else {
|
||||
focus.set({ status: "dragging", itemIds: new Set([id]), connectionIds: emptySet() })
|
||||
}
|
||||
|
||||
dragStart = e
|
||||
dragStartPositions = board.get()
|
||||
}
|
||||
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
e.preventDefault()
|
||||
if (isSingleTouch(e)) {
|
||||
const d = touch2Drag(e)
|
||||
const f = focus.get()
|
||||
if (f.status !== "dragging") {
|
||||
startDrag(touch2Drag(e))
|
||||
}
|
||||
|
||||
coordinateHelper.currentPageCoordinates.set({ x: d.pageX, y: d.pageY })
|
||||
drag(d)
|
||||
}
|
||||
}
|
||||
const onDrag = (e: DragEvent) => {
|
||||
drag(e)
|
||||
}
|
||||
|
||||
const drag = (e: Drag) => {
|
||||
e.stopPropagation()
|
||||
const f = focus.get()
|
||||
if (f.status !== "dragging") {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
const newPos = coordinateHelper.boardCoordDiffFromThisPageCoordinate({
|
||||
x: dragStart!.pageX,
|
||||
y: dragStart!.pageY,
|
||||
})
|
||||
if (currentPos && currentPos.x == newPos.x && currentPos.y === newPos.y) {
|
||||
return
|
||||
}
|
||||
currentPos = newPos
|
||||
const { x: xDiff, y: yDiff } = newPos
|
||||
|
||||
const b = board.get()
|
||||
const items = [...f.itemIds].map((id) => {
|
||||
const current = b.items[id]
|
||||
const dragStartPosition = dragStartPositions.items[id]
|
||||
if (!current || !dragStartPosition) throw Error("Item not found: " + id)
|
||||
return {
|
||||
current,
|
||||
dragStartPosition,
|
||||
}
|
||||
})
|
||||
const connections = [...f.connectionIds].map((id) => {
|
||||
const current = getConnection(b)(id)
|
||||
const dragStartPosition = getConnection(dragStartPositions)(id)
|
||||
if (!current || !dragStartPosition) throw Error("Connection not found: " + id)
|
||||
return { current, dragStartPosition }
|
||||
})
|
||||
const dragStartBoardPos = coordinateHelper.pageToBoardCoordinates({
|
||||
x: dragStart!.pageX,
|
||||
y: dragStart!.pageY,
|
||||
})
|
||||
doWhileDragging(b, dragStartBoardPos, items, connections, xDiff, yDiff)
|
||||
}
|
||||
|
||||
const onTouchEnd = (e: TouchEvent) => {
|
||||
e.preventDefault()
|
||||
if (isSingleTouch(e)) {
|
||||
dragEnd(touch2DragEnd(e))
|
||||
}
|
||||
}
|
||||
const onDragEnd = (e: DragEvent) => {
|
||||
dragEnd(e)
|
||||
}
|
||||
const dragEnd = (e: DragEnd) => {
|
||||
e.stopPropagation()
|
||||
focus.modify((f) => {
|
||||
if (f.status !== "dragging") {
|
||||
return f
|
||||
}
|
||||
if (doOnDrop) {
|
||||
const b = board.get()
|
||||
const items = [...f.itemIds].map(getItem(b))
|
||||
doOnDrop(b, items)
|
||||
}
|
||||
currentPos = null
|
||||
return { status: "selected", itemIds: f.itemIds, connectionIds: f.connectionIds }
|
||||
})
|
||||
}
|
||||
|
||||
dragEnabled.forEach((enabled) => {
|
||||
if (enabled) {
|
||||
elem.addEventListener("dragstart", onDragStart)
|
||||
elem.addEventListener("drag", onDrag)
|
||||
elem.addEventListener("dragend", onDragEnd)
|
||||
elem.addEventListener("touchmove", onTouchMove)
|
||||
elem.addEventListener("touchend", onTouchEnd)
|
||||
} else {
|
||||
elem.removeEventListener("dragstart", onDragStart)
|
||||
elem.removeEventListener("drag", onDrag)
|
||||
elem.removeEventListener("dragend", onDragEnd)
|
||||
elem.removeEventListener("touchmove", onTouchMove)
|
||||
elem.removeEventListener("touchend", onTouchEnd)
|
||||
}
|
||||
})
|
||||
}
|
||||
88
frontend/src/board/item-dragmove.ts
Normal file
88
frontend/src/board/item-dragmove.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import * as L from "lonna"
|
||||
import { BoardCoordinateHelper } from "./board-coordinates"
|
||||
import { Board, BOARD_ITEM_BORDER_MARGIN, Connection, isItemEndPoint, Item, Point } from "../../../common/src/domain"
|
||||
import { BoardFocus } from "./board-focus"
|
||||
import { onBoardItemDrag } from "./item-drag"
|
||||
import { maybeChangeContainerForItem } from "./item-setcontainer"
|
||||
import { Dispatch } from "../store/board-store"
|
||||
import { newConnectionCreator, isConnectionAttachmentPoint } from "./item-connect"
|
||||
import { Tool, ToolController } from "./tool-selection"
|
||||
import { connectionRect } from "../../../common/src/connection-utils"
|
||||
|
||||
export function itemDragToMove(
|
||||
id: string,
|
||||
board: L.Property<Board>,
|
||||
focus: L.Atom<BoardFocus>,
|
||||
toolController: ToolController,
|
||||
coordinateHelper: BoardCoordinateHelper,
|
||||
latestConnection: L.Property<Connection | null>,
|
||||
dispatch: Dispatch,
|
||||
onlyWhenSelected: boolean,
|
||||
) {
|
||||
const connector = newConnectionCreator(board, focus, latestConnection, dispatch)
|
||||
return (elem: HTMLElement) =>
|
||||
onBoardItemDrag(
|
||||
elem,
|
||||
id,
|
||||
board,
|
||||
focus,
|
||||
coordinateHelper,
|
||||
onlyWhenSelected,
|
||||
(b, startPos, items, connections, xDiff, yDiff) => {
|
||||
// Cant drag when connect tool is active
|
||||
const t = toolController.tool.get()
|
||||
|
||||
// While dragging
|
||||
const f = focus.get()
|
||||
if (f.status !== "dragging") throw Error("Assertion fail")
|
||||
|
||||
// TODO: disable multiple selection in connect mode
|
||||
|
||||
if (t === "connect" || t === "line") {
|
||||
const { current, dragStartPosition } = items[0]
|
||||
const from = isConnectionAttachmentPoint(startPos, current) && t === "connect" ? current : startPos
|
||||
connector.whileDragging(from, coordinateHelper.currentBoardCoordinates.get(), t)
|
||||
} else {
|
||||
const margin = BOARD_ITEM_BORDER_MARGIN
|
||||
const movedItems = items.map(({ dragStartPosition, current }) => {
|
||||
const x = Math.min(
|
||||
Math.max(dragStartPosition.x + xDiff, margin),
|
||||
b.width - current.width - margin,
|
||||
)
|
||||
const y = Math.min(
|
||||
Math.max(dragStartPosition.y + yDiff, margin),
|
||||
b.height - current.height - margin,
|
||||
)
|
||||
const container = maybeChangeContainerForItem(current, b.items)
|
||||
return { id: current.id, x, y, containerId: container ? container.id : undefined }
|
||||
})
|
||||
|
||||
const movedConnections = connections.flatMap(({ dragStartPosition, current }) => {
|
||||
if (
|
||||
isItemEndPoint(current.from) ||
|
||||
isItemEndPoint(current.to) ||
|
||||
isItemEndPoint(dragStartPosition.from) ||
|
||||
isItemEndPoint(dragStartPosition.to)
|
||||
)
|
||||
return []
|
||||
const currentRect = connectionRect(b)(current)
|
||||
const x = Math.min(
|
||||
Math.max(dragStartPosition.from.x + xDiff, margin),
|
||||
b.width - currentRect.width - margin,
|
||||
)
|
||||
const y = Math.min(
|
||||
Math.max(dragStartPosition.from.y + yDiff, margin),
|
||||
b.height - currentRect.height - margin,
|
||||
)
|
||||
return { id: current.id, x, y }
|
||||
})
|
||||
|
||||
dispatch({ action: "item.move", boardId: b.id, items: movedItems, connections: movedConnections })
|
||||
}
|
||||
},
|
||||
() => {
|
||||
connector.endDrag()
|
||||
toolController.useDefaultTool()
|
||||
},
|
||||
)
|
||||
}
|
||||
35
frontend/src/board/item-duplicate.ts
Normal file
35
frontend/src/board/item-duplicate.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import * as L from "lonna"
|
||||
import { Board } from "../../../common/src/domain"
|
||||
import { emptySet } from "../../../common/src/sets"
|
||||
import { Dispatch } from "../store/board-store"
|
||||
import { BoardFocus } from "./board-focus"
|
||||
import { augmentWithCRDT, findSelectedItemsAndConnections, makeCopies } from "./item-cut-copy-paste"
|
||||
import { controlKey, installKeyboardShortcut } from "./keyboard-shortcuts"
|
||||
import { CRDTStore } from "../store/crdt-store"
|
||||
|
||||
export function itemDuplicateHandler(
|
||||
board: L.Property<Board>,
|
||||
crdtStore: CRDTStore,
|
||||
dispatch: Dispatch,
|
||||
focus: L.Atom<BoardFocus>,
|
||||
) {
|
||||
installKeyboardShortcut(controlKey("d"), () => {
|
||||
dispatchDuplication(focus, board.get(), dispatch, crdtStore)
|
||||
})
|
||||
}
|
||||
|
||||
export function dispatchDuplication(
|
||||
focus: L.Atom<BoardFocus>,
|
||||
currentBoard: Board,
|
||||
dispatch: Dispatch,
|
||||
crdtStore: CRDTStore,
|
||||
) {
|
||||
const itemsAndConnections = augmentWithCRDT(
|
||||
currentBoard.id,
|
||||
findSelectedItemsAndConnections(focus.get(), currentBoard),
|
||||
crdtStore,
|
||||
)
|
||||
const { toCreate, toSelect, connections } = makeCopies(itemsAndConnections, 1, 1)
|
||||
dispatch({ action: "item.add", boardId: currentBoard.id, items: toCreate, connections })
|
||||
focus.set({ status: "selected", itemIds: new Set(toSelect.map((it) => it.id)), connectionIds: emptySet() })
|
||||
}
|
||||
38
frontend/src/board/item-hide-contents.ts
Normal file
38
frontend/src/board/item-hide-contents.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Board, isContainer, Item, Note } from "../../../common/src/domain"
|
||||
import { Dispatch } from "../store/board-store"
|
||||
import { BoardFocus, getSelectedItems } from "./board-focus"
|
||||
import { getIfSame } from "./contextmenu/textAlignments"
|
||||
import * as L from "lonna"
|
||||
import { installKeyboardShortcut, plainKey } from "./keyboard-shortcuts"
|
||||
|
||||
export function itemHideContentsHandler(board: L.Property<Board>, focus: L.Property<BoardFocus>, dispatch: Dispatch) {
|
||||
installKeyboardShortcut(plainKey("h"), () =>
|
||||
toggleContentsHidden(getSelectedItems(board.get())(focus.get()), board.get(), dispatch),
|
||||
)
|
||||
}
|
||||
|
||||
export function hasContentHidden(items: Item[]) {
|
||||
return getIfSame(items, (item) => (isContainer(item) && item.contentsHidden) ?? false, false)
|
||||
}
|
||||
|
||||
export function toggleContentsHidden(items: Item[], board: Board, dispatch: Dispatch) {
|
||||
const hidden = hasContentHidden(items)
|
||||
|
||||
dispatch({
|
||||
action: "item.update",
|
||||
boardId: board.id,
|
||||
items: findContainers(items, board).map((c) => ({
|
||||
id: c.id,
|
||||
contentsHidden: !hidden,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
function findContainers(items: Item[], board: Board): Item[] {
|
||||
const containers = items.filter(isContainer)
|
||||
const leftOverItems = items.filter((i) => !isContainer(i) && !containers.some((c) => c.id === i.containerId))
|
||||
const containersForLeftOverItems = leftOverItems
|
||||
.map((i) => board.items[i.containerId ?? ""])
|
||||
.filter((i) => i && !containers.some((c) => c.id === i.id))
|
||||
return [...containers, ...containersForLeftOverItems]
|
||||
}
|
||||
61
frontend/src/board/item-move-with-arrow-keys.ts
Normal file
61
frontend/src/board/item-move-with-arrow-keys.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import * as L from "lonna"
|
||||
import { connectionRect, resolveEndpoint } from "../../../common/src/connection-utils"
|
||||
import { Board, BOARD_ITEM_BORDER_MARGIN } from "../../../common/src/domain"
|
||||
import { Rect } from "../../../common/src/geometry"
|
||||
import { Dispatch } from "../store/board-store"
|
||||
import { BoardFocus } from "./board-focus"
|
||||
import { findSelectedItemsAndConnections } from "./item-cut-copy-paste"
|
||||
import { installKeyboardShortcut } from "./keyboard-shortcuts"
|
||||
|
||||
function updatePosition<T extends Rect>(board: Board, item: T, dx: number, dy: number): T {
|
||||
const margin = BOARD_ITEM_BORDER_MARGIN
|
||||
return {
|
||||
...item,
|
||||
x: Math.min(Math.max(item.x + dx, margin), board.width - item.width - margin),
|
||||
y: Math.min(Math.max(item.y + dy, margin), board.height - item.height - margin),
|
||||
}
|
||||
}
|
||||
|
||||
function moveItem<T extends Rect>(board: Board, item: T, key: string, shiftKey: boolean, altKey: boolean): T {
|
||||
const stepSize = shiftKey ? 10 : altKey ? 0.1 : 1
|
||||
switch (key) {
|
||||
case "ArrowLeft":
|
||||
return updatePosition(board, item, -stepSize, 0)
|
||||
case "ArrowRight":
|
||||
return updatePosition(board, item, stepSize, 0)
|
||||
case "ArrowUp":
|
||||
return updatePosition(board, item, 0, -stepSize)
|
||||
case "ArrowDown":
|
||||
return updatePosition(board, item, 0, stepSize)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
export function itemMoveWithArrowKeysHandler(board: L.Property<Board>, dispatch: Dispatch, focus: L.Atom<BoardFocus>) {
|
||||
installKeyboardShortcut(
|
||||
(e) => ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(e.key),
|
||||
(e) => {
|
||||
const currentBoard = board.get()
|
||||
const itemsAndConnections = findSelectedItemsAndConnections(focus.get(), currentBoard)
|
||||
if (itemsAndConnections.items.length > 0 || itemsAndConnections.connections.length > 0) {
|
||||
const movedItems = itemsAndConnections.items.map((item) =>
|
||||
moveItem(currentBoard, item, e.key, e.shiftKey, e.altKey),
|
||||
)
|
||||
const movedConnections = itemsAndConnections.connections.map((connection) => {
|
||||
const rect = connectionRect(currentBoard)(connection)
|
||||
const movedRect = moveItem(currentBoard, rect, e.key, e.shiftKey, e.altKey)
|
||||
const xDiff = movedRect.x - rect.x
|
||||
const yDiff = movedRect.y - rect.y
|
||||
const startPoint = resolveEndpoint(connection.from, currentBoard)
|
||||
return { id: connection.id, x: startPoint.x + xDiff, y: startPoint.y + yDiff }
|
||||
})
|
||||
dispatch({
|
||||
action: "item.move",
|
||||
boardId: currentBoard.id,
|
||||
items: movedItems,
|
||||
connections: movedConnections,
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
55
frontend/src/board/item-organizer.test.ts
Normal file
55
frontend/src/board/item-organizer.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { newNote } from "../../../common/src/domain"
|
||||
import { overlaps } from "../../../common/src/geometry"
|
||||
import { organizeItems } from "./item-organizer"
|
||||
|
||||
describe("organizeItems", () => {
|
||||
const rect = { x: 10, y: 10, width: 100, height: 100 }
|
||||
const itemsToAvoid = [{ ...newNote("a1"), x: 10, y: 10, width: 40, height: 40 }]
|
||||
const b1 = { ...newNote("b1"), width: 20, height: 20 }
|
||||
const b2 = { ...newNote("b2"), width: 20, height: 20 }
|
||||
|
||||
it("Overlap", () => {
|
||||
expect(overlaps({ x: 10, y: 10, width: 5, height: 5 }, { x: 9, y: 9, width: 42, height: 42 })).toEqual(true)
|
||||
expect(overlaps({ x: 51, y: 10, width: 5, height: 5 }, { x: 9, y: 9, width: 42, height: 42 })).toEqual(false)
|
||||
})
|
||||
it("Single row", () => {
|
||||
const itemsToPlace = [b1, b2]
|
||||
expect(organizeItems(itemsToPlace, itemsToAvoid, rect)).toEqual([
|
||||
{ ...b1, x: 51, y: 10 },
|
||||
{ ...b2, x: 72, y: 10 },
|
||||
])
|
||||
})
|
||||
|
||||
it("Multiple rows", () => {
|
||||
const b3 = { ...newNote("b3"), width: 80, height: 20 }
|
||||
const itemsToPlace = [b1, b2, b3]
|
||||
const result1 = [
|
||||
{ ...b1, x: 51, y: 10 },
|
||||
{ ...b2, x: 72, y: 10 },
|
||||
{ ...b3, x: 10, y: 51 },
|
||||
]
|
||||
expect(organizeItems(itemsToPlace, itemsToAvoid, rect)).toEqual(result1)
|
||||
|
||||
// Now test that it's somewhat stable when it comes to existing placements
|
||||
const result1Shuffled = [
|
||||
{ ...b3, x: 10, y: 51 },
|
||||
{ ...b2, x: 72, y: 10 },
|
||||
{ ...b1, x: 51, y: 10 },
|
||||
]
|
||||
expect(organizeItems(result1Shuffled, itemsToAvoid, rect)).toEqual([])
|
||||
})
|
||||
|
||||
it("Clean new rows when different sizes on a row", () => {
|
||||
const itemsToAvoid = [
|
||||
{ ...newNote("a1"), x: 10, y: 10, width: 10, height: 50 },
|
||||
{ ...newNote("a2"), x: 20, y: 10, width: 90, height: 10 },
|
||||
]
|
||||
const itemsToPlace = [b1, b2]
|
||||
expect(organizeItems(itemsToPlace, itemsToAvoid, rect)).toEqual([
|
||||
{ ...b1, x: 10, y: 61 },
|
||||
{ ...b2, x: 31, y: 61 },
|
||||
])
|
||||
})
|
||||
})
|
||||
88
frontend/src/board/item-organizer.ts
Normal file
88
frontend/src/board/item-organizer.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Board, Container, Item, ItemUpdate } from "../../../common/src/domain"
|
||||
import * as G from "../../../common/src/geometry"
|
||||
import _ from "lodash"
|
||||
|
||||
const rowResolution = 1
|
||||
export const ITEM_MARGIN = 1
|
||||
export const CONTAINER_MARGIN = 1
|
||||
|
||||
// This function is run recursively 'maxAttempts' times to find a good fit
|
||||
export function contentRect(cont: Container): G.Rect {
|
||||
const { width, height } = cont
|
||||
const borderTop = (cont.fontSize ?? CONTAINER_MARGIN) * 2
|
||||
const otherBorders = CONTAINER_MARGIN
|
||||
const borderBottom = otherBorders
|
||||
const borderLeft = otherBorders
|
||||
const borderRight = otherBorders
|
||||
return {
|
||||
x: cont.x + borderLeft,
|
||||
y: cont.y + borderTop,
|
||||
width: cont.width - borderLeft - borderRight,
|
||||
height: cont.height - borderTop - borderBottom,
|
||||
}
|
||||
}
|
||||
|
||||
export function packableItems(cont: Container, board: Board): Item[] {
|
||||
const is = board.items
|
||||
const values = Object.values(is)
|
||||
// Packing containers-in-containers not supported yet, and resizing text seems to cause overflow issues
|
||||
const items = values.filter((v) => v.containerId === cont.id && v.type !== "text" && v.type !== "container")
|
||||
return items
|
||||
}
|
||||
|
||||
// TODO: return only changed attributes in ItemUpdate - currently returns full Items
|
||||
export function organizeItems(itemsToPack: Item[], itemsToAvoid: Item[], rect: G.Rect): ItemUpdate[] {
|
||||
if (itemsToPack.length === 0) return itemsToPack
|
||||
const results: Item[] = []
|
||||
let rowY = rect.y
|
||||
let colX = rect.x
|
||||
const rowNumber = (i: Item) => Math.floor((i.y * rect.width) / rowResolution)
|
||||
const colNumber = (i: Item) => i.x
|
||||
|
||||
itemsToPack = _.orderBy(itemsToPack, [rowNumber, colNumber])
|
||||
for (let itemToPlace of itemsToPack) {
|
||||
let item: Item
|
||||
;({ item, colX, rowY } = placeItem(itemToPlace, itemsToAvoid, rect, rowY, colX))
|
||||
itemsToAvoid = [...itemsToAvoid, item]
|
||||
if (item.x !== itemToPlace.x || item.y !== itemToPlace.y) {
|
||||
results.push(item)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export function placeItem(
|
||||
item: Item,
|
||||
itemsToAvoid: Item[],
|
||||
rect: G.Rect,
|
||||
rowY: number,
|
||||
colX: number,
|
||||
): { item: Item; rowY: number; colX: number } {
|
||||
for (let i = 0; i < 1000000; i++) {
|
||||
let place = { x: colX, y: rowY, width: item.width, height: item.height }
|
||||
//console.log(place)
|
||||
const toAvoidWithMargin = itemsToAvoid.map((i) => marginRect(ITEM_MARGIN, i))
|
||||
let overlapping = toAvoidWithMargin.filter((r) => G.overlaps(place, r))
|
||||
if (overlapping.length === 0) {
|
||||
return { item: { ...item, ...place }, rowY, colX }
|
||||
} else {
|
||||
//console.log("Overlaps", overlapping)
|
||||
let nextX = _.max(overlapping.map((r) => r.x + r.width))!
|
||||
if (nextX + item.width <= rect.x + rect.width) {
|
||||
// try same row
|
||||
colX = nextX
|
||||
} else {
|
||||
colX = rect.x
|
||||
const rowArea = { x: rect.x, y: rowY, width: rect.width, height: place.height }
|
||||
const rowItemsWithMargin = toAvoidWithMargin.filter((i) => G.overlaps(i, rowArea))
|
||||
const maxY = _.max(rowItemsWithMargin.map((i) => i.y + i.height))!
|
||||
rowY = maxY
|
||||
}
|
||||
}
|
||||
}
|
||||
throw Error("Failed to pack")
|
||||
}
|
||||
|
||||
function marginRect(margin: number, r: G.Rect): G.Rect {
|
||||
return { x: r.x - margin, y: r.y - margin, width: r.width + 2 * margin, height: r.height + 2 * margin }
|
||||
}
|
||||
128
frontend/src/board/item-packer.ts
Normal file
128
frontend/src/board/item-packer.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// @ts-ignore
|
||||
import { BP2D } from "binpackingjs"
|
||||
const { Bin, Box, Packer, heuristics } = BP2D
|
||||
import { Board, Container, Item, ItemUpdate } from "../../../common/src/domain"
|
||||
import { Rect } from "../../../common/src/geometry"
|
||||
import { ITEM_MARGIN } from "./item-organizer"
|
||||
|
||||
type PackItemsResult =
|
||||
| {
|
||||
ok: true
|
||||
packedItems: ItemUpdate[]
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
error: string
|
||||
}
|
||||
|
||||
const PACK_BINARY_SEARCH_DEFAULT: {
|
||||
max: number
|
||||
min: number
|
||||
multiplier: number
|
||||
attempt: number
|
||||
maxAttempts: number
|
||||
prev: (Item[] | null)[]
|
||||
} = {
|
||||
max: 1,
|
||||
min: 0,
|
||||
multiplier: 0.5,
|
||||
attempt: 1,
|
||||
maxAttempts: 20,
|
||||
prev: [],
|
||||
}
|
||||
|
||||
// TODO: return only changed attributes in ItemUpdate - currently returns full Items
|
||||
export function packItems(targetRect: Rect, items: Item[], binarySearch = PACK_BINARY_SEARCH_DEFAULT): PackItemsResult {
|
||||
const availableWidth = targetRect.width
|
||||
const availableHeight = targetRect.height
|
||||
|
||||
const b = new Bin(availableWidth, availableHeight, new heuristics.BottomLeft())
|
||||
const p = new Packer([b])
|
||||
const avgHeight = items.reduce((acc, i) => i.height + acc, 0) / items.length
|
||||
|
||||
const availableArea = availableWidth * availableHeight * binarySearch.multiplier
|
||||
|
||||
function totalArea(its: { width: number; height: number }[]) {
|
||||
return its.reduce((acc, it) => it.width * it.height + acc, 0)
|
||||
}
|
||||
|
||||
function scaleItems(its: { width: number; height: number }[], scale: number) {
|
||||
const multipleOfAverageHeight = (it: { width: number; height: number }) => Math.round(it.height / avgHeight)
|
||||
const widthMultiplier = (it: { width: number; height: number }) =>
|
||||
(multipleOfAverageHeight(it) * avgHeight * scale) / it.height
|
||||
|
||||
const boxes = its.map((it) => {
|
||||
const width = widthMultiplier(it) * it.width + ITEM_MARGIN
|
||||
const height = multipleOfAverageHeight(it) * avgHeight * scale + ITEM_MARGIN
|
||||
const box = new Box(width, height, true)
|
||||
box.data = { ...it, width, height }
|
||||
return box
|
||||
})
|
||||
return boxes
|
||||
}
|
||||
|
||||
let scale = 1
|
||||
let maxScale = 1
|
||||
let minScale = 0
|
||||
let itemsToPack = scaleItems(items, scale)
|
||||
|
||||
if (totalArea(itemsToPack) > availableArea) {
|
||||
// binary search for a while to find a good fit
|
||||
for (let i = 0; i < 100; i++) {
|
||||
scale = minScale + (maxScale - minScale) / 2
|
||||
itemsToPack = scaleItems(items, scale)
|
||||
if (totalArea(itemsToPack) > availableArea) {
|
||||
maxScale = scale
|
||||
} else {
|
||||
minScale = scale
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const packedBoxes = p.pack(itemsToPack)
|
||||
|
||||
let newItems: Item[] | null = null
|
||||
// The maxrects algorithm was designed for packing sprites into 'n' bind,
|
||||
// we only want there to be one bin that contains all of our items
|
||||
if (items.length === packedBoxes.length) {
|
||||
newItems = items.map((it) => {
|
||||
const rect = packedBoxes.find((p: any) => p.data.id === it.id)!
|
||||
return {
|
||||
...it,
|
||||
width: rect.width - ITEM_MARGIN,
|
||||
height: rect.height - ITEM_MARGIN,
|
||||
x: targetRect.x + rect.x,
|
||||
y: targetRect.y + rect.y,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const failed = newItems === null
|
||||
|
||||
if (binarySearch.attempt <= binarySearch.maxAttempts) {
|
||||
const max = failed ? binarySearch.multiplier : binarySearch.max
|
||||
const min = failed ? binarySearch.min : binarySearch.multiplier
|
||||
const multiplier = min + (max - min) / 2
|
||||
const newBinaryParams = {
|
||||
max,
|
||||
min,
|
||||
multiplier,
|
||||
attempt: binarySearch.attempt + 1,
|
||||
maxAttempts: binarySearch.maxAttempts,
|
||||
prev: [newItems, ...binarySearch.prev],
|
||||
}
|
||||
return packItems(targetRect, items, newBinaryParams)
|
||||
}
|
||||
|
||||
const finalItems = [newItems, ...binarySearch.prev].find((candidate) => candidate !== null)
|
||||
|
||||
return !finalItems
|
||||
? {
|
||||
ok: false,
|
||||
error: "no fit",
|
||||
}
|
||||
: {
|
||||
ok: true,
|
||||
packedItems: finalItems,
|
||||
}
|
||||
}
|
||||
14
frontend/src/board/item-select-all.ts
Normal file
14
frontend/src/board/item-select-all.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import * as L from "lonna"
|
||||
import { Board } from "../../../common/src/domain"
|
||||
import { BoardFocus } from "./board-focus"
|
||||
import { controlKey, installKeyboardShortcut } from "./keyboard-shortcuts"
|
||||
|
||||
export function itemSelectAllHandler(board: L.Property<Board>, focus: L.Atom<BoardFocus>) {
|
||||
installKeyboardShortcut(controlKey("a"), () =>
|
||||
focus.set({
|
||||
status: "selected",
|
||||
itemIds: new Set(Object.keys(board.get().items)),
|
||||
connectionIds: new Set(board.get().connections.map((c) => c.id)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
69
frontend/src/board/item-selection.ts
Normal file
69
frontend/src/board/item-selection.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import * as L from "lonna"
|
||||
import { Board, Connection, ItemType } from "../../../common/src/domain"
|
||||
import { emptySet, toggleInSet } from "../../../common/src/sets"
|
||||
import { Dispatch } from "../store/board-store"
|
||||
import { BoardCoordinateHelper } from "./board-coordinates"
|
||||
import { BoardFocus, getSelectedConnectionIds, getSelectedItemIds } from "./board-focus"
|
||||
import { isConnectionAttachmentPoint, startConnecting } from "./item-connect"
|
||||
import { ToolController } from "./tool-selection"
|
||||
import { isSingleTouch } from "./touchScreen"
|
||||
|
||||
export function itemSelectionHandler(
|
||||
id: string,
|
||||
type: ItemType,
|
||||
focus: L.Atom<BoardFocus>,
|
||||
toolController: ToolController,
|
||||
board: L.Property<Board>,
|
||||
coordinateHelper: BoardCoordinateHelper,
|
||||
latestConnection: L.Property<Connection | null>,
|
||||
dispatch: Dispatch,
|
||||
) {
|
||||
const itemFocus = L.view(focus, (f) => {
|
||||
if (f.status === "none" || f.status === "adding" || f.status === "connection-adding") return "none"
|
||||
if (f.status === "selected") return f.itemIds.has(id) ? "selected" : "none"
|
||||
if (f.status === "dragging") return f.itemIds.has(id) ? "dragging" : "none"
|
||||
if (f.status === "editing") return f.itemId === id ? "editing" : "none"
|
||||
return "none"
|
||||
})
|
||||
|
||||
const selected = L.view(itemFocus, (s) => s !== "none")
|
||||
|
||||
function onTouchStart(e: JSX.TouchEvent) {
|
||||
if (isSingleTouch(e)) {
|
||||
onClick(e)
|
||||
}
|
||||
}
|
||||
function onClick(e: JSX.MouseEvent | JSX.TouchEvent) {
|
||||
const f = focus.get()
|
||||
const selectedIds = getSelectedItemIds(f)
|
||||
const tool = toolController.tool.get()
|
||||
if (tool === "connect") {
|
||||
const item = board.get().items[id]
|
||||
const point = coordinateHelper.currentBoardCoordinates.get()
|
||||
const from = isConnectionAttachmentPoint(point, item)
|
||||
? item
|
||||
: coordinateHelper.currentBoardCoordinates.get()
|
||||
startConnecting(board, coordinateHelper, latestConnection, dispatch, toolController, focus, from)
|
||||
e.stopPropagation()
|
||||
} else if (e.shiftKey && (f.status === "selected" || f.status === "editing")) {
|
||||
focus.set({
|
||||
status: "selected",
|
||||
itemIds: toggleInSet(id, selectedIds),
|
||||
connectionIds: getSelectedConnectionIds(f),
|
||||
})
|
||||
} else if (f.status === "none") {
|
||||
focus.set({ status: "selected", itemIds: new Set([id]), connectionIds: emptySet() })
|
||||
} else if ((f.status === "selected" || f.status === "editing") && !selectedIds.has(id)) {
|
||||
focus.set({ status: "selected", itemIds: new Set([id]), connectionIds: emptySet() })
|
||||
} else if (f.status === "selected" && (type === "note" || type === "text")) {
|
||||
focus.set({ status: "editing", itemId: id })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
itemFocus,
|
||||
selected,
|
||||
onClick,
|
||||
onTouchStart,
|
||||
}
|
||||
}
|
||||
26
frontend/src/board/item-setcontainer.ts
Normal file
26
frontend/src/board/item-setcontainer.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { isFullyContainedConnection } from "../../../common/src/connection-utils"
|
||||
import { Board, Connection, Id, Item } from "../../../common/src/domain"
|
||||
import { containedBy } from "../../../common/src/geometry"
|
||||
|
||||
export function maybeChangeContainerForItem(item: Item, items: Record<Id, Item>): Item | undefined {
|
||||
const candidates = Object.values(items)
|
||||
.filter((i) => i.type === "container" && i.id !== item.id && containedBy(item, i)) // contain the item coordinate-wise
|
||||
.sort((a, b) => (containedBy(b, a) ? 1 : -1)) // most innermost first (containers last)
|
||||
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
export function maybeChangeContainerForConnection(connection: Connection, items: Record<Id, Item>): Item | undefined {
|
||||
const candidates = Object.values(items)
|
||||
.filter((i) => isFullyContainedConnection(connection, i, items)) // contains connection start and endpoints
|
||||
.sort((a, b) => (containedBy(b, a) ? 1 : -1)) // most innermost first (containers last)
|
||||
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
export function withCurrentContainer(item: Item, b: Board): Item {
|
||||
const newContainer = maybeChangeContainerForItem(item, b.items)
|
||||
const containerId = newContainer ? newContainer.id : undefined
|
||||
|
||||
return { ...item, containerId }
|
||||
}
|
||||
8
frontend/src/board/item-undo-redo.ts
Normal file
8
frontend/src/board/item-undo-redo.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Dispatch } from "../store/board-store"
|
||||
import { controlKey, installKeyboardShortcut } from "./keyboard-shortcuts"
|
||||
|
||||
export function itemUndoHandler(dispatch: Dispatch) {
|
||||
installKeyboardShortcut(controlKey("z"), (e) => {
|
||||
dispatch({ action: e.shiftKey ? "ui.redo" : "ui.undo" })
|
||||
})
|
||||
}
|
||||
29
frontend/src/board/keyboard-shortcuts.ts
Normal file
29
frontend/src/board/keyboard-shortcuts.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { componentScope } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
|
||||
export function installKeyboardShortcut(
|
||||
selector: (e: JSX.KeyboardEvent) => boolean,
|
||||
action: (e: JSX.KeyboardEvent) => void,
|
||||
) {
|
||||
;["keydown", "keyup", "keypress"].forEach((eventName) => {
|
||||
// Prevent default for all of these to prevent Backspace=Back behavior on Firefox
|
||||
L.fromEvent<JSX.KeyboardEvent>(document, eventName)
|
||||
.pipe(L.applyScope(componentScope()))
|
||||
.forEach((e) => {
|
||||
if (selector(e)) {
|
||||
e.preventDefault()
|
||||
if (eventName === "keydown") {
|
||||
action(e)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const plainKey = (...k: string[]) => (event: JSX.KeyboardEvent) => {
|
||||
return !(event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) && k.includes(event.key)
|
||||
}
|
||||
|
||||
export const controlKey = (...k: string[]) => (event: JSX.KeyboardEvent) => {
|
||||
return (event.metaKey || event.ctrlKey) && k.includes(event.key)
|
||||
}
|
||||
8
frontend/src/board/local-storage-atom.ts
Normal file
8
frontend/src/board/local-storage-atom.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import * as L from "lonna"
|
||||
|
||||
export function localStorageAtom<T>(key: string, defaultValue: T) {
|
||||
const initialValue = localStorage[key] !== undefined ? JSON.parse(localStorage[key]) : defaultValue
|
||||
const atom = L.atom<T>(initialValue)
|
||||
atom.onChange((v) => (localStorage[key] = JSON.stringify(v)))
|
||||
return atom
|
||||
}
|
||||
15
frontend/src/board/quillClickableLink.ts
Normal file
15
frontend/src/board/quillClickableLink.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import Quill from "quill"
|
||||
var Link = Quill.import("formats/link")
|
||||
|
||||
export default class ClickableLink extends Link {
|
||||
static create(href: any) {
|
||||
let node = super.create(href) as HTMLAnchorElement
|
||||
node.title = href
|
||||
node.addEventListener("click", (e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
window.open(href, "_blank")
|
||||
})
|
||||
return node
|
||||
}
|
||||
}
|
||||
28
frontend/src/board/quillPasteLinkOverText.ts
Normal file
28
frontend/src/board/quillPasteLinkOverText.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import Quill from "quill"
|
||||
import { isURL } from "../components/sanitizeHTML"
|
||||
|
||||
export default class PasteLinkOverText {
|
||||
constructor(quill: Quill) {
|
||||
quill.clipboard.addMatcher(Node.TEXT_NODE, (node, delta) => {
|
||||
if (typeof node.data !== "string") {
|
||||
return undefined as any // This is how it was written
|
||||
}
|
||||
|
||||
const url = node.data
|
||||
if (isURL(url)) {
|
||||
const sel = (quill as any).selection.savedRange as { index: number; length: number } | null
|
||||
if (sel && sel.length > 0) {
|
||||
const existing = quill.getContents(sel.index, sel.length)
|
||||
if (existing.ops.length === 1 && typeof existing.ops[0].insert === "string") {
|
||||
const existingText = existing.ops[0].insert
|
||||
delta.ops = [{ insert: existingText, attributes: { link: url } }]
|
||||
}
|
||||
} else {
|
||||
delta.ops = [{ insert: url, attributes: { link: url } }]
|
||||
}
|
||||
}
|
||||
|
||||
return delta
|
||||
})
|
||||
}
|
||||
}
|
||||
85
frontend/src/board/synchronize-focus-with-server.ts
Normal file
85
frontend/src/board/synchronize-focus-with-server.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import * as L from "lonna"
|
||||
import * as _ from "lodash"
|
||||
import { Board, Id, ItemLocks } from "../../../common/src/domain"
|
||||
import { Dispatch } from "../store/board-store"
|
||||
import {
|
||||
BoardFocus,
|
||||
getSelectedItemIds,
|
||||
noFocus,
|
||||
removeFromSelection,
|
||||
removeNonExistingFromSelection,
|
||||
} from "./board-focus"
|
||||
import { componentScope } from "harmaja"
|
||||
import { emptySet } from "../../../common/src/sets"
|
||||
|
||||
/*
|
||||
Centralized module to handle locking/unlocking items, i.e. disallow operating on
|
||||
items on the board when someone else is already doing so.
|
||||
|
||||
Server should have authoritative answer to who is currently holding the lock to a
|
||||
particular item, so if someone else is holding it, stop editing/dragging/selecting
|
||||
that particular item.
|
||||
|
||||
Dispatch lock/unlock requests to server as selection changes: items not selected
|
||||
anymore should be unlocked, and new selected items should be locked.
|
||||
|
||||
item.lock and item.unlock events can be tycitteld freely because the server decides
|
||||
whether to allow the action or not.
|
||||
*/
|
||||
export function synchronizeFocusWithServer(
|
||||
board: L.Property<Board>,
|
||||
locks: L.Property<ItemLocks>,
|
||||
sessionId: L.Property<string | null>,
|
||||
dispatch: Dispatch,
|
||||
): L.Atom<BoardFocus> {
|
||||
// TODO: not sure if good: item.lock is never dispatched. Instead locker.ts locks items based on
|
||||
// PersistableBoardItemEvents, i.e. relies on the client to send an item.front or similar on selection
|
||||
|
||||
// represents the raw user selection, including possible illegal selections
|
||||
const focusRequest = L.bus<BoardFocus>()
|
||||
type CircumStances = { locks: ItemLocks; sessionId: string | null; board: Board }
|
||||
|
||||
// Circumstances that limit the possible focused selection set
|
||||
const circumstances: L.Property<CircumStances> = L.combineTemplate({
|
||||
locks,
|
||||
sessionId,
|
||||
board,
|
||||
})
|
||||
|
||||
// update focus on new request as well as change to circumstances
|
||||
const events = L.merge(focusRequest, circumstances.pipe(L.changes))
|
||||
|
||||
// selection where illegal (locked) items are removed
|
||||
const resolvedFocus = events.pipe(
|
||||
L.scan(noFocus, (currentFocus, event) => {
|
||||
return narrowFocus("status" in event ? event : currentFocus, circumstances.get())
|
||||
}),
|
||||
L.skipDuplicates<BoardFocus>(_.isEqual, componentScope()),
|
||||
)
|
||||
|
||||
function narrowFocus(focus: BoardFocus, { locks, sessionId: sessionId, board }: CircumStances): BoardFocus {
|
||||
if (!sessionId) return noFocus
|
||||
// TODO consider selected connection in locking as well maybe
|
||||
const itemsWhereSomeoneElseHasLock = new Set(Object.keys(locks).filter((itemId) => locks[itemId] !== sessionId))
|
||||
const nonLocked = removeFromSelection(focus, itemsWhereSomeoneElseHasLock, emptySet())
|
||||
return removeNonExistingFromSelection(nonLocked, board)
|
||||
}
|
||||
|
||||
resolvedFocus.forEach(unlockUnselectedItems)
|
||||
|
||||
// Result atom that allows setting arbitrary focus, but reflects valid selections only
|
||||
return L.atom(resolvedFocus, focusRequest.push)
|
||||
|
||||
function unlockUnselectedItems(f: BoardFocus) {
|
||||
const user = sessionId.get()
|
||||
if (!user) return
|
||||
const l = locks.get()
|
||||
const locksHeld = Object.keys(l).filter((itemId) => l[itemId] === user)
|
||||
const selectedIds = getSelectedItemIds(f)
|
||||
locksHeld.filter((id) => !selectedIds.has(id)).forEach(unlock)
|
||||
}
|
||||
|
||||
function unlock(itemId: Id) {
|
||||
dispatch({ action: "item.unlock", boardId: board.get().id, itemId })
|
||||
}
|
||||
}
|
||||
33
frontend/src/board/tool-selection.ts
Normal file
33
frontend/src/board/tool-selection.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { localStorageAtom } from "./local-storage-atom"
|
||||
import * as L from "lonna"
|
||||
|
||||
export type Tool = "pan" | "select" | "connect" | "note" | "container" | "text" | "line"
|
||||
export type ControlSettings = {
|
||||
tool: Tool
|
||||
defaultTool?: Tool
|
||||
}
|
||||
|
||||
export type ToolController = ReturnType<typeof ToolController>
|
||||
|
||||
export function ToolController() {
|
||||
const controlSettings = localStorageAtom<ControlSettings>("controlSettings", {
|
||||
tool: "pan",
|
||||
})
|
||||
const tool = L.atom(L.view(controlSettings, "tool"), (t) => {
|
||||
controlSettings.modify((s) => ({
|
||||
tool: t,
|
||||
defaultTool: t === "pan" || t === "select" ? t : s.defaultTool,
|
||||
}))
|
||||
})
|
||||
const defaultTool = L.view(controlSettings, (s) => s.defaultTool || "pan")
|
||||
|
||||
const useDefaultTool = () => {
|
||||
tool.set(defaultTool.get())
|
||||
}
|
||||
|
||||
return {
|
||||
controlSettings,
|
||||
tool,
|
||||
useDefaultTool,
|
||||
}
|
||||
}
|
||||
14
frontend/src/board/toolbars/BackToAllBoardsLink.tsx
Normal file
14
frontend/src/board/toolbars/BackToAllBoardsLink.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { h } from "harmaja"
|
||||
import { Link } from "harmaja-router"
|
||||
import { Routes } from "../../board-navigation"
|
||||
import { BackIcon } from "../../components/Icons"
|
||||
export function BackToAllBoardsLink() {
|
||||
return (
|
||||
<Link<Routes> href="/" className="navigation">
|
||||
<span className="icon">
|
||||
<BackIcon />
|
||||
</span>
|
||||
All boards
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
135
frontend/src/board/toolbars/BoardToolLayer.tsx
Normal file
135
frontend/src/board/toolbars/BoardToolLayer.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { AccessLevel, Board, canWrite, Item, Note } from "../../../../common/src/domain"
|
||||
import * as G from "../../../../common/src/geometry"
|
||||
import { BoardStore, Dispatch } from "../../store/board-store"
|
||||
import { UserSessionState } from "../../store/user-session-store"
|
||||
import { BoardCoordinateHelper } from "../board-coordinates"
|
||||
import { BoardFocus } from "../board-focus"
|
||||
import { ZoomAndScrollControls } from "../board-scroll-and-zoom"
|
||||
import { BoardViewMessage } from "../BoardViewMessage"
|
||||
import { ToolController } from "../tool-selection"
|
||||
import { IS_TOUCHSCREEN } from "../touchScreen"
|
||||
import { BackToAllBoardsLink } from "./BackToAllBoardsLink"
|
||||
import { MainToolBar } from "./MainToolBar"
|
||||
import { MiniMapView } from "./MiniMapView"
|
||||
import { UndoRedo } from "./UndoRedo"
|
||||
import { ZoomControls } from "./ZoomControls"
|
||||
|
||||
export const BoardToolLayer = ({
|
||||
boardStore,
|
||||
coordinateHelper,
|
||||
latestNote,
|
||||
containerElement,
|
||||
sessionState,
|
||||
board,
|
||||
accessLevel,
|
||||
onAdd,
|
||||
toolController,
|
||||
dispatch,
|
||||
viewRect,
|
||||
increaseZoom,
|
||||
decreaseZoom,
|
||||
resetZoom,
|
||||
focus,
|
||||
}: {
|
||||
boardStore: BoardStore
|
||||
coordinateHelper: BoardCoordinateHelper
|
||||
latestNote: L.Property<Note>
|
||||
containerElement: L.Atom<HTMLElement | null>
|
||||
sessionState: L.Property<UserSessionState>
|
||||
board: L.Property<Board>
|
||||
accessLevel: L.Property<AccessLevel>
|
||||
onAdd: (i: Item) => void
|
||||
toolController: ToolController
|
||||
dispatch: Dispatch
|
||||
focus: L.Atom<BoardFocus>
|
||||
} & ZoomAndScrollControls) => {
|
||||
const touchMoveStart = L.bus<void>()
|
||||
const boardState = boardStore.state
|
||||
const boardAccessStatus = L.view(boardState, (s) => s.status)
|
||||
const tool = toolController.tool
|
||||
|
||||
return (
|
||||
<div className={L.view(accessLevel, (l) => "tool-layer " + (canWrite(l) ? "" : " read-only"))}>
|
||||
<BoardViewMessage {...{ boardAccessStatus, sessionState, board }} />
|
||||
|
||||
<div className="navigation-toolbar">
|
||||
<BackToAllBoardsLink />
|
||||
</div>
|
||||
<MainToolBar
|
||||
{...{
|
||||
board,
|
||||
containerElement,
|
||||
coordinateHelper,
|
||||
dispatch,
|
||||
focus,
|
||||
latestNote,
|
||||
onAdd,
|
||||
onTouchMoveStart: touchMoveStart.push,
|
||||
toolController,
|
||||
boardStore,
|
||||
}}
|
||||
/>
|
||||
<div className="undo-redo-toolbar board-tool">
|
||||
<UndoRedo {...{ dispatch, boardStore }} />
|
||||
</div>
|
||||
<MiniMapView
|
||||
board={board}
|
||||
viewRect={viewRect}
|
||||
increaseZoom={increaseZoom}
|
||||
decreaseZoom={decreaseZoom}
|
||||
resetZoom={resetZoom}
|
||||
/>
|
||||
|
||||
{L.view(focus, (f) => {
|
||||
if (f.status !== "adding") return null
|
||||
if (IS_TOUCHSCREEN) {
|
||||
return (
|
||||
<span className="tool-instruction">
|
||||
{"Click on the board to place new item"}
|
||||
{f.element}
|
||||
</span>
|
||||
)
|
||||
} else {
|
||||
const style = L.view(coordinateHelper.currentBoardViewPortCoordinates, (p) => ({
|
||||
position: "absolute",
|
||||
left: `${p.x - 20}px`,
|
||||
top: `${p.y - 20}px`,
|
||||
pointerEvents: "none",
|
||||
}))
|
||||
return (
|
||||
<span className="mouse-cursor-message adding-item" style={style}>
|
||||
{f.element}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
})}
|
||||
|
||||
{L.view(focus, tool, (f, t) => {
|
||||
if (t !== "connect") return null
|
||||
const text =
|
||||
f.status === "connection-adding"
|
||||
? "Finish by clicking on target"
|
||||
: "Click on an item or location to make a connection"
|
||||
if (IS_TOUCHSCREEN) {
|
||||
return <span className="tool-instruction">{text}</span>
|
||||
} else {
|
||||
const style = L.view(coordinateHelper.currentBoardViewPortCoordinates, (p) => ({
|
||||
position: "absolute",
|
||||
left: `${p.x}px`,
|
||||
top: `${p.y + 20}px`,
|
||||
fontSize: "0.8rem",
|
||||
pointerEvents: "none",
|
||||
color: "#000000aa",
|
||||
}))
|
||||
return (
|
||||
<span className="mouse-cursor-message" style={style}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
217
frontend/src/board/toolbars/MainToolBar.tsx
Normal file
217
frontend/src/board/toolbars/MainToolBar.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { Board, Item, Note } from "../../../../common/src/domain"
|
||||
import * as G from "../../../../common/src/geometry"
|
||||
import { RedoIcon, UndoIcon } from "../../components/Icons"
|
||||
import { BoardStore } from "../../store/board-store"
|
||||
import { Dispatch } from "../../store/server-connection"
|
||||
import { BoardCoordinateHelper } from "../board-coordinates"
|
||||
import { BoardFocus, getSelectedConnectionIds, getSelectedItemIds } from "../board-focus"
|
||||
import { dispatchDeletion } from "../item-delete"
|
||||
import { DND_GHOST_HIDING_IMAGE } from "../item-drag"
|
||||
import { dispatchDuplication } from "../item-duplicate"
|
||||
import { localStorageAtom } from "../local-storage-atom"
|
||||
import { ToolController } from "../tool-selection"
|
||||
import { PaletteView } from "./PaletteView"
|
||||
import { ToolSelector } from "./ToolSelector"
|
||||
import { CRDTStore } from "../../store/crdt-store"
|
||||
|
||||
export const MainToolBar = ({
|
||||
coordinateHelper,
|
||||
latestNote,
|
||||
containerElement,
|
||||
onAdd,
|
||||
toolController,
|
||||
focus,
|
||||
dispatch,
|
||||
board,
|
||||
onTouchMoveStart,
|
||||
boardStore,
|
||||
}: {
|
||||
coordinateHelper: BoardCoordinateHelper
|
||||
latestNote: L.Property<Note>
|
||||
containerElement: L.Atom<HTMLElement | null>
|
||||
onAdd: (i: Item) => void
|
||||
toolController: ToolController
|
||||
focus: L.Atom<BoardFocus>
|
||||
dispatch: Dispatch
|
||||
board: L.Property<Board>
|
||||
onTouchMoveStart: () => void
|
||||
boardStore: BoardStore
|
||||
}) => {
|
||||
type ToolbarPosition = { x?: number; y?: number; orientation: "vertical" | "horizontal" }
|
||||
const toolbarPosition = localStorageAtom<ToolbarPosition>("toolbarPosition", { orientation: "horizontal" })
|
||||
const toolbarEl = L.atom<HTMLElement | null>(null)
|
||||
let cursorPosAtStart: G.Coordinates | null = null
|
||||
let elementStartPos: G.Rect | null = null
|
||||
const onDrag = (e: JSX.DragEvent) => {
|
||||
if (!cursorPosAtStart) return
|
||||
e.preventDefault()
|
||||
const cursorCurrentPos = coordinateHelper.currentPageCoordinates.get()
|
||||
|
||||
const diff = G.subtract(cursorCurrentPos, cursorPosAtStart)
|
||||
|
||||
const minY = 70
|
||||
const minX = 16
|
||||
const boardRect = containerElement.get()!.getBoundingClientRect()
|
||||
const boardViewCenter = boardRect.x + boardRect.width / 2
|
||||
const toolbarCenter = elementStartPos!.x + diff.x + elementStartPos!.width / 2
|
||||
const centerDiff = Math.abs(toolbarCenter - boardViewCenter)
|
||||
|
||||
const newPos = {
|
||||
x: Math.max(elementStartPos!.x + diff.x, minX),
|
||||
y: Math.max(elementStartPos!.y + diff.y, minY),
|
||||
}
|
||||
|
||||
if (newPos.y === minY && centerDiff < 40) {
|
||||
// If on top, fix to center default location
|
||||
toolbarPosition.set({ orientation: "horizontal" })
|
||||
} else {
|
||||
toolbarPosition.set({ ...newPos, orientation: newPos.x < 100 ? "vertical" : "horizontal" })
|
||||
}
|
||||
}
|
||||
const onDragOver = (e: JSX.DragEvent) => {
|
||||
e.preventDefault()
|
||||
// We need to contribute to currentPageCoordinates, otherwise they won't be updated when dragging over the menubar itself
|
||||
coordinateHelper.currentPageCoordinates.set({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
const onDragStart = (e: JSX.DragEvent) => {
|
||||
if (e.target !== toolbarEl.get()) return // drag started on a palette item
|
||||
cursorPosAtStart = { x: e.clientX, y: e.clientY }
|
||||
e.dataTransfer?.setDragImage(DND_GHOST_HIDING_IMAGE, 0, 0)
|
||||
elementStartPos = toolbarEl.get()!.getBoundingClientRect()
|
||||
}
|
||||
const onDragEnd = (e: JSX.DragEvent) => {
|
||||
cursorPosAtStart = null
|
||||
}
|
||||
const onTouchMove = (e: JSX.TouchEvent) => {
|
||||
e.preventDefault()
|
||||
onTouchMoveStart()
|
||||
}
|
||||
const onTouchStart = (e: JSX.TouchEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
const toolbarStyle = L.view(toolbarPosition, (p) => ({
|
||||
top: p.y || undefined,
|
||||
left: p.x || undefined,
|
||||
transform: p.x !== undefined ? "none" : undefined,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={L.view(toolbarPosition, (o) => `main-toolbar board-tool ${o.orientation}`)}
|
||||
style={toolbarStyle}
|
||||
ref={toolbarEl.set}
|
||||
draggable="true"
|
||||
onDragOver={onDragOver}
|
||||
onDrag={onDrag}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchStart={onTouchStart}
|
||||
>
|
||||
<PaletteView {...{ latestNote, addItem: onAdd, focus, tool: toolController.tool, board }} />
|
||||
<ToolSelector {...{ toolController }} />
|
||||
<DeleteIcon {...{ focus, dispatch, board }} />
|
||||
<DuplicateIcon {...{ focus, dispatch, board, crdtStore: boardStore.crdtStore }} />
|
||||
<UndoToolIcon {...{ boardStore, dispatch }} />
|
||||
<RedoToolIcon {...{ boardStore, dispatch }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type UndoProps = {
|
||||
dispatch: Dispatch
|
||||
boardStore: BoardStore
|
||||
}
|
||||
const UndoToolIcon = ({ dispatch, boardStore }: UndoProps) => {
|
||||
const undo = () => dispatch({ action: "ui.undo" })
|
||||
return (
|
||||
<span className="tool undo" title="Undo" onMouseDown={undo} onTouchStart={undo}>
|
||||
<span className="icon">
|
||||
<UndoIcon enabled={boardStore.canUndo} />
|
||||
</span>
|
||||
<span className="text">Undo</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const RedoToolIcon = ({ dispatch, boardStore }: UndoProps) => {
|
||||
const redo = () => dispatch({ action: "ui.redo" })
|
||||
return (
|
||||
<span className="tool redo" title="Redo" onMouseDown={redo}>
|
||||
<span className="icon">
|
||||
<RedoIcon enabled={boardStore.canRedo} />
|
||||
</span>
|
||||
<span className="text">Redo</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
type DeleteProps = {
|
||||
focus: L.Atom<BoardFocus>
|
||||
board: L.Property<Board>
|
||||
dispatch: Dispatch
|
||||
}
|
||||
|
||||
const DeleteIcon = ({ focus, board, dispatch }: DeleteProps) => {
|
||||
const enabled = L.view(focus, (f) => getSelectedConnectionIds(f).size > 0 || getSelectedItemIds(f).size > 0)
|
||||
const deleteItem = () => dispatchDeletion(board.get().id, focus.get(), dispatch)
|
||||
return (
|
||||
<span className="tool" title="Delete selected item(s)" onMouseDown={deleteItem} onTouchStart={deleteItem}>
|
||||
<span className={L.view(enabled, (e) => (e ? "icon" : "icon disabled"))}>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M9,3V4H4V6H5V19A2,2 0 0,0 7,21H17A2,2 0 0,0 19,19V6H20V4H15V3H9M7,6H17V19H7V6M9,8V17H11V8H9M13,8V17H15V8H13Z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text">Delete</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
type DuplicateProps = {
|
||||
focus: L.Atom<BoardFocus>
|
||||
board: L.Property<Board>
|
||||
dispatch: Dispatch
|
||||
crdtStore: CRDTStore
|
||||
}
|
||||
|
||||
const DuplicateIcon = ({ focus, board, dispatch, crdtStore }: DuplicateProps) => {
|
||||
const enabled = L.view(focus, (f) => getSelectedConnectionIds(f).size > 0 || getSelectedItemIds(f).size > 0)
|
||||
const duplicateItems = () => dispatchDuplication(focus, board.get(), dispatch, crdtStore)
|
||||
return (
|
||||
<span
|
||||
className="tool duplicate"
|
||||
title="Clone selected item(s)"
|
||||
onMouseDown={duplicateItems}
|
||||
onTouchStart={duplicateItems}
|
||||
>
|
||||
<span className={L.view(enabled, (e) => (e ? "icon" : "icon disabled"))}>
|
||||
<svg viewBox="0 0 22 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Make a copy</title>
|
||||
<rect
|
||||
x="6.56582"
|
||||
y="5.86245"
|
||||
width="14.0013"
|
||||
height="14.0013"
|
||||
rx="1.4"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.1"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
/>
|
||||
<path
|
||||
d="M3.6805 15.7398H3.18848C2.08391 15.7398 1.18848 14.8444 1.18848 13.7398V3.18433C1.18848 2.07976 2.08391 1.18433 3.18848 1.18433H13.8317C14.9363 1.18433 15.8317 2.07976 15.8317 3.18433V3.76324"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text">Clone</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
120
frontend/src/board/toolbars/MiniMapView.tsx
Normal file
120
frontend/src/board/toolbars/MiniMapView.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { h, ListView } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { Board, Item } from "../../../../common/src/domain"
|
||||
import { Rect } from "../../../../common/src/geometry"
|
||||
import { DND_GHOST_HIDING_IMAGE } from "../item-drag"
|
||||
import { onSingleTouch } from "../touchScreen"
|
||||
import { ZoomControls } from "./ZoomControls"
|
||||
import { ZoomAndScrollControls } from "../board-scroll-and-zoom"
|
||||
|
||||
export const MiniMapView = ({
|
||||
viewRect,
|
||||
board,
|
||||
increaseZoom,
|
||||
decreaseZoom,
|
||||
resetZoom,
|
||||
}: { viewRect: L.Atom<Rect>; board: L.Property<Board> } & Pick<
|
||||
ZoomAndScrollControls,
|
||||
"increaseZoom" | "decreaseZoom" | "resetZoom"
|
||||
>) => {
|
||||
const minimapWidthPx = 125
|
||||
const minimapDimensions = L.view(board, (rect) => {
|
||||
return { width: minimapWidthPx, height: (minimapWidthPx / rect.width) * rect.height }
|
||||
})
|
||||
const minimapAspectRatio = L.view(minimapDimensions, board, (mm, b) => mm.width / b.width)
|
||||
const minimapStyle = L.view(minimapDimensions, (d) => ({ width: d.width + "px", height: d.height + "px" }))
|
||||
|
||||
const viewAreaStyle = L.view(viewRect, minimapDimensions, board, (vr, mm, b) => {
|
||||
return {
|
||||
width: `${(vr.width * mm.width) / b.width}px`,
|
||||
height: `${(vr.height * mm.height) / b.height}px`,
|
||||
left: `${Math.max(0, (vr.x * mm.width) / b.width)}px`,
|
||||
top: `${Math.max(0, (vr.y * mm.height) / b.height)}px`,
|
||||
}
|
||||
})
|
||||
let startDrag: JSX.DragEvent | null = null
|
||||
let startViewRect: Rect | null = null
|
||||
function onDragEnd(e: JSX.DragEvent) {
|
||||
startDrag = null
|
||||
}
|
||||
function onDragStart(e: JSX.DragEvent) {
|
||||
startDrag = e
|
||||
startViewRect = viewRect.get()
|
||||
e.dataTransfer?.setDragImage(DND_GHOST_HIDING_IMAGE, 0, 0)
|
||||
}
|
||||
function onDragOver(e: JSX.DragEvent) {
|
||||
if (startDrag && startViewRect) {
|
||||
const xDiff = e.clientX - startDrag.clientX
|
||||
const yDiff = e.clientY - startDrag.clientY
|
||||
const ar = minimapAspectRatio.get()
|
||||
const newRect = { ...startViewRect, x: startViewRect.x + xDiff / ar, y: startViewRect.y + yDiff / ar }
|
||||
viewRect.set(newRect)
|
||||
}
|
||||
}
|
||||
const contentElement = L.atom<HTMLDivElement | null>(null)
|
||||
function onClick(e: JSX.MouseEvent | Touch) {
|
||||
const elementArea = contentElement.get()!.getBoundingClientRect()
|
||||
const ar = minimapAspectRatio.get()
|
||||
const x = (e.clientX - elementArea.x) / ar
|
||||
const y = (e.clientY - elementArea.y) / ar
|
||||
viewRect.modify((rect) => ({
|
||||
...rect,
|
||||
x: x - rect.width / 2,
|
||||
y: y - rect.height / 2,
|
||||
}))
|
||||
}
|
||||
|
||||
function onTouchStart(e: JSX.TouchEvent) {
|
||||
e.preventDefault()
|
||||
onSingleTouch(e, (touch) => onClick(touch))
|
||||
}
|
||||
function onTouchMove(e: JSX.TouchEvent) {
|
||||
e.preventDefault()
|
||||
onSingleTouch(e, (touch) => onClick(touch))
|
||||
}
|
||||
function onTouchEnd(e: JSX.TouchEvent) {
|
||||
e.preventDefault()
|
||||
onSingleTouch(e, (touch) => onClick(touch))
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="minimap"
|
||||
style={minimapStyle}
|
||||
onDragOver={onDragOver}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="content" ref={contentElement.set}>
|
||||
<ListView
|
||||
observable={L.view(L.view(board, "items"), Object.values)}
|
||||
renderObservable={renderItem}
|
||||
getKey={(i) => i.id}
|
||||
/>
|
||||
<div
|
||||
className="viewarea"
|
||||
draggable={true}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
style={viewAreaStyle}
|
||||
/>
|
||||
</div>
|
||||
<div className="zoom-toolbar board-tool">
|
||||
<ZoomControls {...{ increaseZoom, decreaseZoom, resetZoom, viewRect }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
function renderItem(id: string, item: L.Property<Item>) {
|
||||
const style = L.view(item, minimapAspectRatio, (item, ratio) => ({
|
||||
left: item.x * ratio + "px",
|
||||
top: item.y * ratio + "px",
|
||||
width: item.width * ratio + "px",
|
||||
height: item.height * ratio + "px",
|
||||
}))
|
||||
const type = item.get().type
|
||||
return <span className={"item " + type} style={style} />
|
||||
}
|
||||
}
|
||||
204
frontend/src/board/toolbars/PaletteView.tsx
Normal file
204
frontend/src/board/toolbars/PaletteView.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { h, Fragment, HarmajaChild } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { Board, Item, newContainer, newSimilarNote, newText, Note } from "../../../../common/src/domain"
|
||||
import { BoardFocus } from "../board-focus"
|
||||
import { Tool } from "../tool-selection"
|
||||
|
||||
export const PaletteView = ({
|
||||
latestNote,
|
||||
addItem,
|
||||
focus,
|
||||
tool,
|
||||
board,
|
||||
}: {
|
||||
latestNote: L.Property<Note>
|
||||
addItem: (item: Item) => void
|
||||
focus: L.Atom<BoardFocus>
|
||||
tool: L.Atom<Tool>
|
||||
board: L.Property<Board>
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<NewNote {...{ addItem, latestNote, focus, tool }} />
|
||||
<NewContainer {...{ addItem, focus, tool, board }} />
|
||||
<NewText {...{ addItem, focus, tool, board }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const NewText = ({
|
||||
addItem: onAdd,
|
||||
focus,
|
||||
tool,
|
||||
board,
|
||||
}: {
|
||||
addItem: (i: Item) => void
|
||||
focus: L.Atom<BoardFocus>
|
||||
tool: L.Atom<Tool>
|
||||
board: L.Property<Board>
|
||||
}) => {
|
||||
const svg = () => (
|
||||
<svg viewBox="0 0 44 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="36" cy="8.5" r="8" fill="#2F80ED" />
|
||||
<path
|
||||
d="M38.5309 8.816V8.002H36.4079V5.879H35.5939V8.002H33.4709V8.816H35.5939V10.939H36.4079V8.816H38.5309Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M10.0695 17.78H12.5015L21.7815 40.5H18.7095L16.0215 33.844H6.4855L3.8295 40.5H0.7575L10.0695 17.78ZM15.3815 31.604L11.2855 21.108L7.0615 31.604H15.3815ZM22.8038 35.668C22.8038 34.6013 23.1024 33.684 23.6998 32.916C24.3184 32.1267 25.1611 31.5187 26.2278 31.092C27.2944 30.6653 28.5318 30.452 29.9398 30.452C30.6864 30.452 31.4758 30.516 32.3078 30.644C33.1398 30.7507 33.8758 30.9213 34.5158 31.156V29.94C34.5158 28.66 34.1318 27.6573 33.3638 26.932C32.5958 26.1853 31.5078 25.812 30.0998 25.812C29.1824 25.812 28.2971 25.9827 27.4438 26.324C26.6118 26.644 25.7264 27.1133 24.7878 27.732L23.7638 25.748C24.8518 25.0013 25.9398 24.4467 27.0278 24.084C28.1158 23.7 29.2464 23.508 30.4198 23.508C32.5531 23.508 34.2384 24.1053 35.4758 25.3C36.7131 26.4733 37.3318 28.116 37.3318 30.228V37.3C37.3318 37.6413 37.3958 37.8973 37.5238 38.068C37.6731 38.2173 37.9078 38.3027 38.2278 38.324V40.5C37.9504 40.5427 37.7051 40.5747 37.4918 40.596C37.2998 40.6173 37.1398 40.628 37.0118 40.628C36.3504 40.628 35.8491 40.4467 35.5078 40.084C35.1878 39.7213 35.0064 39.3373 34.9638 38.932L34.8998 37.876C34.1744 38.8147 33.2251 39.54 32.0518 40.052C30.8784 40.564 29.7158 40.82 28.5638 40.82C27.4544 40.82 26.4624 40.596 25.5878 40.148C24.7131 39.6787 24.0304 39.06 23.5398 38.292C23.0491 37.5027 22.8038 36.628 22.8038 35.668ZM33.6838 36.852C33.9398 36.5533 34.1424 36.2547 34.2918 35.956C34.4411 35.636 34.5158 35.3693 34.5158 35.156V33.076C33.8544 32.82 33.1611 32.628 32.4358 32.5C31.7104 32.3507 30.9958 32.276 30.2917 32.276C28.8624 32.276 27.6998 32.564 26.8038 33.14C25.9291 33.6947 25.4918 34.4627 25.4918 35.444C25.4918 35.9773 25.6304 36.5 25.9078 37.012C26.2064 37.5027 26.6331 37.908 27.1878 38.228C27.7638 38.548 28.4678 38.708 29.2998 38.708C30.1744 38.708 31.0064 38.5373 31.7958 38.196C32.5851 37.8333 33.2144 37.3853 33.6838 36.852Z"
|
||||
fill="black"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
return (
|
||||
<NewItem
|
||||
type="text"
|
||||
title="Text"
|
||||
tooltip="Drag to add new text area"
|
||||
svg={svg}
|
||||
focus={focus}
|
||||
createItem={() => newText(board.get().crdt)}
|
||||
addItem={onAdd}
|
||||
tool={tool}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function lightenDarkenColor(col: string, amt: number) {
|
||||
var num = parseInt(col.replace("#", ""), 16)
|
||||
var r = (num >> 16) + amt
|
||||
var b = ((num >> 8) & 0x00ff) + amt
|
||||
var g = (num & 0x0000ff) + amt
|
||||
var newColor = g | (b << 8) | (r << 16)
|
||||
return "#" + newColor.toString(16)
|
||||
}
|
||||
|
||||
export const NewNote = ({
|
||||
latestNote,
|
||||
addItem: onAdd,
|
||||
focus,
|
||||
tool,
|
||||
}: {
|
||||
latestNote: L.Property<Note>
|
||||
addItem: (i: Item) => void
|
||||
focus: L.Atom<BoardFocus>
|
||||
tool: L.Atom<Tool>
|
||||
}) => {
|
||||
const color = L.view(latestNote, "color")
|
||||
const cornerColor = L.view(color, (c) => lightenDarkenColor(c, -20))
|
||||
|
||||
const noteColor = color
|
||||
const svg = () => (
|
||||
<svg viewBox="0 0 44 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 9.5C0 8.94771 0.447715 8.5 1 8.5H39C39.5523 8.5 40 8.94772 40 9.5V32.1073C40 32.3597 39.9045 32.6028 39.7328 32.7878L33.5 39.5L25.296 48.1866C25.1071 48.3866 24.8441 48.5 24.569 48.5H1C0.447716 48.5 0 48.0523 0 47.5V9.5Z"
|
||||
fill={noteColor}
|
||||
/>
|
||||
<path d="M26 32.5H40L25 48.5V33.5C25 32.9477 25.4477 32.5 26 32.5Z" fill={cornerColor} />
|
||||
<circle cx="36" cy="8.5" r="8" fill="#2F80ED" />
|
||||
<path
|
||||
d="M38.5309 8.816V8.002H36.4079V5.879H35.5939V8.002H33.4709V8.816H35.5939V10.939H36.4079V8.816H38.5309Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
return (
|
||||
<NewItem
|
||||
type="note"
|
||||
title="Note"
|
||||
tooltip="Drag to add new text note"
|
||||
svg={svg}
|
||||
focus={focus}
|
||||
createItem={() => newSimilarNote(latestNote.get())}
|
||||
addItem={onAdd}
|
||||
tool={tool}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const NewContainer = ({
|
||||
addItem: onAdd,
|
||||
focus,
|
||||
tool,
|
||||
board,
|
||||
}: {
|
||||
addItem: (i: Item) => void
|
||||
focus: L.Atom<BoardFocus>
|
||||
tool: L.Atom<Tool>
|
||||
board: L.Property<Board>
|
||||
}) => {
|
||||
const svg = () => (
|
||||
<svg viewBox="0 0 44 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0.5 9.5C0.5 9.22386 0.723857 9 1 9H39C39.2761 9 39.5 9.22386 39.5 9.5V32.5V47.5C39.5 47.7761 39.2761 48 39 48H25H1C0.723858 48 0.5 47.7761 0.5 47.5V9.5Z"
|
||||
fill="white"
|
||||
stroke="#BDBDBD"
|
||||
/>
|
||||
<circle cx="36" cy="8.5" r="8" fill="#2F80ED" />
|
||||
<path
|
||||
d="M38.5309 8.816V8.002H36.4079V5.879H35.5939V8.002H33.4709V8.816H35.5939V10.939H36.4079V8.816H38.5309Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
return (
|
||||
<NewItem
|
||||
type="container"
|
||||
title="Area"
|
||||
tooltip="Drag to add new area for organizing items"
|
||||
svg={svg}
|
||||
focus={focus}
|
||||
createItem={() => newContainer(board.get().crdt)}
|
||||
addItem={onAdd}
|
||||
tool={tool}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const NewItem = ({
|
||||
type,
|
||||
title,
|
||||
tooltip,
|
||||
createItem,
|
||||
focus,
|
||||
svg,
|
||||
addItem,
|
||||
tool,
|
||||
}: {
|
||||
type: "note" | "container" | "text"
|
||||
title: string
|
||||
tooltip: string
|
||||
createItem: () => Item
|
||||
focus: L.Atom<BoardFocus>
|
||||
svg: () => HarmajaChild
|
||||
addItem: (i: Item) => void
|
||||
tool: L.Atom<Tool>
|
||||
}) => {
|
||||
const startAdd = (e: JSX.UIEvent) => {
|
||||
focus.set({ status: "adding", element: svg(), item: createItem() })
|
||||
tool.set(type)
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
const onEndDrag = () => {
|
||||
addItem(createItem())
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={L.view(tool, (t) => `new-item ${type} ${t === type ? "active" : ""}`)}
|
||||
onClick={startAdd}
|
||||
onTouchStart={startAdd}
|
||||
>
|
||||
<span
|
||||
className="icon"
|
||||
data-test={`palette-new-${type}`}
|
||||
title={tooltip}
|
||||
onDragEnd={onEndDrag}
|
||||
draggable={true}
|
||||
>
|
||||
{svg()}
|
||||
</span>
|
||||
<span className="text">{title}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
116
frontend/src/board/toolbars/ToolSelector.tsx
Normal file
116
frontend/src/board/toolbars/ToolSelector.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Fragment, h, HarmajaChild } from "harmaja"
|
||||
import { capitalize } from "lodash"
|
||||
import * as L from "lonna"
|
||||
import { Color } from "../../../../common/src/domain"
|
||||
import { black } from "../../components/UIColors"
|
||||
import { Tool, ToolController } from "../tool-selection"
|
||||
import { IS_TOUCHSCREEN } from "../touchScreen"
|
||||
|
||||
export const ToolSelector = ({ toolController }: { toolController: ToolController }) => {
|
||||
const tool = toolController.tool
|
||||
return (
|
||||
<>
|
||||
{!IS_TOUCHSCREEN && (
|
||||
<ToolIcon
|
||||
{...{
|
||||
name: "select",
|
||||
tooltip: "Select tool",
|
||||
currentTool: tool,
|
||||
svg: (c) => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path fill={c} d="M7,2l12,11.2l-5.8,0.5l3.3,7.3l-2.2,1l-3.2-7.4L7,18.5V2" />
|
||||
</svg>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!IS_TOUCHSCREEN && (
|
||||
<ToolIcon
|
||||
{...{
|
||||
name: "pan",
|
||||
tooltip: "Pan tool",
|
||||
currentTool: tool,
|
||||
svg: (c) => (
|
||||
<svg viewBox="0 0 34 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M17.5657 0.934315C17.2533 0.621895 16.7467 0.621895 16.4343 0.934315L11.3431 6.02548C11.0307 6.3379 11.0307 6.84443 11.3431 7.15685C11.6556 7.46927 12.1621 7.46927 12.4745 7.15685L17 2.63137L21.5255 7.15685C21.8379 7.46927 22.3444 7.46927 22.6569 7.15685C22.9693 6.84443 22.9693 6.3379 22.6569 6.02548L17.5657 0.934315ZM16.4343 34.0657C16.7467 34.3781 17.2533 34.3781 17.5657 34.0657L22.6569 28.9745C22.9693 28.6621 22.9693 28.1556 22.6569 27.8431C22.3444 27.5307 21.8379 27.5307 21.5255 27.8431L17 32.3686L12.4745 27.8431C12.1621 27.5307 11.6556 27.5307 11.3431 27.8431C11.0307 28.1556 11.0307 28.6621 11.3431 28.9745L16.4343 34.0657ZM16.2 1.5V33.5H17.8V1.5H16.2Z"
|
||||
fill={c}
|
||||
/>
|
||||
<path
|
||||
d="M33.5657 18.0657C33.8781 17.7533 33.8781 17.2467 33.5657 16.9343L28.4745 11.8431C28.1621 11.5307 27.6556 11.5307 27.3431 11.8431C27.0307 12.1556 27.0307 12.6621 27.3431 12.9745L31.8686 17.5L27.3431 22.0255C27.0307 22.3379 27.0307 22.8444 27.3431 23.1569C27.6556 23.4693 28.1621 23.4693 28.4745 23.1569L33.5657 18.0657ZM0.434315 16.9343C0.121895 17.2467 0.121895 17.7533 0.434315 18.0657L5.52548 23.1569C5.8379 23.4693 6.34443 23.4693 6.65685 23.1569C6.96927 22.8444 6.96927 22.3379 6.65685 22.0255L2.13137 17.5L6.65685 12.9745C6.96927 12.6621 6.96927 12.1556 6.65685 11.8431C6.34443 11.5307 5.8379 11.5307 5.52548 11.8431L0.434315 16.9343ZM33 16.7L1 16.7L1 18.3L33 18.3L33 16.7Z"
|
||||
fill={c}
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ToolIcon
|
||||
{...{
|
||||
name: "connect",
|
||||
tooltip: "Connect tool",
|
||||
currentTool: tool,
|
||||
svg: (c) => (
|
||||
<svg viewBox="0 -9 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M31.4589 25.5L25.1773 17.9585L34.8493 16.2892L31.4589 25.5ZM2.3871 3.04943C9.59422 1.52064 15.8128 1.84779 20.7217 4.27343C25.6572 6.71218 29.1382 11.2072 30.9758 17.7302L29.3395 18.1911C27.6044 12.0318 24.3875 7.98098 19.9687 5.79752C15.5233 3.60094 9.73115 3.22942 2.73986 4.71243L2.3871 3.04943Z"
|
||||
fill={c}
|
||||
/>
|
||||
<circle
|
||||
r="2.25"
|
||||
transform="matrix(1 0 0 -1 3.54199 3.2746)"
|
||||
fill="white"
|
||||
stroke={c}
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<circle
|
||||
r="2.3"
|
||||
transform="matrix(1 0 0 -1 21.7061 5.97119)"
|
||||
fill="white"
|
||||
stroke={c}
|
||||
stroke-width="1.4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<ToolIcon
|
||||
{...{
|
||||
name: "line",
|
||||
tooltip: "Line tool",
|
||||
currentTool: tool,
|
||||
svg: (c) => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path fill-rule="evenodd" d="M4 11h16v2H4z"></path>
|
||||
</svg>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ToolIcon = ({
|
||||
name,
|
||||
tooltip,
|
||||
currentTool,
|
||||
svg,
|
||||
}: {
|
||||
name: Tool
|
||||
tooltip: string
|
||||
currentTool: L.Atom<Tool>
|
||||
svg: (c: Color) => HarmajaChild
|
||||
}) => {
|
||||
const selectTool = () => currentTool.set(name)
|
||||
return (
|
||||
<span
|
||||
className={L.view(currentTool, (s) => (s === name ? "tool active" : "tool") + " " + name)}
|
||||
title={tooltip}
|
||||
onClick={selectTool}
|
||||
onTouchStart={selectTool}
|
||||
>
|
||||
<span className="icon">{L.view(currentTool, (s) => svg(black))}</span>
|
||||
<span className="text">{capitalize(name)}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
16
frontend/src/board/toolbars/UndoRedo.tsx
Normal file
16
frontend/src/board/toolbars/UndoRedo.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { h } from "harmaja"
|
||||
import { RedoIcon, UndoIcon } from "../../components/Icons"
|
||||
import { BoardStore, Dispatch } from "../../store/board-store"
|
||||
|
||||
export function UndoRedo({ dispatch, boardStore }: { dispatch: Dispatch; boardStore: BoardStore }) {
|
||||
return (
|
||||
<div className="undo-redo">
|
||||
<span className="icon" title="Undo" onClick={() => dispatch({ action: "ui.undo" })}>
|
||||
<UndoIcon enabled={boardStore.canUndo} />
|
||||
</span>
|
||||
<span className="icon" title="Redo" onClick={() => dispatch({ action: "ui.redo" })}>
|
||||
<RedoIcon enabled={boardStore.canRedo} />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
frontend/src/board/toolbars/ZoomControls.tsx
Normal file
23
frontend/src/board/toolbars/ZoomControls.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { h } from "harmaja"
|
||||
import { ResetZoomIcon, ZoomInIcon, ZoomOutIcon } from "../../components/Icons"
|
||||
import { ZoomAndScrollControls } from "../board-scroll-and-zoom"
|
||||
|
||||
export function ZoomControls({
|
||||
increaseZoom,
|
||||
decreaseZoom,
|
||||
resetZoom,
|
||||
}: Pick<ZoomAndScrollControls, "increaseZoom" | "decreaseZoom" | "resetZoom">) {
|
||||
return (
|
||||
<span className="zoom-controls" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="icon" title="Zoom in" onClick={() => increaseZoom("preserveCenter")}>
|
||||
<ZoomInIcon />
|
||||
</span>
|
||||
<span className="icon" title="Reset zoom" onClick={() => resetZoom("preserveCenter")}>
|
||||
<ResetZoomIcon />
|
||||
</span>
|
||||
<span className="icon" title="Zoom out" onClick={() => decreaseZoom("preserveCenter")}>
|
||||
<ZoomOutIcon />
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
17
frontend/src/board/touchScreen.ts
Normal file
17
frontend/src/board/touchScreen.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const IS_TOUCHSCREEN = "ontouchstart" in window
|
||||
|
||||
export function getSingleTouch(e: TouchEvent | JSX.TouchEvent) {
|
||||
if (e.touches.length === 1) return e.touches[0]
|
||||
return null
|
||||
}
|
||||
|
||||
export function isSingleTouch(e: TouchEvent | JSX.TouchEvent) {
|
||||
return getSingleTouch(e) !== null
|
||||
}
|
||||
|
||||
export function onSingleTouch(e: TouchEvent | JSX.TouchEvent, callback: (touch: Touch) => void) {
|
||||
const touch = getSingleTouch(e)
|
||||
if (touch) {
|
||||
callback(touch)
|
||||
}
|
||||
}
|
||||
9
frontend/src/board/zIndices.ts
Normal file
9
frontend/src/board/zIndices.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Item } from "../../../common/src/domain"
|
||||
|
||||
export const Z_CONTAINERS_UP_TO = 1000000
|
||||
export const Z_CONNECTIONS = 200000000
|
||||
export const Z_ITEMS_FROM = Z_CONTAINERS_UP_TO + 10
|
||||
|
||||
export function itemZIndex(item: Item) {
|
||||
return Math.floor(1000000 - item.width * item.height * 10) + item.z
|
||||
}
|
||||
9
frontend/src/board/zoom-shortcuts.ts
Normal file
9
frontend/src/board/zoom-shortcuts.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ZoomAndScrollControls } from "./board-scroll-and-zoom"
|
||||
import { controlKey, installKeyboardShortcut } from "./keyboard-shortcuts"
|
||||
import * as L from "lonna"
|
||||
|
||||
export function installZoomKeyboardShortcuts({ resetZoom, increaseZoom, decreaseZoom }: ZoomAndScrollControls) {
|
||||
installKeyboardShortcut(controlKey("+"), () => increaseZoom("preserveCursor"))
|
||||
installKeyboardShortcut(controlKey("-"), () => decreaseZoom("preserveCursor"))
|
||||
installKeyboardShortcut(controlKey("0"), () => resetZoom("preserveCursor"))
|
||||
}
|
||||
124
frontend/src/components/BoardAccessPolicyEditor.tsx
Normal file
124
frontend/src/components/BoardAccessPolicyEditor.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Fragment, h, ListView } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { AccessListEntry, BoardAccessPolicy, BoardAccessPolicyDefined } from "../../../common/src/domain"
|
||||
import { Checkbox, TextInput } from "../components/components"
|
||||
import { defaultAccessPolicy, LoggedIn } from "../store/user-session-store"
|
||||
|
||||
type BoardAccessPolicyEditorProps = {
|
||||
accessPolicy: L.Atom<BoardAccessPolicy>
|
||||
user: LoggedIn
|
||||
}
|
||||
export const BoardAccessPolicyEditor = ({ accessPolicy, user }: BoardAccessPolicyEditorProps) => {
|
||||
const originalPolicy = accessPolicy.get()
|
||||
const restrictAccessToggle = L.atom(!!originalPolicy && !originalPolicy.publicWrite)
|
||||
restrictAccessToggle.onChange((restrict) => {
|
||||
accessPolicy.set(defaultAccessPolicy(user, restrict))
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="board-access-editor">
|
||||
<div className="restrict-toggle">
|
||||
<Checkbox checked={restrictAccessToggle}>
|
||||
Restrict access to specific domains / email addresses
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className="domain-restrict-details">
|
||||
{L.view(
|
||||
accessPolicy,
|
||||
(a) => !!a && !a.publicWrite,
|
||||
(a) =>
|
||||
a && (
|
||||
<BoardAccessPolicyDetailsEditor
|
||||
accessPolicy={accessPolicy as L.Atom<BoardAccessPolicyDefined>}
|
||||
user={user}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const BoardAccessPolicyDetailsEditor = ({
|
||||
accessPolicy,
|
||||
user,
|
||||
}: {
|
||||
accessPolicy: L.Atom<BoardAccessPolicyDefined>
|
||||
user: LoggedIn
|
||||
}) => {
|
||||
const allowList = L.view(accessPolicy, "allowList")
|
||||
const inputRef = L.atom<HTMLInputElement | null>(null)
|
||||
const allowPublicReadRaw = L.view(accessPolicy, "publicRead")
|
||||
const allowPublicRead = L.atom<boolean>(
|
||||
L.view(allowPublicReadRaw, (r) => !!r),
|
||||
allowPublicReadRaw.set,
|
||||
)
|
||||
const currentInputText = L.atom("")
|
||||
const currentInputValid = L.view(currentInputText, (text) => parseAccessListEntry(text) !== null)
|
||||
|
||||
function parseAccessListEntry(input: string): AccessListEntry | null {
|
||||
// LMAO at this validation
|
||||
return input.includes("@")
|
||||
? { email: input, access: "read-write" }
|
||||
: input.includes(".")
|
||||
? { domain: input, access: "read-write" }
|
||||
: null
|
||||
}
|
||||
|
||||
inputRef.forEach((t) => {
|
||||
if (t) {
|
||||
// Autofocus email/domain input field for better UX
|
||||
t.focus()
|
||||
}
|
||||
})
|
||||
|
||||
function addToAllowListIfValid(input: string) {
|
||||
const entry: AccessListEntry | null = parseAccessListEntry(input)
|
||||
if (entry) {
|
||||
allowList.modify((w) => [entry, ...w])
|
||||
currentInputText.set("")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="input-and-button">
|
||||
<TextInput ref={inputRef} value={currentInputText} type="text" placeholder="Enter email or domain" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
addToAllowListIfValid(currentInputText.get())
|
||||
}}
|
||||
disabled={L.not(currentInputValid)}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ListView
|
||||
observable={allowList}
|
||||
renderItem={(entry) => {
|
||||
return (
|
||||
<div className="input-and-button">
|
||||
<div className="filled-entry">
|
||||
{"domain" in entry
|
||||
? `Allowing everyone with an email address ending in ${entry.domain}`
|
||||
: `Allowing user ${entry.email} (${entry.access})`}
|
||||
</div>
|
||||
<button
|
||||
disabled={"email" in entry ? entry.email === user.email : false}
|
||||
onClick={() => allowList.modify((w) => w.filter((e) => e !== entry))}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="allow-public-read">
|
||||
<Checkbox checked={allowPublicRead}>Anyone with the link can view</Checkbox>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
16
frontend/src/components/BoardCrdtModeSelector.tsx
Normal file
16
frontend/src/components/BoardCrdtModeSelector.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Fragment, h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { Checkbox } from "./components"
|
||||
|
||||
type BoardCrdtModeSelectorProps = {
|
||||
useCollaborativeEditing: L.Atom<boolean>
|
||||
}
|
||||
export const BoardCrdtModeSelector = ({ useCollaborativeEditing }: BoardCrdtModeSelectorProps) => {
|
||||
return (
|
||||
<div className="board-access-editor">
|
||||
<div className="restrict-toggle">
|
||||
<Checkbox checked={useCollaborativeEditing}>Experimental: use collaborative text editor</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
frontend/src/components/EditableSpan.tsx
Normal file
87
frontend/src/components/EditableSpan.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import * as H from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { h, HarmajaOutput } from "harmaja"
|
||||
import { isFirefox } from "./browser"
|
||||
|
||||
export type EditableSpanProps = {
|
||||
value: L.Atom<string>
|
||||
editingThis: L.Atom<boolean>
|
||||
showIcon?: boolean
|
||||
commit?: () => void
|
||||
cancel?: () => void
|
||||
} & JSX.DetailedHTMLProps<JSX.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>
|
||||
|
||||
function clearSelection() {
|
||||
if (!isFirefox) {
|
||||
// Don't clear selection on Firefox, because for an unknown reason, the "selectAll" functionality below breaks after first clearSelection call.
|
||||
window.getSelection()?.removeAllRanges()
|
||||
}
|
||||
}
|
||||
|
||||
export const EditableSpan = (props: EditableSpanProps) => {
|
||||
let { value, editingThis, commit, cancel, ...rest } = props
|
||||
const nameElement = L.atom<HTMLSpanElement | null>(null)
|
||||
const onClick = (e: JSX.MouseEvent) => {
|
||||
if (e.shiftKey) return
|
||||
editingThis.set(true)
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
editingThis.pipe(L.changes).forEach((editing) => {
|
||||
if (editing) {
|
||||
setTimeout(() => {
|
||||
nameElement.get()!.focus()
|
||||
}, 1)
|
||||
} else {
|
||||
clearSelection()
|
||||
}
|
||||
})
|
||||
|
||||
const endEditing = () => {
|
||||
editingThis.set(false)
|
||||
}
|
||||
const onKeyPress = (e: JSX.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
commit && commit()
|
||||
editingThis.set(false)
|
||||
} else if (e.key === "Escape") {
|
||||
// esc
|
||||
cancel && cancel()
|
||||
editingThis.set(false)
|
||||
nameElement.get()!.textContent = value.get()
|
||||
}
|
||||
e.stopPropagation() // To prevent propagating to higher handlers which, for instance prevent defaults for backspace
|
||||
}
|
||||
const onKey = (e: JSX.KeyboardEvent) => {
|
||||
e.stopPropagation() // To prevent propagating to higher handlers which, for instance prevent defaults for backspace
|
||||
}
|
||||
const onInput = (e: JSX.InputEvent<HTMLSpanElement>) => {
|
||||
value.set(e.currentTarget!.textContent || "")
|
||||
}
|
||||
|
||||
const onPaste = (e: JSX.ClipboardEvent<HTMLSpanElement>) => {
|
||||
e.preventDefault()
|
||||
// Paste as plain text, remove formatting.
|
||||
var text = e.clipboardData.getData("text/plain")
|
||||
document.execCommand("insertHTML", false, text)
|
||||
}
|
||||
return (
|
||||
<span onClick={onClick} style={{ cursor: "pointer" }} {...rest}>
|
||||
{!!props.showIcon && <span className="icon edit" style={{ marginRight: "0.3em", fontSize: "0.8em" }} />}
|
||||
<span
|
||||
onBlur={endEditing}
|
||||
contentEditable={editingThis}
|
||||
style={L.view(value, (v) => (v ? {} : { display: "inline-block", minWidth: "1em", minHeight: "1em" }))}
|
||||
ref={nameElement.set}
|
||||
onKeyPress={onKeyPress}
|
||||
onKeyUp={onKeyPress}
|
||||
onKeyDown={onKey}
|
||||
onInput={onInput}
|
||||
onPaste={onPaste}
|
||||
>
|
||||
{props.value}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
133
frontend/src/components/HTMLEditableSpan.tsx
Normal file
133
frontend/src/components/HTMLEditableSpan.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import * as H from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { componentScope, h } from "harmaja"
|
||||
import { isURL, sanitizeHTML, createLinkHTML } from "./sanitizeHTML"
|
||||
import { IS_TOUCHSCREEN } from "../board/touchScreen"
|
||||
|
||||
export type EditableSpanProps = {
|
||||
value: L.Atom<string>
|
||||
editingThis: L.Atom<boolean>
|
||||
editable: L.Property<boolean>
|
||||
}
|
||||
|
||||
const isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1
|
||||
function clearSelection() {
|
||||
if (!isFirefox) {
|
||||
// Don't clear selection on Firefox, because for an unknown reason, the "selectAll" functionality below breaks after first clearSelection call.
|
||||
window.getSelection()?.removeAllRanges()
|
||||
}
|
||||
}
|
||||
|
||||
export const HTMLEditableSpan = (props: EditableSpanProps) => {
|
||||
let { value, editingThis, editable } = props
|
||||
const editableElement = L.atom<HTMLSpanElement | null>(null)
|
||||
editingThis.pipe(L.changes).forEach((editing) => {
|
||||
if (editing) {
|
||||
setTimeout(() => {
|
||||
editableElement.get()!.focus()
|
||||
if (!IS_TOUCHSCREEN) {
|
||||
// On iPhone at least the selectAll command prevent the keyboard from showing up
|
||||
document.execCommand("selectAll", false)
|
||||
}
|
||||
}, 1)
|
||||
} else {
|
||||
clearSelection()
|
||||
}
|
||||
})
|
||||
|
||||
const updateContent = () => {
|
||||
const e = editableElement.get()
|
||||
if (!e) return
|
||||
e.innerHTML = sanitizeHTML(value.get(), true)
|
||||
}
|
||||
|
||||
L.combine(value.pipe(L.applyScope(componentScope())), editableElement, (v, e) => ({ v, e })).forEach(({ v, e }) => {
|
||||
if (!e) return
|
||||
if (e.innerHTML != v) {
|
||||
updateContent()
|
||||
}
|
||||
})
|
||||
editingThis
|
||||
.pipe(
|
||||
L.changes,
|
||||
L.filter((e) => !e),
|
||||
L.applyScope(componentScope()),
|
||||
)
|
||||
.forEach(updateContent)
|
||||
|
||||
const onBlur = (e: JSX.FocusEvent) => {
|
||||
// In Safari, Chrome, some spaces end up being non-breaking spaces in case of pasting content
|
||||
// vs typing it. Didn't find a better way to fix it yet. Replacing innerHTML while editing would
|
||||
// mess up cursor position, so we replace the nbsps onBlur instead.
|
||||
const content = value.get()
|
||||
const fixed = content.replaceAll(" ", " ")
|
||||
if (fixed !== content) {
|
||||
value.set(fixed)
|
||||
}
|
||||
}
|
||||
const onKeyPress = (e: JSX.KeyboardEvent) => {
|
||||
e.stopPropagation() // To prevent propagating to higher handlers which, for instance prevent defaults for backspace
|
||||
}
|
||||
|
||||
const onKeyDown = (e: JSX.KeyboardEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === "b") {
|
||||
document.execCommand("bold", false)
|
||||
e.preventDefault()
|
||||
}
|
||||
if (e.key === "i") {
|
||||
document.execCommand("italic", false)
|
||||
e.preventDefault()
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
// esc
|
||||
editingThis.set(false)
|
||||
}
|
||||
e.stopPropagation() // To prevent propagating to higher handlers which, for instance prevent defaults for backspace
|
||||
}
|
||||
const onKeyUp = onKeyPress
|
||||
const onInput = () => {
|
||||
value.set(editableElement.get()!.innerHTML || "")
|
||||
}
|
||||
const onPaste = (e: JSX.ClipboardEvent<HTMLSpanElement>) => {
|
||||
e.preventDefault()
|
||||
// Paste as plain text, remove formatting.
|
||||
var htmlText = e.clipboardData.getData("text/html") || e.clipboardData.getData("text/plain")
|
||||
const sanitized = isURL(htmlText)
|
||||
? createLinkHTML(htmlText, window.getSelection()!.toString() || undefined)
|
||||
: sanitizeHTML(htmlText)
|
||||
document.execCommand("insertHTML", false, sanitized)
|
||||
}
|
||||
|
||||
const onTouch = (e: JSX.TouchEvent) => {
|
||||
if (!editingThis.get()) {
|
||||
editingThis.set(true)
|
||||
}
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
return (
|
||||
<span style={{ cursor: "pointer" }}>
|
||||
<span
|
||||
className="editable"
|
||||
onBlur={onBlur}
|
||||
contentEditable={L.and(editingThis, editable)}
|
||||
style={L.view(value, (v) => (v ? {} : { display: "inline-block", minWidth: "1em", minHeight: "1em" }))}
|
||||
ref={editableElement.set}
|
||||
onKeyPress={onKeyPress}
|
||||
onKeyUp={onKeyUp}
|
||||
onKeyDown={onKeyDown}
|
||||
onInput={onInput}
|
||||
onPaste={onPaste}
|
||||
onTouchStart={onTouch}
|
||||
onTouchEnd={onTouch}
|
||||
onTouchCancel={onTouch}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
editingThis.set(true)
|
||||
}}
|
||||
></span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
408
frontend/src/components/Icons.tsx
Normal file
408
frontend/src/components/Icons.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import { h } from "harmaja"
|
||||
import { Color } from "../../../common/src/domain"
|
||||
import * as L from "lonna"
|
||||
import { black, disabledColor } from "../components/UIColors"
|
||||
|
||||
export const ZoomInIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||
<path
|
||||
d="M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Zm-40-60v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ZoomOutIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||
<path
|
||||
d="M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400ZM280-540v-80h200v80H280Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ResetZoomIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||
<path
|
||||
d="M822-142 592-372q-32 26-71 39t-81 13q-42 0-80-12.5T290-368l58-58q20 12 43 19t49 7q75 0 127.5-52.5T620-580q0-75-52.5-127.5T440-760q-69 0-119.5 46.5T262-598l50-50 56 56-148 148L72-592l56-56 54 52q6-103 80-173.5T440-840q109 0 184.5 75.5T700-580q0 42-13 82t-39 70l230 230-56 56Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ShapeSquareIcon = (color: Color, fill?: Color) => (
|
||||
<svg viewBox="-2 -2 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect
|
||||
x="0.5"
|
||||
y="0.5"
|
||||
width="31"
|
||||
height="31"
|
||||
rx="1.5"
|
||||
stroke={color}
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
fill={fill}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ShapeRoundIcon = (color: Color, fill?: Color) => (
|
||||
<svg viewBox="-2 -2 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect
|
||||
x="0.5"
|
||||
y="0.5"
|
||||
width="31"
|
||||
height="31"
|
||||
rx="15.5"
|
||||
stroke={color}
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
fill={fill}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ShapeRectIcon = (color: Color, fill?: Color) => (
|
||||
<svg viewBox="-2 -2 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect
|
||||
x="0.5"
|
||||
y="6.5"
|
||||
width="31"
|
||||
height="20"
|
||||
rx="1.5"
|
||||
stroke={color}
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
fill={fill}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ShapeDiamondIcon = (color: Color, fill?: Color) => (
|
||||
<svg viewBox="-2 -2 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect
|
||||
fill={fill}
|
||||
x="4"
|
||||
y="4"
|
||||
width="23"
|
||||
height="23"
|
||||
rx="1.5"
|
||||
stroke={color}
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
transform="rotate(45 15.5 15.5)"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const enabledColor = (enabled: L.Property<boolean>) => L.view(enabled, (c) => (c ? black : disabledColor))
|
||||
|
||||
export const IncreaseFontSizeIcon = () => (
|
||||
<svg viewBox="0 -3 25 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M7.11072 0.959999H8.93472L15.8947 18H13.5907L11.5747 13.008H4.42272L2.43072 18H0.126719L7.11072 0.959999ZM11.0947 11.328L8.02272 3.456L4.85472 11.328H11.0947ZM24.9129 8.616V10.344H22.0809V13.416H20.1609V10.344H17.3289V8.616H20.1609V5.544H22.0809V8.616H24.9129Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const DecreaseFontSizeIcon = () => (
|
||||
<svg viewBox="0 -4 25 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5.01913 0.639999H6.23513L10.8751 12H9.33913L7.99513 8.672H3.22713L1.89913 12H0.363125L5.01913 0.639999ZM7.67513 7.552L5.62713 2.304L3.51513 7.552H7.67513ZM12.0553 8.272V6.992H16.7753V8.272H12.0553Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const UndoIcon = ({ enabled }: { enabled: L.Property<boolean> }) => {
|
||||
const undoColor = enabledColor(enabled)
|
||||
return (
|
||||
<svg width="100%" viewBox="0 0 36 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M4.57075 16.0193C8.69107 8.20216 18.4669 5.25727 26.4057 9.44172C29.6423 11.1477 32.0739 13.7753 33.5397 16.8193"
|
||||
stroke={undoColor}
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M2.43115 11.5003L4.54688 17.1371L10.3929 15.6968"
|
||||
stroke={undoColor}
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const RedoIcon = ({ enabled }: { enabled: L.Property<boolean> }) => {
|
||||
const redoColor = L.view(enabled, (c) => (c ? black : disabledColor))
|
||||
return (
|
||||
<svg width="100%" viewBox="0 0 35 29" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M30.3954 16.1554C26.5718 8.18892 16.9135 4.87867 8.82307 8.76176C5.52461 10.3449 2.99598 12.8793 1.41683 15.8659"
|
||||
stroke={redoColor}
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M32.7031 11.72L30.377 17.2733L24.5893 15.6143"
|
||||
stroke={redoColor}
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const TextAlignHorizontalLeftIcon = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M15 15H3v2h12v-2zm0-8H3v2h12V7zM3 13h18v-2H3v2zm0 8h18v-2H3v2zM3 3v2h18V3H3z"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const TextAlignHorizontalRightIcon = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3 21h18v-2H3v2zm6-4h12v-2H9v2zm-6-4h18v-2H3v2zm6-4h12V7H9v2zM3 3v2h18V3H3z"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const TextAlignHorizontalCenterIcon = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7 15v2h10v-2H7zm-4 6h18v-2H3v2zm0-8h18v-2H3v2zm4-6v2h10V7H7zM3 3v2h18V3H3z"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const TextAlignVerticalBottomIcon = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M16 13h-3V3h-2v10H8l4 4 4-4zM4 19v2h16v-2H4z"></path>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const TextAlignVerticalMiddleIcon = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M8 19h3v4h2v-4h3l-4-4-4 4zm8-14h-3V1h-2v4H8l4 4 4-4zM4 11v2h16v-2H4z"></path>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const TextAlignVerticalTopIcon = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M8 11h3v10h2V11h3l-4-4-4 4zM4 3v2h16V3H4z"></path>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const AlignHorizontalLeftIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M4 22H2V2h2v20zM22 7H6v3h16V7zm-6 7H6v3h10v-3z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const AlignHorizontalRightIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M20,2h2v20h-2V2z M2,10h16V7H2V10z M8,17h10v-3H8V17z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const AlignHorizontalCenterIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<polygon
|
||||
fill="currentColor"
|
||||
points="11,2 13,2 13,7 21,7 21,10 13,10 13,14 18,14 18,17 13,17 13,22 11,22 11,17 6,17 6,14 11,14 11,10 3,10 3,7 11,7"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const AlignVerticalTopIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M22 2v2H2V2h20zM7 22h3V6H7v16zm7-6h3V6h-3v10z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const AlignVerticalCenterIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<polygon
|
||||
fill="currentColor"
|
||||
points="22,11 17,11 17,6 14,6 14,11 10,11 10,3 7,3 7,11 1.84,11 1.84,13 7,13 7,21 10,21 10,13 14,13 14,18 17,18 17,13 22,13"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const AlignVerticalBottomIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M22,22H2v-2h20V22z M10,2H7v16h3V2z M17,8h-3v10h3V8z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const HorizontalDistributeIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M4 22H2V2h2v20zM22 2h-2v20h2V2zm-8.5 5h-3v10h3V7z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const VerticalDistributeIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M22 2v2H2V2h20zM7 10.5v3h10v-3H7zM2 20v2h20v-2H2z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const TileIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3 3v8h8V3H3zm6 6H5V5h4v4zm-6 4v8h8v-8H3zm6 6H5v-4h4v4zm4-16v8h8V3h-8zm6 6h-4V5h4v4zm-6 4v8h8v-8h-8zm6 6h-4v-4h4v4z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const BoldIcon = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42M10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ItalicIcon = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"></path>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const UnderlineIcon = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6m-7 2v2h14v-2z"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const BackIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8l8 8l1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const HistoryIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89l.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7s-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18zm-1 5v5l4.28 2.54l.72-1.21l-3.5-2.08V8H12z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const UserIcon = () => (
|
||||
<svg viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<ellipse
|
||||
cx="9.00008"
|
||||
cy="5.44441"
|
||||
rx="4.44441"
|
||||
ry="4.44441"
|
||||
stroke="#566570"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M17 18C17 13.5817 13.4183 10 9 10C4.58172 10 1 13.5817 1 18"
|
||||
stroke="#566570"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ConnectionLeftArrowIcon = () => (
|
||||
<svg width="30" height="12" viewBox="0 0 30 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M-1.90735e-06 6L10 11.7735V0.226497L-1.90735e-06 6ZM30 5L9 5V7L30 7V5Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ConnectionCenterLineIcon = () => (
|
||||
<svg width="36" height="2" viewBox="0 0 36 2" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line y1="1" x2="36" y2="1" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ConnectionRightArrowIcon = () => (
|
||||
<svg width="30" height="12" viewBox="0 0 30 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M30 6L20 0.226497V11.7735L30 6ZM0 7L21 7V5L0 5L0 7Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ConnectionEndLineIcon = () => (
|
||||
<svg width="30" height="2" viewBox="0 0 30 2" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line y1="1" x2="30" y2="1" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ConnectionLeftDotIcon = () => (
|
||||
<svg width="30" height="8" viewBox="0 0 30 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="3" y1="4" x2="30" y2="4" stroke="currentColor" stroke-width="2" />
|
||||
<circle cx="4" cy="4" r="4" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ConnectionRightDotIcon = () => (
|
||||
<svg width="30" height="8" viewBox="0 0 30 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="27" y1="4" y2="4" stroke="currentColor" stroke-width="2" />
|
||||
<circle cx="26" cy="4" r="4" transform="rotate(-180 26 4)" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ConnectionCenterCurveIcon = () => (
|
||||
<svg width="46" height="26" viewBox="0 0 46 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5.07492 8.4044C4.61425 11.5105 17.3143 24.2626 23.054 8.24941C28.7936 -7.76379 41.033 8.09442 41.033 8.09442"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const LockIcon = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"></path>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const UnlockIcon = () => (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12 17c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm6-9h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6h1.9c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm0 12H6V10h12v10z"></path>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ConnectionCenterCurveDotIcon = () => (
|
||||
<svg width="46" height="26" viewBox="0 0 46 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5.07492 8.4044C4.61425 11.5105 17.3143 24.2626 23.054 8.24941C28.7936 -7.76379 41.033 8.09442 41.033 8.09442"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle cx="23" cy="9" r="4" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const VisibilityIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5M12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5m0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3"></path>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const VisibilityOffIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7M2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2m4.31-.78 3.15 3.15.02-.16c0-1.66-1.34-3-3-3z"></path>
|
||||
</svg>
|
||||
)
|
||||
44
frontend/src/components/ModalContainer.tsx
Normal file
44
frontend/src/components/ModalContainer.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Fragment, h } from "harmaja"
|
||||
import * as L from "lonna"
|
||||
|
||||
export function ModalContainer({ content }: { content: L.Atom<any> }) {
|
||||
const stopPropagation = (e: JSX.KeyboardEvent) => {
|
||||
e.stopPropagation() // To prevent propagating to higher handlers which, for instance prevent defaults for backspace
|
||||
}
|
||||
return L.view(content, (c) => {
|
||||
if (!c) return null
|
||||
return (
|
||||
<div className="modal-container">
|
||||
<div
|
||||
onKeyUp={stopPropagation}
|
||||
onKeyDown={stopPropagation}
|
||||
onKeyPress={stopPropagation}
|
||||
className="modal-dialog"
|
||||
>
|
||||
<div id="modal-close" className="modal-close" onClick={() => content.set(null)}>
|
||||
<CrossIcon />
|
||||
</div>
|
||||
{c}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const CrossIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={"margin: auto"}
|
||||
>
|
||||
<path
|
||||
d="M5.6297 4.46967C5.3368 4.17678 4.86193 4.17678 4.56904 4.46967C4.27614 4.76256 4.27614 5.23744 4.56904 5.53033L5.6297 4.46967ZM18.569 19.5303C18.8619 19.8232 19.3368 19.8232 19.6297 19.5303C19.9226 19.2374 19.9226 18.7626 19.6297 18.4697L18.569 19.5303ZM19.6297 5.53033C19.9226 5.23744 19.9226 4.76256 19.6297 4.46967C19.3368 4.17678 18.8619 4.17678 18.569 4.46967L19.6297 5.53033ZM4.56904 18.4697C4.27614 18.7626 4.27614 19.2374 4.56904 19.5303C4.86193 19.8232 5.3368 19.8232 5.6297 19.5303L4.56904 18.4697ZM4.56904 5.53033L11.569 12.5303L12.6297 11.4697L5.6297 4.46967L4.56904 5.53033ZM11.569 12.5303L18.569 19.5303L19.6297 18.4697L12.6297 11.4697L11.569 12.5303ZM18.569 4.46967L11.569 11.4697L12.6297 12.5303L19.6297 5.53033L18.569 4.46967ZM11.569 11.4697L4.56904 18.4697L5.6297 19.5303L12.6297 12.5303L11.569 11.4697Z"
|
||||
fill="#151515"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
3
frontend/src/components/UIColors.ts
Normal file
3
frontend/src/components/UIColors.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const selectedColor = "#2F80ED"
|
||||
export const black = "#00263A"
|
||||
export const disabledColor = "lightgrey"
|
||||
1
frontend/src/components/browser.ts
Normal file
1
frontend/src/components/browser.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1
|
||||
47
frontend/src/components/components.tsx
Normal file
47
frontend/src/components/components.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as H from "harmaja"
|
||||
import * as L from "lonna"
|
||||
import { componentScope, h, HarmajaOutput } from "harmaja"
|
||||
|
||||
export const TextInput = (props: { value: L.Atom<string> } & any) => {
|
||||
return (
|
||||
<input
|
||||
{...{
|
||||
type: props.type || "text",
|
||||
onInput: (e) => {
|
||||
props.value.set(e.currentTarget.value)
|
||||
},
|
||||
...props,
|
||||
value: props.value,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const TextArea = (props: { value: L.Atom<string> } & any) => {
|
||||
return (
|
||||
<textarea
|
||||
{...{
|
||||
onInput: (e) => {
|
||||
props.value.set(e.currentTarget.value)
|
||||
},
|
||||
...props,
|
||||
value: props.value,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const Checkbox = (props: { checked: L.Atom<boolean>; children?: H.HarmajaChildOrChildren }) => {
|
||||
return (
|
||||
<div
|
||||
className="checkbox"
|
||||
onClick={(e) => {
|
||||
props.checked.modify((c: boolean) => !c)
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<span className={props.checked.pipe(L.map((c) => (c ? "icon checked" : "icon")))} />
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
frontend/src/components/onClickOutside.tsx
Normal file
12
frontend/src/components/onClickOutside.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import * as L from "lonna"
|
||||
import { h, componentScope } from "harmaja"
|
||||
|
||||
export function onClickOutside(elem: L.Property<HTMLElement | null>, handler: () => any) {
|
||||
L.fromEvent<JSX.KeyboardEvent>(window, "mousedown")
|
||||
.pipe(L.applyScope(componentScope()))
|
||||
.forEach((event) => {
|
||||
if (!elem.get()?.contains(event.target as Node)) {
|
||||
handler()
|
||||
}
|
||||
})
|
||||
}
|
||||
88
frontend/src/components/sanitizeHTML.ts
Normal file
88
frontend/src/components/sanitizeHTML.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import sh, { Attributes } from "sanitize-html"
|
||||
|
||||
const sanitizeConfig = {
|
||||
allowedTags: [
|
||||
"b",
|
||||
"i",
|
||||
"em",
|
||||
"strong",
|
||||
"a",
|
||||
"br",
|
||||
"p",
|
||||
"ul",
|
||||
"li",
|
||||
"ol",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"pre",
|
||||
"code",
|
||||
],
|
||||
allowedAttributes: {
|
||||
a: ["href", "target"],
|
||||
},
|
||||
transformTags: {
|
||||
a: (tagName: string, attribs: Attributes) => {
|
||||
return {
|
||||
tagName,
|
||||
attribs: { ...attribs, target: "_blank" },
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
const html = `<h1>HELLO</h1> world <b>BOLD</b> <i>ITALIC</i> <script>alert("LOL")</script>`
|
||||
|
||||
export function isURL(str: string) {
|
||||
var urlRegex =
|
||||
"^(?!mailto:)(?:(?:http|https|ftp)://)(?:\\S+(?::\\S*)?@)?(?:(?:(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}(?:\\.(?:[0-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))|(?:(?:[a-z\\u00a1-\\uffff0-9]+-?)*[a-z\\u00a1-\\uffff0-9]+)(?:\\.(?:[a-z\\u00a1-\\uffff0-9]+-?)*[a-z\\u00a1-\\uffff0-9]+)*(?:\\.(?:[a-z\\u00a1-\\uffff]{2,})))|localhost)(?::\\d{2,5})?(?:(/|\\?|#)[^\\s]*)?$"
|
||||
var url = new RegExp(urlRegex, "i")
|
||||
return str.length < 2083 && url.test(str)
|
||||
}
|
||||
|
||||
const MAX_LINK_LENGTH = 30
|
||||
|
||||
export function createLinkHTML(url: string, text?: string) {
|
||||
return createAnchorElement(url, text).outerHTML
|
||||
}
|
||||
|
||||
function createAnchorElement(url: string, text?: string) {
|
||||
if (text === undefined) {
|
||||
text = url.length > MAX_LINK_LENGTH ? url.slice(0, MAX_LINK_LENGTH - 2) + "..." : url
|
||||
}
|
||||
const anchorNode = document.createElement("a")
|
||||
anchorNode.textContent = text
|
||||
anchorNode.href = url
|
||||
return anchorNode
|
||||
}
|
||||
|
||||
function linkify(htmlText: string) {
|
||||
helperElem.innerHTML = htmlText
|
||||
for (let e of helperElem.childNodes) {
|
||||
if (e instanceof Text) {
|
||||
const urls = e.textContent!.split(" ").filter(isURL)
|
||||
if (urls.length) {
|
||||
const url = urls[0]
|
||||
const [before, after] = e.textContent!.split(url).map((t) => new Text(t))
|
||||
const anchorNode = createAnchorElement(url)
|
||||
e.replaceWith(before, anchorNode, after)
|
||||
}
|
||||
}
|
||||
}
|
||||
return helperElem.innerHTML
|
||||
}
|
||||
|
||||
export function sanitizeHTML(html: string, shouldLinkify?: boolean) {
|
||||
if (shouldLinkify) {
|
||||
html = linkify(sh(html, sanitizeConfig))
|
||||
}
|
||||
return sh(html, sanitizeConfig)
|
||||
}
|
||||
|
||||
const helperElem = document.createElement("span")
|
||||
|
||||
export function toPlainText(html: string) {
|
||||
helperElem.innerHTML = html.replaceAll("<br>", "\n")
|
||||
return helperElem.textContent || ""
|
||||
}
|
||||
366
frontend/src/dashboard/DashboardView.tsx
Normal file
366
frontend/src/dashboard/DashboardView.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
import { Fragment, h, ListView } from "harmaja"
|
||||
import { getNavigator, Link } from "harmaja-router"
|
||||
import * as L from "lonna"
|
||||
import * as R from "ramda"
|
||||
import * as uuid from "uuid"
|
||||
import {
|
||||
BoardAccessPolicy,
|
||||
BoardStub,
|
||||
CrdtDisabled,
|
||||
CrdtEnabled,
|
||||
EventFromServer,
|
||||
exampleBoard,
|
||||
RecentBoard,
|
||||
ServerConfig,
|
||||
} from "../../../common/src/domain"
|
||||
import { BOARD_PATH, createBoardAndNavigate, Routes } from "../board-navigation"
|
||||
import { localStorageAtom } from "../board/local-storage-atom"
|
||||
import { IS_TOUCHSCREEN } from "../board/touchScreen"
|
||||
import { BoardAccessPolicyEditor } from "../components/BoardAccessPolicyEditor"
|
||||
import { BoardCrdtModeSelector } from "../components/BoardCrdtModeSelector"
|
||||
import { TextInput } from "../components/components"
|
||||
import { signIn, signOut } from "../google-auth"
|
||||
import { Dispatch } from "../store/board-store"
|
||||
import { RecentBoards } from "../store/recent-boards"
|
||||
import { canLogin, defaultAccessPolicy, UserSessionState } from "../store/user-session-store"
|
||||
|
||||
export const DashboardView = ({
|
||||
sessionState,
|
||||
dispatch,
|
||||
recentBoards,
|
||||
eventsFromServer,
|
||||
serverConfig,
|
||||
}: {
|
||||
sessionState: L.Property<UserSessionState>
|
||||
recentBoards: RecentBoards
|
||||
dispatch: Dispatch
|
||||
eventsFromServer: L.EventStream<EventFromServer>
|
||||
serverConfig: L.Property<ServerConfig | null>
|
||||
}) => {
|
||||
const boardName = L.atom("")
|
||||
return (
|
||||
<div id="root" className="dashboard">
|
||||
<div className="content">
|
||||
<header>
|
||||
<h1 id="app-title" data-test="app-title">
|
||||
OurBoard
|
||||
</h1>
|
||||
<p>
|
||||
Free and <a href="https://github.com/raimohanska/r-board">open-source</a>{" "}
|
||||
online whiteboard.
|
||||
</p>
|
||||
</header>
|
||||
<div className="user-info">
|
||||
{L.view(sessionState, (user) => {
|
||||
switch (user.status) {
|
||||
case "logged-in":
|
||||
return (
|
||||
<>
|
||||
<span title={user.email}>{user.name}</span>
|
||||
<a onClick={signOut}>Sign out</a>
|
||||
</>
|
||||
)
|
||||
default:
|
||||
if (canLogin(user)) {
|
||||
return <a onClick={signIn}>Sign in</a>
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
<main>
|
||||
<CreateBoard
|
||||
{...{ dispatch, sessionState, boardName, recentBoards, eventsFromServer, serverConfig }}
|
||||
/>
|
||||
<div>
|
||||
<div className="user-content">
|
||||
<RecentBoardsView {...{ recentBoards, boardName }} />
|
||||
</div>
|
||||
</div>
|
||||
<Welcome {...{ recentBoards, dispatch, eventsFromServer, sessionState }} />
|
||||
</main>
|
||||
</div>
|
||||
<div className="sponsor">
|
||||
Sponsored by{" "}
|
||||
<a href="https://www.reaktor.com">
|
||||
<img className="logo" src="/img/reaktor-logo.jpg" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RecentBoardsView = ({ recentBoards, boardName }: { recentBoards: RecentBoards; boardName: L.Atom<string> }) => {
|
||||
const defaultLimit = 25
|
||||
const filter = boardName
|
||||
const filtered = L.view(filter, (f) => !!f)
|
||||
const edit = L.atom(false)
|
||||
const limit = localStorageAtom("recentBoards.limit", defaultLimit)
|
||||
|
||||
const sort = localStorageAtom<"recent-first" | "alphabetical">("recentBoards.sort", "recent-first")
|
||||
|
||||
const matchingBoards = L.view(recentBoards.recentboards, filter, (bs, f) =>
|
||||
bs.filter((b) => b.name.toLowerCase().includes(f.toLowerCase())),
|
||||
)
|
||||
const boardsToShow = L.view(matchingBoards, limit, sort, filter, (bs, l, s, f) =>
|
||||
R.pipe(
|
||||
R.sortWith([R.descend(R.prop("opened"))]),
|
||||
(bs: RecentBoard[]) => bs.slice(0, l),
|
||||
R.sortWith([s === "alphabetical" ? R.ascend((b) => b.name.toLowerCase()) : R.descend(R.prop("opened"))]),
|
||||
)(bs),
|
||||
)
|
||||
const moreBoards = L.view(limit, matchingBoards, (l, bs) => bs.length - l)
|
||||
const aLot = 7
|
||||
const lotsOfShownBoards = L.view(matchingBoards, (bs) => bs.length >= aLot)
|
||||
return (
|
||||
<div>
|
||||
{L.view(
|
||||
matchingBoards,
|
||||
(recent) => recent.length === 0,
|
||||
(empty) =>
|
||||
empty ? null : (
|
||||
<div
|
||||
className={L.view(
|
||||
edit,
|
||||
filtered,
|
||||
(e, f) => `recent-boards${e ? " edit" : ""}${f ? " filtered" : ""}`,
|
||||
)}
|
||||
>
|
||||
<h2>
|
||||
{L.view(filter, (f) =>
|
||||
f === "" ? "Your recent boards" : "Found in your recent boards",
|
||||
)}
|
||||
{IS_TOUCHSCREEN && (
|
||||
<a className="edit" onClick={() => edit.modify((e) => !e)}>
|
||||
{L.view(edit, (e) => (e ? "done" : "edit"))}
|
||||
</a>
|
||||
)}
|
||||
</h2>
|
||||
<ul>
|
||||
<ListView
|
||||
observable={boardsToShow}
|
||||
getKey={(b) => b.id}
|
||||
renderItem={(b) => (
|
||||
<li>
|
||||
<Link<Routes> route={BOARD_PATH} boardId={b.id}>
|
||||
{b.name}
|
||||
</Link>
|
||||
<a className="remove" onClick={() => recentBoards.removeRecentBoard(b)}>
|
||||
remove
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
/>
|
||||
</ul>
|
||||
{
|
||||
<div className="view-options">
|
||||
{L.view(moreBoards, limit, (c, l) =>
|
||||
c > 0 ? (
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
limit.set(Number.MAX_SAFE_INTEGER)
|
||||
}}
|
||||
>
|
||||
Show {moreBoards} more
|
||||
</a>
|
||||
) : l === defaultLimit ? null : (
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
limit.set(defaultLimit)
|
||||
}}
|
||||
>
|
||||
Show less
|
||||
</a>
|
||||
),
|
||||
)}
|
||||
{L.view(sort, lotsOfShownBoards, (s, show) =>
|
||||
show ? (
|
||||
s === "alphabetical" ? (
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
sort.set("recent-first")
|
||||
}}
|
||||
>
|
||||
Show recent first
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
sort.set("alphabetical")
|
||||
}}
|
||||
>
|
||||
Sort alphabetically
|
||||
</a>
|
||||
)
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Welcome = ({
|
||||
recentBoards,
|
||||
dispatch,
|
||||
eventsFromServer,
|
||||
sessionState,
|
||||
}: {
|
||||
recentBoards: RecentBoards
|
||||
dispatch: Dispatch
|
||||
eventsFromServer: L.EventStream<EventFromServer>
|
||||
sessionState: L.Property<UserSessionState>
|
||||
}) => {
|
||||
const navigator = getNavigator<Routes>()
|
||||
function createTutorial() {
|
||||
createBoardAndNavigate(
|
||||
{
|
||||
id: uuid.v4(),
|
||||
name: "My personal tutorial board",
|
||||
templateId: "tutorial",
|
||||
accessPolicy: defaultAccessPolicy(sessionState.get(), false),
|
||||
},
|
||||
dispatch,
|
||||
navigator,
|
||||
eventsFromServer,
|
||||
)
|
||||
}
|
||||
const showExampleLink = L.view(recentBoards.recentboards, (boards) => !boards.some((b) => b.id === exampleBoard.id))
|
||||
return (
|
||||
<span>
|
||||
{L.view(
|
||||
recentBoards.recentboards,
|
||||
(recent) => recent.length < 3 && !recent.some((b) => b.name.toLowerCase().includes("tutorial")),
|
||||
(empty) =>
|
||||
empty ? (
|
||||
<div>
|
||||
<h2>Welcome to OurBoard!</h2>
|
||||
<p>
|
||||
Let us create a <a onClick={createTutorial}>Tutorial Board</a> just for you, or go ahead
|
||||
and create a new blank board above.{" "}
|
||||
{L.view(showExampleLink, (s) =>
|
||||
s ? (
|
||||
<>
|
||||
You may also check out the{" "}
|
||||
<a href={`/b/${exampleBoard.id}`}>Shared test board</a> if you dare!
|
||||
</>
|
||||
) : null,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const CreateBoardOptions = ({
|
||||
accessPolicy,
|
||||
sessionState,
|
||||
useCollaborativeEditing,
|
||||
serverConfig,
|
||||
}: {
|
||||
accessPolicy: L.Atom<BoardAccessPolicy | undefined>
|
||||
sessionState: L.Property<UserSessionState>
|
||||
useCollaborativeEditing: L.Atom<boolean>
|
||||
serverConfig: L.Property<ServerConfig | null>
|
||||
}) => {
|
||||
const optInCollaborative = L.view(
|
||||
sessionState,
|
||||
serverConfig,
|
||||
(s, c) => c?.crdt === "opt-in" || (c?.crdt === "opt-in-authenticated" && s.status === "logged-in"),
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{L.view(optInCollaborative, (optIn) => optIn && <BoardCrdtModeSelector {...{ useCollaborativeEditing }} />)}
|
||||
{L.view(sessionState, (s) =>
|
||||
s.status === "logged-in" ? (
|
||||
<BoardAccessPolicyEditor {...{ accessPolicy, user: s }} />
|
||||
) : (
|
||||
<small className="anonymousBoardDisclaimer">
|
||||
Anonymously created boards are accessible to anyone with a link. You may{" "}
|
||||
<a onClick={signIn}>sign in</a> first in order to restrict access to your new board.
|
||||
</small>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const CreateBoard = ({
|
||||
dispatch,
|
||||
sessionState,
|
||||
boardName,
|
||||
recentBoards,
|
||||
eventsFromServer,
|
||||
serverConfig,
|
||||
}: {
|
||||
dispatch: Dispatch
|
||||
sessionState: L.Property<UserSessionState>
|
||||
boardName: L.Atom<string>
|
||||
recentBoards: RecentBoards
|
||||
eventsFromServer: L.EventStream<EventFromServer>
|
||||
serverConfig: L.Property<ServerConfig | null>
|
||||
}) => {
|
||||
const disabled = L.view(boardName, (n) => !n)
|
||||
const navigator = getNavigator<Routes>()
|
||||
const accessPolicy: L.Atom<BoardAccessPolicy | undefined> = L.atom(defaultAccessPolicy(sessionState.get(), false))
|
||||
sessionState.onChange((s) => {
|
||||
accessPolicy.set(defaultAccessPolicy(s, false))
|
||||
})
|
||||
const hasRecentBoards = L.view(recentBoards.recentboards, (bs) => bs.length > 0)
|
||||
const useCollaborativeEditingAtom = L.atom(false)
|
||||
const useCollaborativeEditing = L.view(serverConfig, useCollaborativeEditingAtom, (c, u) =>
|
||||
c?.crdt === "true" ? true : c?.crdt === "false" ? false : u,
|
||||
)
|
||||
|
||||
function onSubmit(e: JSX.FormEvent) {
|
||||
e.preventDefault()
|
||||
const newBoard: BoardStub = {
|
||||
name: boardName.get(),
|
||||
id: uuid.v4(),
|
||||
accessPolicy: accessPolicy.get(),
|
||||
crdt: useCollaborativeEditing.get() ? CrdtEnabled : CrdtDisabled,
|
||||
}
|
||||
createBoardAndNavigate(newBoard, dispatch, navigator, eventsFromServer)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="create-board">
|
||||
<h2>{L.view(hasRecentBoards, (has) => (has ? "Find or create a board" : "Create a board"))}</h2>
|
||||
<div className="input-and-button">
|
||||
<TextInput value={boardName} autoFocus={!IS_TOUCHSCREEN} placeholder="Enter board name" />
|
||||
<button id="create-board-button" data-test="create-board-submit" type="submit" disabled={disabled}>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
{L.view(
|
||||
disabled,
|
||||
(d) =>
|
||||
!d && (
|
||||
<CreateBoardOptions
|
||||
{...{
|
||||
accessPolicy,
|
||||
sessionState,
|
||||
useCollaborativeEditing: useCollaborativeEditingAtom,
|
||||
serverConfig,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
11
frontend/src/dashboard/bg.svg
Normal file
11
frontend/src/dashboard/bg.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="1222" height="829" viewBox="0 0 1222 829" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="1208" cy="337" r="14" fill="#459ECC"/>
|
||||
<circle cx="203" cy="512" r="14" fill="#E9D184"/>
|
||||
<circle cx="916" cy="14" r="14" fill="#FF998F"/>
|
||||
<circle cx="255" cy="815" r="14" fill="#FF5544"/>
|
||||
<circle cx="14" cy="169" r="14" fill="#FFBBB4"/>
|
||||
<circle cx="1030" cy="474" r="14" fill="#2F80ED"/>
|
||||
<circle cx="378" cy="763" r="14" fill="#2F80ED"/>
|
||||
<rect x="154" y="212" width="49" height="49" fill="#E9D184"/>
|
||||
<rect x="873" y="733" width="92" height="92" fill="#E9D184"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 583 B |
5
frontend/src/embedding.tsx
Normal file
5
frontend/src/embedding.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
const search = new URLSearchParams(location.search)
|
||||
|
||||
const embedded = search.get("embedded") === "true"
|
||||
|
||||
export const isEmbedded = () => embedded
|
||||
14
frontend/src/google-auth.ts
Normal file
14
frontend/src/google-auth.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import LocalStore from "./store/board-local-store"
|
||||
|
||||
export function signIn() {
|
||||
document.location.assign("/login?returnTo=" + encodeURIComponent(getReturnPath()))
|
||||
}
|
||||
|
||||
export async function signOut() {
|
||||
await LocalStore.clearAllPrivateBoards()
|
||||
document.location.assign("/logout?returnTo=" + encodeURIComponent(getReturnPath()))
|
||||
}
|
||||
|
||||
function getReturnPath() {
|
||||
return document.location.pathname + document.location.search
|
||||
}
|
||||
BIN
frontend/src/img/checked-checkbox.png
Normal file
BIN
frontend/src/img/checked-checkbox.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
frontend/src/img/unchecked-checkbox.png
Normal file
BIN
frontend/src/img/unchecked-checkbox.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 851 B |
85
frontend/src/index.tsx
Normal file
85
frontend/src/index.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import * as H from "harmaja"
|
||||
import { h } from "harmaja"
|
||||
import _ from "lodash"
|
||||
import * as L from "lonna"
|
||||
import { EventFromServer, RecentBoardAttributes, ServerConfig } from "../../common/src/domain"
|
||||
import "./app.scss"
|
||||
import { BoardNavigation } from "./board-navigation"
|
||||
import { BoardView } from "./board/BoardView"
|
||||
import { IS_TOUCHSCREEN } from "./board/touchScreen"
|
||||
import { DashboardView } from "./dashboard/DashboardView"
|
||||
import { assetStore } from "./store/asset-store"
|
||||
import boardLocalStore from "./store/board-local-store"
|
||||
import { BoardState, BoardStore } from "./store/board-store"
|
||||
import { CursorsStore } from "./store/cursors-store"
|
||||
import { RecentBoards } from "./store/recent-boards"
|
||||
import { BrowserSideServerConnection } from "./store/server-connection"
|
||||
import { UserSessionStore } from "./store/user-session-store"
|
||||
|
||||
const App = () => {
|
||||
const { boardId, page } = BoardNavigation()
|
||||
const connection = BrowserSideServerConnection(boardId)
|
||||
const sessionStore = UserSessionStore(connection, localStorage)
|
||||
const boardStore = BoardStore(boardId, connection, sessionStore.sessionState, boardLocalStore)
|
||||
const cursorsStore = CursorsStore(connection, sessionStore)
|
||||
const recentBoards = RecentBoards(connection, sessionStore)
|
||||
const assets = assetStore(connection, L.view(boardStore.state, "board"), boardStore.events)
|
||||
const title = L.view(boardStore.state, (s) => (s.board && s.board.name ? `${s.board.name} - OurBoard` : "OurBoard"))
|
||||
title.forEach((t) => (document.querySelector("title")!.textContent = t))
|
||||
const serverConfig = connection.bufferedServerEvents
|
||||
.pipe(
|
||||
L.scan<EventFromServer, ServerConfig | null>(null, (c, e) => (e.action === "server.config" ? e : c)),
|
||||
)
|
||||
.applyScope(H.componentScope())
|
||||
|
||||
boardStore.state
|
||||
.pipe(
|
||||
L.changes,
|
||||
L.filter((s: BoardState) => s.status === "online" && !!s.board),
|
||||
L.map((s: BoardState) => ({ id: s.board!.id, name: s.board!.name } as RecentBoardAttributes)),
|
||||
L.skipDuplicates(_.isEqual),
|
||||
)
|
||||
.forEach(recentBoards.storeRecentBoard)
|
||||
|
||||
return (
|
||||
<div className={IS_TOUCHSCREEN ? "touch" : "notouch"}>
|
||||
{L.view(page, (page) => {
|
||||
switch (page.page) {
|
||||
case "Board":
|
||||
return L.view(
|
||||
boardStore.state,
|
||||
(s) => s.board !== undefined,
|
||||
(hasBoard) =>
|
||||
hasBoard ? (
|
||||
<BoardView
|
||||
{...{
|
||||
boardId: page.boardId,
|
||||
cursors: cursorsStore,
|
||||
assets,
|
||||
boardStore,
|
||||
sessionState: sessionStore.sessionState,
|
||||
dispatch: boardStore.dispatch,
|
||||
}}
|
||||
/>
|
||||
) : null,
|
||||
)
|
||||
case "NotFound":
|
||||
case "Dashboard":
|
||||
return (
|
||||
<DashboardView
|
||||
{...{
|
||||
dispatch: boardStore.dispatch,
|
||||
sessionState: sessionStore.sessionState,
|
||||
recentBoards,
|
||||
eventsFromServer: connection.bufferedServerEvents,
|
||||
serverConfig,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
H.mount(<App />, document.getElementById("root")!)
|
||||
127
frontend/src/store/asset-store.ts
Normal file
127
frontend/src/store/asset-store.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as L from "lonna"
|
||||
import { AppEvent, AssetPutUrlResponse, Board } from "../../../common/src/domain"
|
||||
import md5 from "md5"
|
||||
import { ServerConnection } from "./server-connection"
|
||||
|
||||
export type AssetId = string
|
||||
export type AssetURL = string
|
||||
|
||||
export function assetStore(
|
||||
connection: ServerConnection,
|
||||
board: L.Property<Board | undefined>,
|
||||
events: L.EventStream<AppEvent>,
|
||||
) {
|
||||
let dataURLs: Record<AssetId, AssetURL> = {}
|
||||
|
||||
function assetExists(assetId: string): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (
|
||||
Object.values(board.get()!.items).find(
|
||||
(i) => (i.type === "image" || i.type === "video") && i.assetId === assetId && i.src,
|
||||
)
|
||||
) {
|
||||
resolve(true)
|
||||
}
|
||||
const img = new Image()
|
||||
img.onload = () => resolve(true)
|
||||
img.onerror = () => resolve(false)
|
||||
img.src = assetURL(assetId)
|
||||
})
|
||||
}
|
||||
|
||||
function uploadAsset(file: File): Promise<[AssetId, Promise<AssetURL>]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.addEventListener("loadend", async (x) => {
|
||||
if (typeof reader.result !== "string") {
|
||||
throw Error("Unexpected result type " + reader.result)
|
||||
}
|
||||
const dataURL = reader.result
|
||||
const assetId = md5(dataURL)
|
||||
dataURLs[assetId] = dataURL
|
||||
|
||||
resolve([
|
||||
assetId,
|
||||
(async () => {
|
||||
const exists = await assetExists(assetId)
|
||||
const url = assetURL(assetId)
|
||||
if (exists) {
|
||||
return url
|
||||
}
|
||||
|
||||
console.log("PUT REQ")
|
||||
|
||||
connection.send({ action: "asset.put.request", assetId })
|
||||
const signedUrl = await getAssetPutResponse(assetId, events)
|
||||
|
||||
const response = await fetch(signedUrl, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
})
|
||||
if (response.ok) {
|
||||
return url
|
||||
} else {
|
||||
console.error("Asset PUT failed", response)
|
||||
throw Error("Asset PUT failed with " + response.status)
|
||||
}
|
||||
})(),
|
||||
])
|
||||
})
|
||||
reader.addEventListener("error", (x) => {
|
||||
console.error("File import fail" + x)
|
||||
reject(x)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function getExternalAssetAsBytes(url: string) {
|
||||
return fetch(`/assets/external?src=${encodeURI(url)}`)
|
||||
}
|
||||
|
||||
function getAsset(assetId: string, src?: string): AssetURL {
|
||||
if (src) return src
|
||||
const local = dataURLs[assetId]
|
||||
if (local) return local
|
||||
// When src is not set and asset not found locally, it indicates that this image was added by
|
||||
// some other user and image upload is not complete yet. (src will be set on completion)
|
||||
// Currently returned image URL if currently a 404 which results to a temporary "broken image"
|
||||
// Shown to other users while upload in progress (this is not bad, but could be better)
|
||||
// TODO: show a progress indicator instead.
|
||||
return `/not-uploaded-yet.png`
|
||||
}
|
||||
|
||||
let assetStorageURL = "/assets"
|
||||
function assetURL(assetId: string) {
|
||||
return `${assetStorageURL}/${assetId}`
|
||||
}
|
||||
events.forEach((e) => {
|
||||
if (e.action === "server.config") {
|
||||
assetStorageURL = e.assetStorageURL
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
getAsset,
|
||||
uploadAsset,
|
||||
getExternalAssetAsBytes,
|
||||
}
|
||||
}
|
||||
|
||||
function isAssetPut(e: AppEvent): e is AssetPutUrlResponse {
|
||||
return e.action === "asset.put.response"
|
||||
}
|
||||
|
||||
function getAssetPutResponse(assetId: string, events: L.EventStream<AppEvent>): Promise<AssetURL> {
|
||||
return new Promise((resolve) => {
|
||||
events
|
||||
.pipe(
|
||||
L.filter(isAssetPut),
|
||||
L.map((e) => e.signedUrl),
|
||||
L.take(1),
|
||||
)
|
||||
.forEach(resolve)
|
||||
})
|
||||
}
|
||||
|
||||
export type AssetStore = ReturnType<typeof assetStore>
|
||||
102
frontend/src/store/board-local-store.ts
Normal file
102
frontend/src/store/board-local-store.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import * as localForage from "localforage"
|
||||
import { throttle } from "lodash"
|
||||
import { Board, BoardHistoryEntry, Id } from "../../../common/src/domain"
|
||||
import { migrateBoard, migrateEvent } from "../../../common/src/migration"
|
||||
|
||||
export type LocalStorageBoard = {
|
||||
serverShadow: Board // Our view of the board as it is on the server
|
||||
queue: BoardHistoryEntry[] // Local events to send. serverShadow + queue = current board.
|
||||
}
|
||||
|
||||
const BOARD_STORAGE_KEY_PREFIX = "board_"
|
||||
|
||||
let activeBoardState: LocalStorageBoard | undefined = undefined
|
||||
|
||||
async function getInitialBoardState(boardId: Id) {
|
||||
if (!activeBoardState || activeBoardState.serverShadow.id != boardId) {
|
||||
const localStorageKey = getStorageKey(boardId)
|
||||
activeBoardState = await getStoredState(localStorageKey)
|
||||
}
|
||||
return activeBoardState
|
||||
}
|
||||
|
||||
async function getStoredState(localStorageKey: string): Promise<LocalStorageBoard | undefined> {
|
||||
try {
|
||||
const stringState = await localForage.getItem<string>(localStorageKey)
|
||||
const state = JSON.parse(stringState!) as LocalStorageBoard
|
||||
if (!state || !state.serverShadow) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
serverShadow: migrateBoard(state.serverShadow),
|
||||
queue: state.queue.map(migrateEvent),
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Fetching local state ${localStorageKey} from IndexedDB failed`, e)
|
||||
await clearStateByKey(localStorageKey)
|
||||
}
|
||||
}
|
||||
|
||||
function getStorageKey(boardId: string) {
|
||||
return BOARD_STORAGE_KEY_PREFIX + boardId
|
||||
}
|
||||
|
||||
const maxLocalStoredHistory = 1000 // TODO: limit history to this, using snapshotting
|
||||
|
||||
async function storeBoardState(newState: LocalStorageBoard): Promise<void> {
|
||||
activeBoardState = newState
|
||||
storeThrottled(activeBoardState)
|
||||
}
|
||||
|
||||
const storeThrottled = throttle(
|
||||
(newState: LocalStorageBoard) => {
|
||||
try {
|
||||
localForage.setItem(getStorageKey(newState.serverShadow.id), JSON.stringify(newState))
|
||||
} catch (err) {
|
||||
console.error(`Saving board state for ${newState.serverShadow.id} failed`, err)
|
||||
}
|
||||
},
|
||||
1000,
|
||||
{ leading: true, trailing: true },
|
||||
)
|
||||
|
||||
async function clearBoardState(boardId: Id) {
|
||||
return await clearStateByKey(getStorageKey(boardId))
|
||||
}
|
||||
|
||||
async function clearStateByKey(localStorageKey: string) {
|
||||
try {
|
||||
await localForage.removeItem(localStorageKey)
|
||||
} catch (err) {
|
||||
console.error(`Clearing board state for ${localStorageKey} failed`, err)
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAllPrivateBoards(): Promise<void> {
|
||||
console.log("Clearing all private boards from local storage")
|
||||
const keys = await localForage.keys()
|
||||
await Promise.all(
|
||||
keys.map(async (k) => {
|
||||
if (!k.startsWith(BOARD_STORAGE_KEY_PREFIX)) return
|
||||
const state = await getStoredState(k)
|
||||
if (state && state.serverShadow.accessPolicy) {
|
||||
console.log(`Clearing local state for private board ${k}`)
|
||||
await clearStateByKey(k)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export type BoardLocalStore = {
|
||||
getInitialBoardState: (boardId: Id) => Promise<LocalStorageBoard | undefined>
|
||||
clearBoardState: (boardId: Id) => Promise<void>
|
||||
clearAllPrivateBoards: () => Promise<void>
|
||||
storeBoardState: (newState: LocalStorageBoard) => Promise<void>
|
||||
}
|
||||
|
||||
export default {
|
||||
getInitialBoardState,
|
||||
clearBoardState,
|
||||
clearAllPrivateBoards,
|
||||
storeBoardState,
|
||||
} as BoardLocalStore
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user