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

View File

@@ -0,0 +1,87 @@
import { arrayEquals, arrayIdAndKeysMatch, arrayIdMatch, idsOf } from "./arrays"
import {
AppEvent,
BoardHistoryEntry,
CURSOR_POSITIONS_ACTION_TYPE,
MoveItem,
isBoardHistoryEntry,
isSameUser,
} from "./domain"
type FoldOptions = {
cursorsOnly?: boolean
}
const defaultOptions = {
foldAddUpdate: true,
cursorsOnly: false,
}
export const CURSORS_ONLY: FoldOptions = { cursorsOnly: true }
export function foldActions(a: AppEvent, b: AppEvent, options: FoldOptions = defaultOptions): AppEvent | null {
if (isBoardHistoryEntry(a) && isBoardHistoryEntry(b)) {
if (options.cursorsOnly) return null
if (!isSameUser(a.user, b.user)) return null
const folded = foldActions_(a, b, options)
if (!folded) return null
const firstSerial = a.firstSerial ? a.firstSerial : a.serial
const serial = b.serial
return { ...(folded as BoardHistoryEntry), serial, firstSerial } as BoardHistoryEntry
} else {
return foldActions_(a, b, options)
}
}
/*
Folding can be done if in any given state S, applying actions A and B consecutively can be replaced with a single action C.
This function should return that composite action or null if folding is not possible.
*/
export function foldActions_(a: AppEvent, b: AppEvent, options: FoldOptions = defaultOptions): AppEvent | null {
if (a.action === CURSOR_POSITIONS_ACTION_TYPE && b.action === CURSOR_POSITIONS_ACTION_TYPE) {
return b
}
if (a.action === "cursor.move" && b.action === "cursor.move" && b.boardId === a.boardId) {
return b // This is a local cursor move
}
if (options.cursorsOnly) return null
if (isBoardHistoryEntry(a) && isBoardHistoryEntry(b)) {
if (!isSameUser(a.user, b.user)) return null
}
if (a.action === "item.front") {
if (b.action === "item.front" && b.boardId === a.boardId && arrayEquals(b.itemIds, a.itemIds)) return b
} else if (a.action === "item.move") {
if (b.action === "item.move" && b.boardId === a.boardId && everyMovedItemMatches(b, a)) return b
} else if (a.action === "item.update") {
if (
b.action === "item.update" &&
b.boardId === a.boardId &&
arrayIdAndKeysMatch(b.items, a.items) &&
arrayIdAndKeysMatch(b.connections ?? [], a.connections ?? [])
) {
return b
}
} else if (a.action === "item.lock" || a.action === "item.unlock") {
if (b.action === a.action && b.boardId === a.boardId && b.itemId === a.itemId) return b
} else if (a.action === "connection.modify" && b.action === "connection.modify") {
if (arrayIdMatch(a.connections, b.connections)) return b
} else if (a.action === "connection.modify" && b.action === "connection.delete") {
if (arrayEquals(b.connectionIds, idsOf(a.connections))) return b
}
return null
}
function everyMovedItemMatches(evt: MoveItem, evt2: MoveItem) {
return arrayIdMatch(evt.items, evt2.items) && arrayIdMatch(evt.connections, evt2.connections)
}
export function addOrReplaceEvent<E extends AppEvent>(event: E, q: E[], options: FoldOptions = defaultOptions): E[] {
for (let i = 0; i < q.length; i++) {
let eventInQueue = q[i]
const folded = foldActions(eventInQueue, event, options)
if (folded) {
return [...q.slice(0, i), folded, ...q.slice(i + 1)] as E[]
}
}
return q.concat(event)
}

37
common/src/arrays.ts Normal file
View File

@@ -0,0 +1,37 @@
import { isArray, isEqual } from "lodash"
export function toArray<T>(x: T | T[]) {
if (isArray(x)) return x
return [x]
}
export function arrayIdMatch<T extends { id: string }>(a: T[] | T, b: T[] | T) {
return arrayEquals(idsOf(a), idsOf(b))
}
export function arrayObjectKeysMatch<T extends object>(a: T[] | T, b: T[] | T) {
return arrayEquals(keysOf(a), keysOf(b))
}
export function arrayIdAndKeysMatch<T extends { id: string }>(a: T[] | T, b: T[] | T) {
return arrayIdMatch(a, b) && arrayObjectKeysMatch(a, b)
}
export function idsOf<T extends { id: string }>(a: T[] | T): string[] {
return toArray(a).map((x) => x.id)
}
export function keysOf<T extends object>(a: T[] | T): string[][] {
return toArray(a).map((x) => Object.keys(x))
}
export function arrayEquals<T>(a: T[] | T, b: T[] | T) {
return isEqual(toArray(a), toArray(b))
}
export function arrayToRecordById<T extends { id: string }>(arr: T[], init: Record<string, T> = {}): Record<string, T> {
return arr.reduce((acc: Record<string, T>, elem: T) => {
acc[elem.id] = elem
return acc
}, init)
}

View File

@@ -0,0 +1,4 @@
export function assertNotNull<T>(x: T | null | undefined): T {
if (x === null || x === undefined) throw Error("Assertion failed: " + x)
return x
}

View File

@@ -0,0 +1,6 @@
export type OAuthAuthenticatedUser = {
name: string
email: string
picture?: string
domain: string | null
}

View File

@@ -0,0 +1,40 @@
import * as Y from "yjs"
import { Board, Id, Item, QuillDelta, isTextItem } from "./domain"
export function getCRDTField(doc: Y.Doc, itemId: Id, fieldName: string) {
return doc.getText(`items.${itemId}.${fieldName}`)
}
export function augmentBoardWithCRDT(doc: Y.Doc, board: Board): Board {
const items = augmentItemsWithCRDT(doc, Object.values(board.items))
return {
...board,
items: Object.fromEntries(items.map((i) => [i.id, i])),
}
}
export function augmentItemsWithCRDT(doc: Y.Doc, items: Item[]): Item[] {
return items.map((item) => {
if (isTextItem(item) && item.crdt) {
const field = getCRDTField(doc, item.id, "text")
const textAsDelta = field.toDelta() as QuillDelta
const text = field.toString()
return { ...item, textAsDelta, text }
}
return item
})
}
export function importItemsIntoCRDT(doc: Y.Doc, items: Item[], options?: { fallbackToText: boolean }) {
for (const item of items) {
if (isTextItem(item) && item.crdt) {
if (item.textAsDelta) {
getCRDTField(doc, item.id, "text").applyDelta(item.textAsDelta)
} else if (options?.fallbackToText) {
getCRDTField(doc, item.id, "text").insert(0, item.text)
} else {
throw Error("textAsDelta is missing ")
}
}
}
}

View File

@@ -0,0 +1,59 @@
import _ from "lodash"
import { NOTE_COLORS } from "./colors"
import { Board } from "./domain"
import * as uuid from "uuid"
import { boardReducer } from "./board-reducer"
import { assertNotNull } from "./assertNotNull"
function createBoard(): Board {
const itemCount = 10000
const board: Board = {
id: "0f5b9d6c-02c2-4b81-beb7-3a3b9035e8a2",
name: "Perf3",
width: 800,
height: 600,
serial: 320577,
connections: [],
items: {},
}
for (let i = 0; i < itemCount; i++) {
const id = uuid.v4()
board.items[id] = {
id,
type: "text",
x: Math.random() * 800,
y: Math.random() * 600,
z: 3,
width: 5,
height: 5,
text: "Hello world",
fontSize: 12,
locked: false,
color: "#FBFC86",
}
}
return board
}
const board = createBoard()
const boardId = board.id
const eventCount = 1000
const items = Object.values(board.items)
const started = new Date().getTime()
for (let i = 0; i < eventCount; i++) {
const target = assertNotNull(_.sample(items))
const updated = { ...target, text: "EDIT " + i, color: _.sample(NOTE_COLORS)?.color! }
boardReducer(
board,
{
action: "item.update",
boardId,
items: [updated],
},
{ inplace: true },
)
}
const elapsed = new Date().getTime() - started
console.log(`Processed ${eventCount} events in ${elapsed}ms. (${eventCount / elapsed} events/ms)`)

708
common/src/board-reducer.ts Normal file
View File

@@ -0,0 +1,708 @@
import { partition } from "lodash"
import { maybeChangeContainerForItem } from "../../frontend/src/board/item-setcontainer"
import { arrayToRecordById } from "./arrays"
import { rerouteConnection, resolveEndpoint } from "./connection-utils"
import {
Board,
Connection,
ConnectionEndPoint,
ConnectionUpdate,
Container,
findItem,
findItemIdsRecursively,
getConnection,
getEndPointItemId,
getItem,
Id,
Image,
isBoardHistoryEntry,
isContainedBy,
isContainer,
isItem,
isItemEndPoint,
isTextItem,
Item,
ItemUpdate,
MoveItem,
Note,
PersistableBoardItemEvent,
Point,
Text,
TextItem,
Update,
Video,
} from "./domain"
import { equalRect, Rect } from "./geometry"
import {
BoardPermission,
canChangeFont,
canChangeShapeAndColor,
canChangeText,
canChangeTextAlign,
canDelete,
canMove,
nullablePermission,
} from "../../frontend/src/board/board-permissions"
type BoardReducerOptions = {
inplace?: boolean
strictOnSerials?: boolean
}
export function boardReducer(
board: Board,
event: PersistableBoardItemEvent,
options: BoardReducerOptions = {},
): [Board, (() => PersistableBoardItemEvent) | null] {
const inplace = options.inplace ?? false
if (isBoardHistoryEntry(event) && event.serial) {
const firstSerial = event.firstSerial ? event.firstSerial : event.serial
if (firstSerial !== board.serial + 1) {
const message = `Serial skip on ${event.action}, ${board.serial} -> ${firstSerial} (firstSerial ${event.firstSerial} serial ${event.serial})`
if (options.strictOnSerials) {
throw Error(message)
} else {
console.warn(message)
}
}
board = { ...board, serial: event.serial }
}
//console.log(event.action, inplace)
switch (event.action) {
case "connection.add": {
const newConnections = event.connections
for (let connection of newConnections) {
validateConnection(board, connection)
if (board.connections.some((c) => c.id === connection.id)) {
throw Error(`Connection ${connection.id} already exists on board ${board.id}`)
}
}
return [
{
...board,
connections: applyListModification(
board.connections,
(cs) => {
cs.push(...newConnections)
},
inplace,
),
},
() => ({
action: "connection.delete",
boardId: event.boardId,
connectionIds: newConnections.map((c) => c.id),
}),
]
}
case "connection.modify": {
const connections = event.connections.filter(canMove)
const existingConnections = connections.map((r) => {
validateConnection(board, r)
const existingConnection = board.connections.find((c) => c.id === r.id)
if (!existingConnection) {
throw Error(`Trying to modify nonexisting connection ${r.id} on board ${board.id}`)
}
return existingConnection
})
return [
{
...board,
connections: applyListModification(
board.connections,
(cs) => replaceById(cs, connections),
inplace,
),
},
() => ({ action: "connection.modify", boardId: event.boardId, connections: existingConnections }),
]
}
case "connection.delete": {
const ids = new Set(event.connectionIds)
const existingConnections = board.connections.filter((c) => ids.has(c.id) || !canDelete(c))
return [
{ ...board, connections: board.connections.filter((c) => !ids.has(c.id)) },
() => ({ action: "connection.add", boardId: event.boardId, connections: existingConnections }),
]
}
case "board.rename":
return [{ ...board, name: event.name }, null]
case "board.setAccessPolicy":
return [{ ...board, accessPolicy: event.accessPolicy }, null]
case "item.bootstrap":
//if (board.items.length > 0) throw Error("Trying to bootstrap non-empty board")
return [{ ...board, items: event.items, connections: event.connections }, null]
case "item.add":
if (event.items.some((a) => board.items[a.id])) {
throw new Error("Adding duplicate item " + JSON.stringify(event.items))
}
const updatedItems = applyModification(
board.items,
(items) => {
event.items.forEach((item) => {
if (
item.containerId &&
!findItem(board)(item.containerId) &&
!findItem(arrayToRecordById(event.items))(item.containerId)
) {
// Add item but don't try to assign to a non-existing container
items[item.id] = { ...item, containerId: undefined }
} else {
items[item.id] = item
}
}, {})
},
inplace,
)
const boardWithAddedItems = { ...board, items: updatedItems }
const connectionsToAdd = event.connections || []
connectionsToAdd.forEach((connection) => {
validateConnection(boardWithAddedItems, connection)
if (board.connections.some((c) => c.id === connection.id)) {
throw Error(`Connection ${connection.id} already exists on board ${board.id}`)
}
})
return [
{
...boardWithAddedItems,
connections: applyListModification(
board.connections,
(cs) => {
cs.push(...connectionsToAdd)
},
inplace,
),
},
() => ({
action: "item.delete",
boardId: board.id,
itemIds: event.items.map((i) => i.id),
connectionIds: event.connections.map((c) => c.id),
}),
]
case "item.font.increase":
return [
{
...board,
items: applyFontSize(
board.items,
1.1,
filterItemIdsByPermissions(event.itemIds, board, canChangeFont),
inplace,
),
},
() => ({
...event,
action: "item.font.decrease",
}),
]
case "item.font.decrease":
return [
{
...board,
items: applyFontSize(
board.items,
1 / 1.1,
filterItemIdsByPermissions(event.itemIds, board, canChangeFont),
inplace,
),
},
() => ({
...event,
action: "item.font.increase",
}),
]
case "item.update": {
const updatedConnections = updateConnections(board, event.connections || [])
const updatedItems = updateItems(board, event.items, updatedConnections, inplace)
return [
{
...board,
items: updatedItems,
connections: updatedConnections,
},
() => ({
action: "item.update",
boardId: board.id,
items: event.items.map((update) => copyMatchingKeysFromOriginal(update, getItem(board)(update.id))),
connections: (event.connections || []).map((update) =>
copyMatchingKeysFromOriginal(update, getConnection(board)(update.id)),
),
}),
]
}
case "item.move":
return [
moveItems(board, event, inplace),
() => ({
action: "item.move",
boardId: board.id,
items: event.items.map((i) => {
const item = getItem(board)(i.id)
return { id: i.id, x: item.x, y: item.y, containerId: item.containerId }
}),
connections: event.connections.map((c) => {
const conn = getConnection(board)(c.id)
const startPoint = resolveEndpoint(conn.from, board)
return { id: c.id, x: startPoint.x, y: startPoint.y }
}),
}),
]
case "item.delete": {
const itemIds = filterItemIdsByPermissions(event.itemIds, board, canDelete)
const connectionIds = filterConnectionIdsByPermissions(event.connectionIds, board, canDelete)
const itemIdsToDelete = findItemIdsRecursively(itemIds, board)
const connectionIdsToDelete = new Set(connectionIds)
const updatedItems = inplace ? board.items : { ...board.items }
itemIdsToDelete.forEach((id) => {
delete updatedItems[id]
})
const [connectionsToKeep, connectionsDeleted] = partition(
board.connections,
(c) =>
!connectionIdsToDelete.has(c.id) &&
!(c.containerId && itemIdsToDelete.has(c.containerId)) &&
(!isItemEndPoint(c.from) || !itemIdsToDelete.has(getEndPointItemId(c.from))) &&
(!isItemEndPoint(c.to) || !itemIdsToDelete.has(getEndPointItemId(c.to))),
)
return [
{
...board,
connections: connectionsToKeep,
items: updatedItems,
},
() => ({
action: "item.add",
boardId: board.id,
items: Array.from(itemIdsToDelete).map(getItem(board)),
connections: connectionsDeleted,
}),
]
}
case "item.front":
let maxZ = 0
let maxZCount = 0
const itemsList = Object.values(board.items)
for (let i of itemsList) {
if (i.z > maxZ) {
maxZCount = 1
maxZ = i.z
} else if (i.z === maxZ) {
maxZCount++
}
}
const isFine = (item: Item) => {
return !event.itemIds.includes(item.id) || item.z === maxZ
}
if (maxZCount === event.itemIds.length && itemsList.every(isFine)) {
// Requested items already on front
return [board, null]
}
const updated = event.itemIds.reduce(
(acc: Record<string, Item>, id) => {
const item = board.items[id]
if (!item) {
console.warn(`Warning: trying to "item.front" nonexisting item ${id} on board ${board.id}`)
return acc
}
const u = item.type !== "container" ? { ...item, z: maxZ + 1 } : item
acc[u.id] = u
return acc
},
inplace ? board.items : {},
)
return [
{
...board,
items: inplace
? board.items
: {
...board.items,
...updated,
},
},
null,
] // TODO: return item.back
default:
console.warn("Unknown event", event)
return [board, null]
}
}
function copyMatchingKeysFromOriginal<T extends { id: Id }>(update: Update<T>, original: T): Update<T> {
const keysAndValues = Object.keys(update).map((key) => [key, original[key as keyof T]])
const result = Object.fromEntries(keysAndValues)
return result
}
function validateConnection(board: Board, connection: Connection) {
validateEndPoint(board, connection, "from")
validateEndPoint(board, connection, "to")
}
function validateEndPoint(board: Board, connection: Connection, key: "to" | "from") {
const endPoint = connection[key]
if (isItemEndPoint(endPoint)) {
const toItem = board.items[getEndPointItemId(endPoint)]
if (!toItem) {
throw Error(`Connection ${connection.id} refers to nonexisting item ${endPoint}`)
}
}
}
function updateConnections(board: Board, updates: ConnectionUpdate[]): Connection[] {
if (updates.length === 0) return board.connections
updates = filterConnectionUpdatesByPermissions(updates, board)
const updatedConnections = updates.map((update) => {
const existing = board.connections.find((c) => c.id === update.id)
if (!existing) {
throw Error(`Trying to modify nonexisting connection ${update.id} on board ${board.id}`)
}
const updated = { ...existing, ...update }
validateConnection(board, updated)
return updated
})
return board.connections.map((c) => {
const replacement = updatedConnections.find((r) => r.id === c.id)
return replacement ? replacement : c
})
}
function updateItems(
board: Board,
updateList: ItemUpdate[],
updatedConnections: Connection[],
inplace: boolean,
): Record<Id, Item> {
updateList = filterItemUpdatesByPermissions(updateList, board)
const updatedItems: Item[] = updateList.map((update) => ({ ...board.items[update.id], ...update } as Item))
const resultItems = applyModification(
board.items,
(items) => {
arrayToRecordById(updatedItems, items)
},
inplace,
)
updatedItems.filter(isContainer).forEach((container) => {
const previous = board.items[container.id]
if (previous && !equalRect(previous, container)) {
// Container shape changed -> check items
Object.values(board.items)
.filter(
(i) =>
i.containerId === container.id || // Check all previously contained items
containedBy(i, container), // Check all items inside the new bounds
)
.forEach((item) => {
const newContainer = maybeChangeContainerForItem(item, resultItems)
if (newContainer?.id !== item.containerId) {
resultItems[item.id] = { ...item, containerId: newContainer ? newContainer.id : undefined }
}
})
}
})
function setVisibilityRecursively(parent: Item, hidden: boolean) {
const children = Object.values(resultItems).filter((i) => i.containerId === parent.id)
children.forEach((child) => {
const resultItem = { ...child, hidden }
if (!hidden && isContainer(resultItem) && resultItem.contentsHidden) {
resultItem.contentsHidden = false
}
resultItems[child.id] = resultItem
setVisibilityRecursively(child, hidden)
})
}
function adjustConnectionVisibility() {
updatedConnections.forEach((c, i) => {
let shouldHide: boolean
if (c.containerId) {
// When a connection has containerId, it should be hidden if the container is hidden (or has contentsHidden)
// A connection practically has containerId in case its endpoints are not attached to an item and it's contained by a container
const container = resultItems[c.containerId]
shouldHide = (container?.hidden || (isContainer(container) && container.contentsHidden)) ?? false
} else {
// In other cases, hide connections in case either end is connected to a hidden item
const from = resolveEndpoint(c.from, resultItems)
const to = resolveEndpoint(c.to, resultItems)
shouldHide = ((isItem(from) && from.hidden) || (isItem(to) && to.hidden)) ?? false
}
if (shouldHide !== c.hidden ?? false) {
updatedConnections[i] = { ...c, hidden: shouldHide }
}
})
}
updateList.forEach((update) => {
if ("contentsHidden" in update) {
const container = board.items[update.id]
if (container) {
setVisibilityRecursively(container, update.contentsHidden ?? false)
adjustConnectionVisibility()
}
}
})
return resultItems
}
function applyModification<T>(
items: Record<string, T>,
modification: (items: Record<string, T>) => void,
inplace: boolean,
): Record<string, T> {
const updated = inplace ? items : { ...items }
modification(updated)
return updated
}
function applyListModification<T>(list: T[], modification: (list: T[]) => void, inplace: boolean) {
const newList = inplace ? list : [...list]
modification(newList)
return newList
}
function replaceById<T extends { id: Id }>(list: T[], replacements: T[]) {
replacements.forEach((replacement) => {
const index = list.findIndex((item) => item.id === replacement.id)
if (index === -1) {
throw Error(`Trying to replace nonexisting item ${replacement.id}`)
}
list[index] = replacement
})
}
function applyFontSize(items: Record<string, Item>, factor: number, itemIds: Id[], inplace: boolean) {
return applyModification(
items,
(items) => {
itemIds.forEach((id) => {
const u = items[id] && isTextItem(items[id]) ? (items[id] as TextItem) : null
if (u) {
items[u.id] = {
...u,
fontSize: ((u as TextItem).fontSize || 1) * factor,
}
}
})
},
inplace,
)
}
function filterItemIdsByPermissions(itemIds: Id[], board: Board, permission: BoardPermission) {
return itemIds.filter((id) => nullablePermission(permission)(findItem(board)(id)))
}
function filterConnectionIdsByPermissions(connectionIds: Id[], board: Board, permission: BoardPermission) {
return connectionIds.filter((id) => permission(getConnection(board)(id)))
}
function filterMoveByPermissions(event: MoveItem, board: Board) {
return {
...event,
items: event.items.filter((i) => canMove(getItem(board)(i.id))),
connections: event.connections.filter((c) => canMove(getConnection(board)(c.id))),
}
}
function filterItemUpdatesByPermissions(updates: ItemUpdate[], board: Board): ItemUpdate[] {
type AnyItemKey = keyof Note | keyof Text | keyof Container | keyof Image | keyof Video
const propertyToPermissionMapping: Partial<Record<AnyItemKey, BoardPermission>> = {
align: canChangeTextAlign,
color: canChangeShapeAndColor,
fontSize: canChangeFont,
x: canMove,
y: canMove,
width: canMove,
height: canMove,
text: canChangeText,
}
return updates.filter((update) => {
const item = findItem(board)(update.id)
if (!item) return false
const keys = Object.keys(update) as AnyItemKey[]
const permissionFns = keys.map((key) => propertyToPermissionMapping[key])
for (let fn of permissionFns) {
if (fn && !fn(item)) {
console.log("Deny update", keys)
return false
}
}
return true
})
}
function filterConnectionUpdatesByPermissions(updates: ConnectionUpdate[], board: Board): ConnectionUpdate[] {
const propertyToPermissionMapping: Partial<Record<keyof Connection, BoardPermission>> = {
from: canMove,
to: canMove,
fromStyle: canChangeShapeAndColor,
toStyle: canChangeShapeAndColor,
controlPoints: canMove,
}
return updates.filter((update) => {
const connection = getConnection(board)(update.id)
const keys = Object.keys(update) as (keyof Connection)[]
const permissionFns = keys.map((key) => propertyToPermissionMapping[key])
for (let fn of permissionFns) {
if (fn && !fn(connection)) {
console.log("Deny update", keys)
return false
}
}
return true
})
}
function moveItems(board: Board, event: MoveItem, inplace: boolean) {
event = filterMoveByPermissions(event, board)
const itemMoves: Record<Id, ItemMove> = {}
const itemsOnBoard = board.items
const connectionMovesInEvent = event.connections || []
for (let mainItemMove of event.items) {
const { id, x, y, containerId } = mainItemMove
const mainItem = itemsOnBoard[id]
if (mainItem === undefined) {
console.warn("Moving unknown item", id)
continue
}
const xDiff = x - mainItem.x
const yDiff = y - mainItem.y
for (let movedItem of Object.values(itemsOnBoard)) {
const movedId = movedItem.id
if (movedId === id || isContainedBy(itemsOnBoard, mainItem)(movedItem)) {
const move = { xDiff, yDiff, containerChanged: movedId === id, containerId }
itemMoves[movedId] = move
}
}
}
const connectionMoves: Record<Id, ConnectionMove> = {}
for (let connection of board.connections) {
const move = findConnectionMove(connection, itemMoves, itemsOnBoard)
if (move) {
connectionMoves[connection.id] = move
} else {
const m = connectionMovesInEvent.find((m) => m.id === connection.id)
if (m) {
const startPoint = resolveEndpoint(connection.from, board)
connectionMoves[connection.id] = {
ends: "both",
xDiff: m.x - startPoint.x,
yDiff: m.y - startPoint.y,
}
}
}
}
let updatedConnections: Connection[] = board.connections.flatMap((connection) => {
const move = connectionMoves[connection.id]
if (!move) return connection
if (move.ends === "both") {
return {
...connection,
from: moveEndPoint(connection.from, move),
to: moveEndPoint(connection.to, move),
controlPoints: connection.controlPoints.map((cp) => moveEndPoint(cp, move)),
} as Connection
}
return rerouteConnection(connection, board)
})
const updatedItems = Object.entries(itemMoves).reduce(
(items, [id, move]) => {
const item = items[id]
const updated = { ...item, x: item.x + move.xDiff, y: item.y + move.yDiff }
if (move.containerChanged) {
updated.containerId = move.containerId
if (move.containerId) {
const newContainer = board.items[move.containerId]
if (
newContainer &&
(newContainer.hidden || (isContainer(newContainer) && newContainer.contentsHidden))
) {
updated.hidden = true
}
}
}
items[id] = updated
return items
},
inplace ? board.items : { ...board.items },
)
return {
...board,
items: updatedItems,
connections: updatedConnections,
}
}
type Move = { xDiff: number; yDiff: number }
type ItemMove = Move & { containerChanged: boolean; containerId: Id | undefined }
type ConnectionMove = (Move & { ends: "both" }) | { ends: "one" }
function findConnectionMove(
connection: Connection,
moves: Record<Id, Move>,
items: Record<string, Item>,
): ConnectionMove | null {
const endPoints = [connection.to, connection.from]
let move: Move | null = null
let partial = false
let hasItemEndPoints = false
for (let endPoint of endPoints) {
if (isItemEndPoint(endPoint)) {
hasItemEndPoints = true
const itemId = getEndPointItemId(endPoint)
if (moves[itemId]) {
move = moves[itemId]
} else {
// linked to item not being moved -> maybe a partial move
partial = true
}
}
}
if (!move && !hasItemEndPoints && connection.containerId) {
move = moves[connection.containerId]
}
if (!move) return null
if (partial) return { ends: "one" }
return { ends: "both", ...move }
}
function moveEndPoint(endPoint: ConnectionEndPoint, move: Move) {
if (isItemEndPoint(endPoint)) {
return endPoint // points to an item
}
const x = endPoint.x + move.xDiff
const y = endPoint.y + move.yDiff
return { ...endPoint, x, y }
}
function containedBy(a: Point, b: Rect) {
return a.x > b.x && a.y > b.y && a.x < b.x + b.width && a.y < b.y + b.height
}

26
common/src/colors.ts Normal file
View File

@@ -0,0 +1,26 @@
export const LIGHT_BLUE = "#9FECFC"
export const LIGHT_GREEN = "#C8FC87"
export const YELLOW = "#FBFC86"
export const ORANGE = "#FDDF90"
export const PINK = "#FDC4E7"
export const LIGHT_PURPLE = "#E0BDFA"
export const RED = "#F62A5C"
export const BLACK = "#000000"
export const LIGHT_GRAY = "#f4f4f6"
export const WHITE = "#ffffff"
export const TRANSPARENT = "#ffffff00"
export const DEFAULT_NOTE_COLOR = YELLOW
export const NOTE_COLORS = [
{ name: "light-blue", color: LIGHT_BLUE },
{ name: "light-green", color: LIGHT_GREEN },
{ name: "yellow", color: YELLOW },
{ name: "orange", color: ORANGE },
{ name: "pink", color: PINK },
{ name: "light-purple", color: LIGHT_PURPLE },
{ name: "red", color: RED },
{ name: "black", color: BLACK },
{ name: "light-gray", color: LIGHT_GRAY },
{ name: "white", color: WHITE },
{ name: "transparent", color: TRANSPARENT },
]

View File

@@ -0,0 +1,193 @@
import * as _ from "lodash"
import { maybeChangeContainerForConnection } from "../../frontend/src/board/item-setcontainer"
import {
AttachmentLocation,
AttachmentSide,
Board,
Connection,
ConnectionEndPoint,
ConnectionEndPointToItem,
getEndPointItemId,
getItem,
isItem,
isItemEndPoint,
Item,
ItemAttachmentLocation,
Point,
} from "./domain"
import { centerPoint, containedBy, Rect, subtract } from "./geometry"
import { getAngleDeg, Vector2 } from "./vector2"
export function resolveEndpoint(e: Point | Item | ConnectionEndPoint, b: Board | Record<string, Item>): Point | Item {
if (isItemEndPoint(e)) {
return resolveItemEndpoint(e, b)
}
return e
}
export function resolveItemEndpoint(e: ConnectionEndPointToItem, b: Board | Record<string, Item>): Item {
return getItem(b)(getEndPointItemId(e))
}
export function findNearestAttachmentLocationForConnectionNode(
i: Point | Item,
reference: Point | Item,
): AttachmentLocation {
if (!isItem(i)) return { side: "none", point: i }
const options: ItemAttachmentLocation[] = findItemAttachmentLocations(i)
const from = centerPoint(reference)
return withStraightestAngle(options, from)!
}
function angleDiff(option: ItemAttachmentLocation, from: Point) {
const directionFromEndPoint: Vector2 = subtract(from, option.point)
const endpointDirection = getEndPointDirection(option.side)
const diff = Math.abs(getAngleDeg(directionFromEndPoint) - getAngleDeg(endpointDirection))
if (diff > 180) return 360 - diff
return diff
}
function withStraightestAngle(options: ItemAttachmentLocation[], to: Point) {
return _.minBy(options, (p) => angleDiff(p, to))
}
function getEndPointDirection(side: AttachmentSide): Vector2 {
switch (side) {
case "top":
return Vector2(0, -1)
case "right":
return Vector2(1, 0)
case "bottom":
return Vector2(0, 1)
case "left":
return Vector2(-1, 0)
}
}
const sides: AttachmentSide[] = ["top", "left", "bottom", "right"]
function findItemAttachmentLocations(i: Item): ItemAttachmentLocation[] {
return sides.map((side) => findAttachmentLocation(i, side))
}
function p(x: number, y: number) {
return { x, y }
}
export function findAttachmentLocation(i: Item, side: AttachmentSide): ItemAttachmentLocation {
const margin = 0.1
switch (side) {
case "top":
return { item: i, side, point: p(i.x + i.width / 2, i.y - margin) }
case "left":
return { item: i, side, point: p(i.x - margin, i.y + i.height / 2) }
case "right":
return { item: i, side, point: p(i.x + i.width + margin, i.y + i.height / 2) }
case "bottom":
return { item: i, side, point: p(i.x + i.width / 2, i.y + i.height + margin) }
}
}
function findMidpoint(fromCoords: AttachmentLocation, toCoords: AttachmentLocation) {
const midpoint = {
x: mid(fromCoords.point.x, toCoords.point.x),
y: mid(fromCoords.point.y, toCoords.point.y),
}
if (toCoords.side === "left" || toCoords.side === "right") {
return {
x: midpoint.x,
y: mid(midpoint.y, toCoords.point.y),
}
}
if (toCoords.side === "top" || toCoords.side === "bottom") {
return {
x: mid(midpoint.x, toCoords.point.x),
y: midpoint.y,
}
}
if (fromCoords.side === "left" || fromCoords.side === "right") {
return {
x: midpoint.x,
y: mid(midpoint.y, fromCoords.point.y),
}
}
if (fromCoords.side === "top" || fromCoords.side === "bottom") {
return {
x: mid(midpoint.x, fromCoords.point.x),
y: midpoint.y,
}
}
return midpoint
}
function attachmentLocation2EndPoint(l: AttachmentLocation): ConnectionEndPoint {
if (l.side === "none") {
return l.point
}
return { side: l.side, id: l.item.id }
}
export function rerouteConnection(c: Connection, b: Board): Connection {
const resolvedFrom = resolveEndpoint(c.from, b)
const resolvedTo = resolveEndpoint(c.to, b)
let to = findNearestAttachmentLocationForConnectionNode(resolvedTo, resolvedFrom)
const from = findNearestAttachmentLocationForConnectionNode(resolvedFrom, to.point)
to = findNearestAttachmentLocationForConnectionNode(resolvedTo, from.point)
const rerouted: Connection = {
...c,
from: attachmentLocation2EndPoint(from),
to: attachmentLocation2EndPoint(to),
controlPoints: c.controlPoints.length ? [findMidpoint(from, to)] : [],
}
const container = maybeChangeContainerForConnection(rerouted, b.items)
return { ...rerouted, containerId: container ? container.id : undefined }
}
function rerouteEndPoint(e: ConnectionEndPoint, from: ConnectionEndPoint, b: Board) {
return attachmentLocation2EndPoint(
findNearestAttachmentLocationForConnectionNode(resolveEndpoint(e, b), resolveEndpoint(from, b)),
)
}
export function rerouteByNewControlPoints(c: Connection, controlPoints: Point[], b: Board): Connection {
const first = controlPoints[0]
return {
...c,
from: rerouteEndPoint(c.from, controlPoints[0] || c.to, b),
to: rerouteEndPoint(c.to, controlPoints[controlPoints.length - 1] || c.from, b),
controlPoints,
}
}
function mid(x: number, y: number) {
return (x + y) * 0.5
}
export const connectionRect = (b: Board | Record<string, Item>) => (c: Connection): Rect => {
const start = resolveEndpoint(c.from, b)
const end = resolveEndpoint(c.to, b)
const allPoints = [start, ...c.controlPoints, end]
const minX = _.min(allPoints.map((p) => p.x))!
const maxX = _.max(allPoints.map((p) => p.x))!
const minY = _.min(allPoints.map((p) => p.y))!
const maxY = _.max(allPoints.map((p) => p.y))!
const x = minX
if (isNaN(x)) {
throw Error("Assertion fail")
}
const y = minY
const width = maxX - minX
const height = maxY - minY
return { x, y, width, height }
}
export function isFullyContainedConnection(connection: Connection, item: Item, context: Record<string, Item> | Board) {
const start = resolveEndpoint(connection.from, context)
const end = resolveEndpoint(connection.to, context)
return !isItem(start) && !isItem(end) && containedBy(start, item) && containedBy(end, item)
}

756
common/src/domain.ts Normal file
View File

@@ -0,0 +1,756 @@
import * as t from "io-ts"
import * as uuid from "uuid"
import { LocalStorageBoard } from "../../frontend/src/store/board-local-store"
import { arrayToRecordById } from "./arrays"
import { DEFAULT_NOTE_COLOR, LIGHT_BLUE, PINK, RED } from "./colors"
import { Rect } from "./geometry"
export type Id = string
export type ISOTimeStamp = string
export function newISOTimeStamp(): ISOTimeStamp {
return new Date().toISOString()
}
export function optional<T extends t.Type<any>>(c: T) {
return t.union([c, t.undefined, t.null])
}
export const CrdtDisabled = undefined
export const CrdtEnabled = 1 as const
export type CrdtMode = typeof CrdtDisabled | typeof CrdtEnabled
export type BoardAttributes = {
id: Id
name: string
width: number
height: number
accessPolicy?: BoardAccessPolicy
crdt?: CrdtMode
}
export type BoardContents = {
items: Record<Id, Item>
connections: Connection[]
}
export type Board = BoardAttributes &
BoardContents & {
serial: Serial
}
export type BoardStub = Pick<Board, "id" | "name" | "accessPolicy" | "crdt"> & { templateId?: Id }
export const AccessLevelCodec = t.union([
t.literal("admin"),
t.literal("read-write"),
t.literal("read-only"),
t.literal("none"),
])
export type AccessLevel = t.TypeOf<typeof AccessLevelCodec>
export const AccessListEntryCodec = t.union([
t.type({
email: t.string,
access: optional(AccessLevelCodec),
}),
t.type({
domain: t.string,
access: optional(AccessLevelCodec),
}),
])
export type AccessListEntry = t.TypeOf<typeof AccessListEntryCodec>
export const BoardAccessPolicyDefinedCodec = t.type({
allowList: t.array(AccessListEntryCodec),
publicRead: optional(t.boolean),
publicWrite: optional(t.boolean),
})
export type BoardAccessPolicyDefined = t.TypeOf<typeof BoardAccessPolicyDefinedCodec>
export const BoardAccessPolicyCodec = t.union([t.undefined, BoardAccessPolicyDefinedCodec])
export type BoardAccessPolicy = t.TypeOf<typeof BoardAccessPolicyCodec>
export type EventUserInfo = UnidentifiedUserInfo | SystemUserInfo | EventUserInfoAuthenticated
export type UnidentifiedUserInfo = { nickname: string; userType: "unidentified" }
export type SystemUserInfo = { nickname: string; userType: "system" }
export type EventUserInfoAuthenticated = {
nickname: string
userType: "authenticated"
name: string
email: string
userId: string
}
export type SessionUserInfo = UnidentifiedUserInfo | SystemUserInfo | SessionUserInfoAuthenticated
export type SessionUserInfoAuthenticated = {
nickname: string
userType: "authenticated"
name: string
email: string
picture: string | undefined
userId: string
domain: string | null
}
export type UserSessionInfo = SessionUserInfo & {
sessionId: Id
}
export type BoardHistoryEntry = {
user: EventUserInfo
timestamp: ISOTimeStamp
serial?: Serial
firstSerial?: Serial
} & PersistableBoardItemEvent
export type BoardWithHistory = { board: Board; history: BoardHistoryEntry[] }
export type CompactBoardHistory = { boardAttributes: BoardAttributes; history: BoardHistoryEntry[] }
export const defaultBoardSize = { width: 800, height: 600 }
export interface CursorPosition {
x: number
y: number
}
export type UserCursorPosition = CursorPosition & {
sessionId: Id
}
export type BoardCursorPositions = Record<Id, UserCursorPosition>
export type Color = string
export type ItemBounds = { x: number; y: number; width: number; height: number; z: number }
export type LockState = false | "locked" | "read-only"
export type ItemProperties = { id: string; containerId?: string; locked: LockState; hidden?: boolean } & ItemBounds
export const ITEM_TYPES = {
NOTE: "note",
TEXT: "text",
IMAGE: "image",
VIDEO: "video",
CONTAINER: "container",
} as const
export type ItemType = typeof ITEM_TYPES[keyof typeof ITEM_TYPES]
export type QuillDelta = any // TODO: define this properly
export type TextItemProperties = ItemProperties & {
text: string
fontSize?: number
align?: Align
crdt?: CrdtMode
textAsDelta?: QuillDelta
}
export type NoteShape = "round" | "square" | "rect" | "diamond"
export type Note = TextItemProperties & {
type: typeof ITEM_TYPES.NOTE
color: Color
shape: NoteShape | undefined
}
export type Text = TextItemProperties & { type: typeof ITEM_TYPES.TEXT; color: Color }
export type Image = ItemProperties & { type: typeof ITEM_TYPES.IMAGE; assetId: string; src?: string }
export type Video = ItemProperties & { type: typeof ITEM_TYPES.VIDEO; assetId: string; src?: string }
export type Container = TextItemProperties & {
type: typeof ITEM_TYPES.CONTAINER
color: Color
contentsHidden?: boolean
}
export type Point = { x: number; y: number }
export function Point(x: number, y: number) {
return { x, y }
}
export const isPoint = (u: unknown): u is Point => typeof u === "object" && !!u && "x" in u && "y" in u
export type ConnectionEndStyle = "none" | "arrow" | "black-dot"
export type Connection = {
id: Id
from: ConnectionEndPoint
controlPoints: Point[]
to: ConnectionEndPoint
containerId?: string
locked: LockState
fromStyle: ConnectionEndStyle
toStyle: ConnectionEndStyle
pointStyle: "none" | "black-dot"
action: "connect" | "line"
hidden?: boolean
}
export type ConnectionEndPoint = Point | ConnectionEndPointToItem
export type ConnectionEndPointToItem = Id | ConectionEndPointDirectedToItem
export type ConectionEndPointDirectedToItem = { id: Id; side: AttachmentSide }
export function getEndPointItemId(e: ConnectionEndPointToItem) {
if (typeof e === "string") return e
return e.id
}
export function isItemEndPoint(e: ConnectionEndPoint): e is ConnectionEndPointToItem {
if (typeof e === "string") return true
if ("side" in e) return true
return false
}
export function isDirectedItemEndPoint(e: ConnectionEndPoint): e is ConectionEndPointDirectedToItem {
return isItemEndPoint(e) && typeof e === "object"
}
export type AttachmentSide = "left" | "right" | "top" | "bottom"
export type AttachmentLocation = { side: "none"; point: Point } | ItemAttachmentLocation
export type ItemAttachmentLocation = { side: AttachmentSide; point: Point; item: Item }
export type RenderableConnection = Omit<Connection, "from" | "to"> & {
from: AttachmentLocation
to: AttachmentLocation
}
export type TextItem = Note | Text | Container
export type ColoredItem = Item & { color: Color }
export type ShapedItem = Note
export type Item = TextItem | Image | Video
export type ItemLocks = Record<Id, Id>
export type RecentBoardAttributes = { id: Id; name: string }
export type RecentBoard = RecentBoardAttributes & { opened: ISOTimeStamp; userEmail: string | null }
export type BoardEvent = { boardId: Id }
export type UIEvent = BoardItemEvent | ClientToServerRequest | LocalUIEvent
export type LocalUIEvent = Undo | Redo | SetLocalBoard | GoOnline | BoardLoggedOut | GoOffline | TextFormat
export type EventFromServer = BoardHistoryEntry | BoardStateSyncEvent | LoginResponse | AckAddBoard | ServerConfig
export type ServerConfig = {
action: "server.config"
authSupported: boolean
assetStorageURL: string
crdt: "true" | "false" | "opt-in" | "opt-in-authenticated"
}
export type Serial = number
export type AppEvent =
| BoardItemEvent
| BoardStateSyncEvent
| LocalUIEvent
| ClientToServerRequest
| LoginResponse
| AckAddBoard
| ServerConfig
export type EventWrapper = {
events: AppEvent[]
ackId?: string
}
export type PersistableBoardItemEvent =
| AddItem
| UpdateItem
| MoveItem
| DeleteItem
| AddConnection
| ModifyConnection
| DeleteConnection
| IncreaseItemFont
| DecreaseItemFont
| BringItemToFront
| BootstrapBoard
| RenameBoard
| SetBoardAccessPolicy
export type BoardInit = InitBoardNew | InitBoardDiff
export type TransientBoardItemEvent = LockItem | UnlockItem
export type BoardItemEvent = PersistableBoardItemEvent | TransientBoardItemEvent
export type BoardStateSyncEvent =
| BoardInit
| RecentBoardsFromServer
| GotBoardLocks
| CursorPositions
| JoinedBoard
| LeftBoard
| UserLoggedIn
| AckJoinBoard
| DeniedJoinBoard
| UserInfoUpdate
| ActionApplyFailed
| AssetPutUrlResponse
| Ack
| BringAllToMe
export type ClientToServerRequest =
| CursorMove
| AddBoard
| LockItem
| UnlockItem
| JoinBoard
| AssociateBoard
| DissociateBoard
| SetNickname
| AssetPutUrlRequest
| AuthJWTLogin
| UserLoggedIn
| AuthLogout
| Ping
| BringAllToMe
export type LoginResponse =
| { action: "auth.login.response"; success: false }
| { action: "auth.login.response"; success: true; userId: string }
export type AddConnection = { action: "connection.add"; boardId: Id; connections: Connection[] }
export type ModifyConnection = { action: "connection.modify"; boardId: Id; connections: Connection[] }
export type DeleteConnection = { action: "connection.delete"; boardId: Id; connectionIds: Id[] }
export type UserLoggedIn = {
action: "user.login"
name: string
email: string
picture: string | undefined
}
export type AuthJWTLogin = {
action: "auth.login.jwt"
jwt: string
}
export type AuthLogout = { action: "auth.logout" }
export type Ping = { action: "ping" }
export type AddItem = { action: "item.add"; boardId: Id; items: Item[]; connections: Connection[] }
export type UpdateItem = { action: "item.update"; boardId: Id; items: ItemUpdate[]; connections?: ConnectionUpdate[] }
export type Update<T> = Partial<T> & { id: Id }
export type ItemUpdate<I extends Item = Item> = Update<I>
export type ConnectionUpdate = Update<Connection>
export type MoveItem = {
action: "item.move"
boardId: Id
items: { id: Id; x: number; y: number; containerId?: Id | undefined }[]
connections: { id: Id; x: number; y: number }[] // Coordinates are for connection start point.
}
export type IncreaseItemFont = { action: "item.font.increase"; boardId: Id; itemIds: Id[] }
export type DecreaseItemFont = { action: "item.font.decrease"; boardId: Id; itemIds: Id[] }
export type BringItemToFront = { action: "item.front"; boardId: Id; itemIds: Id[] }
export type DeleteItem = { action: "item.delete"; boardId: Id; itemIds: Id[]; connectionIds: Id[] }
export type BootstrapBoard = { action: "item.bootstrap"; boardId: Id } & BoardContents
export type LockItem = { action: "item.lock"; boardId: Id; itemId: Id }
export type UnlockItem = { action: "item.unlock"; boardId: Id; itemId: Id }
export type GotBoardLocks = { action: "board.locks"; boardId: Id; locks: ItemLocks }
export type AddBoard = { action: "board.add"; payload: Board | BoardStub }
export type AckAddBoard = { action: "board.add.ack"; boardId: Id }
export type JoinBoard = { action: "board.join"; boardId: Id; initAtSerial?: Serial }
export type BringAllToMe = { action: "user.bringAllToMe"; boardId: Id; sessionId: Id; viewRect: Rect; nickname: string }
export type AssociateBoard = { action: "board.associate"; boardId: Id; lastOpened: ISOTimeStamp }
export type DissociateBoard = { action: "board.dissociate"; boardId: Id }
export type SetBoardAccessPolicy = {
action: "board.setAccessPolicy"
boardId: Id
accessPolicy: BoardAccessPolicyDefined
}
export type AckJoinBoard = { action: "board.join.ack"; boardId: Id } & UserSessionInfo
export type DeniedJoinBoard =
| {
action: "board.join.denied"
boardId: Id
reason: "unauthorized" | "forbidden" | "not found"
}
| {
action: "board.join.denied"
boardId: Id
reason: "redirect"
wsAddress: string
}
export type RecentBoardsFromServer = { action: "user.boards"; email: string; boards: RecentBoard[] }
export type Ack = { action: "ack"; ackId: string; serials: Record<Id, Serial> }
export type ActionApplyFailed = { action: "board.action.apply.failed" }
export type JoinedBoard = { action: "board.joined"; boardId: Id } & UserSessionInfo
export type LeftBoard = { action: "board.left"; boardId: Id; sessionId: Id }
export type UserInfoUpdate = { action: "userinfo.set" } & UserSessionInfo
export type InitBoardNew = { action: "board.init"; board: Board; accessLevel: AccessLevel }
export type InitBoardDiff = {
action: "board.init.diff"
initAtSerial: Serial
first: boolean
last: boolean
recentEvents: BoardHistoryEntry[]
boardAttributes: BoardAttributes
accessLevel: AccessLevel
}
export type RenameBoard = { action: "board.rename"; boardId: Id; name: string }
export type CursorMove = { action: "cursor.move"; position: CursorPosition; boardId: Id }
export type SetNickname = { action: "nickname.set"; nickname: string }
export type AssetPutUrlRequest = { action: "asset.put.request"; assetId: string }
export type AssetPutUrlResponse = { action: "asset.put.response"; assetId: string; signedUrl: string }
export type Undo = { action: "ui.undo" }
export type Redo = { action: "ui.redo" }
export type TextFormat = { action: "ui.text.format"; itemIds: Id[]; format: "bold" | "italic" | "underline" }
export type SetLocalBoard = {
action: "ui.board.setLocal"
boardId: Id | undefined
storedInitialState: LocalStorageBoard | undefined
}
export type BoardLoggedOut = { action: "ui.board.logged.out"; boardId: Id }
export type GoOffline = { action: "ui.offline" }
export type GoOnline = { action: "ui.online" }
export const CURSOR_POSITIONS_ACTION_TYPE = "c" as const
export type CursorPositions = { action: typeof CURSOR_POSITIONS_ACTION_TYPE; p: Record<Id, UserCursorPosition> }
export const exampleBoard: Board = {
id: "default",
name: "Test Board",
items: arrayToRecordById([
newNote("Hello", PINK, 10, 5),
newNote("World", LIGHT_BLUE, 20, 10),
newNote("Welcome", RED, 5, 14),
]),
connections: [],
...defaultBoardSize,
serial: 0,
}
export function newBoard(name: string, crdt?: CrdtMode, accessPolicy?: BoardAccessPolicy): Board {
return { id: uuid.v4(), name, items: {}, accessPolicy, connections: [], ...defaultBoardSize, serial: 0, crdt }
}
export function newNote(
text: string,
color: Color = DEFAULT_NOTE_COLOR,
x: number = 20,
y: number = 20,
width: number = 5,
height: number = 5,
shape: NoteShape = "square",
z: number = 0,
): Note {
return { id: uuid.v4(), type: "note", text, color, x, y, width, height, z, shape, locked: false }
}
export function newSimilarNote(note: Note) {
return newNote("HELLO", note.color, 20, 20, note.width, note.height, note.shape)
}
export function newText(
crdt: CrdtMode,
text: string = "HELLO",
x: number = 20,
y: number = 20,
width: number = 5,
height: number = 2,
z: number = 0,
): Text {
return {
id: uuid.v4(),
type: "text",
text,
x,
y,
width,
height,
z,
color: "none",
locked: false,
crdt,
}
}
export function newContainer(
crdt: CrdtMode,
x: number = 20,
y: number = 20,
width: number = 30,
height: number = 20,
z: number = 0,
): Container {
return {
id: uuid.v4(),
type: "container",
text: "Unnamed area",
x,
y,
width,
height,
z,
color: "white",
locked: false,
crdt,
}
}
export function newImage(
assetId: string,
x: number = 20,
y: number = 20,
width: number = 5,
height: number = 5,
z: number = 0,
): Image {
return { id: uuid.v4(), type: "image", assetId, x, y, width, height, z, locked: false }
}
export function newVideo(
assetId: string,
x: number = 20,
y: number = 20,
width: number = 5,
height: number = 5,
z: number = 0,
): Video {
return { id: uuid.v4(), type: "video", assetId, x, y, width, height, z, locked: false }
}
export const isBoardItemEvent = (a: AppEvent): a is BoardItemEvent =>
a.action.startsWith("item.") ||
a.action.startsWith("connection.") ||
a.action === "board.rename" ||
a.action === "board.setAccessPolicy"
export const isPersistableBoardItemEvent = (e: any): e is PersistableBoardItemEvent =>
isBoardItemEvent(e) && !["item.lock", "item.unlock"].includes(e.action)
export const isBoardHistoryEntry = (e: AppEvent): e is BoardHistoryEntry =>
isPersistableBoardItemEvent(e) && !!(e as BoardHistoryEntry).user && !!(e as BoardHistoryEntry).timestamp
export const isLocalUIEvent = (e: AppEvent): e is LocalUIEvent => e.action.startsWith("ui.")
export const isCursorMove = (e: AppEvent): e is CursorMove => e.action === "cursor.move"
export function isSameUser(a: EventUserInfo, b: EventUserInfo) {
return a.userType == b.userType && a.nickname == b.nickname
}
export function isColoredItem(i: Item): i is ColoredItem {
return i.type === "note" || i.type === "container" || i.type === "text"
}
export function isShapedItem(i: Item): i is ShapedItem {
return i.type === "note"
}
export function isTextItem(i: Item): i is TextItem {
return i.type === "note" || i.type === "text" || i.type === "container"
}
export function isNote(i: Item): i is Note {
return i.type === "note"
}
export function isContainer(i: Item): i is Container {
return i.type === "container"
}
export function isText(i: Item): i is Text {
return i.type === "text"
}
export function isItem(i: Item | Point | Connection): i is Item {
return "type" in i
}
export function getItemText(i: Item) {
if (isTextItem(i)) return i.text
return ""
}
export function getItemBackground(i: Item) {
if (isColoredItem(i)) {
return i.color || "white" // Default for legacy containers
}
return "none"
}
export function getItemShape(i: Item) {
return i.type === "note" && i.shape ? i.shape : "rect"
}
type NamespacedEvent<Namespace extends string, T = AppEvent> = T extends { action: `${Namespace}.${string}` }
? T
: never
export function actionNamespaceIs<Namespace extends string>(
ns: Namespace,
a: AppEvent,
): a is NamespacedEvent<Namespace> {
return a.action.startsWith(ns + ".")
}
export function getItemIds(e: BoardHistoryEntry | PersistableBoardItemEvent): Id[] {
switch (e.action) {
case "item.front":
case "item.delete":
case "item.font.decrease":
case "item.font.increase":
return e.itemIds
case "item.move":
return e.items.map((i) => i.id)
case "item.update":
return e.items.map((i) => i.id)
case "item.add":
return e.items.map((i) => i.id)
case "item.bootstrap":
return Object.keys(e.items)
case "board.rename":
case "board.setAccessPolicy":
case "connection.add":
case "connection.modify":
case "connection.delete":
return []
}
}
export const getItem = (boardOrItems: Board | Record<string, Item>) => (id: Id) => {
const item = findItem(boardOrItems)(id)
if (!item) throw Error("Item not found: " + id)
return item
}
export const getConnection = (b: Board) => (id: Id) => {
const conn = b.connections.find((c) => c.id === id)
if (!conn) throw Error("Connection not found: " + id)
return conn
}
export const findItem = (boardOrItems: Board | Record<string, Item>) => (id: Id): Item | null => {
const items = getItems(boardOrItems)
const item = items[id]
return item || null
}
export const findConnection = (board: Board) => (id: Id) => {
const conn = board.connections.find((c) => c.id === id)
return conn || null
}
export function findItemIdsRecursively(ids: Id[], board: Board): Set<Id> {
const recursiveIds = new Set<Id>()
const addIdRecursively = (id: Id) => {
recursiveIds.add(id)
Object.values(board.items).forEach((i) => i.containerId === id && addIdRecursively(i.id))
}
ids.forEach(addIdRecursively)
return recursiveIds
}
export function findItemsRecursively(ids: Id[], board: Board): Item[] {
const recursiveIds = findItemIdsRecursively(ids, board)
return [...recursiveIds].map(getItem(board))
}
export const isContainedBy = (boardOrItems: Board | Record<string, Item>, parentCandidate: Item) => (
i: Item,
): boolean => {
if (!i.containerId) return false
if (i.containerId === parentCandidate!.id) return true
const itemsOnBoard = getItems(boardOrItems)
const parent = findItem(itemsOnBoard)(i.containerId)
if (i.containerId === i.id) throw Error("Self-contained")
if (parent == i) throw Error("self parent")
if (!parent) return false // Don't fail here, because when folding create+move, the action is run in an incomplete board context
return isContainedBy(boardOrItems, parentCandidate)(parent)
}
const isBoard = (u: unknown): u is Board => typeof u === "object" && !!u && "items" in u
const getItems = (boardOrItems: Board | Record<string, Item>) =>
isBoard(boardOrItems) ? boardOrItems.items : boardOrItems
export function isBoardEmpty(board: Board) {
return board.connections.length === 0 && Object.values(board.items).length === 0
}
export function getBoardAttributes(board: Board, userInfo?: EventUserInfo): BoardAttributes {
const accessPolicy = board.accessPolicy
? userInfo && userInfo.userType === "authenticated"
? board.accessPolicy
: { ...board.accessPolicy, allowList: [] } // Anonymize access policy for anonymous users
: undefined
return {
id: board.id,
name: board.name,
width: board.width,
height: board.height,
accessPolicy,
}
}
export const BOARD_ITEM_BORDER_MARGIN = 0.5
export function checkBoardAccess(accessPolicy: BoardAccessPolicy | undefined, userInfo: SessionUserInfo): AccessLevel {
if (!accessPolicy) return "read-write"
let accessLevel: AccessLevel = accessPolicy.publicWrite
? "read-write"
: accessPolicy.publicRead
? "read-only"
: "none"
if (userInfo.userType === "unidentified" || userInfo.userType === "system") {
return accessLevel
}
const email = userInfo.email
const domain = userInfo.domain
const defaultAccess = "read-write"
for (let entry of accessPolicy.allowList) {
const nextLevel =
"email" in entry && entry.email === email
? entry.access || defaultAccess
: "domain" in entry && domain === entry.domain
? entry.access || defaultAccess
: "none"
accessLevel = combineAccessLevels(accessLevel, nextLevel)
}
return accessLevel
}
function combineAccessLevels(a: AccessLevel, b: AccessLevel): AccessLevel {
if (a === "admin" || b === "admin") return "admin"
if (a === "read-write" || b === "read-write") return "read-write"
if (a === "read-only" || b === "read-only") return "read-only"
return "none"
}
export function canRead(a: AccessLevel) {
return a !== "none"
}
export function canWrite(a: AccessLevel) {
return a === "read-write" || a === "admin"
}
export type Align = "TL" | "TC" | "TR" | "ML" | "MC" | "MR" | "BL" | "BC" | "BR"
export function getAlign(item: TextItem) {
return item.align ?? (isNote(item) ? "MC" : "TL")
}
export type HorizontalAlign = "left" | "center" | "right"
export function getHorizontalAlign(align: Align): HorizontalAlign {
switch (align) {
case "TL":
case "ML":
case "BL":
return "left"
case "TC":
case "MC":
case "BC":
return "center"
case "TR":
case "MR":
case "BR":
return "right"
}
console.log("Unknown align", align)
return "center"
}
export type VerticalAlign = "top" | "middle" | "bottom"
export function getVerticalAlign(align: Align): VerticalAlign {
switch (align) {
case "TL":
case "TC":
case "TR":
return "top"
case "ML":
case "MC":
case "MR":
return "middle"
case "BL":
case "BC":
case "BR":
return "bottom"
}
console.log("Unknown align", align)
return "middle"
}
export function setHorizontalAlign<I extends TextItem>(item: I, a: HorizontalAlign): ItemUpdate<I> {
const letter = a === "left" ? "L" : a === "center" ? "C" : "R"
const align = `${getAlign(item)[0]}${letter}`
return { id: item.id, align } as ItemUpdate<I>
}
export function setVerticalAlign<I extends TextItem>(item: I, a: VerticalAlign): ItemUpdate<I> {
const letter = a === "top" ? "T" : a === "middle" ? "M" : "B"
const align = `${letter}${getAlign(item)[1]}`
return { id: item.id, align } as ItemUpdate<I>
}

71
common/src/geometry.ts Normal file
View File

@@ -0,0 +1,71 @@
import { Point } from "./domain"
export const origin = { x: 0, y: 0 }
export type Coordinates = { x: number; y: number }
export type Dimensions = { width: number; height: number }
export type Rect = { x: number; y: number; width: number; height: number }
export const ZERO_RECT = { x: 0, y: 0, height: 0, width: 0 }
export function add(a: Coordinates, b: Coordinates) {
return { x: a.x + b.x, y: a.y + b.y }
}
export function subtract(a: Coordinates, b: Coordinates) {
return { x: a.x - b.x, y: a.y - b.y }
}
export function negate(a: Coordinates) {
return { x: -a.x, y: -a.y }
}
export function multiply(a: Coordinates, factor: number) {
return { x: a.x * factor, y: a.y * factor }
}
export function overlaps(a: Rect, b: Rect) {
if (b.x >= a.x + a.width) return false
if (b.x + b.width <= a.x) return false
if (b.y >= a.y + a.height) return false
if (b.y + b.height <= a.y) return false
return true
}
export function equalRect(a: Rect, b: Rect) {
return a.x == b.x && a.y == b.y && a.width == b.width && a.height == b.height
}
export function distance(a: Coordinates, b: Coordinates) {
return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2))
}
export function containedBy(a: Point, b: Rect): boolean
export function containedBy(a: Rect, b: Rect): boolean
export function containedBy(a: Rect | Point, b: Rect) {
if ("width" in a) {
return a.x > b.x && a.y > b.y && a.x + a.width < b.x + b.width && a.y + a.height < b.y + b.height
} else {
return a.x > b.x && a.y > b.y && a.x < b.x + b.width && a.y < b.y + b.height
}
}
export function rectFromPoints(a: Coordinates, b: Coordinates) {
const x = Math.min(a.x, b.x)
const y = Math.min(a.y, b.y)
const width = Math.abs(a.x - b.x)
const height = Math.abs(a.y - b.y)
return { x, y, width, height }
}
export function isRect(i: Point | Rect): i is Rect {
return "width" in i
}
export function centerPoint(i: Point | Rect) {
if (isRect(i)) {
return {
x: i.x + i.width / 2,
y: i.y + i.height / 2,
}
}
return i
}

View File

@@ -0,0 +1,222 @@
import { describe, expect, it } from "vitest"
import { arrayToRecordById } from "./arrays"
import {
AddConnection,
AddItem,
Board,
Connection,
ConnectionEndPoint,
DeleteConnection,
DeleteItem,
ModifyConnection,
MoveItem,
defaultBoardSize,
exampleBoard,
newISOTimeStamp,
} from "./domain"
import { migrateBoard, migrateEvent } from "./migration"
describe("Migration", () => {
describe("Migrate board", () => {
it("Migrates boards correctly", () => {
const containedNoteWithNoType = {
id: "a",
x: 1,
y: 1,
width: 1,
height: 1,
text: "note a",
color: "yellow",
}
const containedNote2 = {
type: "note",
id: "b",
x: 2,
y: 2,
width: 1,
height: 1,
text: "note b",
color: "yellow",
}
const unContainedNoteWithNoDimensions = {
type: "note",
id: "c",
x: 3,
y: 3,
text: "note c",
color: "yellow",
}
const oldFormContainerWithItemsAndNoText = {
type: "container",
id: "d",
x: 0,
y: 0,
width: 5,
height: 5,
items: ["a", "b"],
}
const legacyBoard: any = {
id: "foo",
name:
"board with no size, where containers have items property and items do not have containerId property",
items: [
containedNoteWithNoType,
containedNote2,
unContainedNoteWithNoDimensions,
oldFormContainerWithItemsAndNoText,
],
}
const board = migrateBoard(legacyBoard)
expect(board).toEqual({
...legacyBoard,
...defaultBoardSize,
connections: [],
items: arrayToRecordById([
{ ...containedNoteWithNoType, type: "note", containerId: "d", z: 0, locked: false },
{ ...containedNote2, containerId: "d", z: 0, locked: false },
{ ...unContainedNoteWithNoDimensions, width: 5, height: 5, z: 0, locked: false },
{ type: "container", id: "d", x: 0, y: 0, width: 5, height: 5, z: 0, text: "", locked: false },
]),
})
})
it("Removes broken connections", () => {
const borkenEndpoint: ConnectionEndPoint = { id: "asdf", side: "bottom" }
const b: Board = {
...exampleBoard,
connections: [
{
from: borkenEndpoint,
to: borkenEndpoint,
id: "asfdoi",
controlPoints: [],
fromStyle: "none",
toStyle: "none",
pointStyle: "none",
action: "connect",
locked: false,
},
],
}
expect(migrateBoard(b)).toEqual(exampleBoard)
})
it("Sets connection end styles", () => {
const b: Board = {
...exampleBoard,
connections: [{ from: { x: 0, y: 0 }, to: { x: 0, y: 0 }, id: "asfdoi", controlPoints: [] } as any],
}
expect(migrateBoard(b)).toEqual({
...exampleBoard,
connections: [
{
from: { x: 0, y: 0 },
to: { x: 0, y: 0 },
id: "asfdoi",
controlPoints: [],
fromStyle: "black-dot",
toStyle: "arrow",
pointStyle: "black-dot",
action: "connect",
locked: false,
} as Connection,
],
})
})
})
describe("Migrate event", () => {
const headers = {
user: { userType: "unidentified", nickname: "asdf" },
timestamp: newISOTimeStamp(),
boardId: "",
}
it("connection.add", () => {
const connection = { from: "a", to: "b", controlPoints: [], id: "c" }
expect(
(migrateEvent({
...headers,
action: "connection.add",
connection,
} as any) as AddConnection).connections,
).toEqual([connection])
expect(
(migrateEvent({
...headers,
action: "connection.add",
connection: [connection],
} as any) as AddConnection).connections,
).toEqual([connection])
})
it("connection.modify", () => {
const connection = { from: "a", to: "b", controlPoints: [], id: "c" }
expect(
(migrateEvent({
...headers,
action: "connection.modify",
connection,
} as any) as ModifyConnection).connections,
).toEqual([connection])
expect(
(migrateEvent({
...headers,
action: "connection.modify",
connection: [connection],
} as any) as ModifyConnection).connections,
).toEqual([connection])
})
it("connection.delete", () => {
const connectionId = "c"
expect(
(migrateEvent({
...headers,
action: "connection.delete",
connectionId,
} as any) as DeleteConnection).connectionIds,
).toEqual([connectionId])
expect(
(migrateEvent({
...headers,
action: "connection.delete",
connectionId: [connectionId],
} as any) as DeleteConnection).connectionIds,
).toEqual([connectionId])
})
it("item.move", () => {
expect(
(migrateEvent({
...headers,
action: "item.move",
items: [],
} as any) as MoveItem).connections,
).toEqual([])
})
it("item.delete", () => {
expect(
(migrateEvent({
...headers,
action: "item.delete",
itemIds: [],
} as any) as DeleteItem).connectionIds,
).toEqual([])
})
it("item.add", () => {
expect(
(migrateEvent({
...headers,
action: "item.add",
items: [],
} as any) as AddItem).connections,
).toEqual([])
})
})
})

126
common/src/migration.ts Normal file
View File

@@ -0,0 +1,126 @@
import { isArray } from "lodash"
import { arrayToRecordById, toArray } from "./arrays"
import { resolveEndpoint } from "./connection-utils"
import { Board, BoardHistoryEntry, Container, Connection, defaultBoardSize, Id, Item, Serial } from "./domain"
export function mkBootStrapEvent(boardId: Id, snapshot: Board, serial: Serial = 1) {
return {
action: "item.bootstrap",
boardId,
items: snapshot.items,
connections: snapshot.connections,
timestamp: new Date().toISOString(),
user: { nickname: "admin", userType: "system" },
serial,
} as BoardHistoryEntry
}
export function migrateBoard(origBoard: Board) {
const board = { ...origBoard }
const items: Item[] = []
const width = Math.max(board.width || 0, defaultBoardSize.width)
const height = Math.max(board.height || 0, defaultBoardSize.height)
for (const item of Object.values(board.items)) {
if (items.find((i) => i.id === item.id)) {
console.warn("Duplicate item", item, "found on table", board.name)
} else {
items.push(migrateItem(item, items, board.items))
}
}
if (board.accessPolicy) {
if (!board.accessPolicy.allowList.some((e) => e.access === "admin")) {
console.log(`No board admin for board ${board.id} -> mapping all read-write users as admins`)
board.accessPolicy.allowList = board.accessPolicy.allowList.map((e) => ({
...e,
access: e.access === "read-write" ? "admin" : e.access,
}))
}
}
const connections = (board.connections ?? [])
.filter((c) => {
try {
resolveEndpoint(c.from, board)
resolveEndpoint(c.to, board)
} catch (e) {
console.error(`Error resolving connection ${JSON.stringify(c)}`)
return false
}
return true
})
.map(migrateConnection)
return { ...board, connections, width, height, items: arrayToRecordById(items) }
}
function migrateConnection(c: Connection): Connection {
c =
c.fromStyle && c.fromStyle !== ("white-dot" as any) && c.toStyle && c.pointStyle
? c
: { ...c, fromStyle: "black-dot", toStyle: "arrow", pointStyle: "black-dot" }
c = c.action !== undefined ? c : { ...c, action: "connect" }
c = c.locked !== undefined ? c : { ...c, locked: false }
return c
}
function migrateItem(item: Item, migratedItems: Item[], boardItems: Record<string, Item>): Item {
const { width, height, z, type, locked, ...rest } = item
// Force type, width and height for all items
let fixedItem = {
type: type || "note",
width: width || 5,
height: height || 5,
z: z || 0,
locked: locked || false,
...rest,
} as Item
if (fixedItem.type === "text") {
fixedItem.color ??= "none"
}
if (fixedItem.type === "container") {
let container = fixedItem as Container & { items?: string[] }
// Force container to have text
container.text = container.text || ""
// If container had items property, migrate each corresponding item to have containerId of that container instead
if (container.items) {
const ids = container.items
delete container.items
ids.forEach((i) => {
const containedItem = migratedItems.find((mi) => mi.id === i) || boardItems[i]
containedItem && (containedItem.containerId = container.id)
})
}
}
return fixedItem
}
export function migrateEvent(event: BoardHistoryEntry): BoardHistoryEntry {
if (event.action === "connection.add") {
if (!isArray(event.connections)) {
return { ...event, connections: toArray((event as any).connection) }
}
} else if (event.action === "connection.modify") {
if (!isArray(event.connections)) {
return { ...event, connections: toArray((event as any).connection) }
}
} else if (event.action === "connection.delete") {
if (!isArray(event.connectionIds)) {
return { ...event, connectionIds: toArray((event as any).connectionId) }
}
} else if (event.action === "item.move") {
if (!event.connections) {
return { ...event, connections: [] }
}
} else if (event.action === "item.delete") {
if (!event.connectionIds) {
return { ...event, connectionIds: [] }
}
} else if (event.action === "item.add") {
if (!event.connections) {
return { ...event, connections: [] }
}
}
return event
}

16
common/src/sets.ts Normal file
View File

@@ -0,0 +1,16 @@
export function toggleInSet<T>(item: T, set: Set<T>) {
if (set.has(item)) {
return new Set([...set].filter((i) => i !== item))
}
return new Set([...set].concat(item))
}
export function difference<A>(setA: Set<A>, setB: Set<A>) {
let _difference = new Set(setA)
for (let elem of setB) {
_difference.delete(elem)
}
return _difference
}
export const emptySet = <A>() => new Set<A>()

3
common/src/sleep.ts Normal file
View File

@@ -0,0 +1,3 @@
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(() => resolve(undefined), ms))
}

50
common/src/vector2.ts Normal file
View File

@@ -0,0 +1,50 @@
export type Vector2 = { x: number; y: number }
export function Vector2(x: number, y: number) {
return { x, y }
}
export function getAngleRad(v: Vector2) {
const unit = withLength(v, 1)
return Math.atan2(unit.y, unit.x)
}
export function getAngleDeg(v: Vector2) {
return radToDeg(getAngleRad(v))
}
export function getLength(v: Vector2) {
return Math.sqrt(v.x * v.x + v.y * v.y)
}
export function withLength(v: Vector2, newLength: number) {
return multiply(v, newLength / getLength(v))
}
export function multiply(v: Vector2, multiplier: number) {
return Vector2(v.x * multiplier, v.y * multiplier)
}
export function add(v: Vector2, other: Vector2) {
return Vector2(v.x + other.x, v.y + other.y)
}
export function rotateRad(v: Vector2, radians: number) {
var length = getLength(v)
var currentRadians = getAngleRad(v)
var resultRadians = radians + currentRadians
var rotatedUnit = { x: Math.cos(resultRadians), y: Math.sin(resultRadians) }
return withLength(rotatedUnit, length)
}
export function rotateDeg(v: Vector2, degrees: number) {
return rotateRad(v, degToRad(degrees))
}
export function degToRad(degrees: number) {
return (degrees * 2 * Math.PI) / 360
}
export function radToDeg(rad: number) {
return (rad * 360) / 2 / Math.PI
}