Files
upstream-ourboard/frontend/src/board/item-cut-copy-paste.ts
upstream-mirror d57328655f
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
snapshot of https://github.com/raimohanska/ourboard.git (master, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:16:17 +05:00

263 lines
11 KiB
TypeScript

import _ from "lodash"
import * as L from "lonna"
import * as uuid from "uuid"
import { DEFAULT_NOTE_COLOR } from "../../../common/src/colors"
import { connectionRect, resolveEndpoint } from "../../../common/src/connection-utils"
import {
BOARD_ITEM_BORDER_MARGIN,
Board,
Connection,
ConnectionEndPoint,
Id,
Item,
Point,
findItemsRecursively,
getEndPointItemId,
isItemEndPoint,
newNote,
newText,
} from "../../../common/src/domain"
import * as G from "../../../common/src/geometry"
import { emptySet } from "../../../common/src/sets"
import { sanitizeHTML } from "../components/sanitizeHTML"
import { Dispatch } from "../store/board-store"
import { CRDTStore } from "../store/crdt-store"
import { BoardCoordinateHelper } from "./board-coordinates"
import { BoardFocus, getSelectedConnectionIds, getSelectedItemIds } from "./board-focus"
const CLIPBOARD_EVENTS = ["cut", "copy", "paste"] as const
export type ItemsAndConnections = {
items: Item[]
connections: Connection[]
}
export function augmentWithCRDT(
boardId: Id,
itemsAndConnections: ItemsAndConnections,
crdtStore: CRDTStore,
): ItemsAndConnections {
return {
...itemsAndConnections,
items: crdtStore.augmentItems(boardId, itemsAndConnections.items),
}
}
export function findSelectedItemsAndConnections(currentFocus: BoardFocus, currentBoard: Board): ItemsAndConnections {
const selectedItemIds = getSelectedItemIds(currentFocus)
const selectedConnectionIds = getSelectedConnectionIds(currentFocus)
const items = findItemsRecursively([...selectedItemIds], currentBoard)
const recursiveItemIds = new Set(items.map((i) => i.id))
const connections = currentBoard.connections
.filter((c) => {
if (selectedConnectionIds.has(c.id)) {
return true
}
// Include connections between these items and connections that have one end
// in these items and the other end not connected.
const ids = connectedIds(c)
if (ids.length > 0 && !ids.some((id) => !recursiveItemIds.has(id))) {
return true
}
if (c.containerId && recursiveItemIds.has(c.containerId)) {
return true
}
})
.map((c) => ({
...c,
from: detachEndPointIfItemNotFound(c.from, recursiveItemIds, currentBoard),
to: detachEndPointIfItemNotFound(c.to, recursiveItemIds, currentBoard),
}))
return { items, connections }
}
function detachEndPointIfItemNotFound(ep: ConnectionEndPoint, itemIds: Set<Id>, currentBoard: Board) {
if (isItemEndPoint(ep) && !itemIds.has(getEndPointItemId(ep))) {
const resolved = resolveEndpoint(ep, currentBoard)
return Point(resolved.x, resolved.y)
}
return ep
}
function connectedIds(connection: Connection) {
const endpoints = [connection.to, connection.from]
return endpoints.flatMap((ep) => (isItemEndPoint(ep) ? [getEndPointItemId(ep)] : []))
}
export function makeCopies(
itemsAndConnections: ItemsAndConnections,
xDiff: number,
yDiff: number,
): { toCreate: Item[]; toSelect: Item[]; connections: Connection[] } {
const items = itemsAndConnections.items
const ids = new Set(items.map((i) => i.id))
const contained = items.filter((i) => !!i.containerId && ids.has(i.containerId))
const notContained = items.filter((i) => !contained.some((c) => c.id === i.id))
const oldToNewId: Record<Id, Id> = {}
let toCreate: Item[] = []
// As a side effect makeCopy adds items to toCreate
const toSelect = notContained.map(makeCopy)
// As a result, the top-level (notContained) copied items will be selected
const connections = itemsAndConnections.connections.map((c) => {
return {
...c,
from: translateEndpoint(c.from),
to: translateEndpoint(c.to),
controlPoints: c.controlPoints.map(translateEndpoint) as Point[],
id: uuid.v4(),
containerId: c.containerId && oldToNewId[c.containerId],
}
})
return { toCreate, toSelect, connections }
function translateEndpoint(endPoint: ConnectionEndPoint) {
if (isItemEndPoint(endPoint)) {
const newId = oldToNewId[getEndPointItemId(endPoint)]
if (!newId) {
console.warn(`Target item ${endPoint} not found from pasted contents, assuming it exists on board`)
return endPoint
}
return newId
} else {
return G.add(endPoint, { x: xDiff, y: yDiff })
}
}
function makeCopy(i: Item): Item {
const containerId = i.id
const newContainer = flatCopy(i)
toCreate.push(newContainer)
contained
.filter((ctd) => ctd.containerId === containerId)
.forEach((ctd) => {
makeCopy({ ...ctd, containerId: newContainer.id })
})
return newContainer
}
function flatCopy(i: Item) {
const newId = uuid.v4()
oldToNewId[i.id] = newId
return { ...i, id: newId, x: i.x + xDiff, y: i.y + yDiff }
}
}
export function cutCopyPasteHandler(
board: L.Property<Board>,
crdtStore: CRDTStore,
focus: L.Atom<BoardFocus>,
coordinateHelper: BoardCoordinateHelper,
dispatch: Dispatch,
uploadImageFile: (file: File) => Promise<void>,
) {
const clipboardEventHandler = (e: ClipboardEvent) => {
const currentFocus = focus.get()
const currentBoard = board.get()
switch (e.type) {
case "cut": {
if (currentFocus.status !== "selected") return
const clipboard = augmentWithCRDT(
board.get().id,
findSelectedItemsAndConnections(currentFocus, currentBoard),
crdtStore,
)
dispatch({
action: "item.delete",
boardId: currentBoard.id,
itemIds: clipboard.items.map((i) => i.id),
connectionIds: [...getSelectedConnectionIds(currentFocus)],
})
e.clipboardData!.setData("application/rboard", JSON.stringify(clipboard))
e.preventDefault()
break
}
case "copy": {
if (currentFocus.status !== "selected") return
console.log("Copying to clipboard", currentBoard)
const clipboard = augmentWithCRDT(
board.get().id,
findSelectedItemsAndConnections(currentFocus, currentBoard),
crdtStore,
)
e.clipboardData!.setData("application/rboard", JSON.stringify(clipboard))
e.preventDefault()
break
}
case "paste": {
if (e.clipboardData) {
const imageFile = [...e.clipboardData.files].find((file) => file.type.startsWith("image/"))
if (imageFile) {
uploadImageFile(imageFile)
return
}
}
if (currentFocus.status === "editing") return
const rboardData = e.clipboardData?.getData("application/rboard")
if (!rboardData) {
const html = e.clipboardData?.getData("text/html") || e.clipboardData?.getData("text/plain")
if (html) {
const sanitized = sanitizeHTML(html)
const currentCenter = coordinateHelper.currentBoardCoordinates.get()
let toCreate
if (sanitized.length > 50) {
toCreate = [newText(board.get().crdt, sanitized, currentCenter.x, currentCenter.y)]
} else {
toCreate = [newNote(sanitized, DEFAULT_NOTE_COLOR, currentCenter.x, currentCenter.y)]
}
dispatch({ action: "item.add", boardId: currentBoard.id, items: toCreate, connections: [] })
} else if (e.clipboardData) {
console.log(
"Unsupported data from clipboard.",
Object.fromEntries(e.clipboardData.types.map((t) => [t, e.clipboardData?.getData(t)])),
)
}
} else {
const clipboard = JSON.parse(rboardData) as ItemsAndConnections
if (!("items" in clipboard)) {
console.warn("Unexpected clipboard content", clipboard)
return
}
const requiredMargin = BOARD_ITEM_BORDER_MARGIN
const itemRecord = Object.fromEntries(clipboard.items.map((i) => [i.id, i]))
const theItems = [...clipboard.items, ...clipboard.connections.map(connectionRect(itemRecord))]
if (theItems.length === 0) {
console.log("Empty clipboard")
return
}
const xDiffMin = -_.min(theItems.map((i) => i.x))! + requiredMargin
const yDiffMin = -_.min(theItems.map((i) => i.y))! + requiredMargin
const xDiffMax = board.get().width - _.max(theItems.map((i) => i.x + i.width))! - requiredMargin
const yDiffMax = board.get().height - _.max(theItems.map((i) => i.y + i.height))! - requiredMargin
const xCenterOld = _.sum(theItems.map((i) => i.x + i.width / 2)) / theItems.length
const yCenterOld = _.sum(theItems.map((i) => i.y + i.height / 2)) / theItems.length
const currentCenter = coordinateHelper.currentBoardCoordinates.get()
const xDiff = Math.min(Math.max(currentCenter.x - xCenterOld, xDiffMin), xDiffMax)
const yDiff = Math.min(Math.max(currentCenter.y - yCenterOld, yDiffMin), yDiffMax)
const { toCreate, toSelect, connections } = makeCopies(clipboard, xDiff, yDiff)
dispatch({ action: "item.add", boardId: currentBoard.id, items: toCreate, connections })
focus.set({
status: "selected",
itemIds: new Set(toSelect.map((it) => it.id)),
connectionIds: emptySet(),
})
e.preventDefault()
}
break
}
}
}
CLIPBOARD_EVENTS.forEach((eventType) => {
document.addEventListener(eventType, clipboardEventHandler)
})
return () => {
CLIPBOARD_EVENTS.forEach((eventType) => {
document.removeEventListener(eventType, clipboardEventHandler)
})
}
}