mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-05 19:24:39 +00:00
feat: add new copy rubric button
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
parseAssessments,
|
||||
processAssessments,
|
||||
} from "./utils.ts";
|
||||
import { injectRubricCopyButtons } from "./rubricCopy.ts";
|
||||
|
||||
interface weightingsStorage {
|
||||
weightings: Record<string, string>;
|
||||
@@ -80,6 +81,7 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
||||
});
|
||||
api.seqta.onMount("[class*='SelectedAssessment__']", () => {
|
||||
injectWeightingsTab(api);
|
||||
injectRubricCopyButtons();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
const RUBRIC_SELECTOR =
|
||||
"[class*='AssessableCriterion__rubric___'][class*='Rubric__Rubric___'], [class*='Rubric__Rubric___'][class*='AssessableCriterion__rubric___']";
|
||||
const ENHANCED_ATTR = "data-betterseqta-rubric-copy";
|
||||
const STYLE_ID = "betterseqta-rubric-copy-styles-v2";
|
||||
|
||||
let observer: MutationObserver | null = null;
|
||||
|
||||
function ensureStyles() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = `
|
||||
.betterseqta-rubric-copy-host {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.betterseqta-rubric-copy-overlay {
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
padding: 0.75rem 0.85rem;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transition:
|
||||
opacity 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(0, 0, 0, 0.72) 0%,
|
||||
rgba(0, 0, 0, 0.42) 42%,
|
||||
rgba(0, 0, 0, 0.08) 72%,
|
||||
transparent 100%
|
||||
);
|
||||
border-radius: 0 0 8px 8px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.betterseqta-rubric-copy-host:hover .betterseqta-rubric-copy-overlay,
|
||||
.betterseqta-rubric-copy-host:focus-within .betterseqta-rubric-copy-overlay {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.betterseqta-rubric-copy-btn {
|
||||
pointer-events: auto;
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.75rem !important;
|
||||
margin: 0 !important;
|
||||
border: 1px solid rgba(15, 23, 42, 0.12) !important;
|
||||
border-radius: 8px !important;
|
||||
background: rgba(255, 255, 255, 0.96) !important;
|
||||
color: #0f172a !important;
|
||||
font-family: Rubik, system-ui, sans-serif !important;
|
||||
font-size: 0.8125rem !important;
|
||||
font-weight: 600 !important;
|
||||
line-height: 1 !important;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.28);
|
||||
transform: translateY(0) scale(1);
|
||||
transition:
|
||||
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
background 0.28s ease,
|
||||
color 0.28s ease,
|
||||
box-shadow 0.28s ease,
|
||||
border-color 0.28s ease;
|
||||
}
|
||||
|
||||
.betterseqta-rubric-copy-btn:hover {
|
||||
transform: translateY(-1px) scale(1.04) !important;
|
||||
background: #f1f5f9 !important;
|
||||
color: #0f172a !important;
|
||||
border-color: rgba(15, 23, 42, 0.18) !important;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
.betterseqta-rubric-copy-btn:active {
|
||||
transform: translateY(0) scale(0.98) !important;
|
||||
background: #e2e8f0 !important;
|
||||
color: #0f172a !important;
|
||||
}
|
||||
|
||||
.betterseqta-rubric-copy-btn:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(255, 255, 255, 0.95),
|
||||
0 0 0 4px rgba(59, 130, 246, 0.85) !important;
|
||||
}
|
||||
|
||||
.betterseqta-rubric-copy-btn svg {
|
||||
width: 1rem !important;
|
||||
height: 1rem !important;
|
||||
flex-shrink: 0;
|
||||
stroke: currentColor !important;
|
||||
fill: none !important;
|
||||
}
|
||||
|
||||
.betterseqta-rubric-copy-btn.is-copied {
|
||||
background: #ecfdf5 !important;
|
||||
color: #047857 !important;
|
||||
border-color: rgba(4, 120, 87, 0.25) !important;
|
||||
}
|
||||
|
||||
.betterseqta-rubric-copy-btn.is-copied:hover {
|
||||
background: #d1fae5 !important;
|
||||
color: #065f46 !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.betterseqta-rubric-copy-overlay,
|
||||
.betterseqta-rubric-copy-btn {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function cellText(element: Element | null | undefined): string {
|
||||
return element?.textContent?.replace(/\s+/g, " ").trim() ?? "";
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
interface RubricCell {
|
||||
text: string;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
interface RubricTableData {
|
||||
header: string[];
|
||||
rows: RubricCell[][];
|
||||
}
|
||||
|
||||
function parseRubricTable(rubric: Element): RubricTableData | null {
|
||||
const lines = rubric.querySelectorAll("[class*='Rubric__line___']");
|
||||
const rows: RubricCell[][] = [];
|
||||
|
||||
lines.forEach((line) => {
|
||||
const meta = line.querySelector("[class*='Rubric__meta___']");
|
||||
const label = cellText(meta?.querySelector("[class*='Rubric__label___']"));
|
||||
const criterion = cellText(
|
||||
meta?.querySelector("[class*='Rubric__description___']"),
|
||||
);
|
||||
|
||||
const row: RubricCell[] = [
|
||||
{ text: label, selected: false },
|
||||
{ text: criterion, selected: false },
|
||||
];
|
||||
|
||||
line.querySelectorAll("[class*='Rubric__descriptor___']").forEach((descriptor) => {
|
||||
const text = cellText(
|
||||
descriptor.querySelector("[class*='Rubric__description___']"),
|
||||
);
|
||||
const selected = Array.from(descriptor.classList).some((cls) =>
|
||||
cls.startsWith("Rubric__selected___"),
|
||||
);
|
||||
row.push({ text, selected });
|
||||
});
|
||||
|
||||
if (row.some((cell) => cell.text)) rows.push(row);
|
||||
});
|
||||
|
||||
if (!rows.length) return null;
|
||||
|
||||
const maxCols = Math.max(...rows.map((row) => row.length));
|
||||
const normalized = rows.map((row) => {
|
||||
const copy = [...row];
|
||||
while (copy.length < maxCols) {
|
||||
copy.push({ text: "", selected: false });
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
|
||||
const header = [
|
||||
"Category",
|
||||
"Criterion",
|
||||
...Array.from({ length: maxCols - 2 }, (_, i) => `Band ${i + 1}`),
|
||||
].slice(0, maxCols);
|
||||
|
||||
return { header, rows: normalized };
|
||||
}
|
||||
|
||||
function rubricToPlainText(table: RubricTableData): string {
|
||||
const formatCell = (cell: RubricCell) =>
|
||||
cell.selected && cell.text ? `${cell.text} (selected)` : cell.text;
|
||||
|
||||
return [
|
||||
table.header.join("\t"),
|
||||
...table.rows.map((row) => row.map(formatCell).join("\t")),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const RUBRIC_PASTE_FONT_PT = 7;
|
||||
|
||||
function rubricPasteFontStyle(): string {
|
||||
return [
|
||||
`font-size:${RUBRIC_PASTE_FONT_PT}pt`,
|
||||
"mso-ansi-font-size:8.0pt",
|
||||
"mso-bidi-font-size:8.0pt",
|
||||
"font-family:Calibri,Arial,sans-serif",
|
||||
"line-height:1.2",
|
||||
].join(";");
|
||||
}
|
||||
|
||||
function rubricPasteCellContent(text: string): string {
|
||||
return `<span style="${rubricPasteFontStyle()}">${escapeHtml(text)}</span>`;
|
||||
}
|
||||
|
||||
function rubricToHtmlTable(table: RubricTableData): string {
|
||||
const baseFont = rubricPasteFontStyle();
|
||||
const cellStyle =
|
||||
`border:1px solid #000000;border-collapse:collapse;padding:4px;vertical-align:top;${baseFont}`;
|
||||
const headerStyle = `${cellStyle}background:#f3f4f6;font-weight:700;`;
|
||||
const selectedStyle = `${cellStyle}background:#dbeafe;font-weight:600;`;
|
||||
|
||||
const headerRow = table.header
|
||||
.map(
|
||||
(heading) =>
|
||||
`<th style="${headerStyle}">${rubricPasteCellContent(heading)}</th>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const bodyRows = table.rows
|
||||
.map((row) => {
|
||||
const cells = row
|
||||
.map((cell) => {
|
||||
const style = cell.selected ? selectedStyle : cellStyle;
|
||||
return `<td style="${style}">${rubricPasteCellContent(cell.text)}</td>`;
|
||||
})
|
||||
.join("");
|
||||
return `<tr>${cells}</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return [
|
||||
`<table border="1" cellpadding="0" cellspacing="0" style="border-collapse:collapse;width:100%;${baseFont}">`,
|
||||
`<thead><tr>${headerRow}</tr></thead>`,
|
||||
`<tbody>${bodyRows}</tbody>`,
|
||||
"</table>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function rubricToHtmlDocument(table: RubricTableData): string {
|
||||
return [
|
||||
"<!DOCTYPE html>",
|
||||
"<html>",
|
||||
"<head><meta charset=\"utf-8\"></head>",
|
||||
`<body style="${rubricPasteFontStyle()}">`,
|
||||
rubricToHtmlTable(table),
|
||||
"</body>",
|
||||
"</html>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
async function copyRubricTable(rubric: Element, button: HTMLButtonElement) {
|
||||
const table = parseRubricTable(rubric);
|
||||
if (!table) return;
|
||||
|
||||
const plain = rubricToPlainText(table);
|
||||
const htmlTable = rubricToHtmlTable(table);
|
||||
const htmlDocument = rubricToHtmlDocument(table);
|
||||
|
||||
let copied = false;
|
||||
|
||||
if (navigator.clipboard?.write && typeof ClipboardItem !== "undefined") {
|
||||
try {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
"text/plain": new Blob([plain], { type: "text/plain" }),
|
||||
"text/html": new Blob([htmlDocument], { type: "text/html" }),
|
||||
}),
|
||||
]);
|
||||
copied = true;
|
||||
} catch {
|
||||
// Fall through to legacy rich-text copy.
|
||||
}
|
||||
}
|
||||
|
||||
if (!copied) {
|
||||
const host = document.createElement("div");
|
||||
host.contentEditable = "true";
|
||||
host.innerHTML = htmlTable;
|
||||
host.style.position = "fixed";
|
||||
host.style.left = "-9999px";
|
||||
host.style.top = "0";
|
||||
document.body.appendChild(host);
|
||||
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(host);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
|
||||
copied = document.execCommand("copy");
|
||||
|
||||
selection?.removeAllRanges();
|
||||
host.remove();
|
||||
|
||||
if (!copied) {
|
||||
await navigator.clipboard.writeText(plain);
|
||||
}
|
||||
}
|
||||
|
||||
const label = button.querySelector(".betterseqta-rubric-copy-label");
|
||||
const original = label?.textContent ?? "Copy rubric";
|
||||
button.classList.add("is-copied");
|
||||
if (label) label.textContent = "Copied!";
|
||||
window.setTimeout(() => {
|
||||
button.classList.remove("is-copied");
|
||||
if (label) label.textContent = original;
|
||||
}, 1800);
|
||||
}
|
||||
|
||||
function createCopyButton(rubric: Element): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "betterseqta-rubric-copy-btn";
|
||||
button.setAttribute("aria-label", "Copy rubric");
|
||||
button.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||
</svg>
|
||||
<span class="betterseqta-rubric-copy-label">Copy rubric</span>
|
||||
`;
|
||||
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void copyRubricTable(rubric, button);
|
||||
});
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
function enhanceRubric(rubric: HTMLElement) {
|
||||
if (rubric.hasAttribute(ENHANCED_ATTR)) return;
|
||||
|
||||
const host = document.createElement("div");
|
||||
host.className = "betterseqta-rubric-copy-host";
|
||||
rubric.parentElement?.insertBefore(host, rubric);
|
||||
host.appendChild(rubric);
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "betterseqta-rubric-copy-overlay";
|
||||
overlay.appendChild(createCopyButton(rubric));
|
||||
host.appendChild(overlay);
|
||||
|
||||
rubric.setAttribute(ENHANCED_ATTR, "true");
|
||||
}
|
||||
|
||||
function enhanceRubrics(root: ParentNode = document) {
|
||||
ensureStyles();
|
||||
root.querySelectorAll<HTMLElement>(RUBRIC_SELECTOR).forEach(enhanceRubric);
|
||||
}
|
||||
|
||||
function watchRubrics(root: ParentNode) {
|
||||
observer?.disconnect();
|
||||
enhanceRubrics(root);
|
||||
|
||||
observer = new MutationObserver(() => {
|
||||
enhanceRubrics(root);
|
||||
});
|
||||
|
||||
observer.observe(root, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
export function injectRubricCopyButtons() {
|
||||
const root =
|
||||
document.querySelector("[class*='SelectedAssessment__']") ?? document;
|
||||
watchRubrics(root);
|
||||
}
|
||||
|
||||
export function teardownRubricCopyButtons() {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
}
|
||||
Reference in New Issue
Block a user