From 8d67198aadd81749e95959c70eaea51a74756bf2 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sat, 20 Dec 2025 09:40:25 -0500 Subject: [PATCH 01/20] add grid extension --- .../javascript/rhino/custom-editor.js | 13 ++ .../javascript/rhino/extend-editor.js | 8 +- app/frontend/javascript/rhino/grid/grid.js | 121 ++++++++++++++++++ .../javascript/rhino/grid/gridCell.js | 21 +++ app/frontend/javascript/rhino/grid/gridRow.js | 22 ++++ 5 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 app/frontend/javascript/rhino/grid/grid.js create mode 100644 app/frontend/javascript/rhino/grid/gridCell.js create mode 100644 app/frontend/javascript/rhino/grid/gridRow.js diff --git a/app/frontend/javascript/rhino/custom-editor.js b/app/frontend/javascript/rhino/custom-editor.js index b5e03d6cd..7d2108caf 100644 --- a/app/frontend/javascript/rhino/custom-editor.js +++ b/app/frontend/javascript/rhino/custom-editor.js @@ -135,6 +135,7 @@ class CustomEditor extends TipTapEditor { ${this.renderRedoButton()} + ${this.renderGridButton()} ${this.renderToolbarEnd()} @@ -142,6 +143,18 @@ class CustomEditor extends TipTapEditor { `; } +renderGridButton() { + return html` + + ` +} renderTableButton() { const tableEnabled = true; // Boolean(this.editor?.commands.setAttachment); diff --git a/app/frontend/javascript/rhino/extend-editor.js b/app/frontend/javascript/rhino/extend-editor.js index 3fd6a6b53..22d110b56 100644 --- a/app/frontend/javascript/rhino/extend-editor.js +++ b/app/frontend/javascript/rhino/extend-editor.js @@ -5,6 +5,9 @@ import { TableHeader } from '@tiptap/extension-table-header' import { TableRow } from '@tiptap/extension-table-row' import Youtube from '@tiptap/extension-youtube' import TextAlign from '@tiptap/extension-text-align' +import { Grid } from './grid/grid' +import { GridRow } from './grid/gridRow' +import { GridCell } from './grid/gridCell' function extendRhinoEditor(event) { const rhinoEditor = event.target @@ -18,7 +21,10 @@ function extendRhinoEditor(event) { Youtube.configure({ nocookie: true }), TextAlign.configure({ types: ['heading', 'paragraph'], - }) + }), + Grid, + GridRow, + GridCell ) } diff --git a/app/frontend/javascript/rhino/grid/grid.js b/app/frontend/javascript/rhino/grid/grid.js new file mode 100644 index 000000000..2d3eb294d --- /dev/null +++ b/app/frontend/javascript/rhino/grid/grid.js @@ -0,0 +1,121 @@ +import { Node, mergeAttributes, findParentNodeClosestToPos } from '@tiptap/core' + +// Static mapping for Tailwind classes +const columnClasses = { + 1: 'grid-cols-1', + 2: 'grid-cols-2', + 3: 'grid-cols-3', + 4: 'grid-cols-4', + 5: 'grid-cols-5', + 6: 'grid-cols-6', +} + +export const Grid = Node.create({ + name: 'grid', + group: 'block', + content: 'gridCell+', + isolating: true, + + addAttributes() { + return { + columns: { default: 2 }, + } + }, + + parseHTML() { + return [{ tag: 'div[data-type="grid"]' }] + }, + + renderHTML({ node, HTMLAttributes }) { + const cols = node.attrs.columns + const colsClass = columnClasses[cols] || 'grid-cols-2' + + return [ + 'div', + mergeAttributes(HTMLAttributes, { + 'data-type': 'grid', + class: `grid gap-4 ${colsClass}`, + }), + 0, // render children (gridCell nodes) + ] + }, + + addCommands() { + return { + // Insert a new grid with rows x columns + insertGrid: + (columns = 2, rows = 2) => + ({ commands }) => { + const content = Array.from({ length: rows * columns }).map(() => ({ + type: 'gridCell', + content: [{ type: 'paragraph' }], + })) + + return commands.insertContent({ + type: this.name, + attrs: { columns }, + content, + }) + }, + + // Add a new row + addGridRow: + () => + ({ state, commands }) => { + const { selection } = state + const grid = findParentNodeClosestToPos( + selection.$from, + node => node.type.name === 'grid' + ) + if (!grid) return false + + const columns = grid.node.attrs.columns + const newCells = Array.from({ length: columns }).map(() => ({ + type: 'gridCell', + content: [{ type: 'paragraph' }], + })) + + return commands.insertContentAt( + grid.pos + grid.node.nodeSize - 1, + newCells + ) + }, + + // Add a new column to all rows + addGridColumn: + () => + ({ state, tr, commands }) => { + const { selection, schema } = state + const grid = findParentNodeClosestToPos( + selection.$from, + node => node.type.name === 'grid' + ) + if (!grid) return false + + const { node, pos } = grid + const newColumns = node.attrs.columns + 1 + + // Update the columns attribute + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + columns: newColumns, + }) + + // Append one new cell per row + const totalCells = node.content.size + const oldColumns = node.attrs.columns + const rowCount = Math.ceil(totalCells / oldColumns) + let insertPos = pos + 1 + + for (let i = 0; i < rowCount; i++) { + tr.insert( + insertPos + i * (oldColumns + 1) + oldColumns, + schema.nodes.gridCell.create({}, schema.nodes.paragraph.create()) + ) + } + + return commands.editor?.view?.dispatch(tr) || true + }, + } + }, +}) diff --git a/app/frontend/javascript/rhino/grid/gridCell.js b/app/frontend/javascript/rhino/grid/gridCell.js new file mode 100644 index 000000000..1b7755613 --- /dev/null +++ b/app/frontend/javascript/rhino/grid/gridCell.js @@ -0,0 +1,21 @@ +import { Node, mergeAttributes } from '@tiptap/core' + +export const GridCell = Node.create({ + name: 'gridCell', + content: 'block+', + + parseHTML() { + return [{ tag: 'div[data-type="grid-cell"]' }] + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + mergeAttributes(HTMLAttributes, { + 'data-type': 'grid-cell', + class: 'border border-gray-300 p-3 rounded', + }), + 0, + ] + }, +}) diff --git a/app/frontend/javascript/rhino/grid/gridRow.js b/app/frontend/javascript/rhino/grid/gridRow.js new file mode 100644 index 000000000..a33a94ffc --- /dev/null +++ b/app/frontend/javascript/rhino/grid/gridRow.js @@ -0,0 +1,22 @@ +import { Node, mergeAttributes } from '@tiptap/core' + +export const GridRow = Node.create({ + name: 'gridRow', + content: 'gridCell+', + + parseHTML() { + return [{ tag: 'div[data-type="grid-row"]' }] + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + mergeAttributes(HTMLAttributes, { + 'data-type': 'grid-row', + class: 'contents', // Tailwind sees children directly + style: 'display: contents;', // fallback in case CSS missing + }), + 0, + ] + }, +}) From 5646ab0b96c319acf114f1592d501ec78bc39080 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sat, 20 Dec 2025 09:56:44 -0500 Subject: [PATCH 02/20] add delete grid button --- .../javascript/rhino/custom-editor.js | 45 +++++++++++++------ app/frontend/javascript/rhino/grid/grid.js | 21 ++++++++- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/app/frontend/javascript/rhino/custom-editor.js b/app/frontend/javascript/rhino/custom-editor.js index 7d2108caf..905fef179 100644 --- a/app/frontend/javascript/rhino/custom-editor.js +++ b/app/frontend/javascript/rhino/custom-editor.js @@ -133,9 +133,11 @@ class CustomEditor extends TipTapEditor { ${this.renderRedoButton()} + + ${this.renderDeleteGridButton()} - ${this.renderGridButton()} + ${this.renderAddGridButton()} ${this.renderToolbarEnd()} @@ -143,18 +145,35 @@ class CustomEditor extends TipTapEditor { `; } -renderGridButton() { - return html` - - ` -} + + renderAddGridButton() { + return html` + + ` + } + + renderDeleteGridButton() { + const isGridActive = this.editor?.isActive?.('grid') ?? false + + return html` + + ` + } renderTableButton() { const tableEnabled = true; // Boolean(this.editor?.commands.setAttachment); diff --git a/app/frontend/javascript/rhino/grid/grid.js b/app/frontend/javascript/rhino/grid/grid.js index 2d3eb294d..22be767bd 100644 --- a/app/frontend/javascript/rhino/grid/grid.js +++ b/app/frontend/javascript/rhino/grid/grid.js @@ -44,7 +44,7 @@ export const Grid = Node.create({ return { // Insert a new grid with rows x columns insertGrid: - (columns = 2, rows = 2) => + (columns = 2, rows = 1) => ({ commands }) => { const content = Array.from({ length: rows * columns }).map(() => ({ type: 'gridCell', @@ -116,6 +116,25 @@ export const Grid = Node.create({ return commands.editor?.view?.dispatch(tr) || true }, + + deleteGrid: + () => + ({ state, commands }) => { + const { selection } = state + + const grid = findParentNodeClosestToPos( + selection.$from, + node => node.type.name === 'grid' + ) + + if (!grid) return false + + // Delete the grid node entirely + return commands.deleteRange({ + from: grid.pos, + to: grid.pos + grid.node.nodeSize, + }) + }, } }, }) From f0c2acff7c4adbac19a411c55682b006a0352f24 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sat, 20 Dec 2025 10:06:43 -0500 Subject: [PATCH 03/20] add button for add grid row --- .../javascript/rhino/custom-editor.js | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/app/frontend/javascript/rhino/custom-editor.js b/app/frontend/javascript/rhino/custom-editor.js index 905fef179..a2d8b89d4 100644 --- a/app/frontend/javascript/rhino/custom-editor.js +++ b/app/frontend/javascript/rhino/custom-editor.js @@ -134,10 +134,11 @@ class CustomEditor extends TipTapEditor { ${this.renderRedoButton()} - ${this.renderDeleteGridButton()} ${this.renderAddGridButton()} + ${this.renderDeleteGridButton()} + ${this.renderAddGridRowButton()} ${this.renderToolbarEnd()} @@ -159,15 +160,32 @@ class CustomEditor extends TipTapEditor { ` } - renderDeleteGridButton() { - const isGridActive = this.editor?.isActive?.('grid') ?? false + renderAddGridRowButton() { + if (!this.editor || !this.editor.isActive('grid')) { + return html``; + } return html` + ` + } + + renderDeleteGridButton() { + if (!this.editor || !this.editor.isActive('grid')) { + return html``; + } + return html` + + ` + } + renderDeleteGridButton() { if (!this.editor || !this.editor.isActive('grid')) { return html``; diff --git a/app/frontend/javascript/rhino/grid/grid.js b/app/frontend/javascript/rhino/grid/grid.js index 22be767bd..9782a727d 100644 --- a/app/frontend/javascript/rhino/grid/grid.js +++ b/app/frontend/javascript/rhino/grid/grid.js @@ -1,19 +1,9 @@ import { Node, mergeAttributes, findParentNodeClosestToPos } from '@tiptap/core' -// Static mapping for Tailwind classes -const columnClasses = { - 1: 'grid-cols-1', - 2: 'grid-cols-2', - 3: 'grid-cols-3', - 4: 'grid-cols-4', - 5: 'grid-cols-5', - 6: 'grid-cols-6', -} - export const Grid = Node.create({ name: 'grid', group: 'block', - content: 'gridCell+', + content: 'gridRow+', isolating: true, addAttributes() { @@ -27,8 +17,15 @@ export const Grid = Node.create({ }, renderHTML({ node, HTMLAttributes }) { - const cols = node.attrs.columns - const colsClass = columnClasses[cols] || 'grid-cols-2' + const columnClasses = { + 1: 'grid-cols-1', + 2: 'grid-cols-2', + 3: 'grid-cols-3', + 4: 'grid-cols-4', + 5: 'grid-cols-5', + 6: 'grid-cols-6', + } + const colsClass = columnClasses[node.attrs.columns] || 'grid-cols-2' return [ 'div', @@ -36,19 +33,22 @@ export const Grid = Node.create({ 'data-type': 'grid', class: `grid gap-4 ${colsClass}`, }), - 0, // render children (gridCell nodes) + 0, ] }, addCommands() { return { - // Insert a new grid with rows x columns + // Insert a new grid with given rows and columns insertGrid: - (columns = 2, rows = 1) => + (columns = 2, rows = 2) => ({ commands }) => { - const content = Array.from({ length: rows * columns }).map(() => ({ - type: 'gridCell', - content: [{ type: 'paragraph' }], + const content = Array.from({ length: rows }).map(() => ({ + type: 'gridRow', + content: Array.from({ length: columns }).map(() => ({ + type: 'gridCell', + content: [{ type: 'paragraph' }], + })), })) return commands.insertContent({ @@ -70,70 +70,72 @@ export const Grid = Node.create({ if (!grid) return false const columns = grid.node.attrs.columns - const newCells = Array.from({ length: columns }).map(() => ({ - type: 'gridCell', - content: [{ type: 'paragraph' }], - })) + const newRow = { + type: 'gridRow', + content: Array.from({ length: columns }).map(() => ({ + type: 'gridCell', + content: [{ type: 'paragraph' }], + })), + } - return commands.insertContentAt( - grid.pos + grid.node.nodeSize - 1, - newCells - ) + return commands.insertContentAt(grid.pos + grid.node.nodeSize - 1, newRow) }, // Add a new column to all rows - addGridColumn: + // + + +addGridColumn: + () => + ({ state, commands }) => { + const { selection, schema } = state; + const gridPos = findParentNodeClosestToPos( + selection.$from, + node => node.type.name === 'grid' + ); + if (!gridPos) return false; + + const { node: gridNode, pos } = gridPos; + const oldColumns = gridNode.attrs.columns; + const newColumns = oldColumns + 1; + + // Update columns attribute first + commands.updateAttributes('grid', { columns: newColumns }); + + // Collect all row positions + const rowPositions = []; + let offset = 1; + gridNode.forEach(row => { + rowPositions.push(pos + offset); + offset += row.nodeSize; + }); + + // Insert a new cell at the end of each row + rowPositions.reverse().forEach(rowPos => { + commands.insertContentAt( + rowPos + state.doc.nodeAt(rowPos).nodeSize - 1, + schema.nodes.gridCell.create({}, schema.nodes.paragraph.create()) + ); + }); + + return true; + }, + + // Delete grid + deleteGrid: () => - ({ state, tr, commands }) => { - const { selection, schema } = state + ({ state, commands }) => { + const { selection } = state const grid = findParentNodeClosestToPos( selection.$from, node => node.type.name === 'grid' ) if (!grid) return false - const { node, pos } = grid - const newColumns = node.attrs.columns + 1 - - // Update the columns attribute - tr.setNodeMarkup(pos, undefined, { - ...node.attrs, - columns: newColumns, + return commands.deleteRange({ + from: grid.pos, + to: grid.pos + grid.node.nodeSize, }) - - // Append one new cell per row - const totalCells = node.content.size - const oldColumns = node.attrs.columns - const rowCount = Math.ceil(totalCells / oldColumns) - let insertPos = pos + 1 - - for (let i = 0; i < rowCount; i++) { - tr.insert( - insertPos + i * (oldColumns + 1) + oldColumns, - schema.nodes.gridCell.create({}, schema.nodes.paragraph.create()) - ) - } - - return commands.editor?.view?.dispatch(tr) || true - }, - - deleteGrid: - () => - ({ state, commands }) => { - const { selection } = state - - const grid = findParentNodeClosestToPos( - selection.$from, - node => node.type.name === 'grid' - ) - - if (!grid) return false - - // Delete the grid node entirely - return commands.deleteRange({ - from: grid.pos, - to: grid.pos + grid.node.nodeSize, - }) }, } }, diff --git a/app/frontend/javascript/rhino/grid/gridCell.js b/app/frontend/javascript/rhino/grid/gridCell.js index 1b7755613..b810d2f83 100644 --- a/app/frontend/javascript/rhino/grid/gridCell.js +++ b/app/frontend/javascript/rhino/grid/gridCell.js @@ -3,11 +3,9 @@ import { Node, mergeAttributes } from '@tiptap/core' export const GridCell = Node.create({ name: 'gridCell', content: 'block+', - parseHTML() { return [{ tag: 'div[data-type="grid-cell"]' }] }, - renderHTML({ HTMLAttributes }) { return [ 'div', diff --git a/app/frontend/javascript/rhino/grid/gridRow.js b/app/frontend/javascript/rhino/grid/gridRow.js index a33a94ffc..e80349d9a 100644 --- a/app/frontend/javascript/rhino/grid/gridRow.js +++ b/app/frontend/javascript/rhino/grid/gridRow.js @@ -3,18 +3,16 @@ import { Node, mergeAttributes } from '@tiptap/core' export const GridRow = Node.create({ name: 'gridRow', content: 'gridCell+', - parseHTML() { return [{ tag: 'div[data-type="grid-row"]' }] }, - renderHTML({ HTMLAttributes }) { return [ 'div', mergeAttributes(HTMLAttributes, { 'data-type': 'grid-row', - class: 'contents', // Tailwind sees children directly - style: 'display: contents;', // fallback in case CSS missing + class: 'contents', // display: contents so cells are direct grid items + style: 'display: contents;', }), 0, ] From 9031de38ea844ef0ecd522aac19753377e37b8bf Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sat, 20 Dec 2025 11:15:40 -0500 Subject: [PATCH 05/20] use grid row and col in parent --- .../javascript/rhino/custom-editor.js | 18 +++ app/frontend/javascript/rhino/grid/grid.js | 107 ++++++++++-------- .../javascript/rhino/grid/gridCell.js | 4 + app/frontend/javascript/rhino/grid/gridRow.js | 7 +- 4 files changed, 85 insertions(+), 51 deletions(-) diff --git a/app/frontend/javascript/rhino/custom-editor.js b/app/frontend/javascript/rhino/custom-editor.js index ef1a32e83..c509e9704 100644 --- a/app/frontend/javascript/rhino/custom-editor.js +++ b/app/frontend/javascript/rhino/custom-editor.js @@ -140,6 +140,7 @@ class CustomEditor extends TipTapEditor { ${this.renderDeleteGridButton()} ${this.renderAddGridRowButton()} ${this.renderAddGridColumnButton()} + ${this.renderAddGridCellButton()} ${this.renderToolbarEnd()} @@ -196,6 +197,23 @@ class CustomEditor extends TipTapEditor { ` } + renderAddGridCellButton() { + if (!this.editor || !this.editor.isActive('grid')) { + return html``; + } + + return html` + + ` + } + renderDeleteGridButton() { if (!this.editor || !this.editor.isActive('grid')) { return html``; diff --git a/app/frontend/javascript/rhino/grid/grid.js b/app/frontend/javascript/rhino/grid/grid.js index 9782a727d..3f54af784 100644 --- a/app/frontend/javascript/rhino/grid/grid.js +++ b/app/frontend/javascript/rhino/grid/grid.js @@ -9,6 +9,7 @@ export const Grid = Node.create({ addAttributes() { return { columns: { default: 2 }, + rows: { default: 1 }, } }, @@ -25,13 +26,24 @@ export const Grid = Node.create({ 5: 'grid-cols-5', 6: 'grid-cols-6', } + + const rowClasses = { + 1: 'grid-rows-1', + 2: 'grid-rows-2', + 3: 'grid-rows-3', + 4: 'grid-rows-4', + 5: 'grid-rows-5', + 6: 'grid-rows-6', + } + const colsClass = columnClasses[node.attrs.columns] || 'grid-cols-2' + const rowsClass = rowClasses[node.attrs.rows] || 'grid-rows-1' return [ 'div', mergeAttributes(HTMLAttributes, { 'data-type': 'grid', - class: `grid gap-4 ${colsClass}`, + class: `grid gap-4 ${colsClass} ${rowsClass}`, }), 0, ] @@ -39,7 +51,6 @@ export const Grid = Node.create({ addCommands() { return { - // Insert a new grid with given rows and columns insertGrid: (columns = 2, rows = 2) => ({ commands }) => { @@ -53,12 +64,11 @@ export const Grid = Node.create({ return commands.insertContent({ type: this.name, - attrs: { columns }, + attrs: { columns, rows }, content, }) }, - // Add a new row addGridRow: () => ({ state, commands }) => { @@ -78,51 +88,11 @@ export const Grid = Node.create({ })), } + commands.updateAttributes('grid', { rows: grid.node.attrs.rows + 1 }) return commands.insertContentAt(grid.pos + grid.node.nodeSize - 1, newRow) }, - // Add a new column to all rows - // - - -addGridColumn: - () => - ({ state, commands }) => { - const { selection, schema } = state; - const gridPos = findParentNodeClosestToPos( - selection.$from, - node => node.type.name === 'grid' - ); - if (!gridPos) return false; - - const { node: gridNode, pos } = gridPos; - const oldColumns = gridNode.attrs.columns; - const newColumns = oldColumns + 1; - - // Update columns attribute first - commands.updateAttributes('grid', { columns: newColumns }); - - // Collect all row positions - const rowPositions = []; - let offset = 1; - gridNode.forEach(row => { - rowPositions.push(pos + offset); - offset += row.nodeSize; - }); - - // Insert a new cell at the end of each row - rowPositions.reverse().forEach(rowPos => { - commands.insertContentAt( - rowPos + state.doc.nodeAt(rowPos).nodeSize - 1, - schema.nodes.gridCell.create({}, schema.nodes.paragraph.create()) - ); - }); - - return true; - }, - - // Delete grid - deleteGrid: + addGridColumn: () => ({ state, commands }) => { const { selection } = state @@ -132,11 +102,50 @@ addGridColumn: ) if (!grid) return false - return commands.deleteRange({ - from: grid.pos, - to: grid.pos + grid.node.nodeSize, + const newColumns = grid.node.attrs.columns + 1 + commands.updateAttributes('grid', { columns: newColumns }) + + grid.node.forEach((row, offset) => { + const rowPos = grid.pos + 1 + offset + commands.insertContentAt( + rowPos + row.nodeSize - 1, + state.schema.nodes.gridCell.create({}, state.schema.nodes.paragraph.create()) + ) }) + + return true }, + + addGridCell: + () => + ({ state, commands }) => { + const { selection } = state + const cell = findParentNodeClosestToPos( + selection.$from, + node => node.type.name === 'gridCell' + ) + if (!cell) return false + + return commands.insertContentAt( + cell.pos + cell.node.nodeSize, + state.schema.nodes.gridCell.create({}, state.schema.nodes.paragraph.create()) + ) + }, + + deleteGrid: () => ({ state, commands }) => { + const { selection } = state + const grid = findParentNodeClosestToPos( + selection.$from, + node => node.type.name === 'grid' + ) + if (!grid) return false + + return commands.deleteRange({ + from: grid.pos, + to: grid.pos + grid.node.nodeSize, + }) + }, + } }, }) diff --git a/app/frontend/javascript/rhino/grid/gridCell.js b/app/frontend/javascript/rhino/grid/gridCell.js index b810d2f83..76faa68f8 100644 --- a/app/frontend/javascript/rhino/grid/gridCell.js +++ b/app/frontend/javascript/rhino/grid/gridCell.js @@ -2,10 +2,14 @@ import { Node, mergeAttributes } from '@tiptap/core' export const GridCell = Node.create({ name: 'gridCell', + group: 'block', content: 'block+', + isolating: true, + parseHTML() { return [{ tag: 'div[data-type="grid-cell"]' }] }, + renderHTML({ HTMLAttributes }) { return [ 'div', diff --git a/app/frontend/javascript/rhino/grid/gridRow.js b/app/frontend/javascript/rhino/grid/gridRow.js index e80349d9a..1d67a4872 100644 --- a/app/frontend/javascript/rhino/grid/gridRow.js +++ b/app/frontend/javascript/rhino/grid/gridRow.js @@ -2,17 +2,20 @@ import { Node, mergeAttributes } from '@tiptap/core' export const GridRow = Node.create({ name: 'gridRow', + group: 'block', content: 'gridCell+', + isolating: true, + parseHTML() { return [{ tag: 'div[data-type="grid-row"]' }] }, + renderHTML({ HTMLAttributes }) { return [ 'div', mergeAttributes(HTMLAttributes, { 'data-type': 'grid-row', - class: 'contents', // display: contents so cells are direct grid items - style: 'display: contents;', + class: 'contents', // allows Tailwind grid to manage layout }), 0, ] From a67924ebe173f1ddd558499ea61384b5c93f6a91 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sat, 20 Dec 2025 12:29:45 -0500 Subject: [PATCH 06/20] change to use grid col and row via tailwind --- .../javascript/rhino/extend-editor.js | 4 +- app/frontend/javascript/rhino/grid/grid.js | 203 +++++++++++------- app/frontend/javascript/rhino/grid/gridRow.js | 46 ++-- 3 files changed, 145 insertions(+), 108 deletions(-) diff --git a/app/frontend/javascript/rhino/extend-editor.js b/app/frontend/javascript/rhino/extend-editor.js index 22d110b56..7b2003af3 100644 --- a/app/frontend/javascript/rhino/extend-editor.js +++ b/app/frontend/javascript/rhino/extend-editor.js @@ -6,7 +6,7 @@ import { TableRow } from '@tiptap/extension-table-row' import Youtube from '@tiptap/extension-youtube' import TextAlign from '@tiptap/extension-text-align' import { Grid } from './grid/grid' -import { GridRow } from './grid/gridRow' +// import { GridRow } from './grid/gridRow' import { GridCell } from './grid/gridCell' function extendRhinoEditor(event) { @@ -23,7 +23,7 @@ function extendRhinoEditor(event) { types: ['heading', 'paragraph'], }), Grid, - GridRow, + // GridRow, GridCell ) } diff --git a/app/frontend/javascript/rhino/grid/grid.js b/app/frontend/javascript/rhino/grid/grid.js index 3f54af784..160fdb321 100644 --- a/app/frontend/javascript/rhino/grid/grid.js +++ b/app/frontend/javascript/rhino/grid/grid.js @@ -3,7 +3,7 @@ import { Node, mergeAttributes, findParentNodeClosestToPos } from '@tiptap/core' export const Grid = Node.create({ name: 'grid', group: 'block', - content: 'gridRow+', + content: 'gridCell+', isolating: true, addAttributes() { @@ -51,101 +51,138 @@ export const Grid = Node.create({ addCommands() { return { - insertGrid: - (columns = 2, rows = 2) => - ({ commands }) => { - const content = Array.from({ length: rows }).map(() => ({ - type: 'gridRow', - content: Array.from({ length: columns }).map(() => ({ - type: 'gridCell', - content: [{ type: 'paragraph' }], - })), - })) - - return commands.insertContent({ - type: this.name, - attrs: { columns, rows }, - content, - }) - }, - - addGridRow: - () => - ({ state, commands }) => { - const { selection } = state + insertGrid: (columns = 2, rows = 2) => ({ commands }) => { + const content = Array.from({ length: columns * rows }).map(() => ({ + type: 'gridCell', + content: [{ type: 'paragraph' }], + })) + + return commands.insertContent({ + type: this.name, + attrs: { columns, rows }, + content, + }) + }, + addGridRow: () => ({ state, dispatch }) => { + const { selection, tr, schema } = state; const grid = findParentNodeClosestToPos( selection.$from, node => node.type.name === 'grid' - ) - if (!grid) return false - - const columns = grid.node.attrs.columns - const newRow = { - type: 'gridRow', - content: Array.from({ length: columns }).map(() => ({ - type: 'gridCell', - content: [{ type: 'paragraph' }], - })), - } - - commands.updateAttributes('grid', { rows: grid.node.attrs.rows + 1 }) - return commands.insertContentAt(grid.pos + grid.node.nodeSize - 1, newRow) + ); + if (!grid) return false; + + const { node: gridNode, pos: gridPos } = grid; + const columns = gridNode.attrs.columns; + + // Create new cells for the row + const newCells = Array.from({ length: columns }).map(() => + schema.nodes.gridCell.create({}, [schema.nodes.paragraph.create()]) + ); + + // Insert new cells at the end of the grid + let insertPos = gridPos + gridNode.nodeSize - 1; // -1 to insert before the closing node + newCells.forEach(cell => { + tr.insert(insertPos, cell); + insertPos += cell.nodeSize; + }); + + // Update the rows attribute + tr.setNodeMarkup(gridPos, undefined, { + ...gridNode.attrs, + rows: gridNode.attrs.rows + 1, + }); + + dispatch(tr); + return true; }, - addGridColumn: - () => - ({ state, commands }) => { - const { selection } = state - const grid = findParentNodeClosestToPos( - selection.$from, - node => node.type.name === 'grid' - ) - if (!grid) return false - - const newColumns = grid.node.attrs.columns + 1 - commands.updateAttributes('grid', { columns: newColumns }) - - grid.node.forEach((row, offset) => { - const rowPos = grid.pos + 1 + offset - commands.insertContentAt( - rowPos + row.nodeSize - 1, - state.schema.nodes.gridCell.create({}, state.schema.nodes.paragraph.create()) - ) - }) + addGridColumn: () => ({ state, dispatch }) => { + const { selection, tr, schema } = state; + const grid = findParentNodeClosestToPos( + selection.$from, + node => node.type.name === 'grid' + ); + if (!grid) return false; + + const { node: gridNode, pos: gridPos } = grid; + const rows = gridNode.attrs.rows; + + // Create new cells equal to number of rows + const newCells = Array.from({ length: rows }).map(() => + schema.nodes.gridCell.create({}, [schema.nodes.paragraph.create()]) + ); + + // Insert all new cells at the end of the grid + let insertPos = gridPos + gridNode.nodeSize - 1; // before closing node + newCells.forEach(cell => { + tr.insert(insertPos, cell); + insertPos += cell.nodeSize; + }); + + // Update columns attribute + tr.setNodeMarkup(gridPos, undefined, { + ...gridNode.attrs, + columns: gridNode.attrs.columns + 1, + }); + + dispatch(tr); + return true; + }, + addGridCell: () => ({ state, dispatch }) => { + const { selection, tr, schema } = state; - return true - }, + // Find the current grid cell + const gridCell = findParentNodeClosestToPos( + selection.$from, + node => node.type.name === 'gridCell' + ); + if (!gridCell) return false; - addGridCell: - () => - ({ state, commands }) => { - const { selection } = state - const cell = findParentNodeClosestToPos( - selection.$from, - node => node.type.name === 'gridCell' - ) - if (!cell) return false - - return commands.insertContentAt( - cell.pos + cell.node.nodeSize, - state.schema.nodes.gridCell.create({}, state.schema.nodes.paragraph.create()) - ) - }, + const { pos: cellPos } = gridCell; - deleteGrid: () => ({ state, commands }) => { - const { selection } = state + // Find the parent grid const grid = findParentNodeClosestToPos( selection.$from, node => node.type.name === 'grid' - ) - if (!grid) return false + ); + if (!grid) return false; - return commands.deleteRange({ - from: grid.pos, - to: grid.pos + grid.node.nodeSize, - }) - }, + const { node: gridNode, pos: gridPos } = grid; + const columns = gridNode.attrs.columns; + + // Create a new empty grid cell + const newCell = schema.nodes.gridCell.create({}, [ + schema.nodes.paragraph.create(), + ]); + // Insert it immediately after the current cell + tr.insert(cellPos + gridCell.node.nodeSize, newCell); + + // Count total cells after insertion + const totalCells = gridNode.childCount + 1; // +1 for the new cell + const newRows = Math.ceil(totalCells / columns); + + // Update grid attributes + tr.setNodeMarkup(gridPos, undefined, { + ...gridNode.attrs, + rows: newRows, + }); + + dispatch(tr); + return true; + }, + deleteGrid: () => ({ state, commands }) => { + const grid = findParentNodeClosestToPos( + state.selection.$from, + node => node.type.name === 'grid' + ) + if (!grid) return false + + return commands.deleteRange({ + from: grid.pos, + to: grid.pos + grid.node.nodeSize, + }) + }, } }, }) diff --git a/app/frontend/javascript/rhino/grid/gridRow.js b/app/frontend/javascript/rhino/grid/gridRow.js index 1d67a4872..017fc14b7 100644 --- a/app/frontend/javascript/rhino/grid/gridRow.js +++ b/app/frontend/javascript/rhino/grid/gridRow.js @@ -1,23 +1,23 @@ -import { Node, mergeAttributes } from '@tiptap/core' - -export const GridRow = Node.create({ - name: 'gridRow', - group: 'block', - content: 'gridCell+', - isolating: true, - - parseHTML() { - return [{ tag: 'div[data-type="grid-row"]' }] - }, - - renderHTML({ HTMLAttributes }) { - return [ - 'div', - mergeAttributes(HTMLAttributes, { - 'data-type': 'grid-row', - class: 'contents', // allows Tailwind grid to manage layout - }), - 0, - ] - }, -}) +// import { Node, mergeAttributes } from '@tiptap/core' +// +// export const GridRow = Node.create({ +// name: 'gridRow', +// group: 'block', +// content: 'gridCell+', +// isolating: true, +// +// parseHTML() { +// return [{ tag: 'div[data-type="grid-row"]' }] +// }, +// +// renderHTML({ HTMLAttributes }) { +// return [ +// 'div', +// mergeAttributes(HTMLAttributes, { +// 'data-type': 'grid-row', +// class: 'contents', // allows Tailwind grid to manage layout +// }), +// 0, +// ] +// }, +// }) From 2f6658f049c392b27ecb1e2b5110f2985e61d711 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 21 Dec 2025 05:24:35 -0500 Subject: [PATCH 07/20] fix image printing --- .../stylesheets/application.tailwind.css | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/app/frontend/stylesheets/application.tailwind.css b/app/frontend/stylesheets/application.tailwind.css index 0e5f6fd00..92cc7c4f9 100644 --- a/app/frontend/stylesheets/application.tailwind.css +++ b/app/frontend/stylesheets/application.tailwind.css @@ -84,6 +84,8 @@ } + +/* Custom Rhino Editor */ custom-rhino-editor table { border-collapse: collapse; margin: 0; @@ -136,5 +138,26 @@ custom-rhino-editor a { .tableWrapper { padding: 1rem 0; overflow-x: auto; +} + +/* Prevent pag breaks on images when printing */ +@media print { + table { + page-break-inside: auto; + } + + tr { + break-inside: avoid; + page-break-inside: avoid; + } + + td { + break-inside: avoid; + page-break-inside: avoid; + } + img { + max-width: 100%; + height: auto; + } } From 137be442f04b50d570b794369775b548ed49ffbe Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 21 Dec 2025 05:50:34 -0500 Subject: [PATCH 08/20] move css --- .../javascript/rhino/custom-editor.css | 54 ++++++++++++++++ .../javascript/rhino/extend-editor.js | 3 +- .../stylesheets/application.tailwind.css | 63 ++----------------- 3 files changed, 61 insertions(+), 59 deletions(-) create mode 100644 app/frontend/javascript/rhino/custom-editor.css diff --git a/app/frontend/javascript/rhino/custom-editor.css b/app/frontend/javascript/rhino/custom-editor.css new file mode 100644 index 000000000..0f90970a8 --- /dev/null +++ b/app/frontend/javascript/rhino/custom-editor.css @@ -0,0 +1,54 @@ +custom-rhino-editor table { + border-collapse: collapse; + margin: 0; + overflow: hidden; + table-layout: fixed; + width: 100%; +} +custom-rhino-editor table td, custom-rhino-editor table th { + border: 2px solid #ced4da; + box-sizing: border-box; + min-width: 1em; + padding: 3px 5px; + position: relative; + vertical-align: top; +} +custom-rhino-editor table td > *, custom-rhino-editor table th > * { + margin-bottom: 0; +} +custom-rhino-editor table th { + background-color: #f1f3f5; + font-weight: bold; + text-align: left; +} +custom-rhino-editor table .selectedCell:after { + background: rgba(200, 200, 255, 0.4); + content: ""; + left: 0; + right: 0; + top: 0; + bottom: 0; + pointer-events: none; + position: absolute; + z-index: 2; +} +custom-rhino-editor table .column-resize-handle { + background-color: #adf; + bottom: -2px; + position: absolute; + right: -2px; + pointer-events: none; + top: 0; + width: 4px; +} +custom-rhino-editor table p { + margin: 0; +} +custom-rhino-editor a { + text-decoration: underline; + color: #2563eb; /* Tailwind blue-600 */ +} + +custom-rhino-editor a:hover { + color: #1e40af; /* Tailwind blue-800 */ +} diff --git a/app/frontend/javascript/rhino/extend-editor.js b/app/frontend/javascript/rhino/extend-editor.js index 7b2003af3..4ef2a9a01 100644 --- a/app/frontend/javascript/rhino/extend-editor.js +++ b/app/frontend/javascript/rhino/extend-editor.js @@ -1,4 +1,5 @@ import "./custom-editor.js" +import "./custom-editor.css" import { Table } from '@tiptap/extension-table' import { TableCell } from '@tiptap/extension-table-cell' import { TableHeader } from '@tiptap/extension-table-header' @@ -6,7 +7,6 @@ import { TableRow } from '@tiptap/extension-table-row' import Youtube from '@tiptap/extension-youtube' import TextAlign from '@tiptap/extension-text-align' import { Grid } from './grid/grid' -// import { GridRow } from './grid/gridRow' import { GridCell } from './grid/gridCell' function extendRhinoEditor(event) { @@ -23,7 +23,6 @@ function extendRhinoEditor(event) { types: ['heading', 'paragraph'], }), Grid, - // GridRow, GridCell ) } diff --git a/app/frontend/stylesheets/application.tailwind.css b/app/frontend/stylesheets/application.tailwind.css index 92cc7c4f9..80773d269 100644 --- a/app/frontend/stylesheets/application.tailwind.css +++ b/app/frontend/stylesheets/application.tailwind.css @@ -83,63 +83,6 @@ @apply text-primary border-b-2 border-primary font-bold bg-gray-100; } - - -/* Custom Rhino Editor */ -custom-rhino-editor table { - border-collapse: collapse; - margin: 0; - overflow: hidden; - table-layout: fixed; - width: 100%; -} -custom-rhino-editor table td, custom-rhino-editor table th { - border: 2px solid #ced4da; - box-sizing: border-box; - min-width: 1em; - padding: 3px 5px; - position: relative; - vertical-align: top; -} -custom-rhino-editor table td > *, custom-rhino-editor table th > * { - margin-bottom: 0; -} -custom-rhino-editor table th { - background-color: #f1f3f5; - font-weight: bold; - text-align: left; -} -custom-rhino-editor table .selectedCell:after { - background: rgba(200, 200, 255, 0.4); - content: ""; - left: 0; - right: 0; - top: 0; - bottom: 0; - pointer-events: none; - position: absolute; - z-index: 2; -} -custom-rhino-editor table .column-resize-handle { - background-color: #adf; - bottom: -2px; - position: absolute; - right: -2px; - pointer-events: none; - top: 0; - width: 4px; -} -custom-rhino-editor table p { - margin: 0; -} -custom-rhino-editor a { - @apply underline text-blue-600 hover:text-blue-800; -} -.tableWrapper { - padding: 1rem 0; - overflow-x: auto; -} - /* Prevent pag breaks on images when printing */ @media print { table { @@ -161,3 +104,9 @@ custom-rhino-editor a { height: auto; } } + +/* Rich Text tables horizontal scroll */ +.tableWrapper { + padding: 1rem 0; + overflow-x: auto; +} From ae77df6344ba73e42e8ec4a26727db4b37bb8b3a Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 21 Dec 2025 06:32:03 -0500 Subject: [PATCH 09/20] add grid menu --- .../javascript/rhino/custom-editor.js | 76 +------------------ .../javascript/rhino/grid/grid-menu.js | 57 ++++++++++++++ app/frontend/javascript/rhino/grid/grid.js | 1 + 3 files changed, 61 insertions(+), 73 deletions(-) create mode 100644 app/frontend/javascript/rhino/grid/grid-menu.js diff --git a/app/frontend/javascript/rhino/custom-editor.js b/app/frontend/javascript/rhino/custom-editor.js index c509e9704..a3da30b78 100644 --- a/app/frontend/javascript/rhino/custom-editor.js +++ b/app/frontend/javascript/rhino/custom-editor.js @@ -8,6 +8,7 @@ import { TipTapEditor } from "rhino-editor/exports/elements/tip-tap-editor.js" import * as table_icons from "./table-icons.js" import * as table_translations from "./table-translations.js" import { application } from "../controllers/application" +import { renderGridMenu } from "./grid/grid-menu.js"; class CustomEditor extends TipTapEditor { @@ -137,14 +138,11 @@ class CustomEditor extends TipTapEditor { ${this.renderAddGridButton()} - ${this.renderDeleteGridButton()} - ${this.renderAddGridRowButton()} - ${this.renderAddGridColumnButton()} - ${this.renderAddGridCellButton()} ${this.renderToolbarEnd()} ${this.renderTableMenu()} + ${renderGridMenu(this.editor)} `; } @@ -154,7 +152,7 @@ class CustomEditor extends TipTapEditor { - ` - } - - - renderAddGridColumnButton() { - if (!this.editor || !this.editor.isActive('grid')) { - return html``; - } - - return html` - - ` - } - - renderAddGridCellButton() { - if (!this.editor || !this.editor.isActive('grid')) { - return html``; - } - - return html` - - ` - } - - renderDeleteGridButton() { - if (!this.editor || !this.editor.isActive('grid')) { - return html``; - } - return html` - - ` - } - renderTableButton() { const tableEnabled = true; // Boolean(this.editor?.commands.setAttachment); diff --git a/app/frontend/javascript/rhino/grid/grid-menu.js b/app/frontend/javascript/rhino/grid/grid-menu.js new file mode 100644 index 000000000..32d224f55 --- /dev/null +++ b/app/frontend/javascript/rhino/grid/grid-menu.js @@ -0,0 +1,57 @@ +import { html } from "lit"; +import "rhino-editor/exports/styles/trix.css" + +/** + * Render the grid menu toolbar (like table menu) + * @param {Editor} editor - Tiptap editor instance + */ +export function renderGridMenu(editor) { + if (!editor || !editor.isActive("grid")) return html``; + + const buttons = [ + { + tooltip: "Add Row", + icon: "+", + action: () => editor.chain().focus().addGridRow().run(), + }, + { + tooltip: "Add Column", + icon: "+", + action: () => editor.chain().focus().addGridColumn().run(), + }, + { + tooltip: "Add Cell", + icon: "+", + action: () => editor.chain().focus().addGridCell().run(), + }, + { + tooltip: "Delete Grid", + icon: "🗑", + action: () => editor.chain().focus().deleteGrid().run(), + }, + ]; + + return html` + + ${buttons.map( + btn => html` + + ` + )} + + `; +} diff --git a/app/frontend/javascript/rhino/grid/grid.js b/app/frontend/javascript/rhino/grid/grid.js index 160fdb321..42aed80e8 100644 --- a/app/frontend/javascript/rhino/grid/grid.js +++ b/app/frontend/javascript/rhino/grid/grid.js @@ -63,6 +63,7 @@ export const Grid = Node.create({ content, }) }, + addGridRow: () => ({ state, dispatch }) => { const { selection, tr, schema } = state; const grid = findParentNodeClosestToPos( From 4289b64f94f65b21bb612530ba59f39598040abe Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 21 Dec 2025 07:04:09 -0500 Subject: [PATCH 10/20] fix focus --- app/frontend/javascript/rhino/grid/grid.js | 76 +++++++++++++--------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/app/frontend/javascript/rhino/grid/grid.js b/app/frontend/javascript/rhino/grid/grid.js index 42aed80e8..0d71c6c65 100644 --- a/app/frontend/javascript/rhino/grid/grid.js +++ b/app/frontend/javascript/rhino/grid/grid.js @@ -1,4 +1,5 @@ import { Node, mergeAttributes, findParentNodeClosestToPos } from '@tiptap/core' +import { TextSelection } from '@tiptap/pm/state' export const Grid = Node.create({ name: 'grid', @@ -51,19 +52,32 @@ export const Grid = Node.create({ addCommands() { return { - insertGrid: (columns = 2, rows = 2) => ({ commands }) => { - const content = Array.from({ length: columns * rows }).map(() => ({ - type: 'gridCell', - content: [{ type: 'paragraph' }], - })) - - return commands.insertContent({ - type: this.name, - attrs: { columns, rows }, - content, - }) - }, + insertGrid: (columns = 1, rows = 1) => ({ tr, dispatch, editor }) => { + const { schema } = editor; + + const cells = Array.from({ length: columns * rows }).map(() => + schema.nodes.gridCell.create({}, [schema.nodes.paragraph.create()]) + ); + + const gridNode = schema.nodes.grid.create( + { columns, rows }, + cells + ); + + if (dispatch) { + const offset = tr.selection.from; + tr.replaceSelectionWith(gridNode) + .scrollIntoView(); + + // Focus first cell + const firstCellPos = offset + 1; + tr.setSelection(TextSelection.near(tr.doc.resolve(firstCellPos))); + dispatch(tr); + } + + return true; + }, addGridRow: () => ({ state, dispatch }) => { const { selection, tr, schema } = state; const grid = findParentNodeClosestToPos( @@ -75,13 +89,12 @@ export const Grid = Node.create({ const { node: gridNode, pos: gridPos } = grid; const columns = gridNode.attrs.columns; - // Create new cells for the row const newCells = Array.from({ length: columns }).map(() => schema.nodes.gridCell.create({}, [schema.nodes.paragraph.create()]) ); // Insert new cells at the end of the grid - let insertPos = gridPos + gridNode.nodeSize - 1; // -1 to insert before the closing node + let insertPos = gridPos + gridNode.nodeSize - 1; // insert before closing node newCells.forEach(cell => { tr.insert(insertPos, cell); insertPos += cell.nodeSize; @@ -108,7 +121,6 @@ export const Grid = Node.create({ const { node: gridNode, pos: gridPos } = grid; const rows = gridNode.attrs.rows; - // Create new cells equal to number of rows const newCells = Array.from({ length: rows }).map(() => schema.nodes.gridCell.create({}, [schema.nodes.paragraph.create()]) ); @@ -132,7 +144,6 @@ export const Grid = Node.create({ addGridCell: () => ({ state, dispatch }) => { const { selection, tr, schema } = state; - // Find the current grid cell const gridCell = findParentNodeClosestToPos( selection.$from, node => node.type.name === 'gridCell' @@ -141,7 +152,6 @@ export const Grid = Node.create({ const { pos: cellPos } = gridCell; - // Find the parent grid const grid = findParentNodeClosestToPos( selection.$from, node => node.type.name === 'grid' @@ -151,7 +161,6 @@ export const Grid = Node.create({ const { node: gridNode, pos: gridPos } = grid; const columns = gridNode.attrs.columns; - // Create a new empty grid cell const newCell = schema.nodes.gridCell.create({}, [ schema.nodes.paragraph.create(), ]); @@ -159,8 +168,7 @@ export const Grid = Node.create({ // Insert it immediately after the current cell tr.insert(cellPos + gridCell.node.nodeSize, newCell); - // Count total cells after insertion - const totalCells = gridNode.childCount + 1; // +1 for the new cell + const totalCells = gridNode.childCount + 1; const newRows = Math.ceil(totalCells / columns); // Update grid attributes @@ -172,18 +180,22 @@ export const Grid = Node.create({ dispatch(tr); return true; }, - deleteGrid: () => ({ state, commands }) => { - const grid = findParentNodeClosestToPos( - state.selection.$from, - node => node.type.name === 'grid' - ) - if (!grid) return false - - return commands.deleteRange({ - from: grid.pos, - to: grid.pos + grid.node.nodeSize, - }) - }, + deleteGrid: () => ({ state, dispatch, tr }) => { + const grid = findParentNodeClosestToPos( + state.selection.$from, + node => node.type.name === 'grid' + ); + if (!grid) return false; + + const { pos, node } = grid; + + tr.delete(pos, pos + node.nodeSize); + + tr.setSelection(TextSelection.near(tr.doc.resolve(pos))); + + if (dispatch) dispatch(tr); + return true; + }, } }, }) From 2c2e5d194dc3d80d06cec4c276ebb2c22da6b739 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 21 Dec 2025 08:03:09 -0500 Subject: [PATCH 11/20] add grid icon --- .../javascript/rhino/custom-editor.js | 82 ++++++++++++++----- .../javascript/rhino/grid/grid-icons.js | 39 +++++++++ .../javascript/rhino/grid/grid-menu.js | 14 +++- app/frontend/javascript/rhino/grid/grid.js | 43 ++++++++++ 4 files changed, 154 insertions(+), 24 deletions(-) create mode 100644 app/frontend/javascript/rhino/grid/grid-icons.js diff --git a/app/frontend/javascript/rhino/custom-editor.js b/app/frontend/javascript/rhino/custom-editor.js index a3da30b78..cba4b5673 100644 --- a/app/frontend/javascript/rhino/custom-editor.js +++ b/app/frontend/javascript/rhino/custom-editor.js @@ -7,6 +7,7 @@ import "rhino-editor/exports/styles/trix.css" import { TipTapEditor } from "rhino-editor/exports/elements/tip-tap-editor.js" import * as table_icons from "./table-icons.js" import * as table_translations from "./table-translations.js" +import * as grid_icons from "./grid/grid-icons.js" import { application } from "../controllers/application" import { renderGridMenu } from "./grid/grid-menu.js"; @@ -86,6 +87,7 @@ class CustomEditor extends TipTapEditor { + ${this.renderGridButton()} ${this.renderTableButton()} @@ -98,6 +100,20 @@ class CustomEditor extends TipTapEditor { > + + + + + + ${this.renderUndoButton()} + + + + + ${this.renderRedoButton()} + + + - ` + `; } renderTableButton() { diff --git a/app/frontend/javascript/rhino/grid/grid-icons.js b/app/frontend/javascript/rhino/grid/grid-icons.js new file mode 100644 index 000000000..df6b51ae8 --- /dev/null +++ b/app/frontend/javascript/rhino/grid/grid-icons.js @@ -0,0 +1,39 @@ +import { html, svg } from "lit"; + +function toSvg(path, size = 24) { + return html` + + ` +} + +export const insertGrid = toSvg( + svg` + + ` + +); diff --git a/app/frontend/javascript/rhino/grid/grid-menu.js b/app/frontend/javascript/rhino/grid/grid-menu.js index 32d224f55..48bcc0e34 100644 --- a/app/frontend/javascript/rhino/grid/grid-menu.js +++ b/app/frontend/javascript/rhino/grid/grid-menu.js @@ -10,22 +10,27 @@ export function renderGridMenu(editor) { const buttons = [ { - tooltip: "Add Row", + title: "Add Row", icon: "+", action: () => editor.chain().focus().addGridRow().run(), }, { - tooltip: "Add Column", + title: "Add Column", icon: "+", action: () => editor.chain().focus().addGridColumn().run(), }, { - tooltip: "Add Cell", + title: "Add Cell", icon: "+", action: () => editor.chain().focus().addGridCell().run(), }, { - tooltip: "Delete Grid", + title: "Delete Cell", + icon: "-", + action: () => editor.chain().focus().deleteGridCell().run(), + }, + { + title: "Delete Grid", icon: "🗑", action: () => editor.chain().focus().deleteGrid().run(), }, @@ -38,6 +43,7 @@ export function renderGridMenu(editor) { - ` + `, )} `; diff --git a/app/frontend/javascript/rhino/grid/grid.js b/app/frontend/javascript/rhino/grid/grid.js index a01fcd43a..e8d8708c6 100644 --- a/app/frontend/javascript/rhino/grid/grid.js +++ b/app/frontend/javascript/rhino/grid/grid.js @@ -1,244 +1,211 @@ -import { Node, mergeAttributes, findParentNodeClosestToPos } from '@tiptap/core' -import { TextSelection } from '@tiptap/pm/state' +import { + Node, + mergeAttributes, + findParentNodeClosestToPos, +} from "@tiptap/core"; +import { TextSelection } from "@tiptap/pm/state"; export const Grid = Node.create({ - name: 'grid', - group: 'block', - content: 'gridCell+', + name: "grid", + group: "block", + content: "gridCell+", isolating: true, addAttributes() { return { - columns: { default: 2 }, - rows: { default: 1 }, - } + columns: { + default: 1, + }, + }; }, parseHTML() { - return [{ tag: 'div[data-type="grid"]' }] + return [{ tag: 'div[data-type="grid"]' }]; }, renderHTML({ node, HTMLAttributes }) { - const columnClasses = { - 1: 'grid-cols-1', - 2: 'grid-cols-2', - 3: 'grid-cols-3', - 4: 'grid-cols-4', - 5: 'grid-cols-5', - 6: 'grid-cols-6', - } - - const rowClasses = { - 1: 'grid-rows-1', - 2: 'grid-rows-2', - 3: 'grid-rows-3', - 4: 'grid-rows-4', - 5: 'grid-rows-5', - 6: 'grid-rows-6', - } - - const colsClass = columnClasses[node.attrs.columns] || 'grid-cols-2' - const rowsClass = rowClasses[node.attrs.rows] || 'grid-rows-1' - + const GRID_COL_CLASSES = { + 1: "grid-cols-1", + 2: "grid-cols-2", + 3: "grid-cols-3", + 4: "grid-cols-4", + 5: "grid-cols-5", + 6: "grid-cols-6", + }; + const colsClass = + GRID_COL_CLASSES[node.attrs.columns] || GRID_COL_CLASSES[1]; return [ - 'div', + "div", mergeAttributes(HTMLAttributes, { - 'data-type': 'grid', - class: `grid gap-4 ${colsClass} ${rowsClass}`, + "data-type": "grid", + class: `grid gap-4 ${colsClass}`, }), 0, - ] + ]; }, addCommands() { return { - insertGrid: (columns = 1, rows = 1) => ({ tr, dispatch, editor }) => { - const { schema } = editor; + /** + * Insert a grid with ONE cell + */ + insertGrid: + () => + ({ tr, dispatch, editor }) => { + const { schema } = editor; - const cells = Array.from({ length: columns * rows }).map(() => - schema.nodes.gridCell.create({}, [schema.nodes.paragraph.create()]) - ); + const gridNode = schema.nodes.grid.create({ columns: 1 }, [ + schema.nodes.gridCell.create({}, [schema.nodes.paragraph.create()]), + ]); - const gridNode = schema.nodes.grid.create( - { columns, rows }, - cells - ); + if (!dispatch) return true; - if (dispatch) { - const offset = tr.selection.from; - tr.replaceSelectionWith(gridNode) - .scrollIntoView(); + const pos = tr.selection.from; - // Focus first cell - const firstCellPos = offset + 1; - tr.setSelection(TextSelection.near(tr.doc.resolve(firstCellPos))); + tr.replaceSelectionWith(gridNode); + tr.setSelection(TextSelection.near(tr.doc.resolve(pos + 1))); + tr.scrollIntoView(); dispatch(tr); - } + return true; + }, - return true; - }, - addGridRow: () => ({ state, dispatch }) => { + /** + * Add ONE new cell to the grid + */ + addGridCell: + () => + ({ state, dispatch }) => { const { selection, tr, schema } = state; + + const grid = findParentNodeClosestToPos( + selection.$from, + (node) => node.type.name === "grid", + ); + if (!grid) return false; + + const insertPos = grid.pos + grid.node.nodeSize - 1; + + const newCell = schema.nodes.gridCell.create({}, [ + schema.nodes.paragraph.create(), + ]); + + tr.insert(insertPos, newCell); + + if (dispatch) dispatch(tr); + return true; + }, + + deleteLastGridCell: + () => + ({ state, dispatch }) => { + const { selection, tr } = state; + + const grid = findParentNodeClosestToPos( + selection.$from, + (node) => node.type.name === "grid", + ); + if (!grid) return false; + + const cellCount = grid.node.childCount; + if (cellCount <= 1) return true; // keep at least one cell + + // Position of last cell + let offset = 0; + for (let i = 0; i < cellCount - 1; i++) { + offset += grid.node.child(i).nodeSize; + } + + const lastCellPos = grid.pos + offset + 1; + const lastCell = grid.node.child(cellCount - 1); + + tr.delete(lastCellPos, lastCellPos + lastCell.nodeSize); + + if (dispatch) dispatch(tr); + return true; + }, + increaseGridColumns: + () => + ({ state, dispatch }) => { + const { selection, tr } = state; + const grid = findParentNodeClosestToPos( selection.$from, - node => node.type.name === 'grid' + (node) => node.type.name === "grid", ); if (!grid) return false; - const { node: gridNode, pos: gridPos } = grid; - const columns = gridNode.attrs.columns; + const current = grid.node.attrs.columns || 1; + const next = Math.min(current + 1, 6); + + if (next === current) return true; + + tr.setNodeMarkup(grid.pos, undefined, { + ...grid.node.attrs, + columns: next, + }); - const newCells = Array.from({ length: columns }).map(() => - schema.nodes.gridCell.create({}, [schema.nodes.paragraph.create()]) + if (dispatch) dispatch(tr); + return true; + }, + + decreaseGridColumns: + () => + ({ state, dispatch }) => { + const { selection, tr } = state; + + const grid = findParentNodeClosestToPos( + selection.$from, + (node) => node.type.name === "grid", ); + if (!grid) return false; + + const currentCols = grid.node.attrs.columns || 1; + const nextCols = Math.max(currentCols - 1, 1); - // Insert new cells at the end of the grid - let insertPos = gridPos + gridNode.nodeSize - 1; // insert before closing node - newCells.forEach(cell => { - tr.insert(insertPos, cell); - insertPos += cell.nodeSize; + if (nextCols === currentCols) return true; + + // 1️⃣ Update grid columns + tr.setNodeMarkup(grid.pos, undefined, { + ...grid.node.attrs, + columns: nextCols, }); - // Update the rows attribute - tr.setNodeMarkup(gridPos, undefined, { - ...gridNode.attrs, - rows: gridNode.attrs.rows + 1, + // 2️⃣ Clamp cell spans if needed + grid.node.forEach((child, offset) => { + if (child.type.name !== "gridCell") return; + + if (child.attrs.columnSpan > nextCols) { + tr.setNodeMarkup(grid.pos + offset + 1, undefined, { + ...child.attrs, + columnSpan: nextCols, + }); + } }); - dispatch(tr); + if (dispatch) dispatch(tr); return true; }, + /** + * Delete entire grid + */ + deleteGrid: + () => + ({ state, dispatch }) => { + const { selection, tr } = state; - addGridColumn: () => ({ state, dispatch }) => { - const { selection, tr, schema } = state; - const grid = findParentNodeClosestToPos( - selection.$from, - node => node.type.name === 'grid' - ); - if (!grid) return false; - - const { node: gridNode, pos: gridPos } = grid; - const rows = gridNode.attrs.rows; - - const newCells = Array.from({ length: rows }).map(() => - schema.nodes.gridCell.create({}, [schema.nodes.paragraph.create()]) - ); - - // Insert all new cells at the end of the grid - let insertPos = gridPos + gridNode.nodeSize - 1; // before closing node - newCells.forEach(cell => { - tr.insert(insertPos, cell); - insertPos += cell.nodeSize; - }); - - // Update columns attribute - tr.setNodeMarkup(gridPos, undefined, { - ...gridNode.attrs, - columns: gridNode.attrs.columns + 1, - }); - - dispatch(tr); - return true; - }, - addGridCell: () => ({ state, dispatch }) => { - const { selection, tr, schema } = state; - - const gridCell = findParentNodeClosestToPos( - selection.$from, - node => node.type.name === 'gridCell' - ); - if (!gridCell) return false; - - const { pos: cellPos } = gridCell; - - const grid = findParentNodeClosestToPos( - selection.$from, - node => node.type.name === 'grid' - ); - if (!grid) return false; - - const { node: gridNode, pos: gridPos } = grid; - const columns = gridNode.attrs.columns; - - const newCell = schema.nodes.gridCell.create({}, [ - schema.nodes.paragraph.create(), - ]); - - // Insert it immediately after the current cell - tr.insert(cellPos + gridCell.node.nodeSize, newCell); - - const totalCells = gridNode.childCount + 1; - const newRows = Math.ceil(totalCells / columns); - - // Update grid attributes - tr.setNodeMarkup(gridPos, undefined, { - ...gridNode.attrs, - rows: newRows, - }); - - dispatch(tr); - return true; - }, - - deleteGridCell: () => ({ state, dispatch }) => { - const { selection, tr } = state; - - // Find the current grid cell - const gridCell = findParentNodeClosestToPos( - selection.$from, - node => node.type.name === 'gridCell' - ); - if (!gridCell) return false; - - const { pos: cellPos, node: cellNode } = gridCell; - - // Find the parent grid - const grid = findParentNodeClosestToPos( - selection.$from, - node => node.type.name === 'grid' - ); - if (!grid) return false; - - const { node: gridNode, pos: gridPos } = grid; - const columns = gridNode.attrs.columns; - - // Delete the current cell - tr.delete(cellPos, cellPos + cellNode.nodeSize); - - // Recalculate rows - const totalCells = gridNode.childCount - 1; // one less now - const newRows = Math.ceil(totalCells / columns); - - // Update grid attributes - tr.setNodeMarkup(gridPos, undefined, { - ...gridNode.attrs, - rows: newRows, - }); - - // Move selection to a safe position - tr.setSelection(TextSelection.near(tr.doc.resolve(gridPos))); - - if (dispatch) dispatch(tr); - return true; - }, - - deleteGrid: () => ({ state, dispatch, tr }) => { - const grid = findParentNodeClosestToPos( - state.selection.$from, - node => node.type.name === 'grid' - ); - if (!grid) return false; - - const { pos, node } = grid; - - tr.delete(pos, pos + node.nodeSize); - - tr.setSelection(TextSelection.near(tr.doc.resolve(pos))); - - if (dispatch) dispatch(tr); - return true; - }, - } + const grid = findParentNodeClosestToPos( + selection.$from, + (node) => node.type.name === "grid", + ); + if (!grid) return false; + + tr.delete(grid.pos, grid.pos + grid.node.nodeSize); + tr.setSelection(TextSelection.near(tr.doc.resolve(grid.pos))); + + if (dispatch) dispatch(tr); + return true; + }, + }; }, -}) +}); diff --git a/app/frontend/javascript/rhino/grid/gridCell.js b/app/frontend/javascript/rhino/grid/gridCell.js index 21fab6bec..d96b3df3a 100644 --- a/app/frontend/javascript/rhino/grid/gridCell.js +++ b/app/frontend/javascript/rhino/grid/gridCell.js @@ -1,103 +1,136 @@ -import { Node, mergeAttributes, findParentNodeClosestToPos } from '@tiptap/core' +import { + Node, + mergeAttributes, + findParentNodeClosestToPos, +} from "@tiptap/core"; export const GridCell = Node.create({ - name: 'gridCell', - group: 'block', - content: 'block+', + name: "gridCell", + group: "block", + content: "block+", isolating: true, addAttributes() { return { verticalAlign: { - default: 'top', + default: "top", }, columnSpan: { default: 1, }, - } + }; }, parseHTML() { - return [{ tag: 'div[data-type="grid-cell"]' }] + return [{ tag: 'div[data-type="grid-cell"]' }]; }, renderHTML({ node, HTMLAttributes }) { const alignClasses = { - top: 'justify-start', - center: 'justify-center', - bottom: 'justify-end', - } + top: "justify-start", + center: "justify-center", + bottom: "justify-end", + }; const colSpanClasses = { - 1: 'col-span-1', - 2: 'col-span-2', - 3: 'col-span-3', - 4: 'col-span-4', - 5: 'col-span-5', - 6: 'col-span-6', - } + 1: "col-span-1", + 2: "col-span-2", + 3: "col-span-3", + 4: "col-span-4", + 5: "col-span-5", + 6: "col-span-6", + }; - const verticalClass = alignClasses[node.attrs.verticalAlign] || 'justify-start' - const spanClass = colSpanClasses[node.attrs.columnSpan] || 'col-span-1' + const verticalClass = + alignClasses[node.attrs.verticalAlign] || alignClasses.top; + + const spanClass = + colSpanClasses[node.attrs.columnSpan] || colSpanClasses[1]; return [ - 'div', + "div", mergeAttributes(HTMLAttributes, { - 'data-type': 'grid-cell', - 'data-vertical-align': node.attrs.verticalAlign, - 'data-column-span': node.attrs.columnSpan, + "data-type": "grid-cell", class: `border border-gray-300 p-3 rounded flex flex-col ${verticalClass} ${spanClass}`, }), 0, - ] + ]; }, addCommands() { return { - setVerticalAlign: alignment => ({ state, dispatch }) => { - const gridCell = findParentNodeClosestToPos( - state.selection.$from, - node => node.type.name === 'gridCell' - ) - if (!gridCell) return false - - const { pos, node } = gridCell - const tr = state.tr.setNodeMarkup(pos, undefined, { - ...node.attrs, - verticalAlign: alignment, - }) - - if (dispatch) dispatch(tr) - return true - }, - - setColumnSpan: span => ({ state, dispatch }) => { - const gridCell = findParentNodeClosestToPos( - state.selection.$from, - node => node.type.name === 'gridCell' - ) - if (!gridCell) return false - - const { pos, node } = gridCell - - const parentGrid = findParentNodeClosestToPos( - state.selection.$from, - node => node.type.name === 'grid' - ) - if (!parentGrid) return false - - const maxColumns = parentGrid.node.attrs.columns - - const newSpan = Math.min(span, maxColumns) - - const tr = state.tr.setNodeMarkup(pos, undefined, { - ...node.attrs, - columnSpan: newSpan, - }) - - if (dispatch) dispatch(tr) - return true - }, - } + setVerticalAlign: + (alignment) => + ({ state, dispatch }) => { + const gridCell = findParentNodeClosestToPos( + state.selection.$from, + (node) => node.type.name === "gridCell", + ); + if (!gridCell) return false; + + const { pos, node } = gridCell; + const tr = state.tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + verticalAlign: alignment, + }); + + if (dispatch) dispatch(tr); + return true; + }, + + // setColumnSpan: span => ({ state, dispatch }) => { + // const gridCell = findParentNodeClosestToPos( + // state.selection.$from, + // node => node.type.name === 'gridCell' + // ) + // if (!gridCell) return false + // + // const { pos, node } = gridCell + // + // const parentGrid = findParentNodeClosestToPos( + // state.selection.$from, + // node => node.type.name === 'grid' + // ) + // if (!parentGrid) return false + // + // const maxColumns = parentGrid.node.attrs.columns + // + // const newSpan = Math.min(span, maxColumns) + // + // const tr = state.tr.setNodeMarkup(pos, undefined, { + // ...node.attrs, + // columnSpan: newSpan, + // }) + // + // if (dispatch) dispatch(tr) + // return true + // }, + setColumnSpan: + (span) => + ({ state, dispatch }) => { + const cell = findParentNodeClosestToPos( + state.selection.$from, + (node) => node.type.name === "gridCell", + ); + if (!cell) return false; + + const grid = findParentNodeClosestToPos( + state.selection.$from, + (node) => node.type.name === "grid", + ); + if (!grid) return false; + + const max = grid.node.attrs.columns; + const safeSpan = Math.max(1, Math.min(span, max)); + + const tr = state.tr.setNodeMarkup(cell.pos, undefined, { + ...cell.node.attrs, + columnSpan: safeSpan, + }); + + if (dispatch) dispatch(tr); + return true; + }, + }; }, -}) +}); From 162a7a5b6bfbad294bdbcdff2d57b97272cd45fd Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Thu, 1 Jan 2026 15:01:08 -0500 Subject: [PATCH 16/20] change captions to text-center --- app/views/active_storage/blobs/_blob.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/active_storage/blobs/_blob.html.erb b/app/views/active_storage/blobs/_blob.html.erb index 49ba357dd..d317e8557 100644 --- a/app/views/active_storage/blobs/_blob.html.erb +++ b/app/views/active_storage/blobs/_blob.html.erb @@ -3,7 +3,7 @@ <%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %> <% end %> -
+
<% if caption = blob.try(:caption) %> <%= caption %> <% else %> From 3f90ea63f197ade1398fb91b3221af1d8b43d237 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Thu, 1 Jan 2026 15:26:05 -0500 Subject: [PATCH 17/20] add toggle border --- .../javascript/rhino/custom-editor.css | 4 + .../javascript/rhino/grid/grid-menu.js | 7 ++ .../javascript/rhino/grid/gridCell.js | 88 +++++++++++-------- 3 files changed, 62 insertions(+), 37 deletions(-) diff --git a/app/frontend/javascript/rhino/custom-editor.css b/app/frontend/javascript/rhino/custom-editor.css index 0f90970a8..52fe51f96 100644 --- a/app/frontend/javascript/rhino/custom-editor.css +++ b/app/frontend/javascript/rhino/custom-editor.css @@ -52,3 +52,7 @@ custom-rhino-editor a { custom-rhino-editor a:hover { color: #1e40af; /* Tailwind blue-800 */ } + +/* Dashed border for grid cells that have hasBorder=false (editor only) */ +custom-rhino-editor .grid-cell-editor[hasborder="false"] { + border: 2px dashed #ced4da; } diff --git a/app/frontend/javascript/rhino/grid/grid-menu.js b/app/frontend/javascript/rhino/grid/grid-menu.js index 49f3d9a01..3eeddc6c6 100644 --- a/app/frontend/javascript/rhino/grid/grid-menu.js +++ b/app/frontend/javascript/rhino/grid/grid-menu.js @@ -16,6 +16,13 @@ export function renderGridMenu(editor) { icon: "−", action: () => editor.chain().focus().deleteLastGridCell().run(), }, + + { + title: "Toggle Border", + icon: "border", + action: () => editor.chain().focus().toggleCellBorder().run(), + }, + { title: "Add Column", icon: "+", diff --git a/app/frontend/javascript/rhino/grid/gridCell.js b/app/frontend/javascript/rhino/grid/gridCell.js index d96b3df3a..7ed886030 100644 --- a/app/frontend/javascript/rhino/grid/gridCell.js +++ b/app/frontend/javascript/rhino/grid/gridCell.js @@ -13,16 +13,19 @@ export const GridCell = Node.create({ addAttributes() { return { verticalAlign: { - default: "top", + default: "top", // top | center | bottom }, columnSpan: { default: 1, }, + hasBorder: { + default: true, + }, }; }, parseHTML() { - return [{ tag: 'div[data-type="grid-cell"]' }]; + return [{ tag: "div[data-type='grid-cell']" }]; }, renderHTML({ node, HTMLAttributes }) { @@ -43,15 +46,26 @@ export const GridCell = Node.create({ const verticalClass = alignClasses[node.attrs.verticalAlign] || alignClasses.top; - const spanClass = colSpanClasses[node.attrs.columnSpan] || colSpanClasses[1]; + // Border class: solid if hasBorder, otherwise helper class for editor CSS + const borderClass = node.attrs.hasBorder ? "border border-gray-300" : ""; + return [ "div", mergeAttributes(HTMLAttributes, { "data-type": "grid-cell", - class: `border border-gray-300 p-3 rounded flex flex-col ${verticalClass} ${spanClass}`, + hasborder: node.attrs.hasBorder ? "true" : "false", + class: [ + "grid-cell-editor", // always present for editor dashed border + borderClass, + "p-3 rounded flex flex-col", + verticalClass, + spanClass, + ] + .filter(Boolean) + .join(" "), }), 0, ]; @@ -59,18 +73,42 @@ export const GridCell = Node.create({ addCommands() { return { - setVerticalAlign: - (alignment) => + /** + * Toggle solid/dashed border + */ + toggleCellBorder: + () => ({ state, dispatch }) => { - const gridCell = findParentNodeClosestToPos( + const cell = findParentNodeClosestToPos( state.selection.$from, (node) => node.type.name === "gridCell", ); - if (!gridCell) return false; + if (!cell) return false; - const { pos, node } = gridCell; + const { pos, node } = cell; const tr = state.tr.setNodeMarkup(pos, undefined, { ...node.attrs, + hasBorder: !node.attrs.hasBorder, + }); + + if (dispatch) dispatch(tr); + return true; + }, + + /** + * Set vertical alignment + */ + setVerticalAlign: + (alignment) => + ({ state, dispatch }) => { + const cell = findParentNodeClosestToPos( + state.selection.$from, + (node) => node.type.name === "gridCell", + ); + if (!cell) return false; + + const tr = state.tr.setNodeMarkup(cell.pos, undefined, { + ...cell.node.attrs, verticalAlign: alignment, }); @@ -78,33 +116,9 @@ export const GridCell = Node.create({ return true; }, - // setColumnSpan: span => ({ state, dispatch }) => { - // const gridCell = findParentNodeClosestToPos( - // state.selection.$from, - // node => node.type.name === 'gridCell' - // ) - // if (!gridCell) return false - // - // const { pos, node } = gridCell - // - // const parentGrid = findParentNodeClosestToPos( - // state.selection.$from, - // node => node.type.name === 'grid' - // ) - // if (!parentGrid) return false - // - // const maxColumns = parentGrid.node.attrs.columns - // - // const newSpan = Math.min(span, maxColumns) - // - // const tr = state.tr.setNodeMarkup(pos, undefined, { - // ...node.attrs, - // columnSpan: newSpan, - // }) - // - // if (dispatch) dispatch(tr) - // return true - // }, + /** + * Set column span (clamped by parent grid) + */ setColumnSpan: (span) => ({ state, dispatch }) => { @@ -120,7 +134,7 @@ export const GridCell = Node.create({ ); if (!grid) return false; - const max = grid.node.attrs.columns; + const max = grid.node.attrs.columns || 1; const safeSpan = Math.max(1, Math.min(span, max)); const tr = state.tr.setNodeMarkup(cell.pos, undefined, { From 6275264c4e4a8c915c078d0502f8d792ea3e4dc0 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Thu, 1 Jan 2026 15:30:28 -0500 Subject: [PATCH 18/20] reorder menu --- .../javascript/rhino/grid/grid-menu.js | 42 +++++++++---------- .../javascript/rhino/grid/gridCell.js | 13 +----- 2 files changed, 22 insertions(+), 33 deletions(-) diff --git a/app/frontend/javascript/rhino/grid/grid-menu.js b/app/frontend/javascript/rhino/grid/grid-menu.js index 3eeddc6c6..1919e497e 100644 --- a/app/frontend/javascript/rhino/grid/grid-menu.js +++ b/app/frontend/javascript/rhino/grid/grid-menu.js @@ -6,6 +6,11 @@ export function renderGridMenu(editor) { if (!editor || !editor.isActive("grid")) return html``; const buttons = [ + { + title: "Delete Grid", + icon: "🗑", + action: () => editor.chain().focus().deleteGrid().run(), + }, { title: "Add Cell", icon: "+", @@ -34,31 +39,11 @@ export function renderGridMenu(editor) { icon: "−", action: () => editor.chain().focus().decreaseGridColumns().run(), }, - { - title: "Delete Grid", - icon: "🗑", - action: () => editor.chain().focus().deleteGrid().run(), - }, - { - title: "Align Top", - icon: "↑", - action: () => editor.chain().focus().setVerticalAlign("top").run(), - }, - { - title: "Align Center", - icon: "↕", - action: () => editor.chain().focus().setVerticalAlign("center").run(), - }, - { - title: "Align Botton", - icon: "↓", - action: () => editor.chain().focus().setVerticalAlign("bottom").run(), - }, + { title: "Set Column Span", icon: "S", action: () => { - // Make sure editor is defined if (!editor) return; const { state } = editor; @@ -98,6 +83,21 @@ export function renderGridMenu(editor) { } }, }, + { + title: "Align Top", + icon: "↑", + action: () => editor.chain().focus().setVerticalAlign("top").run(), + }, + { + title: "Align Center", + icon: "↕", + action: () => editor.chain().focus().setVerticalAlign("center").run(), + }, + { + title: "Align Botton", + icon: "↓", + action: () => editor.chain().focus().setVerticalAlign("bottom").run(), + }, ]; return html` diff --git a/app/frontend/javascript/rhino/grid/gridCell.js b/app/frontend/javascript/rhino/grid/gridCell.js index 7ed886030..c6574fec7 100644 --- a/app/frontend/javascript/rhino/grid/gridCell.js +++ b/app/frontend/javascript/rhino/grid/gridCell.js @@ -58,7 +58,7 @@ export const GridCell = Node.create({ "data-type": "grid-cell", hasborder: node.attrs.hasBorder ? "true" : "false", class: [ - "grid-cell-editor", // always present for editor dashed border + "grid-cell-editor", borderClass, "p-3 rounded flex flex-col", verticalClass, @@ -73,9 +73,6 @@ export const GridCell = Node.create({ addCommands() { return { - /** - * Toggle solid/dashed border - */ toggleCellBorder: () => ({ state, dispatch }) => { @@ -94,10 +91,6 @@ export const GridCell = Node.create({ if (dispatch) dispatch(tr); return true; }, - - /** - * Set vertical alignment - */ setVerticalAlign: (alignment) => ({ state, dispatch }) => { @@ -115,10 +108,6 @@ export const GridCell = Node.create({ if (dispatch) dispatch(tr); return true; }, - - /** - * Set column span (clamped by parent grid) - */ setColumnSpan: (span) => ({ state, dispatch }) => { From ac6a03495308638cac997d52b67d9346be3454f5 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Thu, 1 Jan 2026 15:41:31 -0500 Subject: [PATCH 19/20] clean up --- .../javascript/rhino/custom-editor.js | 541 +++++++++--------- .../javascript/rhino/grid/grid-menu.js | 3 - app/frontend/javascript/rhino/grid/grid.js | 3 - 3 files changed, 274 insertions(+), 273 deletions(-) diff --git a/app/frontend/javascript/rhino/custom-editor.js b/app/frontend/javascript/rhino/custom-editor.js index a43b1cc0e..01a58945f 100644 --- a/app/frontend/javascript/rhino/custom-editor.js +++ b/app/frontend/javascript/rhino/custom-editor.js @@ -2,20 +2,18 @@ // extends the default tiptap editor to have a toolbar // with table editing buttons in it. -import { html } from "lit" -import "rhino-editor/exports/styles/trix.css" -import { TipTapEditor } from "rhino-editor/exports/elements/tip-tap-editor.js" -import * as table_icons from "./table-icons.js" -import * as table_translations from "./table-translations.js" -import * as grid_icons from "./grid/grid-icons.js" -import * as align_icons from "./align-icons.js" -import { application } from "../controllers/application" +import { html } from "lit"; +import "rhino-editor/exports/styles/trix.css"; +import { TipTapEditor } from "rhino-editor/exports/elements/tip-tap-editor.js"; +import * as table_icons from "./table-icons.js"; +import * as table_translations from "./table-translations.js"; +import * as grid_icons from "./grid/grid-icons.js"; +import * as align_icons from "./align-icons.js"; +import { application } from "../controllers/application"; import { renderGridMenu } from "./grid/grid-menu.js"; class CustomEditor extends TipTapEditor { - renderToolbar() { - if (this.readonly) return html``; return html` @@ -89,23 +87,18 @@ class CustomEditor extends TipTapEditor { ${this.renderGridButton()} - - ${this.renderTableButton()} - + ${this.renderTableButton()} ${this.renderAttachmentButton()} - - + - + ${this.renderUndoButton()} @@ -120,13 +113,14 @@ class CustomEditor extends TipTapEditor { class="toolbar__button rhino-toolbar-button" type="button" @click=${() => { - const modalController = application.getControllerForElementAndIdentifier( - document.querySelector("[data-controller='rhino-source']"), - "rhino-source" - ) + const modalController = + application.getControllerForElementAndIdentifier( + document.querySelector("[data-controller='rhino-source']"), + "rhino-source", + ); if (modalController) { - modalController.registerEditor(this.editor) - modalController.show() + modalController.registerEditor(this.editor); + modalController.show(); } }} > @@ -144,8 +138,7 @@ class CustomEditor extends TipTapEditor { ${this.renderToolbarEnd()} - ${this.renderTableMenu()} - ${renderGridMenu(this.editor)} + ${this.renderTableMenu()} ${renderGridMenu(this.editor)} `; } @@ -157,13 +150,13 @@ class CustomEditor extends TipTapEditor { class="toolbar__button rhino-toolbar-button" title="Insert grid (Shift + click to enter custom dimensions)" @click=${(event) => { - this.editor.chain().focus(); // always focus first + this.editor.chain().focus(); if (event.shiftKey) { // Prompt user for custom dimensions const input = prompt( "Enter grid dimensions as columns,rows (e.g., 2,4):", - "2,2" + "2,2", ); if (!input) return; @@ -172,11 +165,16 @@ class CustomEditor extends TipTapEditor { const rows = parseInt(rowsStr.trim(), 10); if ( - isNaN(rows) || isNaN(columns) || - rows <= 0 || columns <= 0 || - rows > 6 || columns > 6 + isNaN(rows) || + isNaN(columns) || + rows <= 0 || + columns <= 0 || + rows > 6 || + columns > 6 ) { - alert("Invalid dimensions! Rows and columns must be between 1 and 6."); + alert( + "Invalid dimensions! Rows and columns must be between 1 and 6.", + ); return; } @@ -187,7 +185,6 @@ class CustomEditor extends TipTapEditor { } }} > - @@ -231,236 +232,235 @@ class CustomEditor extends TipTapEditor { ${table_icons.insertTable} - `; } renderTableMenu() { - if (!this.editor || !this.editor.isActive('table')) { + if (!this.editor || !this.editor.isActive("table")) { return html``; } return html` - - - - - - - - - - + + + + + + + + + + `; } @@ -469,31 +469,38 @@ class CustomEditor extends TipTapEditor { if (!this.editor) return html``; const alignmentOptions = [ - { name: 'left', icon: align_icons.alignLeft }, - { name: 'center', icon: align_icons.alignCenter}, - { name: 'right', icon: align_icons.alignRight}, - { name: 'justify', icon: align_icons.alignJustify, style: 'margin-inline-end:1rem;' }, + { name: "left", icon: align_icons.alignLeft }, + { name: "center", icon: align_icons.alignCenter }, + { name: "right", icon: align_icons.alignRight }, + { + name: "justify", + icon: align_icons.alignJustify, + style: "margin-inline-end:1rem;", + }, ]; - const canAlign = ['paragraph', 'heading'].some(type => this.editor.isActive(type)); + const canAlign = ["paragraph", "heading"].some((type) => + this.editor.isActive(type), + ); if (!canAlign) return html``; return html` ${alignmentOptions.map( - align => html` + (align) => html` - ` + `, )} `; } } -CustomEditor.define("custom-rhino-editor") +CustomEditor.define("custom-rhino-editor"); diff --git a/app/frontend/javascript/rhino/grid/grid-menu.js b/app/frontend/javascript/rhino/grid/grid-menu.js index 1919e497e..3f28a2cb6 100644 --- a/app/frontend/javascript/rhino/grid/grid-menu.js +++ b/app/frontend/javascript/rhino/grid/grid-menu.js @@ -48,14 +48,12 @@ export function renderGridMenu(editor) { const { state } = editor; - // Find the current grid cell const gridCell = findParentNodeClosestToPos( state.selection.$from, (node) => node.type.name === "gridCell", ); if (!gridCell) return; - // Find the parent grid node const parentGrid = findParentNodeClosestToPos( state.selection.$from, (node) => node.type.name === "grid", @@ -77,7 +75,6 @@ export function renderGridMenu(editor) { return; } - // Use chain() safely if (editor.chain) { editor.chain().focus().setColumnSpan(num).run(); } diff --git a/app/frontend/javascript/rhino/grid/grid.js b/app/frontend/javascript/rhino/grid/grid.js index e8d8708c6..6d9191421 100644 --- a/app/frontend/javascript/rhino/grid/grid.js +++ b/app/frontend/javascript/rhino/grid/grid.js @@ -70,9 +70,6 @@ export const Grid = Node.create({ return true; }, - /** - * Add ONE new cell to the grid - */ addGridCell: () => ({ state, dispatch }) => { From f68ff9a65109efa728e620f3dc042585a91a2a24 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Thu, 1 Jan 2026 19:26:54 -0500 Subject: [PATCH 20/20] clean up --- app/frontend/javascript/rhino/grid/grid.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/frontend/javascript/rhino/grid/grid.js b/app/frontend/javascript/rhino/grid/grid.js index 6d9191421..fd72f2612 100644 --- a/app/frontend/javascript/rhino/grid/grid.js +++ b/app/frontend/javascript/rhino/grid/grid.js @@ -162,13 +162,13 @@ export const Grid = Node.create({ if (nextCols === currentCols) return true; - // 1️⃣ Update grid columns + // Update grid columns tr.setNodeMarkup(grid.pos, undefined, { ...grid.node.attrs, columns: nextCols, }); - // 2️⃣ Clamp cell spans if needed + // Clamp cell spans if needed grid.node.forEach((child, offset) => { if (child.type.name !== "gridCell") return;