snapshot of https://github.com/raimohanska/ourboard.git (master, 2026-09-23; blobs > 131072B omitted)
Some checks failed
build / test (18.x) (push) Failing after 10s
build / lint (18.x) (push) Failing after 0s
build / docker-image (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-23 16:16:17 +05:00
commit d57328655f
233 changed files with 21282 additions and 0 deletions

10
frontend/src/app.scss Normal file
View 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";

View 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 }),
)
}

View 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)
}
})
}
}

View 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
})
}

View 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>
)
}

View 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)
}

View 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}
/>
)
}

View 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}`} />
}
}

View 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>
)
}

View 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
}

View 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",
}}
/>
),
)
}

View 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>
)
}

View 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}`} />
}
}

View 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>
)
}

View 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>
)
}

View 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 }
}

View 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,
}
}

View 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()),
),
}
}

View 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" }

View 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)

View 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,
}
}

View 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
}

View 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>
)
},
)
}

View 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>,
]
})
}

View 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 })
}
}

View 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
}

View 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>,
]
})
}

View 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>,
]
})
}

View 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),
})
}
}

View 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>,
]
})
}

View 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>,
]
: []
})
}

View 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 })
}
}
}

View 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
}

View 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>,
,
]
})
}

View 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
}

View 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
}

View 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>
)
}

View 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>
)
})
}

View 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>
)
}

View 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} />
}

View 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>
)
}

View 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)
})
}

View 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
)
}

View 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
}
}

View 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)
})
}
}

View 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 })
}
}

View 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)
}
})
}

View 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()
},
)
}

View 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() })
}

View 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]
}

View 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,
})
}
},
)
}

View 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 },
])
})
})

View 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 }
}

View 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,
}
}

View 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)),
}),
)
}

View 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,
}
}

View 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 }
}

View 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" })
})
}

View 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)
}

View 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
}

View 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
}
}

View 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
})
}
}

View 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 })
}
}

View 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,
}
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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} />
}
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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)
}
}

View 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
}

View 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"))
}

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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("&nbsp;", " ")
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>
)
}

View 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>
)

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export const selectedColor = "#2F80ED"
export const black = "#00263A"
export const disabledColor = "lightgrey"

View File

@@ -0,0 +1 @@
export const isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1

View 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>
)
}

View 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()
}
})
}

View 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 || ""
}

View 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&nbsp;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>
)
}

View 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

View File

@@ -0,0 +1,5 @@
const search = new URLSearchParams(location.search)
const embedded = search.get("embedded") === "true"
export const isEmbedded = () => embedded

View 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
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 B

85
frontend/src/index.tsx Normal file
View 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")!)

View 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>

View 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

View File

@@ -0,0 +1,363 @@
import { describe, expect, it } from "vitest"
import { BoardStore } from "./board-store"
import * as L from "lonna"
import {
EventFromServer,
UIEvent,
EventWrapper,
Id,
newBoard,
newNote,
Board,
BoardHistoryEntry,
getBoardAttributes,
JoinBoard,
} from "../../../common/src/domain"
import { UserSessionState } from "./user-session-store"
import { LocalStorageBoard } from "./board-local-store"
import { sleep } from "../../../common/src/sleep"
import { mkBootStrapEvent } from "../../../common/src/migration"
import * as WebSocket from "ws"
const otherUserEventAttributes = { user: { userType: "unidentified", nickname: "joe" }, timestamp: "0" } as const
const localUser = { userType: "unidentified", nickname: "" } as const
const board0 = newBoard("testboard")
const item1 = newNote("hello1")
const addItem1: BoardHistoryEntry = {
action: "item.add",
boardId: board0.id,
items: [item1],
connections: [],
serial: 1,
...otherUserEventAttributes,
}
const board1 = { ...board0, serial: 1, items: { [item1.id]: item1 } }
const item1_1 = { ...item1, text: "hello1.1" }
const board1_1 = { ...board1, serial: 2, items: { ...board1.items, [item1.id]: item1_1 } }
const item2 = newNote("hello2")
const board2 = { ...board1, serial: 1, items: { ...board1.items, [item2.id]: item2 } }
const item2_1 = { ...item2, text: "hello2.1" }
const board2_1 = { ...board2, serial: 2, items: { ...board2.items, [item1.id]: item1_1 } }
const board2_2 = { ...board2, serial: 2, items: { ...board2.items, [item1.id]: item1_1, [item2.id]: item2_1 } }
describe("Board Store", () => {
it("Applies event from server", async () => {
const { store, serverEvents } = await initBoardStore({ serverSideBoard: board0 })
expect(store.state.get().board).toEqual(board0)
serverEvents.push(addItem1)
expect(store.state.get().board).toEqual(board1)
expect(store.state.get().serverShadow).toEqual(store.state.get().board)
})
it("Applies local event", async () => {
const { store, serverEvents } = await initBoardStore({ serverSideBoard: board0 })
expect(store.state.get().board).toEqual(board0)
// 1. Event applied locally, serverShadow and serial unchanged
store.dispatch({ action: "item.add", boardId: board0.id, items: [item1], connections: [] })
expect(store.state.get().board).toEqual({ ...board0, serial: 0, items: { [item1.id]: item1 } })
expect(store.state.get().serverShadow).toEqual(board0)
// 2. Ack from server, update serverShadow and serial
serverEvents.push({ action: "ack", ackId: "", serials: { [board0.id]: 1 } })
expect(store.state.get().board).toEqual(board1)
expect(store.state.get().serverShadow).toEqual(store.state.get().board)
})
it("Rebases local event when remote event arrives before ack", async () => {
const { store, serverEvents } = await initBoardStore({ serverSideBoard: board1 })
expect(store.state.get().board).toEqual(board1)
// 1. Event applied locally, serverShadow and serial unchanged
store.dispatch({ action: "item.add", boardId: board0.id, items: [item2], connections: [] })
expect(store.state.get().board).toEqual(board2)
expect(store.state.get().serverShadow).toEqual(board1)
expect(store.state.get().queue.length).toEqual(0)
expect(store.state.get().sent.length).toEqual(1)
// 2. Second local event gets queued, not sent to server before ack
store.dispatch({ action: "item.update", boardId: board0.id, items: [item2_1] })
expect(store.state.get().board).toEqual({ ...board2, items: { ...board2.items, [item2.id]: item2_1 } })
expect(store.state.get().serverShadow).toEqual(board1)
expect(store.state.get().queue.length).toEqual(1)
expect(store.state.get().sent.length).toEqual(1)
// 3. Event from server applied to serverShadow and locally, local event still unapplied to serverShadow
serverEvents.push({
action: "item.update",
boardId: board0.id,
items: [{ ...item1, text: "hello1.1" }],
serial: 2,
...otherUserEventAttributes,
})
expect(store.state.get().board).toEqual(board2_2)
expect(store.state.get().serverShadow).toEqual(board1_1)
// 4. Ack from server, update serverShadow and serial for the first local event
serverEvents.push({ action: "ack", ackId: "", serials: { [board0.id]: 3 } })
expect(store.state.get().board).toEqual({ ...board2_2, serial: 3 })
expect(store.state.get().serverShadow).toEqual({ ...board2_1, serial: 3 })
expect(store.state.get().queue.length).toEqual(0)
expect(store.state.get().sent.length).toEqual(1)
// 5. Ack from server, update for the second local event
serverEvents.push({ action: "ack", ackId: "", serials: { [board0.id]: 4 } })
expect(store.state.get().board).toEqual({ ...board2_2, serial: 4 })
expect(store.state.get().serverShadow).toEqual(store.state.get().board)
expect(store.state.get().queue.length).toEqual(0)
expect(store.state.get().sent.length).toEqual(0)
})
})
describe("With stored local state", () => {
it("Without server-side changes", async () => {
const { store } = await initBoardStore({
serverSideBoard: board1,
serverSideHistory: [mkBootStrapEvent(board1.id, board1, board1.serial)],
locallyStoredBoard: {
serverShadow: board1,
queue: [],
},
})
expect(store.state.get().board).toEqual(board1)
expect(store.state.get().serverShadow).toEqual(store.state.get().board)
})
it("With server-side changes", async () => {
const { store } = await initBoardStore({
serverSideBoard: { ...board2, serial: 2 },
serverSideHistory: [
mkBootStrapEvent(board1.id, board1, board1.serial),
{
action: "item.add",
boardId: board0.id,
items: [item2],
connections: [],
serial: 2,
...otherUserEventAttributes,
},
],
locallyStoredBoard: {
serverShadow: board1,
queue: [],
},
})
expect(store.state.get().board).toEqual({ ...board2, serial: 2 })
expect(store.state.get().serverShadow).toEqual(store.state.get().board)
})
it("With queued local changes", async () => {
const { store } = await initBoardStore({
serverSideBoard: board1,
serverSideHistory: [],
locallyStoredBoard: {
serverShadow: board1,
queue: [
{ action: "item.update", boardId: board0.id, items: [item1_1], timestamp: "0", user: localUser },
],
},
})
expect(store.state.get().board).toEqual({ ...board1, items: { [item1.id]: item1_1 } })
})
it("With queued local changes and server-side changes", async () => {
const { store } = await initBoardStore({
serverSideBoard: { ...board2, serial: 2 },
serverSideHistory: [
mkBootStrapEvent(board1.id, board1, board1.serial),
{
action: "item.add",
boardId: board0.id,
items: [item2],
connections: [],
serial: 2,
...otherUserEventAttributes,
},
],
locallyStoredBoard: {
serverShadow: board1,
queue: [
{ action: "item.update", boardId: board0.id, items: [item1_1], timestamp: "0", user: localUser },
],
},
})
// The server-side changes should be applied first, then the queued local changes
expect(store.state.get().board).toEqual({
...board2,
serial: 2,
items: { ...board2.items, [item1.id]: item1_1 },
})
})
it("Going offline and back online", async () => {
const { store, serverEvents, connected, sentEvents, replyToJoinRequest } = await initBoardStore({
serverSideBoard: board0,
})
expect(store.state.get().board).toEqual(board0)
const sentBeforeOffline = sentEvents.slice()
// Create a local change (add item2)
const localAddEvent: UIEvent = { action: "item.add", boardId: board0.id, items: [item2], connections: [] }
const expectedSentEvent = {
action: "item.add",
boardId: board0.id,
items: [item2],
connections: [],
timestamp: expect.any(String),
user: { userType: "unidentified", nickname: "<unknown>" },
} as const
store.dispatch(localAddEvent)
expect(store.state.get().sent).toEqual([expectedSentEvent])
const expectedAckBundle = {
// Events are sent in bundles, which include all events in the "sent" buffer when sending.
ackId: "1",
events: [expectedSentEvent],
}
expect(sentEvents).toEqual([...sentBeforeOffline, expectedAckBundle])
// Go offline. Sent but not acknowledged events are discarded.
connected.set(false)
expect(store.state.get().status).toEqual("offline")
expect(store.state.get().sent).toEqual([])
expect(store.state.get().board).toEqual(board0) // Also local state is reset to reflect the discarded event
// Create local event offline (add item2)
store.dispatch(localAddEvent)
expect(store.state.get().board).toEqual({ ...board0, serial: 0, items: { [item2.id]: item2 } })
expect(store.state.get().serverShadow).toEqual(board0)
// Go back online
connected.set(true)
expect(store.state.get().status).toEqual("joining")
const expectedRejoinRequest = {
action: "board.join",
boardId: board0.id,
initAtSerial: 0,
} as const
expect(sentEvents).toEqual([...sentBeforeOffline, expectedAckBundle, expectedRejoinRequest])
// Meanwhile, item1 was added on the server. Now server responds to the rejoin request
replyToJoinRequest(expectedRejoinRequest, board1, [addItem1])
expect(store.state.get().board).toEqual(board2) // Client has merged changes from server
expect(store.state.get().sent).toEqual([expectedSentEvent])
expect(store.state.get().serverShadow).toEqual(board1)
expect(sentEvents).toEqual([...sentBeforeOffline, expectedAckBundle, expectedRejoinRequest, expectedAckBundle])
// Ack from server, update serverShadow and serial
serverEvents.push({ action: "ack", ackId: "", serials: { [board0.id]: 2 } })
expect(store.state.get().board).toEqual({ ...board2, serial: 2 })
expect(store.state.get().serverShadow).toEqual({ ...board2, serial: 2 })
})
})
// TODO: test going offline before getting board.init (case server redirects to other URL)
// TODO: test effects of server event buffering (bufferedServerEvents)
// TODO: test undo, redo buffers
async function waitForBackgroundJobs() {
await sleep(10)
}
async function initBoardStore({
serverSideBoard,
locallyStoredBoard,
serverSideHistory,
}: {
serverSideBoard: Board
locallyStoredBoard?: LocalStorageBoard
serverSideHistory?: BoardHistoryEntry[]
}) {
const serverEvents = L.bus<EventFromServer>()
const boardId = L.constant(serverSideBoard.id)
const connected = L.atom(true)
let sentEvents: (UIEvent | EventWrapper)[] = []
const send = (x: UIEvent | EventWrapper) => sentEvents.push(x)
const sessionInfo = L.atom<UserSessionState>({
status: "anonymous",
sessionId: "",
nickname: "",
nicknameSetByUser: false,
loginSupported: true,
})
const connection = {
connected,
send,
bufferedServerEvents: serverEvents,
sentUIEvents: null as any, // not used by BoardStore,
newSocket: function () {},
}
const localStore = {
getInitialBoardState: async (boardId: Id): Promise<LocalStorageBoard | undefined> => {
return locallyStoredBoard
},
clearBoardState: async (boardId: Id) => {},
clearAllPrivateBoards: async () => {},
storeBoardState: async (newState: LocalStorageBoard) => {},
}
const store = BoardStore(boardId, connection, sessionInfo, localStore, WebSocket as any)
expect(store.state.get().board).toEqual(undefined)
expect(store.state.get().queue).toEqual([])
expect(store.state.get().sent).toEqual([])
expect(store.state.get().serverShadow).toEqual(undefined)
expect(store.state.get().status).toEqual("none")
expect(sentEvents).toEqual([])
await waitForBackgroundJobs()
sessionInfo.set({
status: "anonymous",
nickname: localUser.nickname,
nicknameSetByUser: false,
sessionId: "",
loginSupported: false,
})
await waitForBackgroundJobs()
const expectedJoinRequest = {
action: "board.join",
boardId: board0.id,
initAtSerial: locallyStoredBoard?.serverShadow?.serial,
} as const
expect(sentEvents).toEqual([expectedJoinRequest])
function replyToJoinRequest(
joinRequest: JoinBoard,
serverSideBoard: Board,
serverSideHistory: BoardHistoryEntry[],
) {
const initAtSerial = joinRequest.initAtSerial
if (initAtSerial != undefined) {
serverEvents.push({
action: "board.init.diff",
first: true,
last: true,
recentEvents: serverSideHistory!.filter((e) => e.serial! > initAtSerial),
boardAttributes: getBoardAttributes(serverSideBoard),
initAtSerial,
accessLevel: "read-write",
})
} else {
serverEvents.push({ action: "board.init", board: serverSideBoard, accessLevel: "read-write" })
}
}
replyToJoinRequest(expectedJoinRequest, serverSideBoard, serverSideHistory || [])
await waitForBackgroundJobs()
expect(store.state.get().serverShadow).toEqual(serverSideBoard)
return { store, serverEvents, connected, sentEvents, replyToJoinRequest } as const
}

View File

@@ -0,0 +1,604 @@
import _ from "lodash"
import * as L from "lonna"
import { globalScope } from "lonna"
import { addOrReplaceEvent, foldActions } from "../../../common/src/action-folding"
import { assertNotNull } from "../../../common/src/assertNotNull"
import { boardReducer } from "../../../common/src/board-reducer"
import {
AccessLevel,
AckAddBoard,
Board,
BoardHistoryEntry,
BoardStateSyncEvent,
ClientToServerRequest,
CursorMove,
EventUserInfo,
Id,
Item,
ItemLocks,
JoinBoard,
LocalUIEvent,
LoginResponse,
PersistableBoardItemEvent,
ServerConfig,
SessionUserInfo,
TransientBoardItemEvent,
UIEvent,
UserSessionInfo,
canWrite,
defaultBoardSize,
isBoardHistoryEntry,
isCursorMove,
isLocalUIEvent,
isPersistableBoardItemEvent,
isTextItem,
newISOTimeStamp,
} from "../../../common/src/domain"
import { BoardLocalStore, LocalStorageBoard } from "./board-local-store"
import { ServerConnection, getWebSocketRootUrl } from "./server-connection"
import { UserSessionState, isLoginInProgress } from "./user-session-store"
import { CRDTStore, WebSocketPolyfill } from "./crdt-store"
import { serve } from "esbuild"
export type Dispatch = (e: UIEvent) => void
export type BoardStore = ReturnType<typeof BoardStore>
export type BoardAccessStatus =
| "none"
| "loading"
| "joining"
| "offline"
| "online"
| "denied-temporarily"
| "denied-permanently"
| "login-required"
| "not-found"
export type BoardState = {
status: BoardAccessStatus
accessLevel: AccessLevel
board: Board | undefined // Current board shown on the client
serverShadow: Board | undefined // Our view of the board as it is on the server
queue: (BoardHistoryEntry | CursorMove)[] // Local events to send. serverShadow + sent + queue = current board
sent: (BoardHistoryEntry | CursorMove)[] // Events sent to server, waiting for ack
locks: ItemLocks
users: UserSessionInfo[]
}
function emptyBoard(boardId: Id) {
return { id: boardId, name: "", ...defaultBoardSize, items: {}, connections: [], serial: 0 }
}
export function BoardStore(
boardId: L.Property<Id | undefined>,
connection: ServerConnection,
sessionInfo: L.Property<UserSessionState>,
localStore: BoardLocalStore,
WebSocketPolyfill: WebSocketPolyfill = WebSocket,
) {
type BoardStoreEvent =
| BoardHistoryEntry
| TransientBoardItemEvent
| BoardStateSyncEvent
| LocalUIEvent
| ClientToServerRequest
| LoginResponse
| AckAddBoard
| ServerConfig
function tagWithUserFromState(e: PersistableBoardItemEvent): BoardHistoryEntry {
const user: EventUserInfo = sessionState2UserInfo(sessionInfo.get())
return {
...e,
user,
timestamp: newISOTimeStamp(),
}
}
interface CommandStack {
add(event: PersistableBoardItemEvent): void
}
const ACK_ID = "1"
type BoardStateFromLocalStorage = {
boardId: Id
storedInitialState: LocalStorageBoard | undefined
}
function flushIfPossible(state: BoardState): BoardState {
// Only flush when board is ready and we are not waiting for ack.
if (state.status === "online" && state.sent.length === 0 && state.queue.length > 0) {
//console.log(`Send ${state.queue.map(i => i.action)}, await ack for ${state.queue.length}`)
connection.send({ events: state.queue, ackId: ACK_ID })
return { ...state, queue: [], sent: state.queue }
}
return state
}
function resetForBoard(boardId: Id) {
console.log("Reseting and joining board")
console.log("Sending board.join without initAtSerial")
localStore.clearBoardState(boardId).then(() =>
connection.send({
action: "board.join",
boardId,
}),
)
}
function CommandStack() {
let stack = L.atom<PersistableBoardItemEvent[]>([])
let canPop = L.view(stack, (s) => s.length > 0)
return {
add(event: PersistableBoardItemEvent) {
stack.modify((s) => addToStack(event, s))
},
pop(state: BoardState, otherStack: CommandStack): BoardState {
const undoOperation = _.last(stack.get())
if (!undoOperation) return state
stack.modify((s) => s.slice(0, s.length - 1))
const operationAsHistoryEntry = tagWithUserFromState(undoOperation)
const [board, reverse] = boardReducer(state.board!, operationAsHistoryEntry)
if (reverse) otherStack.add(reverse())
return flushIfPossible({
...state,
board,
queue: addOrReplaceEvent(operationAsHistoryEntry, state.queue),
})
},
clear() {
stack.set([])
},
canPop,
}
function addToStack(
event: PersistableBoardItemEvent,
b: PersistableBoardItemEvent[],
): PersistableBoardItemEvent[] {
const latest = b[b.length - 1]
if (latest) {
const folded = foldActions(event, latest)
if (folded) {
// Replace top of stack with folded
return [...b.slice(0, b.length - 1), folded] as any // TODO: can we get better types?
}
}
return b.concat(event)
}
}
let undoStack = CommandStack()
let redoStack = CommandStack()
let initialServerSyncEventBuffer: BoardHistoryEntry[] = []
const eventsReducer = (state: BoardState, event: BoardStoreEvent): BoardState => {
if (state.status === "online") {
// Process these events only when online
if (event.action === "cursor.move") {
return flushIfPossible({ ...state, queue: addOrReplaceEvent(event, state.queue) })
}
}
if (state.status === "online" || state.status === "offline") {
// Process these events only when online or offline
if (event.action === "ui.undo") {
return undoStack.pop(state, redoStack)
} else if (event.action === "ui.redo") {
return redoStack.pop(state, undoStack)
} else if (isPersistableBoardItemEvent(event)) {
try {
if (event.serial == undefined) {
// No serial == is local event.
if (!canWrite(state.accessLevel)) return state
redoStack.clear()
const [board, reverse] = boardReducer(state.board!, event)
if (reverse) undoStack.add(reverse())
return flushIfPossible({ ...state, board, queue: addOrReplaceEvent(event, state.queue) })
} else {
// Remote event
if (state.status !== "online") {
// Skip while not online. For instance, when recently reconnected, we may receive events from others while still
// Waiting for our final board.init.diff sync event. It would be disastrous to process these events before fully synced.
// The server will re-send any missed events after sync, after which we can start processing normally.
return state
}
const [newServerShadow] = boardReducer(state.serverShadow!, event, { strictOnSerials: true })
// Rebase local events on top of new server shadow. If this fails, there's a catch below.
const localEvents = [...state.sent, ...state.queue]
const board = localEvents
.filter(isBoardHistoryEntry)
.reduce((b, e) => boardReducer(b, e)[0], newServerShadow)
//console.log(`Processed remote board event and rebased ${localEvents.length} local events on top`, event)
return { ...state, serverShadow: newServerShadow, board }
}
} catch (e) {
console.error("Error applying event. Fetching as new board...", e)
resetForBoard(event.boardId)
return {
...state,
status: "joining",
sent: [],
queue: [],
}
}
} else if (event.action === "board.joined") {
return { ...state, users: state.users.concat(event) }
} else if (event.action === "board.left") {
return { ...state, users: state.users.filter((u) => u.sessionId !== event.sessionId) }
} else if (event.action === "board.locks") {
return { ...state, locks: event.locks }
} else if (event.action === "ack") {
const newSerial = state.board ? event.serials[state.board.id] : undefined
if (!newSerial) {
//console.log("Got ack")
return flushIfPossible({ ...state, sent: [] })
} else {
// Our sent events now acknowledged and will be incorporated into serverShadow
const newServerEvents = state.sent.filter(isBoardHistoryEntry)
const newServerShadow =
state.queue.length > 0
? newServerEvents.reduce((b, e) => boardReducer(b, e)[0], state.serverShadow!)
: state.board! // No queued events -> no need to calculate
//console.log(`Got ack. Joined ${state.sent.length} local events to server history and shadow (${state.serverShadow?.serial}->${newSerial}), shortcutted=${newServerShadow===state.board}`)
return flushIfPossible({
...state,
board: { ...state.board!, serial: newSerial },
serverShadow: { ...newServerShadow, serial: newSerial },
sent: [],
})
}
} else if (event.action === "board.action.apply.failed") {
console.error("Failed to apply previous action. Resetting to server-side state...")
if (state.board) {
resetForBoard(state.board.id)
return {
...state,
status: "joining",
}
}
return state
}
}
// Process these events in both ready and not-ready states
if (event.action === "ui.board.logged.out") {
return { ...state, board: emptyBoard(event.boardId), status: "denied-permanently" }
}
if (event.action === "board.join.denied") {
state = { ...state, board: emptyBoard(event.boardId) }
const loginStatus = sessionInfo.get().status
if (state.status !== "loading" && state.status != "joining") {
console.error(`Got board.join.denied while in status ${state.status}`)
}
if (loginStatus === "logging-in-server") {
console.log(`Access denied to board: login in progress`)
return { ...state, status: "denied-temporarily" }
} else if (event.reason === "not found") {
console.log(`Access denied to board: ${event.reason}`)
return { ...state, status: "not-found" }
} else if (loginStatus === "anonymous" || loginStatus === "logged-out" || loginStatus === "login-failed") {
console.log(`Access denied to board: login required`)
return { ...state, status: "login-required" }
} else if (event.reason === "unauthorized") {
console.warn(`Got "unauthorized" while logged in, likely login in progress...`)
return state
} else if (event.reason === "forbidden") {
console.log(`Access denied to board: ${event.reason}`)
return { ...state, status: "denied-permanently" }
} else {
console.error(`Unexpected board access denial: ${state.status}/${loginStatus}/${event.reason}`)
return state
}
}
if (event.action === "ui.board.setLocal") {
// Locally dispatched, see below
console.log("ui.board.join.request -> reset")
undoStack.clear()
redoStack.clear()
if (!event.boardId) {
console.log("Board id reseted. Reseting board state.")
return initialState
}
const storedInitialState = event.storedInitialState
if (storedInitialState && storedInitialState.serverShadow.id === event.boardId) {
console.log(`Starting offline with local board state, serial=${storedInitialState.serverShadow.serial}`)
const board = storedInitialState.queue.reduce(
(b, e) => boardReducer(b, e)[0],
storedInitialState.serverShadow,
)
return {
...initialState,
status: "offline",
queue: storedInitialState.queue,
sent: [],
serverShadow: storedInitialState.serverShadow,
board,
}
}
console.log(`Starting with empty board state`)
return {
...initialState,
status: "loading",
queue: [],
sent: [],
board: emptyBoard(event.boardId),
}
} else if (event.action === "ui.online") {
if (state.status !== "online" && state.status !== "joining") {
if (!state.board) throw Error("Trying to go online without a board")
const initAtSerial = state.serverShadow?.serial
const joinRequest: JoinBoard = {
action: "board.join",
boardId: state.board.id,
initAtSerial,
}
console.log(`Sending board.join at serial ${joinRequest.initAtSerial}`)
connection.send(joinRequest)
return {
...state,
status: "joining",
}
} else {
console.warn("Not joining in state", state.status)
}
return state
} else if (event.action === "userinfo.set") {
const users = state.users.map((u) => (u.sessionId === event.sessionId ? event : u))
return { ...state, users }
} else if (event.action === "board.init") {
if (event.board.id !== state.board?.id) {
console.warn(`Got board.init for non-matching board ${event.board.id} != ${boardId.get()}`)
return state
}
console.log(`Going to online mode. Init as new board at serial ${event.board.serial}`)
const newServerShadow = event.board
// Local board = server shadow + local queue
const queue = state.queue.filter(isBoardHistoryEntry)
const board = queue.reduce((b, e) => boardReducer(b, e)[0], newServerShadow)
return {
...state,
status: "online",
board,
accessLevel: event.accessLevel,
serverShadow: newServerShadow,
sent: [],
}
} else if (event.action === "board.init.diff") {
if (event.boardAttributes.id !== state.board?.id) {
console.warn(`Got board.init for non-matching board ${event.boardAttributes.id} != ${state.board?.id}`)
return state
}
if (event.first) {
// Ensure local buffer empty on first chunk even if an earlier init was aborted.
initialServerSyncEventBuffer = []
}
const boardId = event.boardAttributes.id
try {
if (!state.serverShadow) throw Error(`Trying to init at ${event.initAtSerial} without serverShadow`)
if (state.serverShadow.id !== event.boardAttributes.id)
throw Error(`Trying to init board with nonmatching id`)
const events = event.recentEvents
initialServerSyncEventBuffer.push(...events)
if (!event.last) {
console.log(
`Got board.init.diff chunk of ${events.length} events (${events[0].serial}..${
events[events.length - 1].serial
}), waiting for more...`,
)
return state
}
if (state.serverShadow.serial != event.initAtSerial)
throw Error(
`Trying to init at ${event.initAtSerial} with local serverShadow at ${state.serverShadow.serial}`,
)
const initialBoard = {
...state.serverShadow,
...event.boardAttributes,
} as Board
const queue = state.queue.filter(isBoardHistoryEntry) // Discard old cursor events etc, but keep any offline board events that need to be sent
if (initialServerSyncEventBuffer.length > 0) {
console.log(
`Going to online mode. Init at ${event.initAtSerial} with ${
initialServerSyncEventBuffer.length
} new events. Board starts at ${initialBoard.serial} and first event is ${
initialServerSyncEventBuffer[0].serial
} and last ${initialServerSyncEventBuffer[initialServerSyncEventBuffer.length - 1].serial}. ${
queue.length
} local events queued, ${state.sent.length} awaiting ack.`,
)
} else {
console.log(
`Init at ${event.initAtSerial}, no new events. ${queue.length} local events queued, ${state.sent.length} awaiting ack.`,
)
}
// New server shadow = old server shadow + recent events from server
const newServerShadow = initialServerSyncEventBuffer.reduce(
(b, e) => boardReducer(b, e)[0],
initialBoard,
)
// Local board = server shadow + local queue
const board = queue.reduce((b, e) => boardReducer(b, e)[0], newServerShadow)
initialServerSyncEventBuffer = []
return flushIfPossible({
...state,
accessLevel: event.accessLevel,
status: "online",
board,
serverShadow: newServerShadow,
sent: [], // Discard information about anything that was sent earlierly and to which we never got an ack for
queue,
})
} catch (e) {
console.error("Error initializing board. Fetching as new board...", e)
resetForBoard(boardId)
return {
...state,
status: "loading",
board: emptyBoard(boardId),
}
}
} else if (event.action === "ui.offline") {
if (state.status === "online" || state.status === "joining" || state.status === "loading") {
console.log(`Disconnected. Going to offline mode.`)
}
let board = state.board
if (state.sent.length > 0 && state.serverShadow) {
console.log(`Discarding ${state.sent.length} sent events of which we don't have an ack yet`)
// Roll back to serverShadow+queue, now that sent are discarded
board = state.queue
.filter(isBoardHistoryEntry)
.reduce((b, e) => boardReducer(b, e)[0], assertNotNull(state.serverShadow))
}
return {
...state,
board,
status: "offline",
sent: [], // Discard information about anything that was sent earlierly and to which we never got an ack for
users: [],
locks: {},
}
}
//console.warn("Unhandled event", event.action);
return state
}
const initialState: BoardState = {
status: "none" as const,
accessLevel: "none",
serverShadow: undefined,
board: undefined,
locks: {},
users: [],
queue: [],
sent: [],
}
function tagWithUser(e: UIEvent): BoardHistoryEntry | ClientToServerRequest | LocalUIEvent {
return isPersistableBoardItemEvent(e) ? tagWithUserFromState(e) : e
}
const uiEvents = L.bus<UIEvent>()
const dispatch: Dispatch = uiEvents.push
const userTaggedLocalEvents = L.view(uiEvents, tagWithUser)
const events = L.merge(userTaggedLocalEvents, connection.bufferedServerEvents)
const state = events.pipe(L.scan(initialState, eventsReducer, globalScope))
// persistable events and undo/redo are put to the state queue above, others are sent here immediately
uiEvents
.pipe(L.filter((e) => !isLocalUIEvent(e) && !isPersistableBoardItemEvent(e) && !isCursorMove(e)))
.forEach(connection.send)
const localBoardToSave = state.pipe(
L.changes,
L.filter((state) => {
if (state.serverShadow !== undefined && (state.status === "online" || state.status === "offline")) {
return true
}
return false
}),
L.map((state) => {
return {
serverShadow: assertNotNull(state.serverShadow),
queue: state.queue.filter(isBoardHistoryEntry),
}
}),
)
localBoardToSave.forEach(async (board) => {
await localStore.storeBoardState(board)
})
boardId.forEach(async (boardId) => {
// Reset board id in state, until we have fetched the local state
dispatch({ action: "ui.board.setLocal", boardId: undefined, storedInitialState: undefined })
if (boardId) {
console.log("Got board id, fetching local state", boardId)
const storedInitialState = await localStore.getInitialBoardState(boardId)
if (storedInitialState && storedInitialState.serverShadow.id !== boardId) {
console.log("Abort: board id already changed")
return
}
dispatch({ action: "ui.board.setLocal", boardId, storedInitialState }) // This is for the reducer locally to start offline mode
checkReadyToJoin()
}
})
const sessionStatus = L.view(sessionInfo, (s) => s.status)
const ss = sessionStatus.pipe(
L.changes,
L.scan([sessionStatus.get(), sessionStatus.get()] as const, ([a, b], next) => [b, next] as const),
L.applyScope(globalScope),
)
ss.onChange(([prev, s]) => {
if (s === "logged-out" && prev === "logged-in") {
localStore.clearAllPrivateBoards()
const board = state.get().board
if (board && board.accessPolicy) {
dispatch({ action: "ui.board.logged.out", boardId: board.id })
return
}
connection.newSocket()
}
checkReadyToJoin()
})
connection.connected.onChange((connected) => {
if (connected) {
checkReadyToJoin()
} else {
dispatch({ action: "ui.offline" })
}
})
function checkReadyToJoin() {
if (state.get().board && connection.connected.get() && !isLoginInProgress(sessionStatus.get())) {
// Go to online mode if we have a board id, are connected and not in the middle of login
dispatch({ action: "ui.online" })
}
}
const localBoardItemEvents = uiEvents.pipe(L.filter(isPersistableBoardItemEvent, globalScope))
const crdtStore = CRDTStore(
L.view(state, (s) => s.board?.id),
L.view(state, (s) => s.status === "online"),
localBoardItemEvents,
sessionInfo,
getWebSocketRootUrl,
WebSocketPolyfill,
)
return {
state,
events,
eventsFromServer: connection.bufferedServerEvents,
dispatch,
canUndo: undoStack.canPop,
canRedo: redoStack.canPop,
crdtStore,
}
}
export function sessionState2UserInfo(state: UserSessionState): SessionUserInfo {
if (state.status === "logged-in") {
return {
userType: "authenticated",
email: state.email,
nickname: state.nickname,
name: state.name,
userId: state.userId,
domain: state.domain,
picture: state.picture,
}
} else {
return {
userType: "unidentified",
nickname: state.nickname || "<unknown>",
}
}
}

View File

@@ -0,0 +1,143 @@
import * as L from "lonna"
import { IndexeddbPersistence } from "y-indexeddb"
import { WebsocketProvider } from "y-websocket"
import * as Y from "yjs"
import { augmentItemsWithCRDT, getCRDTField, importItemsIntoCRDT } from "../../../common/src/board-crdt-helper"
import { Id, Item, PersistableBoardItemEvent } from "../../../common/src/domain"
import { getWebSocketRootUrl } from "./server-connection"
import { UserSessionState } from "./user-session-store"
type BoardCRDT = ReturnType<typeof BoardCRDT>
export type WebSocketPolyfill =
| {
new (url: string | URL, protocols?: string | string[] | undefined): WebSocket
prototype: WebSocket
readonly CLOSED: number
readonly CLOSING: number
readonly CONNECTING: number
readonly OPEN: number
}
| undefined
function BoardCRDT(
boardId: Id,
online: L.Property<boolean>,
localBoardItemEvents: L.EventStream<PersistableBoardItemEvent>,
getSocketRoot: () => string,
WebSocketPolyfill: WebSocketPolyfill,
) {
const doc = new Y.Doc()
function getField(itemId: Id, fieldName: string) {
return getCRDTField(doc, itemId, fieldName)
}
function augmentItems(items: Item[]): Item[] {
return augmentItemsWithCRDT(doc, items)
}
if (typeof indexedDB != "undefined") {
const persistence = new IndexeddbPersistence(`b/${boardId}`, doc)
persistence.on("synced", () => {
console.log("CRDT data from indexedDB is loaded")
})
}
const provider = new WebsocketProvider(`${getSocketRoot()}/socket/yjs`, `board/${boardId}`, doc, {
connect: online.get(),
WebSocketPolyfill,
})
const disconnected = L.bus()
online.pipe(L.changes, L.takeUntil(disconnected)).forEach((c) => (c ? provider.connect() : provider.disconnect()))
localBoardItemEvents
.pipe(
L.takeUntil(disconnected),
L.filter((e) => e.boardId === boardId),
)
.forEach((event) => {
if (event.action === "item.add") {
importItemsIntoCRDT(doc, event.items, { fallbackToText: true })
}
})
provider.on("status", (event: any) => {
console.log("YJS Provider status", boardId, event.status)
})
function disconnect() {
console.log("Disconnecting YJS provider for board", boardId)
provider.destroy()
}
return {
boardId,
doc,
getField,
augmentItems,
disconnect,
awareness: provider.awareness,
}
}
export type CRDTStore = ReturnType<typeof CRDTStore>
export function CRDTStore(
currentBoardId: L.Property<Id | undefined>,
online: L.Property<boolean>,
localBoardItemEvents: L.EventStream<PersistableBoardItemEvent>,
sessionState: L.Property<UserSessionState>,
getSocketRoot: () => string = getWebSocketRootUrl,
WebSocketPolyfill: WebSocketPolyfill = WebSocket as any,
) {
let boardCrdt = L.atom<BoardCRDT | undefined>(undefined)
currentBoardId.forEach((boardId) => {
boardCrdt.modify((prev) => {
if (!prev || prev.boardId === boardId) {
return prev
}
prev.disconnect()
})
})
const userInfo = L.view(sessionState, (state) => ({
name: state.nickname,
color: "#35b2dc",
}))
L.view(userInfo, boardCrdt, (u, c) => [u, c] as const).forEach(([u, c]) => {
if (c) {
c.awareness.setLocalStateField("user", u)
}
})
function getBoardCrdt(boardId: Id): BoardCRDT {
if (boardId != currentBoardId.get()) {
throw Error(`Requested CRDT for board ${boardId} but current board is ${currentBoardId.get()}`)
}
boardCrdt.modify((prev) => {
if (prev) {
return prev
}
return BoardCRDT(boardId, online, localBoardItemEvents, getSocketRoot, WebSocketPolyfill)
})
return boardCrdt.get()!
}
function augmentItems(boardId: Id, items: Item[]): Item[] {
const bc = boardCrdt.get()
if (!bc || bc.boardId !== boardId) {
return items
}
return bc.augmentItems(items)
}
return {
getBoardCrdt,
augmentItems,
}
}

View File

@@ -0,0 +1,44 @@
import { ServerConnection } from "./server-connection"
import { CURSOR_POSITIONS_ACTION_TYPE, CursorPositions, UserCursorPosition, AppEvent } from "../../../common/src/domain"
import * as L from "lonna"
import { globalScope } from "lonna"
import { UserSessionStore } from "./user-session-store"
export function CursorsStore(connection: ServerConnection, sessionStore: UserSessionStore) {
const cursorUpdates = connection.bufferedServerEvents.pipe(
L.filter(isCursors),
L.map((event) => {
const otherCursors = { ...event.p }
const session = sessionStore.sessionId.get()
session && delete otherCursors[session] // Remove my own cursor. Server includes all because it's cheaper that way.
const cursors = Object.values(otherCursors)
return cursors
}),
)
const cursorReset = connection.connected.pipe(
L.changes,
L.filter((connected) => !connected),
L.map(() => [] as UserCursorPosition[]),
)
const cursors = L.merge([cursorUpdates, cursorReset]).pipe(L.toProperty([]), L.applyScope(globalScope))
let cursorsReceivedLast = 0
const cursorDelay = cursors.pipe(
L.map(() => {
const now = new Date().getTime()
const delay = cursorsReceivedLast ? now - cursorsReceivedLast : 0
cursorsReceivedLast = now
return delay
}),
)
return {
cursors,
cursorDelay,
}
}
export type CursorsStore = ReturnType<typeof CursorsStore>
function isCursors(e: AppEvent): e is CursorPositions {
return e.action === CURSOR_POSITIONS_ACTION_TYPE
}

View File

@@ -0,0 +1,50 @@
import { Board, Id, newISOTimeStamp, RecentBoard, RecentBoardAttributes } from "../../../common/src/domain"
import * as L from "lonna"
import { ServerConnection } from "./server-connection"
import { getAuthenticatedUser, UserSessionStore } from "./user-session-store"
export function RecentBoards(connection: ServerConnection, sessionStore: UserSessionStore) {
let recentBoards = L.atom<RecentBoard[]>(localStorage.recentBoards ? JSON.parse(localStorage.recentBoards) : [])
function storeRecentBoard(board: RecentBoardAttributes) {
const userEmail = getAuthenticatedUser(sessionStore.sessionState.get())?.email || null
const recentBoard = { name: board.name, id: board.id, opened: newISOTimeStamp(), userEmail }
storeRecentBoardLocally(recentBoard)
}
function storeRecentBoardLocally(recentBoard: RecentBoard) {
storeRecentBoards((boards) => [recentBoard, ...boards.filter((b) => b.id !== recentBoard.id)])
}
function removeRecentBoard(board: RecentBoard) {
storeRecentBoards((boards) => boards.filter((b) => b.id !== board.id))
connection.send({ action: "board.dissociate", boardId: board.id })
}
function storeRecentBoards(fn: (boards: RecentBoard[]) => RecentBoard[]) {
recentBoards.modify(fn)
localStorage.recentBoards = JSON.stringify(recentBoards.get())
}
connection.bufferedServerEvents.subscribe((e) => {
if (e.action === "user.boards") {
// Board list from server, let's sync
const boardsFromServer = e.boards
const localBoards = recentBoards.get()
const boardsOnlyFoundLocally = localBoards.filter((b) => !boardsFromServer.some((bs) => bs.id === b.id))
const boardsToSend = boardsOnlyFoundLocally.filter((b) => !b.userEmail || b.userEmail === e.email)
boardsToSend.forEach((b) =>
connection.send({ action: "board.associate", boardId: b.id, lastOpened: b.opened }),
)
storeRecentBoards(() => [...boardsFromServer, ...boardsOnlyFoundLocally])
}
})
return {
storeRecentBoard,
recentboards: recentBoards as L.Property<RecentBoard[]>,
removeRecentBoard,
}
}
export type RecentBoards = ReturnType<typeof RecentBoards>

Some files were not shown because too many files have changed in this diff Show More