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`
+