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,
+ },
+ },
+});