diff --git a/app/frontend/javascript/rhino/align-icons.js b/app/frontend/javascript/rhino/align-icons.js
new file mode 100644
index 000000000..90dd8bd0f
--- /dev/null
+++ b/app/frontend/javascript/rhino/align-icons.js
@@ -0,0 +1,41 @@
+import { html, svg } from "lit";
+
+function toSvg(path, size = 24) {
+ return html`
+
+ `
+}
+
+export const alignLeft = toSvg(
+svg`
+
+`
+);
+
+export const alignCenter = toSvg(
+svg`
+
+`
+);
+
+export const alignRight= toSvg(
+svg`
+
+`
+);
+
+export const alignJustify= toSvg(
+svg`
+
+`
+);
diff --git a/app/frontend/javascript/rhino/custom-editor.css b/app/frontend/javascript/rhino/custom-editor.css
new file mode 100644
index 000000000..52fe51f96
--- /dev/null
+++ b/app/frontend/javascript/rhino/custom-editor.css
@@ -0,0 +1,58 @@
+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 */
+}
+
+/* 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/custom-editor.js b/app/frontend/javascript/rhino/custom-editor.js
index b5e03d6cd..01a58945f 100644
--- a/app/frontend/javascript/rhino/custom-editor.js
+++ b/app/frontend/javascript/rhino/custom-editor.js
@@ -2,17 +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 { 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`
@@ -85,30 +86,41 @@ class CustomEditor extends TipTapEditor {
-
- ${this.renderTableButton()}
-
+ ${this.renderGridButton()}
+ ${this.renderTableButton()}
${this.renderAttachmentButton()}
-
+
+
+
+
+
+ ${this.renderUndoButton()}
+
+
+
+
+ ${this.renderRedoButton()}
+
+
+
-
-
-
-
- ${this.renderUndoButton()}
-
-
-
-
- ${this.renderRedoButton()}
-
-
${this.renderToolbarEnd()}
- ${this.renderTableMenu()}
+ ${this.renderTableMenu()} ${renderGridMenu(this.editor)}
`;
}
- renderTableButton() {
- const tableEnabled = true; // Boolean(this.editor?.commands.setAttachment);
-
- if (!tableEnabled) return html``;
-
- const isDisabled = this.editor == null;
+ renderGridButton() {
return html`
`;
}
- renderTableMenu() {
- if (!this.editor || !this.editor.isActive('table')) {
- return html``;
- }
+
+ renderTableButton() {
+ const tableEnabled = true; // Boolean(this.editor?.commands.setAttachment);
+
+ if (!tableEnabled) return html``;
+
+ const isDisabled = this.editor == null;
return html`
-
-
-
-
-
-
-
-
-
-
+ `;
+ }
+ renderTableMenu() {
+ if (!this.editor || !this.editor.isActive("table")) {
+ return html``;
+ }
+ return html`
+
+
+
+
+
+
+
+
+
+
+
`;
}
@@ -409,31 +469,38 @@ class CustomEditor extends TipTapEditor {
if (!this.editor) return html``;
const alignmentOptions = [
- { name: 'left', icon: '⬅️' },
- { name: 'center', icon: '↔️' },
- { name: 'right', icon: '➡️' },
- { name: 'justify', icon: '⏹', 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/extend-editor.js b/app/frontend/javascript/rhino/extend-editor.js
index 3fd6a6b53..4ef2a9a01 100644
--- a/app/frontend/javascript/rhino/extend-editor.js
+++ b/app/frontend/javascript/rhino/extend-editor.js
@@ -1,10 +1,13 @@
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'
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 { GridCell } from './grid/gridCell'
function extendRhinoEditor(event) {
const rhinoEditor = event.target
@@ -18,7 +21,9 @@ function extendRhinoEditor(event) {
Youtube.configure({ nocookie: true }),
TextAlign.configure({
types: ['heading', 'paragraph'],
- })
+ }),
+ Grid,
+ GridCell
)
}
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..514317481
--- /dev/null
+++ b/app/frontend/javascript/rhino/grid/grid-icons.js
@@ -0,0 +1,63 @@
+import { html, svg } from "lit";
+
+function toSvg(path, size = 24) {
+ return html`
+
+ `
+}
+
+export const insertGrid = toSvg(
+ svg`
+
+ `
+
+);
+
+export const alignLeft = toSvg(
+svg`
+
+`
+);
+
+export const alignCenter = toSvg(
+svg`
+
+`
+);
+
+export const alignRight= toSvg(
+svg`
+
+`
+);
+
+export const alignJustify= toSvg(
+svg`
+
+`
+);
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..3f28a2cb6
--- /dev/null
+++ b/app/frontend/javascript/rhino/grid/grid-menu.js
@@ -0,0 +1,121 @@
+import { html } from "lit";
+import "rhino-editor/exports/styles/trix.css";
+import { findParentNodeClosestToPos } from "@tiptap/core";
+
+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: "+",
+ action: () => editor.chain().focus().addGridCell().run(),
+ },
+ {
+ title: "Delete Cell",
+ icon: "−",
+ action: () => editor.chain().focus().deleteLastGridCell().run(),
+ },
+
+ {
+ title: "Toggle Border",
+ icon: "border",
+ action: () => editor.chain().focus().toggleCellBorder().run(),
+ },
+
+ {
+ title: "Add Column",
+ icon: "+",
+ action: () => editor.chain().focus().increaseGridColumns().run(),
+ },
+
+ {
+ title: "Remove Column",
+ icon: "−",
+ action: () => editor.chain().focus().decreaseGridColumns().run(),
+ },
+
+ {
+ title: "Set Column Span",
+ icon: "S",
+ action: () => {
+ if (!editor) return;
+
+ const { state } = editor;
+
+ const gridCell = findParentNodeClosestToPos(
+ state.selection.$from,
+ (node) => node.type.name === "gridCell",
+ );
+ if (!gridCell) return;
+
+ const parentGrid = findParentNodeClosestToPos(
+ state.selection.$from,
+ (node) => node.type.name === "grid",
+ );
+ if (!parentGrid) return;
+
+ const maxColumns = parentGrid.node.attrs.columns;
+
+ const span = prompt(
+ `Enter column span (1–${maxColumns}):`,
+ gridCell.node.attrs.columnSpan,
+ );
+ const num = parseInt(span, 10);
+
+ if (!num || num < 1 || num > maxColumns) {
+ alert(
+ `Invalid input! Please enter a number between 1 and ${maxColumns} or add more columns first.`,
+ );
+ return;
+ }
+
+ if (editor.chain) {
+ editor.chain().focus().setColumnSpan(num).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(),
+ },
+ ];
+
+ return html`
+
+ ${buttons.map(
+ (btn) => html`
+
+ `,
+ )}
+
+ `;
+}
diff --git a/app/frontend/javascript/rhino/grid/grid.js b/app/frontend/javascript/rhino/grid/grid.js
new file mode 100644
index 000000000..fd72f2612
--- /dev/null
+++ b/app/frontend/javascript/rhino/grid/grid.js
@@ -0,0 +1,208 @@
+import {
+ Node,
+ mergeAttributes,
+ findParentNodeClosestToPos,
+} from "@tiptap/core";
+import { TextSelection } from "@tiptap/pm/state";
+
+export const Grid = Node.create({
+ name: "grid",
+ group: "block",
+ content: "gridCell+",
+ isolating: true,
+
+ addAttributes() {
+ return {
+ columns: {
+ default: 1,
+ },
+ };
+ },
+
+ parseHTML() {
+ return [{ tag: 'div[data-type="grid"]' }];
+ },
+
+ renderHTML({ node, HTMLAttributes }) {
+ 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",
+ mergeAttributes(HTMLAttributes, {
+ "data-type": "grid",
+ class: `grid gap-4 ${colsClass}`,
+ }),
+ 0,
+ ];
+ },
+
+ addCommands() {
+ return {
+ /**
+ * Insert a grid with ONE cell
+ */
+ insertGrid:
+ () =>
+ ({ tr, dispatch, editor }) => {
+ const { schema } = editor;
+
+ const gridNode = schema.nodes.grid.create({ columns: 1 }, [
+ schema.nodes.gridCell.create({}, [schema.nodes.paragraph.create()]),
+ ]);
+
+ if (!dispatch) return true;
+
+ const pos = tr.selection.from;
+
+ tr.replaceSelectionWith(gridNode);
+ tr.setSelection(TextSelection.near(tr.doc.resolve(pos + 1)));
+ tr.scrollIntoView();
+
+ dispatch(tr);
+ return true;
+ },
+
+ 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",
+ );
+ if (!grid) return false;
+
+ 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,
+ });
+
+ 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);
+
+ if (nextCols === currentCols) return true;
+
+ // Update grid columns
+ tr.setNodeMarkup(grid.pos, undefined, {
+ ...grid.node.attrs,
+ columns: nextCols,
+ });
+
+ // 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,
+ });
+ }
+ });
+
+ if (dispatch) dispatch(tr);
+ return true;
+ },
+ /**
+ * Delete entire grid
+ */
+ deleteGrid:
+ () =>
+ ({ state, dispatch }) => {
+ const { selection, tr } = state;
+
+ 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
new file mode 100644
index 000000000..c6574fec7
--- /dev/null
+++ b/app/frontend/javascript/rhino/grid/gridCell.js
@@ -0,0 +1,139 @@
+import {
+ Node,
+ mergeAttributes,
+ findParentNodeClosestToPos,
+} from "@tiptap/core";
+
+export const GridCell = Node.create({
+ name: "gridCell",
+ group: "block",
+ content: "block+",
+ isolating: true,
+
+ addAttributes() {
+ return {
+ verticalAlign: {
+ default: "top", // top | center | bottom
+ },
+ columnSpan: {
+ default: 1,
+ },
+ hasBorder: {
+ default: true,
+ },
+ };
+ },
+
+ parseHTML() {
+ return [{ tag: "div[data-type='grid-cell']" }];
+ },
+
+ renderHTML({ node, HTMLAttributes }) {
+ const alignClasses = {
+ 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",
+ };
+
+ 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",
+ hasborder: node.attrs.hasBorder ? "true" : "false",
+ class: [
+ "grid-cell-editor",
+ borderClass,
+ "p-3 rounded flex flex-col",
+ verticalClass,
+ spanClass,
+ ]
+ .filter(Boolean)
+ .join(" "),
+ }),
+ 0,
+ ];
+ },
+
+ addCommands() {
+ return {
+ toggleCellBorder:
+ () =>
+ ({ state, dispatch }) => {
+ const cell = findParentNodeClosestToPos(
+ state.selection.$from,
+ (node) => node.type.name === "gridCell",
+ );
+ if (!cell) return false;
+
+ const { pos, node } = cell;
+ const tr = state.tr.setNodeMarkup(pos, undefined, {
+ ...node.attrs,
+ hasBorder: !node.attrs.hasBorder,
+ });
+
+ if (dispatch) dispatch(tr);
+ return true;
+ },
+ 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,
+ });
+
+ 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 || 1;
+ 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;
+ },
+ };
+ },
+});
diff --git a/app/frontend/stylesheets/application.tailwind.css b/app/frontend/stylesheets/application.tailwind.css
index 0e5f6fd00..80773d269 100644
--- a/app/frontend/stylesheets/application.tailwind.css
+++ b/app/frontend/stylesheets/application.tailwind.css
@@ -83,58 +83,30 @@
@apply text-primary border-b-2 border-primary font-bold bg-gray-100;
}
+/* Prevent pag breaks on images when printing */
+@media print {
+ table {
+ page-break-inside: auto;
+ }
-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;
+ tr {
+ break-inside: avoid;
+ page-break-inside: avoid;
+ }
+
+ td {
+ break-inside: avoid;
+ page-break-inside: avoid;
+ }
+
+ img {
+ max-width: 100%;
+ height: auto;
+ }
}
+
+/* Rich Text tables horizontal scroll */
.tableWrapper {
padding: 1rem 0;
overflow-x: auto;
-
}
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 %>