diff --git a/examples/sticky-pair-clusterer/README.md b/examples/sticky-pair-clusterer/README.md new file mode 100644 index 000000000..85a76818b --- /dev/null +++ b/examples/sticky-pair-clusterer/README.md @@ -0,0 +1,91 @@ +# Miro Template Builder App + +This app shows how you can create a template with shape and text items on the board. + +# 👨🏻‍💻 App Demo + +https://github.com/miroapp/app-examples/assets/10428517/24aacae3-5183-4142-bdae-9cbfeff06a69 + +# 📒 Table of Contents + +- [Included Features](#features) +- [Tools and Technologies](#tools) +- [Prerequisites](#prerequisites) +- [Associated Developer Tutorial](#tutorial) +- [Run the app locally](#run) +- [Folder Structure](#folder) +- [Contributing](#contributing) +- [License](#license) + +# ⚙️ Included Features + +- [Miro Web SDK](https://developers.miro.com/docs/web-sdk-reference) + - [miro.board.createText()](https://developers.miro.com/docs/board_board#createtext) + - [miro.board.createShape()](https://developers.miro.com/docs/board_board#createshape) + - [miro.board.ui.openPanel()](https://developers.miro.com/docs/ui_boardui#openpanel) + +# 🛠️ Tools and Technologies + +- [Vite](https://vitejs.dev/) + +# ✅ Prerequisites + +- You have a [Miro account](https://miro.com/signup/). +- You're [signed in to Miro](https://miro.com/login/). +- Your Miro account has a [Developer team](https://developers.miro.com/docs/create-a-developer-team). +- Your development environment includes [Node.js 14.13](https://nodejs.org/en/download) or a later version. +- All examples use `npm` as a package manager and `npx` as a package runner. + +# 🏃🏽‍♂️ Run the app locally + +1. Run `npm install` to install dependencies. +2. Run `npm start` to start developing. \ + Your URL should be similar to this example: + ``` + http://localhost:3000 + ``` +3. Open the [app manifest editor](https://developers.miro.com/docs/manually-create-an-app#step-2-configure-your-app-in-miro) by clicking **Edit in Manifest**. \ + In the app manifest editor, configure the app as follows: + +```yaml +# See https://developers.miro.com/docs/app-manifest on how to use this +appName: Template Builder +sdkVersion: SDK_V2 +sdkUri: http://localhost:3000 +scopes: + - boards:read + - boards:write +``` + +4. Go back to your app home page, and under the `Permissions` section, you will see a blue button that says `Install app and get OAuth token`. Click that button. Then click on `Add` as shown in the video below. In the video we install a different app, but the process is the same regardless of the app. + +> ⚠️ We recommend to install your app on a [developer team](https://developers.miro.com/docs/create-a-developer-team) while you are developing or testing apps.⚠️ + +https://github.com/miroapp/app-examples/assets/10428517/1e6862de-8617-46ef-b265-97ff1cbfe8bf + +5. Go to your developer team, and open your boards. +6. Click on the plus icon from the bottom section of your left sidebar. If you hover over it, it will say `More apps`. +7. Search for your app `Template Builder` or whatever you chose to name it. Click on your app to use it, as shown in the video below. In the video we search for a different app, but the process is the same regardless of the app. + +https://github.com/horeaporutiu/app-examples-template/assets/10428517/b23d9c4c-e785-43f9-a72e-fa5d82c7b019 + +# 🗂️ Folder structure + +``` +. +├── src +│ ├── assets +│ │ └── style.css +│ ├── app.js // The code for the app lives here. +│ └── index.js // The code for the app entry point lives here. +├── app.html // The app itself. It's loaded on the board inside the 'appContainer'. +└── index.html // The app entry point. This is the value you assign to 'sdkUri' in the app manifest file. +``` + +# 🫱🏻‍🫲🏽 Contributing + +If you want to contribute to this example, or any other Miro Open Source project, please review [Miro's contributing guide](https://github.com/miroapp/app-examples/blob/main/CONTRIBUTING.md). + +# 🪪 License + +[MIT License](https://github.com/miroapp/app-examples/blob/main/LICENSE). diff --git a/examples/sticky-pair-clusterer/app-manifest.yaml b/examples/sticky-pair-clusterer/app-manifest.yaml new file mode 100644 index 000000000..e5baa531b --- /dev/null +++ b/examples/sticky-pair-clusterer/app-manifest.yaml @@ -0,0 +1,7 @@ +# See https://developers.miro.com/docs/app-manifest on how to use this +#appName: Template Builder +appName: Sticky notes to shapes +sdkUri: "http://localhost:3000" +scopes: + - boards:read + - boards:write diff --git a/examples/sticky-pair-clusterer/app.html b/examples/sticky-pair-clusterer/app.html new file mode 100644 index 000000000..8b14b984a --- /dev/null +++ b/examples/sticky-pair-clusterer/app.html @@ -0,0 +1,62 @@ + + + + + + + + +
+
+ + +
+
+ + +
+
+ + + + + + + + +
+
+
+ +
+ + diff --git a/examples/sticky-pair-clusterer/index.html b/examples/sticky-pair-clusterer/index.html new file mode 100644 index 000000000..54bad6699 --- /dev/null +++ b/examples/sticky-pair-clusterer/index.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/examples/sticky-pair-clusterer/jsconfig.json b/examples/sticky-pair-clusterer/jsconfig.json new file mode 100644 index 000000000..d23878618 --- /dev/null +++ b/examples/sticky-pair-clusterer/jsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "typeRoots": ["./node_modules/@types", "./node_modules/@mirohq"] + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/examples/sticky-pair-clusterer/package.json b/examples/sticky-pair-clusterer/package.json new file mode 100644 index 000000000..141d17033 --- /dev/null +++ b/examples/sticky-pair-clusterer/package.json @@ -0,0 +1,15 @@ +{ + "name": "sticky-pair-clusterer", + "description": "Create a template programmatically with the Miro Web SDK. This app uses Vite.", + "keywords": ["Miro SDK", "Vite"], + "version": "0.0.0", + "scripts": { + "start": "vite", + "build": "vite build", + "serve": "vite preview" + }, + "devDependencies": { + "@mirohq/websdk-types": "latest", + "vite": "2.9.17" + } +} diff --git a/examples/sticky-pair-clusterer/src/app.js b/examples/sticky-pair-clusterer/src/app.js new file mode 100644 index 000000000..acbb634d4 --- /dev/null +++ b/examples/sticky-pair-clusterer/src/app.js @@ -0,0 +1,944 @@ +/** + * Feature List: + * ------------- + * 1. Matrix Creation and Management + * - Create matrix with specified rows and columns + * - Set and update cell values + * - Link columns to frames + * - Map rows to sticky notes + * + * 2. UI Interactions + * - Detect selected sticky note and double its size + * - Detect deselected sticky note and change its size to normal + * - Detect content changes in sticky notes and update correspoding cell values and sticky notes that their content should be the same + * - Detect selected sticky note and double the size of its corresponding sticky notes in all other frames + * - Detect deselected sticky note and change the updated sticky notes to their normal size + * + * 3. Benefit-Trait Matrix Generation + * - Create benefit-trait matrix with specified dimensions + * - Calculate and populate matrix values + * + * 4. Visualization + * - Create visual representation of matrix on Miro board + * - Position squares within frames + * + * 5. Debug and Testing + * - Debug button to test matrix creation + * - Console logging for various operations + * + * 6. Event Handling + * - Listen for selection updates + * - Listen for content updates + * + * TODO: Implement clustering algorithm for sticky note pairs + * TODO: Add functionality to save and load matrix state + * TODO: Implement undo/redo functionality for matrix operations + * Release Tasks: + * 1. Name change. + * 2. Icon change. + * 3. Added debug mode in code to visualize some buttons or not. + * 4. Add beautification for the UI. + * 5. Add Adding supoort for benefits and traits. + */ + +const { board } = window.miro; + +// Global definitions +let g_matrix = null; +let g_stickyNoteSize = null; +let g_tagDefinitions = [ + { title: "Very Important", color: "red", id: null, value: 9 }, + { title: "Highly Important", color: "yellow", id: null, value: 3 }, + { title: "Moderately Important", color: "light_green", id: null, value: 0 }, + { title: "Low Importance", color: "cyan", id: null, value: -1 }, + { title: "Not Important", color: "blue", id: null, value: -3 }, +]; + +// app setup on load functions should be called in this order +async function init() { + g_stickyNoteSize = await board.storage + .collection("benefitTraitMatrix") + .get("effectiveSquareSize"); + console.log("line no. 42, g_stickyNoteSize:", g_stickyNoteSize); + loadMatrixFromBoard(); + getSetPredefinedTagsFromBoard(); +} + +init(); + +async function saveMatrixToBoard(matrix) { + const collection = board.storage.collection("benefitTraitMatrix"); + + // Remove the old data + await collection.remove("matrixData"); + + const matrixData = JSON.stringify({ + rows: matrix.rows, + columns: matrix.columns, + data: matrix.matrix, + columnToFrameMap: Object.fromEntries(matrix.columnToFrameMap), + rowToStickyNotesMap: Object.fromEntries(matrix.rowToStickyNotesMap), + rowNames: matrix.rowNames, + columnNames: matrix.columnNames, + }); + + // Calculate and print the size of matrixData + const matrixDataSize = new Blob([matrixData]).size; + console.log(`Size of matrixData: ${matrixDataSize} bytes`); + + if (matrixDataSize > 30 * 1024) { + console.warn( + `Warning: matrixData size (${matrixDataSize} bytes) exceeds the 30KB limit!`, + ); + } + + // Set the new data + await collection.set("matrixData", matrixData); +} + +// Function to load the matrix from the board +async function loadMatrixFromBoard() { + const collection = board.storage.collection("benefitTraitMatrix"); + const storedMatrixData = await collection.get("matrixData"); + + if (storedMatrixData) { + const storedMatrix = JSON.parse(storedMatrixData); + const matrix = new Matrix(storedMatrix.rows, storedMatrix.columns); + matrix.matrix = [...storedMatrix.data]; + matrix.columnToFrameMap = new Map( + Object.entries(storedMatrix.columnToFrameMap), + ); + matrix.rowToStickyNotesMap = new Map( + Object.entries(storedMatrix.rowToStickyNotesMap), + ); + if (storedMatrix.rowNames) { + matrix.rowNames = [...storedMatrix.rowNames]; + } + if (storedMatrix.columnNames) { + matrix.columnNames = [...storedMatrix.columnNames]; + } + g_matrix = matrix; + return matrix; + } + return null; +} + +class MatrixCell { + constructor(stickyNoteId) { + this.stickyNoteId = stickyNoteId; // Reference to a sticky note + this.tagIds = []; + } +} + +class Matrix { + constructor(rows, columns) { + this.rows = rows; // traits + this.columns = columns; // benefits + this.matrix = Array.from({ length: rows }, () => Array(columns).fill(null)); + this.columnToFrameMap = new Map(); // Maps column index to frame ID + this.rowToStickyNotesMap = new Map(); // Maps row index to an array of sticky note IDs + this.rowNames = Array(rows).fill(""); // Array to store row names + this.columnNames = Array(columns).fill(""); // Array to store column names + } + + setCell(row, col, stickyNoteId) { + const cell = new MatrixCell(stickyNoteId); + this.matrix[row][col] = cell; + + // Add sticky note ID to the row map + if (!this.rowToStickyNotesMap.has(row)) { + this.rowToStickyNotesMap.set(row, []); + } + this.rowToStickyNotesMap.get(row).push(stickyNoteId); + } + + linkColumnToFrame(col, frameId) { + this.columnToFrameMap.set(col, frameId); + } + + getStickyNotesForRow(row) { + return this.rowToStickyNotesMap.get(row) || []; + } + + getFrameForColumn(col) { + return this.columnToFrameMap.get(col); + } + // CRUD operations for Matrix + + getCell(row, col) { + return this.matrix[row][col]; + } + + updateCell(row, col, stickyNoteId) { + if (row >= 0 && row < this.rows && col >= 0 && col < this.columns) { + this.setCell(row, col, stickyNoteId); + return true; + } + return false; + } + + addRow() { + this.matrix.push(Array(this.columns).fill(null)); + this.rows++; + } + + removeRow(rowIndex) { + if (rowIndex >= 0 && rowIndex < this.rows) { + this.matrix.splice(rowIndex, 1); + this.rows--; + this.rowToStickyNotesMap.delete(rowIndex); + return true; + } + return false; + } + setRowName(rowIndex, name) { + if (rowIndex >= 0 && rowIndex < this.rows) { + this.rowNames[rowIndex] = name; + return true; + } + return false; + } + + getRowName(rowIndex) { + if (rowIndex >= 0 && rowIndex < this.rows) { + return this.rowNames[rowIndex]; + } + return null; + } + + setColumnName(colIndex, name) { + if (colIndex >= 0 && colIndex < this.columns) { + this.columnNames[colIndex] = name; + return true; + } + return false; + } + + getColumnName(colIndex) { + if (colIndex >= 0 && colIndex < this.columns) { + return this.columnNames[colIndex]; + } + return null; + } + + removeColumn(colIndex) { + if (colIndex >= 0 && colIndex < this.columns) { + for (let i = 0; i < this.rows; i++) { + this.matrix[i].splice(colIndex, 1); + } + this.columns--; + this.columnToFrameMap.delete(colIndex); + return true; + } + return false; + } + updateRowName(rowIndex, newName) { + if (rowIndex >= 0 && rowIndex < this.rows) { + this.rowNames[rowIndex] = newName; + return true; + } + return false; + } + + updateColumnName(colIndex, newName) { + if (colIndex >= 0 && colIndex < this.columns) { + this.columnNames[colIndex] = newName; + return true; + } + return false; + } + + deleteRowName(rowIndex) { + if (rowIndex >= 0 && rowIndex < this.rows) { + this.rowNames[rowIndex] = ""; + return true; + } + return false; + } + + deleteColumnName(colIndex) { + if (colIndex >= 0 && colIndex < this.columns) { + this.columnNames[colIndex] = ""; + return true; + } + return false; + } + + getAllRowNames() { + return this.rowNames ? Object.fromEntries(this.rowNames) : {}; + } + + getAllColumnNames() { + return this.columnNames ? Object.fromEntries(this.columnNames) : {}; + } + + async addColumn() { + const newColumnIndex = this.columns; + this.columns++; + + // Create a new frame for the column + const frame = await board.createFrame({ + title: `Column ${newColumnIndex}`, + width: 1920, // 1080p width + height: 1080, // 1080p height + }); + + // Link the new column to the frame + this.linkColumnToFrame(newColumnIndex, frame.id); + + // Add a new cell to each row and create a sticky note for it + for (let i = 0; i < this.rows; i++) { + if (!this.matrix[i]) { + this.matrix[i] = []; + } + const sticky = await board.createStickyNote({ + content: "", + x: frame.x, + y: frame.y + i * 100, // Adjust vertical position for each sticky note + width: 200, + }); + this.matrix[i][newColumnIndex] = { value: 0, stickyNoteId: sticky.id }; + } + + // Update column names if necessary + if (this.columnNames) { + this.updateColumnName(newColumnIndex, `Column ${newColumnIndex}`); + } + + return newColumnIndex; + } + + // Additional methods to manage the matrix, rows, and columns can be added here + findCellByStickyNoteId(stickyNoteId) { + for (let row = 0; row < this.rows; row++) { + for (let col = 0; col < this.columns; col++) { + const cell = this.matrix[row][col]; + if (cell && cell.stickyNoteId === stickyNoteId) { + return { cell, row, col }; + } + } + } + return null; + } +} + +async function createMatrix() { + console.log("Create Matrix button clicked"); + + // Get the number of rows and columns from the input fields + const rowsCount = parseInt(document.getElementById("rowsCount").value, 10); + const columnsCount = parseInt( + document.getElementById("columnsCount").value, + 10, + ); + + // Validate the input + if ( + isNaN(rowsCount) || + isNaN(columnsCount) || + rowsCount < 1 || + columnsCount < 1 + ) { + alert("Please enter valid numbers for rows and columns."); + return; + } + + g_matrix = new Matrix(rowsCount, columnsCount); + const frameWidth = 1920; + const frameHeight = 1080; + + const squarePositions = calculateBestSquaresInRectangle( + frameWidth, + frameHeight, + rowsCount, + ); + + // Store effectiveSquareSize as a number + g_stickyNoteSize = squarePositions.gridInfo.effectiveSquareSize; + board.storage + .collection("benefitTraitMatrix") + .set("effectiveSquareSize", g_stickyNoteSize); + + // Create frames for each column + for (let j = 0; j < columnsCount; j++) { + let frame; + let attempts = 0; + const MAX_ATTEMPTS = 3; + + while (attempts < MAX_ATTEMPTS) { + try { + frame = await board.createFrame({ + title: `Benefit ${j + 1}`, + width: frameWidth, + height: frameHeight, + x: j * frameWidth, // Offset each frame horizontally + y: 0, + style: { + fillColor: "#ffffff", // Set background color to white + }, + }); + break; // Exit the loop if successful + } catch (error) { + if (error.message.includes("API rate limit")) { + console.error("API rate limit exceeded. Retrying in 10 seconds..."); + await new Promise((resolve) => setTimeout(resolve, 10000)); // Wait for 10 seconds + } else { + throw error; // Rethrow if it's a different error + } + } + } + g_matrix.linkColumnToFrame(j, frame.id); + g_matrix.columnNames[j] = frame.title; + } + + // Create sticky notes for each cell in each column + for (let j = 0; j < columnsCount; j++) { + const frameId = g_matrix.getFrameForColumn(j); + const frame = await board.getById(frameId); + const squarePositions = calculateBestSquaresInRectangle( + frame.width, + frame.height, + rowsCount, + ); + + const frameLeft = frame.x - frame.width / 2; + const frameTop = frame.y - frame.height / 2; + + let stickyNotePromises = []; + for (let i = 0; i < rowsCount; i++) { + const row = Math.floor(i / squarePositions.gridInfo.columns); + const col = i % squarePositions.gridInfo.columns; + const position = squarePositions.placement.getSquarePosition(row, col); + const stickyNotePromise = ((currentI, currentJ) => { + return (async () => { + let sticky; + let attempts = 0; + const MAX_ATTEMPTS = 3; + + while (attempts < MAX_ATTEMPTS) { + try { + sticky = await board.createStickyNote({ + content: `Trait ${currentI + 1}`, + // content: `Cell ${currentI + 1},${currentJ + 1}`, + x: + frameLeft + + position.x + + squarePositions.gridInfo.effectiveSquareSize / 2, + y: + frameTop + + position.y + + squarePositions.gridInfo.effectiveSquareSize / 2, + width: squarePositions.gridInfo.effectiveSquareSize, + style: { + fillColor: "light_yellow", + }, + }); + g_matrix.setCell(currentI, currentJ, sticky.id); + g_matrix.setRowName(currentI, sticky.content); //TODO: move this to outside of the first while loop + return sticky; + } catch (error) { + if (error.message.includes("API rate limit")) { + console.error( + `API rate limit exceeded. Retrying in ${ + 10 * (attempts + 1) + } seconds... (Attempt ${attempts + 1})`, + ); + console.error(`Full error message: ${error.message}`); + await new Promise((resolve) => + setTimeout(resolve, 10000 * (attempts + 1)), + ); + attempts++; // Increment attempts counter + } else { + console.error(`Error processing:`, error); + break; // Exit the retry loop on other errors + } + } + } + + if (attempts >= MAX_ATTEMPTS) { + console.error("Max retry attempts reached. Operation failed."); + } + })(); + })(i, j); + + stickyNotePromises.push(stickyNotePromise); + } + // Wait for all sticky notes to be created + await Promise.all(stickyNotePromises); + } + // Notify user that sticky notes are being added to frames + await board.notifications.showInfo("Attaching sticky notes to frames..."); + // Add all sticky notes to their respective frames + for (let j = 0; j < g_matrix.columns; j++) { + const frameId = g_matrix.getFrameForColumn(j); + if (frameId) { + const frame = await board.getById(frameId); + if (frame) { + for (let i = 0; i < g_matrix.rows; i++) { + let attempts = 0; // Initialize attempts counter + while (attempts < 10) { + // Retry up to 10 times + try { + const cell = g_matrix.getCell(i, j); + if (cell && cell.stickyNoteId) { + const stickyNote = await board.getById(cell.stickyNoteId); + if (stickyNote) { + await frame.add(stickyNote); + } else { + console.error( + `Sticky note with ID ${cell.stickyNoteId} not found for cell (${i}, ${j})`, + ); + } + } + break; // Exit the retry loop if successful + } catch (error) { + if (error.message.includes("API rate limit")) { + console.error( + `API rate limit exceeded. Retrying in ${ + 10 * (attempts + 1) + } seconds... (Attempt ${attempts + 1})`, + ); + console.error(`Full error message: ${error.message}`); + attempts++; // Increment attempts counter + await new Promise( + (resolve) => setTimeout(resolve, 10000 * attempts), // Wait for 10 seconds multiplied by attempts + ); + } else { + console.error(`Error processing cell (${i}, ${j}):`, error); + break; // Exit the retry loop on other errors + } + } + } + } + frame.style = { + fillColor: "#ADD8E6", // Light blue color + }; + await frame.sync(); + } else { + console.error(`Frame with ID ${frameId} not found for column ${j}`); + } + } else { + console.error(`No frame ID found for column ${j}`); + } + } + console.log("Sticky notes attached to frames successfully!"); + await board.notifications.showInfo( + "Sticky notes attached to frames successfully!", + ); + + saveMatrixToBoard(g_matrix); + + console.log("Matrix created and saved:", g_matrix); + // Display message on board, matrix creation completed + await board.notifications.showInfo("Matrix creation completed successfully!"); +} + +// Function to check if content of selected sticky note has changed +// async function checkStickyNoteContentChange(item) { +// if (item.type === "sticky_note") { +// const currentContent = item.content; +// +// if (!item.originalContent) { +// // If it's newly selected, store the original content and print it +// item.originalContent = currentContent; +// console.log(`Sticky note ${item.id} selected. Content:`, currentContent); +// } else { +// // If it's already selected, check for changes +// if (currentContent !== item.originalContent) { +// console.log(`Content changed for sticky note ${item.id}`); +// console.log(`Original: ${item.originalContent}`); +// console.log(`New: ${currentContent}`); +// +// // Update the original content +// item.originalContent = currentContent; +// } +// } +// } +// } + +// Update the selection:update event listener +let previouslySelectedItems = []; +let previouslySelectedContent = { id: null, content: null }; +board.ui.on("selection:update", async (event) => { + if (g_matrix === null) { + await loadMatrixFromBoard(); // todo: either this or loadmatrixfromboard + if (g_matrix === null) { + console.log("Matrix not found"); + return; + } + } + + // Store the previously selected items for comparison + // Get the currently selected items + + // Check if only one item is selected and it's a sticky note + if (event.items.length === 1 && event.items[0].type === "sticky_note") { + const result = g_matrix.findCellByStickyNoteId(event.items[0].id); + if (null == result) { + return; + } + // Double the size of all sticky notes in the same row + for (let col = 0; col < g_matrix.columns; col++) { + const cell = g_matrix.matrix[result.row][col]; + if (cell && cell.stickyNoteId) { + let stickyNote = await board.getById(cell.stickyNoteId); + // Add the sticky note to the previously selected items + if (stickyNote) { + previouslySelectedItems.push(stickyNote.id); + stickyNote.width = g_stickyNoteSize * 2; + // Bring the sticky note to the front + stickyNote.bringToFront(); + stickyNote.sync(); + } + } + } + previouslySelectedContent = { + id: event.items[0].id, + content: event.items[0].content, + }; + } else if (event.items.length === 0 && previouslySelectedItems.length > 0) { + // User has deselected everything + // Restore the size of all previously selected sticky notes + for (const prevSelectedItemId of previouslySelectedItems) { + const prevSelectedItem = await board.getById(prevSelectedItemId); + if (prevSelectedItem) { + prevSelectedItem.width = g_stickyNoteSize; + await prevSelectedItem.sync(); + // potentially the tags have changed, so we need to update the tags for the cell + const result = g_matrix.findCellByStickyNoteId(prevSelectedItemId); + if (result) { + updateCellTags(result.row, result.col, prevSelectedItem.tagIds); + } + } + } + // Check if the content of the previously selected sticky note has changed + // safa + if ( + previouslySelectedContent !== null && + previouslySelectedContent.content !== + (await board.getById(previouslySelectedContent.id)).content + ) { + const result = g_matrix.findCellByStickyNoteId( + previouslySelectedContent.id, + ); + if (result) { + const newContent = (await board.getById(previouslySelectedContent.id)) + .content; + g_matrix.rowNames[result.row] = newContent; + console.log("g_matrix.rowNames:", g_matrix.rowNames); + for (let col = 0; col < g_matrix.columns; col++) { + const cell = g_matrix.matrix[result.row][col]; + if (cell && cell.stickyNoteId) { + let stickyNote = await board.getById(cell.stickyNoteId); + if (stickyNote) { + stickyNote.content = newContent; + stickyNote.sync(); + } + } + } + } + } + // Clear the previouslySelectedItems array + previouslySelectedItems = []; + } else { + // User has selected something else (multiple items or non-sticky note) + // Restore the size of all previously selected sticky notes + for (const prevSelectedItemId of previouslySelectedItems) { + const prevSelectedItem = await board.getById(prevSelectedItemId); + if (prevSelectedItem) { + prevSelectedItem.width = g_stickyNoteSize; + await prevSelectedItem.sync(); + } + } + // Clear the previouslySelectedItems array + previouslySelectedItems = []; + } + saveMatrixToBoard(g_matrix); +}); + +async function assignRandomTagsToSelection() { + console.log("Assign Random Tags To Selection button clicked"); + g_tagDefinitions = await getSetPredefinedTagsFromBoard(); + + async function assignRandomTags() { + // Get currently selected items + const selectedItems = await board.getSelection(); + const stickyNotes = selectedItems.filter( + (item) => item.type === "sticky_note", + ); + + // Randomly assign or not assign tags to selected sticky notes + const chanceToNotAssign = 1 / (g_tagDefinitions.length + 1); + for (const stickyNote of stickyNotes) { + if (Math.random() >= chanceToNotAssign) { + const randomTag = + g_tagDefinitions[Math.floor(Math.random() * g_tagDefinitions.length)]; + stickyNote.tagIds = [randomTag.id]; + stickyNote.sync(); + const result = g_matrix.findCellByStickyNoteId(stickyNote.id); + if (result) { + updateCellTags(result.row, result.col, stickyNote.tagIds); + console.log( + `Updated tagIds for cell at row ${result.row}, col ${result.col}:`, + stickyNote.tagIds, + ); + } + } + } + } + + // Call the function to update matrix tags + await assignRandomTags(); + console.log("line no. 601, assigned random tags"); + console.log("line no. 604, g_matrix:", g_matrix); +} + +async function getSetPredefinedTagsFromBoard() { + // Define tagDefinitions if not already defined + + // Fetch all tags from the board + const boardTags = await board.get({ type: ["tag"] }); + + // Create a map for quick lookup + const boardTagMap = new Map(boardTags.map((tag) => [tag.title, tag])); + + // Update or create tags + for (const tagDef of g_tagDefinitions) { + const existingTag = boardTagMap.get(tagDef.title); + if (existingTag) { + tagDef.id = existingTag.id; + } else { + try { + const newTag = await board.createTag({ + title: tagDef.title, + color: tagDef.color, + }); + tagDef.id = newTag.id; + } catch (error) { + console.error(`Error creating tag "${tagDef.title}":`, error); + } + } + } + return g_tagDefinitions; +} + +function getTagValueByTagId(tagId) { + const tag = g_tagDefinitions.find((t) => t.id === tagId); + return tag ? tag.value : null; // Return null if the tag is not found +} + +function updateCellTags(row, col, newTagIds) { + if (g_matrix && g_matrix.matrix[row][col]) { + g_matrix.matrix[row][col].tagIds = newTagIds; + } else { + console.error( + `Cell at row ${row}, col ${col} is not a MatrixCell instance`, + ); + } +} + +function calculateBestSquaresInRectangle( + rectangleWidth, + rectangleHeight, + numSquares, + margin = 0.1, +) { + // Compute ratio of the rectangle + var ratio = rectangleWidth / rectangleHeight; + + // Initial estimates for number of columns and rows + var ncols_float = Math.sqrt(numSquares * ratio); + var nrows_float = numSquares / ncols_float; + + // Find the best option for filling the whole height + var nrows1 = Math.ceil(nrows_float); + var ncols1 = Math.ceil(numSquares / nrows1); + while (nrows1 * ratio < ncols1) { + nrows1++; + ncols1 = Math.ceil(numSquares / nrows1); + } + var cell_size1 = rectangleHeight / nrows1; + + // Find the best option for filling the whole width + var ncols2 = Math.ceil(ncols_float); + var nrows2 = Math.ceil(numSquares / ncols2); + while (ncols2 < nrows2 * ratio) { + ncols2++; + nrows2 = Math.ceil(numSquares / ncols2); + } + var cell_size2 = rectangleWidth / ncols2; + + // Determine the best configuration + var nrows, ncols, cell_size; + if (cell_size1 < cell_size2) { + nrows = nrows2; + ncols = ncols2; + cell_size = cell_size2; + } else { + nrows = nrows1; + ncols = ncols1; + cell_size = cell_size1; + } + + // Calculate effective cell size including margin + const effectiveCellSize = cell_size * (1 - margin); + + // Calculate usable area within the rectangle + const usableWidth = ncols * effectiveCellSize; + const usableHeight = nrows * effectiveCellSize; + + // Calculate offset to center the grid in the rectangle + const offsetX = (rectangleWidth - usableWidth) / 2; + const offsetY = (rectangleHeight - usableHeight) / 2; + + return { + gridInfo: { + columns: ncols, + rows: nrows, + squareSize: cell_size, + margin: margin, + effectiveSquareSize: effectiveCellSize, + }, + rectangleInfo: { + width: rectangleWidth, + height: rectangleHeight, + usableWidth: usableWidth, + usableHeight: usableHeight, + }, + placement: { + offsetX: offsetX, + offsetY: offsetY, + getSquarePosition: (row, col) => ({ + x: offsetX + col * effectiveCellSize, + y: offsetY + row * effectiveCellSize, + }), + }, + totalSquares: nrows * ncols, + }; +} + +async function removeAllTags() { + console.log("Remove All Tags button clicked"); + const allTags = await board.get({ type: ["tag"] }); + for (const tag of allTags) { + await board.remove(tag); + console.log(`Removed tag: ${tag.title}`); + } + await board.notifications.showInfo(`Removed all tags from board`); +} + +// Add event listener for the "Remove All Tags" button +document + .getElementById("removeAllTags") + .addEventListener("click", removeAllTags); + +document.getElementById("createMatrix").addEventListener("click", createMatrix); +document + .getElementById("assignRandomTagsToSelection") + .addEventListener("click", assignRandomTagsToSelection); +document.getElementById("printAppData").addEventListener("click", async () => { + const frames = await board.get({ type: ["frame"] }); + for (const frame of frames) { + console.log("Frame title:", frame.title); + } + + console.log("Print App Data button clicked"); + + // Load the matrix from the board + const matrix = await loadMatrixFromBoard(); + if (!matrix) { + console.error("Matrix not found in board data."); + return; + } + console.log("g_matrix:", g_matrix); + // console.log("Matrix:", matrix); + + // Notify user that app data has been printed + await board.notifications.showInfo("App data printed to console."); +}); + +document + .getElementById("copyToClipboard") + .addEventListener("click", async () => { + // Notify user that app data has been copied to clipboard + const timestamp = new Date().toISOString(); + await navigator.clipboard.writeText(timestamp); + await board.notifications.showInfo("App data copied to clipboard."); + + const stickyNoteContent = JSON.stringify(g_matrix, null, 2); + + const stickyNote = await board.createStickyNote({ + content: stickyNoteContent, + x: 100, + y: 100, + width: 600, + }); + await board.viewport.zoomToObject(stickyNote); + + console.log("Created new sticky note with matrix content:", stickyNote); + }); + +document.getElementById("debugButton").addEventListener("click", () => { + console.log("Debug button clicked"); + generateTableFromTags(); +}); + +function generateTableFromTags( + rowNames = g_matrix.rowNames, + columnNames = g_matrix.columnNames, +) { + const rows = rowNames.length; + const columns = columnNames.length; + const matrix = Array.from({ length: rows + 1 }, () => + Array(columns + 1).fill(0), + ); + + // Set the first row with column names + for (let col = 1; col <= columns; col++) { + matrix[0][col] = columnNames[col - 1]; + } + + // Set the first column with row names + for (let row = 1; row <= rows; row++) { + matrix[row][0] = rowNames[row - 1]; + } + console.log("g_tagDefinitions:", g_tagDefinitions); + for (let row = 0; row < rows; row++) { + for (let col = 0; col < columns; col++) { + const cell = g_matrix.matrix[row][col]; + if (cell) { + console.log(`Processing cell at row ${row}, col ${col}`); + console.log(`Cell tagIds: ${cell.tagIds}`); + + if (cell.tagIds.length > 0) { + const tagValues = cell.tagIds.map((tagId) => { + const tagValue = getTagValueByTagId(tagId); + console.log(`Tag ID: ${tagId}, Tag Value: ${tagValue}`); + return tagValue; + }); + + matrix[row + 1][col + 1] = tagValues.join(", "); + console.log( + `Assigned to matrix[${row + 1}][${col + 1}]: ${ + matrix[row + 1][col + 1] + }`, + ); + } else { + console.log(`No tags found for cell at row ${row}, col ${col}`); + } + } else { + console.log(`No cell found at row ${row}, col ${col}`); + } + } + } + + // Convert the matrix to a tab-separated string + const excelFormattedString = matrix.map((row) => row.join("\t")).join("\n"); + console.log("Excel formatted matrix:\n", excelFormattedString); + + // Write the excelFormattedString to clipboard + navigator.clipboard.writeText(excelFormattedString); + board.notifications.showInfo("Matrix data copied to clipboard."); + console.log("Matrix data copied to clipboard"); + return excelFormattedString; +} diff --git a/examples/sticky-pair-clusterer/src/index.js b/examples/sticky-pair-clusterer/src/index.js new file mode 100644 index 000000000..e68777e43 --- /dev/null +++ b/examples/sticky-pair-clusterer/src/index.js @@ -0,0 +1,9 @@ +const { board } = window.miro; + +async function init() { + board.ui.on("icon:click", async () => { + await board.ui.openPanel({ url: "app.html" }); + }); +} + +init(); diff --git a/examples/sticky-pair-clusterer/src/styles.css b/examples/sticky-pair-clusterer/src/styles.css new file mode 100644 index 000000000..a93644213 --- /dev/null +++ b/examples/sticky-pair-clusterer/src/styles.css @@ -0,0 +1,82 @@ +html, +body { + height: 100%; + margin: 0; + padding: 0; + font-family: "Inter", sans-serif; + background-color: #f4f7fa; +} + +.scrollable-container { + height: 100%; + overflow-y: auto; + padding: 30px 23px 20px; + box-sizing: border-box; + background-color: #ffffff; + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.scrollable-content { + height: 2000px; + background-color: #2a79ff; +} + +.input-container { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.input-container label { + min-width: 120px; /* Adjust width to ensure enough space for alignment */ + text-align: left; +} + +.input-container input { + width: 80px; + padding: 5px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; + box-sizing: border-box; + height: 30px; + text-align: right; +} + +button { + display: inline-block; + width: 200px; + background: none; + border: 1px solid #4262ff; + color: #4262ff; + height: 40px; /* Increased height for larger buttons */ + box-sizing: border-box; + border-radius: 4px; + text-align: center; + font-size: 14px; + cursor: pointer; + vertical-align: middle; + transition: + background-color 0.3s, + color 0.3s; + margin-bottom: 10px; /* Added margin for spacing between buttons */ +} + +button:hover { + background-color: #4262ff; + color: #ffffff; +} + +button:active { + background-color: #2a79ff; + color: #ffffff; +} + +.input-button-container { + display: flex; + align-items: center; + margin-bottom: 20px; + gap: 10px; /* Added gap for spacing between inputs and buttons */ +} diff --git a/examples/sticky-pair-clusterer/vite.config.js b/examples/sticky-pair-clusterer/vite.config.js new file mode 100644 index 000000000..1a0e09fef --- /dev/null +++ b/examples/sticky-pair-clusterer/vite.config.js @@ -0,0 +1,21 @@ +const path = require("path"); +const fs = require("fs"); +const { defineConfig } = require("vite"); + +// make sure vite picks up all html files in root +const allHtmlEntries = fs + .readdirSync(".") + .filter((file) => path.extname(file) === ".html") + .reduce((acc, file) => { + acc[path.basename(file, ".html")] = path.resolve(__dirname, file); + + return acc; + }, {}); + +module.exports = defineConfig({ + build: { + rollupOptions: { + input: allHtmlEntries, + }, + }, +});