diff --git a/DESIGN.md b/DESIGN.md index a6210f794..9cfd01d97 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -212,7 +212,7 @@ A UC Davis institutional palette: Aggie Blue is the foundation, Aggie Gold is th - **Ink** (`ink`): Quasar `dark`; default high-contrast text. `dark-page` is the dark page background. - **Body Grey** (`body-grey`): The AA-safe muted text color, `--ucdavis-black-60`. Quasar's default `.text-grey` and `.bg-grey` are remapped to this so muted text still clears 4.5:1. - **Surface** (`surface`): Card, panel, and workspace background, and the welcome card over the hero photo. -- **Table Header** (`table-header`): Sticky `q-table` header fill, and the fill on `q-table__top` and `q-table__bottom`. +- **Table Header** (`table-header`): Sticky `q-table` header fill, the fill on `q-table__top` and `q-table__bottom`, and the header fill on bordered tables in CMS content. - **Gold Text** (`gold-text`): The darkened gold used for gold-colored *text* on light backgrounds, since bright gold fails AA at text sizes. - **Focus Blue** (`focus-blue`): The outer ring of the keyboard focus halo in the app. The only non-brand hue in the system. - **Splash Card Ink** (`splash-card-ink`) and **Splash Card Muted** (`splash-card-muted`): Body and secondary text inside the white sign-in card. Slightly warmer and softer than the workspace pairing, because the card sits on a photograph rather than on a page. diff --git a/VueApp/src/components/RichTextEditor.vue b/VueApp/src/components/RichTextEditor.vue index b7d3bc820..e2f11472e 100644 --- a/VueApp/src/components/RichTextEditor.vue +++ b/VueApp/src/components/RichTextEditor.vue @@ -39,7 +39,7 @@ import EditorLinkDialog from "@/components/editor/EditorLinkDialog.vue" import EditorImageDialog from "@/components/editor/EditorImageDialog.vue" import EditorTableDialog from "@/components/editor/EditorTableDialog.vue" import { buildImageHtml, buildLinkHtml, buildTableHtml, parseLinkHref } from "@/components/editor/editor-html" -import type { LinkKind } from "@/components/editor/editor-html" +import type { LinkKind, TableOptions } from "@/components/editor/editor-html" /** * Shared rich-text (HTML) editor wrapping Quasar's QEditor. Centralizes the accessibility and @@ -309,7 +309,7 @@ function onImageSubmit(value: { src: string; alt: string }) { void closeThenRun(imageDialogOpen, savedRange, "insertHTML", buildImageHtml(value)) } -function onTableSubmit(value: { rows: number; cols: number; header: boolean }) { +function onTableSubmit(value: TableOptions) { void closeThenRun(tableDialogOpen, savedRange, "insertHTML", buildTableHtml(value)) } diff --git a/VueApp/src/components/__tests__/editor-html.test.ts b/VueApp/src/components/__tests__/editor-html.test.ts index 252b3dcf4..af4c3c44b 100644 --- a/VueApp/src/components/__tests__/editor-html.test.ts +++ b/VueApp/src/components/__tests__/editor-html.test.ts @@ -121,7 +121,7 @@ test("buildImageHtml makes src relative and escapes alt, emitting alt even when test("buildTableHtml with a header splits header row from body rows", () => { const html = buildTableHtml({ rows: 3, cols: 3, header: true }) expect(html).toBe( - "" + + '
   
' + "" + "
   
   
   


", ) @@ -135,10 +135,15 @@ test("buildTableHtml with rows=1 and header=true has no tbody at all", () => { test("buildTableHtml with rows=1 and header=false has a single body row and no thead", () => { const html = buildTableHtml({ rows: 1, cols: 2, header: false }) - expect(html).toBe("
  


") + expect(html).toBe('
  


') expect(html).not.toContain(" { + const html = buildTableHtml({ rows: 1, cols: 1, header: false, border: false, align: "center" }) + expect(html).toBe('
 


') +}) + test("buildTableHtml clamps cols to 20", () => { const html = buildTableHtml({ rows: 1, cols: 99, header: false }) expect(html.match(//gu) ?? []).toHaveLength(20) diff --git a/VueApp/src/components/__tests__/rich-text-editor.test.ts b/VueApp/src/components/__tests__/rich-text-editor.test.ts index 5fa076fce..d4441e342 100644 --- a/VueApp/src/components/__tests__/rich-text-editor.test.ts +++ b/VueApp/src/components/__tests__/rich-text-editor.test.ts @@ -5,13 +5,15 @@ import RichTextEditor from "@/components/RichTextEditor.vue" import EditorImageDialog from "@/components/editor/EditorImageDialog.vue" import EditorLinkDialog from "@/components/editor/EditorLinkDialog.vue" import EditorTableDialog from "@/components/editor/EditorTableDialog.vue" +import RecordFormDialog from "@/components/RecordFormDialog.vue" import { MAX_TABLE_COLS } from "@/components/editor/editor-html" +import type { TableAlign } from "@/components/editor/editor-html" // diff --git a/VueApp/src/components/editor/editor-html.ts b/VueApp/src/components/editor/editor-html.ts index 6284ce65f..ea1750d5e 100644 --- a/VueApp/src/components/editor/editor-html.ts +++ b/VueApp/src/components/editor/editor-html.ts @@ -120,22 +120,34 @@ function tbody(rowCount: number, bodyRow: string): string { const MAX_TABLE_ROWS = 50 const MAX_TABLE_COLS = 20 +/** Table alignment, written as the presentational align attribute; "" leaves the attribute off. */ +type TableAlign = "" | "left" | "center" | "right" + +interface TableOptions { + rows: number + cols: number + header: boolean + /** Off writes border="0", CKEditor's (VIPER 1) marker for a layout table, which base.css leaves unstyled. */ + border?: boolean + align?: TableAlign +} + /** * Build a skeleton. `rows` is the total row count including the header row when `header` * is true. Every cell holds   so the caret can enter it in contenteditable. A trailing * `


` is appended so the user can type below the table. */ -function buildTableHtml(opts: { rows: number; cols: number; header: boolean }): string { +function buildTableHtml(opts: TableOptions): string { const rows = clamp(opts.rows, 1, MAX_TABLE_ROWS) const cols = clamp(opts.cols, 1, MAX_TABLE_COLS) const headerRow = `${"".repeat(cols)}` const bodyRow = `${"".repeat(cols)}` - const table = opts.header - ? `
 
 
${headerRow}${tbody(rows - 1, bodyRow)}
` - : `${tbody(rows, bodyRow)}
` - return `${table}


` + const border = (opts.border ?? true) ? 1 : 0 + const align = opts.align ? ` align="${opts.align}"` : "" + const inner = opts.header ? `${headerRow}${tbody(rows - 1, bodyRow)}` : tbody(rows, bodyRow) + return `${inner}


` } export { @@ -150,4 +162,4 @@ export { MAX_TABLE_ROWS, MAX_TABLE_COLS, } -export type { LinkKind } +export type { LinkKind, TableAlign, TableOptions } diff --git a/VueApp/src/styles/base.css b/VueApp/src/styles/base.css index 5dd213e58..de42ed570 100644 --- a/VueApp/src/styles/base.css +++ b/VueApp/src/styles/base.css @@ -238,7 +238,7 @@ div.breadcrumbs { .q-table__bottom, .q-table__middle table thead tr:first-child th { /* bg color is important for th; just specify one */ - background-color: #eee; + background-color: var(--table-header); white-space: nowrap; } @@ -621,33 +621,41 @@ header .q-avatar { line-height: 1.4; } -/* Tables and images inside sanitized CMS content, in the live block and the editor alike. Tables - carry no default borders, so a table inserted from the editor would be invisible without these. - A table too wide for the block scrolls inside it (overflow-x on the two containers) instead of - widening the page. */ +/* Tables and images inside sanitized CMS content, in the live block and the editor alike. A table + too wide for the block scrolls inside it (overflow-x on the two containers) instead of widening + the page. */ .content-block, .content-block-editor .q-editor__content { overflow-x: auto; } -.content-block table, -.content-block-editor .q-editor__content table { +:is(.content-block, .content-block-editor .q-editor__content) table { max-width: 100%; +} + +/* Only tables that opt in with a border attribute get borders, cell padding and a header fill. The + editor's table dialog emits border="1"; legacy VIPER 1 content used the same attribute, with + border="0" meaning "no borders". Tables without it (mostly layout tables in migrated content) + keep the browser defaults they rendered with in VIPER 1. */ +:is(.content-block, .content-block-editor .q-editor__content) table[border]:not([border="0"]) { border-collapse: collapse; } -.content-block th, -.content-block td, -.content-block-editor .q-editor__content th, -.content-block-editor .q-editor__content td { +:is(.content-block, .content-block-editor .q-editor__content) table[border]:not([border="0"]) :is(th, td) { padding: 0.25rem 0.5rem; border: 1px solid var(--ucdavis-black-20); } -.content-block th, -.content-block-editor .q-editor__content th { - /* The table-header token, matching the q-table header fill above */ - background-color: #eee; +:is(.content-block, .content-block-editor .q-editor__content) table[border]:not([border="0"]) th { + background-color: var(--table-header); +} + +/* A table without visible borders has nothing marking its cells in the editor's formatted view, so + the editor alone draws a dotted guide, as VIPER 1's CKEditor did with its showborders plugin. The + live block has no such rule, so the page still renders the table borderless. */ +.content-block-editor .q-editor__content table:not([border]) :is(th, td), +.content-block-editor .q-editor__content table[border="0"] :is(th, td) { + border: 1px dotted var(--ucdavis-black-20); } .content-block img, diff --git a/VueApp/src/styles/colors.css b/VueApp/src/styles/colors.css index 644bce9d1..4fd6c6532 100644 --- a/VueApp/src/styles/colors.css +++ b/VueApp/src/styles/colors.css @@ -65,6 +65,10 @@ ring in base.css / site.css so components can reference a token instead of repeating the hex. */ --focus-ring-color: #258cfb; + + /* Table header fill, shared by q-table headers and bordered CMS content + tables so the two treatments cannot drift apart. */ + --table-header: #eee; } /* Background utility classes — UC Davis palette */ diff --git a/test/Services/HtmlSanitizerServiceTests.cs b/test/Services/HtmlSanitizerServiceTests.cs index aa94f8293..73ddfa216 100644 --- a/test/Services/HtmlSanitizerServiceTests.cs +++ b/test/Services/HtmlSanitizerServiceTests.cs @@ -140,6 +140,10 @@ public void Strips_legacy_custom_tags(string input) [InlineData("x", "target=\"_blank\"", "rel=\"noopener\"")] [InlineData("x", "download=\"file.pdf\"", "href=\"https://example.com/file.pdf\"")] [InlineData("
h
", "", "scope=\"col\"")] + [InlineData("
x
", "border=\"1\"", "")] + [InlineData("
x
", "align=\"center\"", "align=\"right\"")] + [InlineData("
x
", "cellpadding=\"4\"", "cellspacing=\"0\"")] + [InlineData("
x
", "valign=\"top\"", "")] public void Preserves_allowed_constructs(string input, string mustContain1, string mustContain2) { var output = _sanitizer.Sanitize(input); diff --git a/web/Services/HtmlSanitizerService.cs b/web/Services/HtmlSanitizerService.cs index 0001f47ec..366e9ead0 100644 --- a/web/Services/HtmlSanitizerService.cs +++ b/web/Services/HtmlSanitizerService.cs @@ -51,6 +51,9 @@ private static HtmlSanitizer BuildSanitizer(bool allowDiffMarkers) { "href", "src", "alt", "title", "class", "id", "name", "width", "height", "colspan", "rowspan", "scope", + // Presentational table attributes migrated VIPER 1 content carries (the set legacy + // antisamy-cms.xml allowed); border additionally selects bordered styling in base.css. + "border", "align", "valign", "cellpadding", "cellspacing", "target", "rel", "download", "style" })