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 %>