Page MenuHomeMTRNord's Forge

importActions.ts
No OneTemporary

Authored By
Unknown
Size
23 KB
Referenced Files
None
Subscribers
None

importActions.ts

'use server';
import { AppCardItem, CardItem, Connector, DocumentItem, EmbedItem, FrameItem, ImageItem, Item, ShapeItem, StickyNoteItem, TextItem } from '@mirohq/miro-api';
import initMiroAPI from '../utils/initMiroAPI';
import { Path, Shape, Slide, Whiteboard, Image, neoboardWhiteboardWidth, neoboardWhiteboardHeight } from './neoboardTypes';
import { stripHtml } from 'string-strip-html';
import { GenericItemCursorPaged } from '@mirohq/miro-api/dist/api';
import { createCanvas } from 'canvas';
export type FormState = {
neoboards: {
id: string;
name?: string;
neoboard: Whiteboard;
}[];
message: string;
url?: string;
}
const mapColorNameToHex = (color: string | undefined): string => {
switch (color) {
case 'light_green':
return '#cee741';
case 'light_yellow':
return '#fef445';
default:
return color ?? '#ffffff';
}
}
/**
* Gets the ID from the URL
*
* A miro looks like this: https://miro.com/app/board/<id>/
*
* @param url The URL to parse
* @returns The ID from the URL
* @throws If the URL is not valid
*/
function parseUrl(url: string): string {
const url_parsed = new URL(url);
const path = url_parsed.pathname.split('/');
// Note that we might have something behind the slash of the board ID or no slash at all at the end
if (path.length < 3) {
throw new Error('Invalid URL');
}
return path[3];
}
export async function importBoard(prevState: FormState, formData: FormData): Promise<FormState> {
const boardIdsRaw = formData.getAll('board');
if (!boardIdsRaw) {
return prevState;
}
const boardIds: string[] = Array.isArray(boardIdsRaw) ? boardIdsRaw as string[] : [boardIdsRaw] as string[];
if (formData.has("url")) {
const url = formData.get("url") as string;
if (url && url.length > 0) {
const boardId = parseUrl(url);
boardIds.push(boardId);
}
}
// Get board from Miro API
const { miro, userId } = initMiroAPI();
const userIdAwaited = await userId();
// redirect to auth url if user has not authorized the app
if (!userIdAwaited || !(await miro.isAuthorized(userIdAwaited))) {
return {
neoboards: prevState.neoboards,
message: 'User has not authorized the app',
};
}
const api = miro.as(userIdAwaited);
const resultingBoards: {
id: string;
name: string;
neoboard: Whiteboard;
}[] = [];
for (const boardId of boardIds) {
const neoboard: Whiteboard = {
version: 'net.nordeck.whiteboard@v1',
whiteboard: {
slides: [],
}
}
const board = await api.getBoard(boardId as string);
// Get board items and split them into frames and other items
const framesGenerator = await board.getAllItems({ type: 'frame' });
const frames: {
frame: FrameItem;
children: (Item | AppCardItem | CardItem | DocumentItem | EmbedItem | ImageItem | ShapeItem | StickyNoteItem | TextItem)[];
connectors: Connector[];
}[] = [];
for await (const frame of framesGenerator) {
let cursor = undefined;
let children: (Item | AppCardItem | CardItem | DocumentItem | EmbedItem | ImageItem | ShapeItem | StickyNoteItem | TextItem)[] = [];
while (true) {
const response: GenericItemCursorPaged = (await board._api.getItemsWithinFrame(boardId as string, frame.id, {
cursor,
})).body;
children = [...children, ...(response.data ?? [])] as (Item | AppCardItem | CardItem | DocumentItem | EmbedItem | ImageItem | ShapeItem | StickyNoteItem | TextItem)[]
cursor = response.cursor;
const size = response.data?.length || 0;
const total = response.total || 0;
if (!total || !size) {
break;
}
if (!cursor) {
break;
}
}
const connectors: Connector[] = [];
const connectorsRaw = board.getAllConnectors();
for await (const connector of connectorsRaw) {
// Check if the connector has start and end item IDs matching any of the items in the frame
if (connector.startItem && connector.endItem) {
if (children.some(c => c.id === connector.startItem?.id) && children.some(c => c.id === connector.endItem?.id)) {
connectors.push(connector);
}
}
}
frames.push({
frame: frame as FrameItem,
children,
connectors
});
}
// Create slides from frames
for (const frameData of frames) {
// Find items which are within the geometry of the frame. Keep in mind the position of the frame as well.
const slide: Slide = {
elements: [],
};
for (const item of frameData.children) {
switch (item.type) {
case 'shape':
const shape = convertShape(item as ShapeItem);
if (shape) {
slide.elements.push(shape);
}
break;
case 'path':
slide.elements.push(convertPath(item));
break;
case 'image':
const image = await convertImage(item as ImageItem);
if (image) {
slide.elements.push(image.imageElement);
if (!neoboard.whiteboard.files) {
neoboard.whiteboard.files = [];
}
neoboard.whiteboard.files.push(image.file);
}
break;
case 'text':
slide.elements.push(convertText(item as TextItem));
break;
case 'sticky_note':
slide.elements.push(convertStickyNote(item as StickyNoteItem));
break;
default:
console.log('unknown item type', item.type);
}
}
for (const connector of frameData.connectors) {
const startItem = frameData.children.find(c => c.id === connector.startItem?.id);
const endItem = frameData.children.find(c => c.id === connector.endItem?.id);
if (startItem && endItem) {
let startItemX = startItem.position?.x ?? 0;
let startItemY = startItem.position?.y ?? 0;
let endItemX = endItem.position?.x ?? 0;
let endItemY = endItem.position?.y ?? 0;
// Adjust based on the connector item's position which contains a relative offset where x=0% and y=0% equals the top left corner of the item
const startRelativeOffset = connector.startItem?.position;
const endRelativeOffset = connector.endItem?.position;
// Note that the startItemX and startItemY are the center of the item and not the top left corner
// Same goes for endItemX and endItemY
if (startRelativeOffset && startRelativeOffset.x && startRelativeOffset.y) {
// We need to remove the % sign from the string and parse it as a number
const offsetXNumber = parseFloat(startRelativeOffset.x.replace('%', ''));
const offsetYNumber = parseFloat(startRelativeOffset.y.replace('%', ''));
// Note that startItemX and startItemY are the center of the item and not the top left corner so offset x of 50% is no change to the x position and not half of the width
// 1. Move the x corrdinate to the top left corner by subtracting half of the width
// 2. Add the offset in percentage
startItemX = (startItemX - (startItem.geometry?.width ?? 1) / 2) + (offsetXNumber / 100) * (startItem.geometry?.width ?? 1);
startItemY = (startItemY - (startItem.geometry?.height ?? 1) / 2) + (offsetYNumber / 100) * (startItem.geometry?.height ?? 1);
}
if (endRelativeOffset && endRelativeOffset.x && endRelativeOffset.y) {
// We need to remove the % sign from the string and parse it as a number
const offsetXNumber = parseFloat(endRelativeOffset.x.replace('%', ''));
const offsetYNumber = parseFloat(endRelativeOffset.y.replace('%', ''));
// Note that endItemX and endItemY are the center of the item and not the top left corner so offset x of 50% is no change to the x position and not half of the width
// 1. Move the x corrdinate to the top left corner by subtracting half of the width
// 2. Add the offset in percentage
endItemX = (endItemX - (endItem.geometry?.width ?? 1) / 2) + (offsetXNumber / 100) * (endItem.geometry?.width ?? 1);
endItemY = (endItemY - (endItem.geometry?.height ?? 1) / 2) + (offsetYNumber / 100) * (endItem.geometry?.height ?? 1);
}
const points = [
{
x: startItemX,
y: startItemY,
},
{
x: endItemX,
y: endItemY,
}
];
const path: Path = {
type: 'path',
kind: 'line',
position: {
x: 0,
y: 0,
},
points: points,
strokeColor: connector.style?.color ?? '#1a1a1a',
endMarker: connector.style?.endStrokeCap === 'arrow' ? 'arrow-head-line' : undefined
};
slide.elements.push(path);
}
}
neoboard.whiteboard.slides.push(slide);
}
// Transform the board into neoboard coordinates
const fittedNeoboard = fitItemsBestIntoFrame(neoboard);
resultingBoards.push({ id: boardId, neoboard: fittedNeoboard, name: board.name });
}
return {
neoboards: [...prevState.neoboards, ...resultingBoards],
message: ''
}
}
/**
* The transform doesn't perfectly use the available space in the frame. We need to fit the items into the frame's maximum size.
*
* The algorithm should:
* 1. Find the bounding box of all the items in the slide
* 2. Scale all the items to fit within the bounding box which is the maximum size of the frame minus some padding
* 3. Align the items top left in the neoboard frame
* 4. Return the slides with the transformed items
*
* Note that the points of a path are relative to the path position and not the frame position.
* Note that that all the coordinates coming in are in the miro coordinate system which is likely larger than the neoboard frame.
* Note that the miro coordinate system is top left based and the neoboard frame is too.
* Note that the coordinates in miro are the center of the item and not a corner.
*
* @param board The board with items which should be fitted into the frame's maximum size
* @returns The board with the items transformed to fit into the frame
*/
function fitItemsBestIntoFrame(board: Whiteboard): Whiteboard {
const neoboardPadding = 10;
const transformedBoard: Whiteboard = {
version: board.version,
whiteboard: {
slides: [],
}
};
for (const slide of board.whiteboard.slides) {
const transformedSlide: Slide = {
elements: [],
};
// Find the bounding box of all the items in the slide
let minX = Number.MAX_VALUE;
let minY = Number.MAX_VALUE;
let maxX = Number.MIN_VALUE;
let maxY = Number.MIN_VALUE;
for (const item of slide.elements) {
// @ts-expect-error - Internal type for scaling used for text shapes
if (item.type === 'shape' || item.type === 'image' || item.type === 'textshape') {
const shape = item as Shape;
// Make sure that we deal with x and y being in the center of the shape and not the corner.
// This means the bounds of the shape itself are x - width / 2, y - height / 2, x + width / 2, y + height / 2
minX = Math.min(minX, shape.position.x - shape.width / 2);
minY = Math.min(minY, shape.position.y - shape.height / 2);
maxX = Math.max(maxX, shape.position.x + shape.width / 2);
maxY = Math.max(maxY, shape.position.y + shape.height / 2);
} else if (item.type === 'path') {
// We know that our paths are always relative to 0,0 as we set the position to 0,0
const path = item as Path;
for (const point of path.points) {
minX = Math.min(minX, point.x);
minY = Math.min(minY, point.y);
maxX = Math.max(maxX, point.x);
maxY = Math.max(maxY, point.y);
}
}
}
// Scale all the items to fit within the bounding box which is the maximum size of the frame minus some padding
const boundingBoxWidth = maxX - minX;
const boundingBoxHeight = maxY - minY;
const scale = Math.min((neoboardWhiteboardWidth - neoboardPadding) / boundingBoxWidth, (neoboardWhiteboardHeight - neoboardPadding) / boundingBoxHeight);
for (const item of slide.elements) {
if (item.type === 'shape' || item.type === 'image') {
const shape = item;
// Scale the font size as well
if (shape.type === 'shape') {
if (shape.textSize) {
shape.textSize = shape.textSize * scale;
}
}
// Ensure we account for the fact that our coordinates are in the center of the shape currently but we require them to be at the top left
// This means we need to substract half of the width from the x and substract half of the height to the y
const transformedShape = {
...shape,
position: {
x: (shape.position.x - minX - shape.width / 2) * scale + neoboardPadding,
y: (shape.position.y - minY - shape.height / 2) * scale + neoboardPadding,
},
width: shape.width * scale,
height: shape.height * scale,
};
transformedSlide.elements.push(transformedShape);
} else if (item.type === 'path') {
// We want to keep the path set as 0,0 and only scale the points as they are relative to the path position
const path = item as Path;
const transformedPath = {
...path,
points: path.points.map(point => ({
x: (point.x - minX) * scale + neoboardPadding,
y: (point.y - minY) * scale + neoboardPadding,
}))
};
transformedSlide.elements.push(transformedPath);
// @ts-expect-error - Internal type for scaling used for text shapes
} else if (item.type === 'textshape') {
const shape = item as Shape;
if (shape.textSize) {
shape.textSize = shape.textSize * scale;
}
const newWidth = shape.width * scale;
// Recalculate the bounding box of the text as the font and width are changed
const textBounds = calculateTextBoundingBox(shape.text, shape.textSize ?? 16, "Inter", newWidth);
const transformedShape = {
...shape,
type: 'shape' as const,
position: {
x: (shape.position.x - minX - shape.width / 2) * scale + neoboardPadding,
y: (shape.position.y - minY - shape.height / 2) * scale + neoboardPadding,
},
width: textBounds.width,
height: textBounds.height,
};
transformedSlide.elements.push(transformedShape);
}
}
transformedBoard.whiteboard.slides.push(transformedSlide);
}
return transformedBoard;
}
function hexToRGB(hex: string, alpha?: number): string {
const r = parseInt(hex.slice(1, 3), 16),
g = parseInt(hex.slice(3, 5), 16),
b = parseInt(hex.slice(5, 7), 16);
if (alpha) {
return "rgba(" + r + ", " + g + ", " + b + ", " + alpha + ")";
} else {
return "rgb(" + r + ", " + g + ", " + b + ")";
}
}
function convertShape(item: ShapeItem): Shape | undefined {
let shapeType = item.data?.shape;
let borderRadius = 0;
if (shapeType !== 'rectangle' && shapeType !== 'circle' && shapeType !== 'ellipse' && shapeType !== 'triangle' && shapeType !== 'round_rectangle') {
console.log('unknown shape type', shapeType);
return undefined;
}
if (shapeType === 'round_rectangle') {
shapeType = 'rectangle';
borderRadius = 25;
}
const neoboardPadding = 5;
const fillColor = item.style?.fillColor ? mapColorNameToHex(item.style.fillColor) : "#ffffff";
const opacity = parseFloat(item.style?.fillOpacity ?? (fillColor ? "1.0" : "0.0"));
const borderColorOpacity = parseFloat(item.style?.borderOpacity ?? "1.0");
return {
type: 'shape',
kind: shapeType as "rectangle" | "circle" | "ellipse" | "triangle",
position: {
x: item.position?.x ?? 0,
y: item.position?.y ?? 0,
},
width: (item.geometry?.width ?? 1) + neoboardPadding,
height: (item.geometry?.height ?? 1) + neoboardPadding,
fillColor: opacity > 0 ? hexToRGB(fillColor, opacity) : "transparent",
strokeColor: borderColorOpacity > 0 ? hexToRGB((item.style?.borderColor ?? '#1a1a1a'), opacity) : "transparent",
strokeWidth: parseFloat(item.style?.borderWidth ?? "2.0"),
borderRadius: borderRadius,
text: stripHtml(item.data?.content ?? "").result,
textAlignment: (item.style?.textAlign ?? "left") as "left" | "center" | "right",
textColor: item.style?.color,
textSize: parseInt(item.style?.fontSize ?? "16"),
// TODO: Revisit this when neoboard can do html or inline styles
textBold: false,
textItalic: false,
};
}
// TODO: Is this the right way to handle paths?
function convertPath(item: Item): Path {
return {
type: 'path',
kind: 'line',
position: {
x: item.position?.x ?? 0,
y: item.position?.y ?? 0,
},
points: [],
strokeColor: "#000000",
endMarker: 'arrow-head-line',
};
}
async function convertImage(item: ImageItem): Promise<{
imageElement: Image; file: {
mxc: string;
data: string;
};
} | undefined> {
const uuid = item.id;
if (!item.data?.imageUrl) {
return undefined;
}
// Download the image and convert it to base64
const file = await fetch(item.data?.imageUrl);
if (!file.ok) {
return undefined
};
const fileBuffer = await file.arrayBuffer();
const fileBase64 = Buffer.from(fileBuffer).toString('base64');
return {
imageElement: {
type: 'image',
mxc: `mxc://example.com/${uuid}`,
fileName: item.data?.title ?? uuid,
position: {
x: item.position?.x ?? 0,
y: item.position?.y ?? 0,
},
width: item.geometry?.width ?? 1,
height: item.geometry?.height ?? 1,
},
file: {
mxc: `mxc://example.com/${uuid}`,
data: fileBase64,
}
};
}
// Calculate the bounding box of the text where only font size and one side of the box is known.
// We expect to see multiline text here.
// Use canvas to calculate the bounding box of the text
// We use node-canvas as this is a server-side function
function calculateTextBoundingBox(text: string, fontSize: number, fontFamily: string, width: number): { width: number, height: number } {
const canvas = createCanvas(1, 1);
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Failed to get 2D context');
}
const neoboardPadding = 10;
const maxWidth = width - neoboardPadding * 2;
context.font = `${fontSize}px ${fontFamily}`;
const words = text.split(' ');
let line = '';
let totalHeight = 0;
const lineHeight = fontSize * 1.2; // Assuming line height is 1.2 times the font size
for (let n = 0; n < words.length; n++) {
const testLine = line + words[n] + ' ';
const metrics = context.measureText(testLine);
const testWidth = metrics.width;
if (testWidth > maxWidth && n > 0) {
totalHeight += lineHeight;
line = words[n] + ' ';
} else {
line = testLine;
}
}
totalHeight += lineHeight; // Add height for the last line
// Add padding to the height
totalHeight += neoboardPadding * 2;
return { width, height: totalHeight };
}
function convertText(item: TextItem): Shape {
const text = stripHtml(item.data?.content ?? "").result;
const textBounds = calculateTextBoundingBox(text, parseInt(item.style?.fontSize ?? "16"), "Inter", item.geometry?.width ?? 16);
const neoboardPadding = 5;
const fillOpacity = parseFloat(item.style?.fillOpacity ?? "1.0");
const fillColor = fillOpacity > 0 ? hexToRGB(mapColorNameToHex(item.style?.fillColor ?? '#ffffff'), fillOpacity) : "transparent";
return {
// @ts-expect-error - Internal type for scaling
type: 'textshape',
kind: 'rectangle',
position: {
x: item.position?.x ?? 0,
y: item.position?.y ?? 0,
},
width: textBounds.width + neoboardPadding,
height: textBounds.height + neoboardPadding,
fillColor: fillColor,
fillOpacity: fillOpacity,
strokeColor: "transparent",
strokeWidth: 0,
borderRadius: 0,
text: text,
textAlignment: (item.style?.textAlign ?? "left") as "left" | "center" | "right",
textColor: item.style?.color,
textSize: parseInt(item.style?.fontSize ?? "16"),
// TODO: Revisit this when neoboard can do html or inline styles
textBold: false,
textItalic: false,
};
}
// Make sticky notes a rounded rectangle
function convertStickyNote(item: StickyNoteItem): Shape {
const fillColor = item.style?.fillColor ? mapColorNameToHex(item.style.fillColor) : "#ffffff";
return {
type: 'shape',
kind: 'rectangle',
position: {
x: item.position?.x ?? 0,
y: item.position?.y ?? 0,
},
width: item.geometry?.width ?? 1,
// We remove the shadow size from the height as miro adds the shadow to the height
height: (item.geometry?.height ?? 1) - 43,
fillColor: fillColor ?? "#ffffff",
strokeColor: 'transparent',
strokeWidth: 0,
borderRadius: 5,
text: stripHtml(item.data?.content ?? "").result,
textAlignment: (item.style?.textAlign ?? "left") as "left" | "center" | "right",
textColor: 'black',
}
}

File Metadata

Mime Type
application/javascript
Expires
Fri, Mar 20, 4:55 PM (1 d, 19 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
20
Default Alt Text
importActions.ts (23 KB)

Event Timeline