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