snapshot of https://github.com/raimohanska/ourboard.git (master, 2026-09-23; blobs > 131072B omitted)
This commit is contained in:
87
common/src/action-folding.ts
Normal file
87
common/src/action-folding.ts
Normal 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
37
common/src/arrays.ts
Normal 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)
|
||||
}
|
||||
4
common/src/assertNotNull.ts
Normal file
4
common/src/assertNotNull.ts
Normal 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
|
||||
}
|
||||
6
common/src/authenticated-user.ts
Normal file
6
common/src/authenticated-user.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type OAuthAuthenticatedUser = {
|
||||
name: string
|
||||
email: string
|
||||
picture?: string
|
||||
domain: string | null
|
||||
}
|
||||
40
common/src/board-crdt-helper.ts
Normal file
40
common/src/board-crdt-helper.ts
Normal 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 ")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
common/src/board-reducer.benchmark.ts
Normal file
59
common/src/board-reducer.benchmark.ts
Normal 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
708
common/src/board-reducer.ts
Normal 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
26
common/src/colors.ts
Normal 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 },
|
||||
]
|
||||
193
common/src/connection-utils.ts
Normal file
193
common/src/connection-utils.ts
Normal 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
756
common/src/domain.ts
Normal 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
71
common/src/geometry.ts
Normal 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
|
||||
}
|
||||
222
common/src/migration.test.ts
Normal file
222
common/src/migration.test.ts
Normal 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
126
common/src/migration.ts
Normal 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
16
common/src/sets.ts
Normal 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
3
common/src/sleep.ts
Normal 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
50
common/src/vector2.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user