md in notes

This commit is contained in:
Guy Sandler 2026-08-13 20:16:50 -07:00
parent 2ac702887f
commit 37dc65f488
10 changed files with 844 additions and 29 deletions

View File

@ -56,11 +56,18 @@ Canvas Refined adds more with more to come!
- Searching themes (the original didn't actually impliment that)
- made the dark mode into a css file instead of a reallllllly long string
- Card Styles (image size, card roundness, card spacing, width, height, theme compatible)
- Custom Background (by URL, theme compatible)
- Custom Background
- Use presets or add your own by URL
- Theme compatible
- Daily backgrounds
- Popup UI revamp
- NEW Better todo list
- Better sidebar
- Simplified UI
- Some options won't show up when not needed
- Markdown in dashboard notes
- Smaller additions:
- Equal height cards for card assignments
## Planned Features (by priority)
- auto rotate theme + theme history + fix theme submissions

View File

@ -2,6 +2,9 @@
"Canvas_Refined": {
"message": "Canvas Refined"
},
"search_features": {
"message": "Search features..."
},
"updates": {
"message": "Updates"
},

View File

@ -22,7 +22,7 @@
.canvasrefined-export-output {position: fixed;height:100vh;width:100vw;top:0;left:0;z-index:10000;background:#000000c7;display:flex;align-items:center;justify-content:center}
.canvasrefined-export-copy { font-size:12px;white-space: pre-wrap;max-height:80vh;overflow:auto;padding:8px}
.canvasrefined-export-output-inner {width: 50%;background:#000;color:#fff;}
.canvasrefined-dashboard-notes {width: 100%; box-sizing: border-box; overflow: hidden; resize: none;}
.canvasrefined-dashboard-notes {width:100%;box-sizing:border-box;margin:18px 0 14px;border:1px solid var(--bcborders,#c7cdd1);border-radius:4px;overflow:hidden;background:var(--bcbackground-1,#fff);}
.canvasrefined-card-container {padding-bottom: 4px;}
.canvasrefined-assignment-container {margin: 0;padding:0;color: var(--ic-brand-font-color-dark-lightened-30);display: flex;justify-content:space-between;font-size:14px;align-items: center;transition:.2s all;}
.canvasrefined-card-header-container {align-items: center;display: flex;justify-content: space-between;padding: 0 18px;color:var(--ic-brand-font-color-dark-lightened-30);font-size:16px;font-weight:700;margin: 0;}
@ -97,7 +97,7 @@
.canvasrefined-gpa-courses {min-width:600px;width:max-content;}
.canvasrefined-gpa-course-top, .canvasrefined-gpa-course-bottom {display: flex;}
.canvasrefined-gpa-header {font-weight:700;font-size:16px;margin-top:0;margin-bottom:6px;padding-bottom:6px;}
.canvasrefined-dashboard-notes {width: 100%; box-sizing: border-box; margin-top: 18px;border: 1px solid #c7cdd1;border-radius:3px;padding:8px 14px;resize:vertical}
.canvasrefined-notes-editor {box-sizing:border-box;width:100%;min-height:140px;border:none;background:transparent;resize:vertical;outline:none;font-family:inherit;font-size:14px;line-height:1.5;padding:12px 16px;color:var(--bctext-0,var(--ic-brand-font-color-dark-lightened-30,#2d3b45));}
.canvasrefined-todosidebar, #canvasrefined-todo-list, #canvasrefined-announcement-list {margin: 0}
.canvasrefined-todo-container {display: flex; margin-top: 14px;position: relative;transition: .2s all;}
.canvasrefined-removing {max-height: 0;margin-top: 0}
@ -176,4 +176,42 @@
50% {
opacity: .5;
}
}
}
/* ===== Dashboard notes (Markdown) ===== */
.canvasrefined-notes-toolbar {display:flex;flex-wrap:wrap;gap:2px;padding:6px 8px;align-items:center;background:var(--bcbackground-2,#f5f7f8);border-bottom:1px solid var(--bcborders,#c7cdd1);}
.cr-fmt {border:1px solid transparent;background:transparent;color:var(--bctext-1,#5b6770);font:inherit;font-size:13px;line-height:1;padding:5px 7px;border-radius:4px;cursor:pointer;}
.cr-fmt:hover {background:var(--bcbackground-1,rgba(0,0,0,.06));color:var(--bctext-0,#2d3b45);}
.cr-fmt:active {background:var(--bcbackground-1,rgba(0,0,0,.12));}
.cr-fmt code {font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
.cr-fmt-sep {width:1px;height:18px;align-self:center;background:var(--bcborders,#c7cdd1);margin:0 3px;}
.canvasrefined-notes-surface {position:relative;background:var(--bcbackground-1,#fff);}
.canvasrefined-notes-rendered {box-sizing:border-box;width:100%;min-height:140px;padding:12px 16px;font-size:14px;line-height:1.5;color:var(--bctext-0,var(--ic-brand-font-color-dark-lightened-30,#2d3b45));overflow-wrap:break-word;cursor:text;outline:none;border-radius:0 0 4px 4px;}
.canvasrefined-notes-rendered:hover {background:var(--bcbackground-2,rgba(0,0,0,.02));}
.canvasrefined-notes-rendered:empty::before {content:"Click to add a note…";color:var(--bctext-2,#9aa3ab);font-style:italic;}
.canvasrefined-notes-rendered > :first-child {margin-top:0;}
.canvasrefined-notes-rendered > :last-child {margin-bottom:0;}
.canvasrefined-notes-rendered h1,.canvasrefined-notes-rendered h2,.canvasrefined-notes-rendered h3,.canvasrefined-notes-rendered h4,.canvasrefined-notes-rendered h5,.canvasrefined-notes-rendered h6 {margin:14px 0 6px;font-weight:600;line-height:1.3;color:var(--bctext-0,#1f2d35);}
.canvasrefined-notes-rendered h1 {font-size:1.5em;}
.canvasrefined-notes-rendered h2 {font-size:1.3em;}
.canvasrefined-notes-rendered h3 {font-size:1.15em;}
.canvasrefined-notes-rendered h4 {font-size:1em;}
.canvasrefined-notes-rendered p {margin:8px 0;}
.canvasrefined-notes-rendered ul,.canvasrefined-notes-rendered ol {margin:8px 0;padding-left:24px;}
.canvasrefined-notes-rendered li {margin:3px 0;}
.canvasrefined-notes-rendered li.cr-task {list-style:none;margin-left:-18px;display:flex;align-items:flex-start;gap:6px;}
.canvasrefined-notes-rendered li.cr-task input {margin-top:4px;}
.canvasrefined-notes-rendered blockquote {margin:8px 0;padding:4px 12px;border-left:3px solid var(--bcborders,#c7cdd1);color:var(--bctext-1,#5b6770);background:var(--bcbackground-2,rgba(0,0,0,.03));}
.canvasrefined-notes-rendered code {font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.9em;background:var(--bcbackground-2,#f1f3f5);padding:1px 5px;border-radius:3px;}
.canvasrefined-notes-rendered pre {margin:10px 0;padding:10px 12px;background:var(--bcbackground-2,#f1f3f5);border:1px solid var(--bcborders,#e2e6e8);border-radius:4px;overflow:auto;}
.canvasrefined-notes-rendered pre code {background:transparent;padding:0;}
.canvasrefined-notes-rendered a {color:var(--bclinks,var(--ic-link-color,#0072b5));text-decoration:underline;}
.canvasrefined-notes-rendered a:hover {text-decoration:none;}
.canvasrefined-notes-rendered img {max-width:100%;height:auto;border-radius:3px;}
.canvasrefined-notes-rendered hr {border:none;border-top:1px solid var(--bcborders,#c7cdd1);margin:14px 0;}
.canvasrefined-notes-rendered strong {font-weight:600;}
.canvasrefined-notes-rendered del {color:var(--bctext-2,#9aa3ab);}
.canvasrefined-notes-editor::placeholder {color:var(--bctext-2,#9aa3ab);}
.canvasrefined-dashboard-notes:not(.is-editing) .canvasrefined-notes-toolbar {display:none;}
.canvasrefined-dashboard-notes.is-editing .canvasrefined-notes-rendered {display:none;}
.canvasrefined-dashboard-notes:not(.is-editing) .canvasrefined-notes-editor {display:none;}

View File

@ -252,4 +252,21 @@ input[type="checkbox"]:checked {background: #8dd28d;}
100% {
opacity: 1;
}
}
}
/* ===== Header feature search ===== */
.header-search { position: relative; margin-left: auto; display: flex; align-items: center; gap: 8px; width: 170px; max-width: 42%; background: var(--inputbg); border-radius: 8px; padding: 7px 10px; box-sizing: border-box; }
.header-search-icon { color: #9a9a9a; flex: none; }
.header-search:focus-within { box-shadow: 0 0 0 1.5px #56Caf0; }
#feature-search { flex: 1; min-width: 0; background: none; border: none; outline: none; color: #e2e2e2; font-family: inherit; font-size: 13px; font-weight: 600; }
#feature-search::placeholder { color: #8d8d8d; font-weight: 500; }
.search-results { position: absolute; top: calc(100% + 6px); right: 0; min-width: 240px; max-width: 320px; max-height: 320px; overflow-y: auto; background: #0e0e0ee0; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); border-radius: 8px; padding: 5px; z-index: 1000; display: none; box-shadow: 0 8px 24px rgba(0,0,0,.45); }
.search-results.open { display: block; }
.search-result { display: flex; flex-direction: column; gap: 1px; padding: 8px 10px; border-radius: 6px; cursor: pointer; }
.search-result.active, .search-result:hover { background: #ffffff14; }
.search-result-title { font-size: 13px; font-weight: 600; color: #f0f0f0; }
.search-result-category { font-size: 11px; color: #9a9a9a; }
.search-result.empty { text-align: center; color: #8d8d8d; font-size: 12px; cursor: default; padding: 12px; }
.search-result.empty:hover { background: none; }
.search-highlight { outline: 2px solid #56Caf0; outline-offset: 2px; border-radius: 8px; animation: search-pulse 1.8s ease-out; }
@keyframes search-pulse { 0% { box-shadow: 0 0 0 0 #56Caf099; } 40% { box-shadow: 0 0 0 6px #56Caf000; } 100% { box-shadow: 0 0 0 0 #56Caf000; } }

View File

@ -24,6 +24,11 @@
<div class="header">
<img id="bclogo" src="../icon/icon-128.png">
<h1 data-i18n="Canvas_Refined">Canvas Refined</h1>
<div class="header-search" id="header-search">
<svg class="header-search-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0" /><path d="M21 21l-6 -6" /></svg>
<input type="text" id="feature-search" data-i18n-placeholder="search_features" placeholder="Search features..." autocomplete="off" spellcheck="false">
<div class="search-results" id="feature-search-results"></div>
</div>
</div>
<div class="more-options-container">
<button id="customize-dark-btn" class="big-button tab-btn">

View File

@ -37,6 +37,7 @@ chrome.runtime.onInstalled.addListener(function () {
"assignment_date_format": false,
"dashboard_notes": false,
"dashboard_notes_text": "",
"dashboard_notes_mode": "edit",
"better_todo": false,
"todo_hr24": false,
"todo_separate_scrollbar": false,

View File

@ -657,6 +657,8 @@ function applyOptionsChanges(changes) {
changeGradientCards();
break;
case "dashboard_notes":
case "dashboard_notes_text":
case "dashboard_notes_mode":
loadDashboardNotes();
break;
case "dashboard_grades":
@ -4133,7 +4135,6 @@ Dashboard notes
*/
let dashboardNotesTimer;
let dashboardNotesResizeFrame;
function delayDashboardNotesStorage(text) {
clearTimeout(dashboardNotesTimer);
dashboardNotesTimer = setTimeout(() => {
@ -4141,39 +4142,278 @@ function delayDashboardNotesStorage(text) {
}, 250);
}
function resizeDashboardNotes(notes) {
if (!notes) return;
notes.style.height = "auto";
notes.style.height = `${notes.scrollHeight + 5}px`;
/*
Built-in fallback Markdown renderer. Used only if js/markdown.js failed to load
(window.renderMarkdown missing) so the notes still render formatted output instead
of showing raw markdown text. Covers the common subset: headings, bold/italic/strike,
inline + fenced code, links, images, lists, task lists, blockquotes, hr, paragraphs.
All user text is HTML-escaped before formatting.
*/
function crRenderMarkdownFallback(src) {
if (src == null) return "";
const escapeHtml = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
const sanitizeUrl = (u) => {
const v = String(u == null ? "" : u).trim();
if (!v) return "";
if (/^(https?:|mailto:|ftp:|tel:)/i.test(v)) return v;
if (/^(javascript:|vbscript:|file:|data:)/i.test(v)) return "#";
if (/^[#/?]/.test(v)) return v;
if (/^[a-z][a-z0-9+.-]*:/i.test(v)) return "#";
return v;
};
let text = String(src).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const out = [];
const lines = text.split("\n");
let i = 0;
const inline = (t) => {
let h = escapeHtml(t);
h = h.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (m, alt, url, title) =>
`<img src="${sanitizeUrl(url)}" alt="${alt}"${title ? ` title="${title}"` : ""}>`);
h = h.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (m, txt, url, title) =>
`<a href="${sanitizeUrl(url)}" target="_blank" rel="noopener noreferrer"${title ? ` title="${title}"` : ""}>${txt}</a>`);
h = h.replace(/`([^`\n]+)`/g, (m, c) => `<code>${c}</code>`);
h = h.replace(/\*\*([^*]+?)\*\*/g, "<strong>$1</strong>");
h = h.replace(/~~([^~]+?)~~/g, "<del>$1</del>");
h = h.replace(/(^|[^*])\*([^*]+?)\*(?!\*)/g, "$1<em>$2</em>");
return h;
};
while (i < lines.length) {
const line = lines[i];
if (/^\s*$/.test(line)) { i++; continue; }
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) { const l = h[1].length; out.push(`<h${l}>${inline(h[2])}</h${l}>`); i++; continue; }
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { out.push("<hr>"); i++; continue; }
if (/^>\s?/.test(line)) {
const q = []; while (i < lines.length && /^>\s?/.test(lines[i])) { q.push(inline(lines[i].replace(/^>\s?/, ""))); i++; }
out.push(`<blockquote>${q.join("<br>")}</blockquote>`); continue;
}
if (/^\s*[-*+]\s+/.test(line)) {
const items = []; while (i < lines.length) { const m = lines[i].match(/^\s*[-*+]\s+(.*)$/); if (!m) break; const tk = m[1].match(/^\[([ xX])\]\s+(.*)$/); if (tk) { items.push(`<li class="cr-task"><input type="checkbox" disabled${/x/i.test(tk[1]) ? " checked" : ""}> ${inline(tk[2])}</li>`); } else { items.push(`<li>${inline(m[1])}</li>`); } i++; } out.push(`<ul>${items.join("")}</ul>`); continue;
}
if (/^\s*\d+\.\s+/.test(line)) {
const items = []; while (i < lines.length) { const m = lines[i].match(/^\s*\d+\.\s+(.*)$/); if (!m) break; items.push(`<li>${inline(m[1])}</li>`); i++; } out.push(`<ol>${items.join("")}</ol>`); continue;
}
const para = [line]; i++; while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^(#{1,6}\s|\s*[-*+]\s|\s*\d+\.\s|>)/.test(lines[i])) { para.push(lines[i]); i++; }
out.push(`<p>${para.map(inline).join("<br>")}</p>`);
}
return out.join("\n");
}
function scheduleDashboardNotesResize(notes) {
if (dashboardNotesResizeFrame) cancelAnimationFrame(dashboardNotesResizeFrame);
dashboardNotesResizeFrame = requestAnimationFrame(() => {
dashboardNotesResizeFrame = null;
resizeDashboardNotes(notes);
function renderDashboardNotesPreview(preview, text) {
if (!preview) return;
// Skip identical re-renders: writing innerHTML is a childList mutation that the
// dashboard MutationObserver picks up, which re-calls loadDashboardNotes, which
// re-renders... Without this guard the notes box drives a tight self-sustaining
// loop (setTimeout 0) that starves the main thread so the render never paints.
if (preview._crLastText === text) return;
preview._crLastText = text;
const renderer = (typeof window.renderMarkdown === "function") ? window.renderMarkdown : crRenderMarkdownFallback;
preview.innerHTML = renderer(text);
}
/*
Insert/wrap Markdown formatting in the notes editor at the current selection.
Dispatches a synthetic `input` event so the live preview + storage handler runs.
*/
function notesApplyFormat(editor, action) {
if (!editor) return;
const start = editor.selectionStart;
const end = editor.selectionEnd;
const value = editor.value;
const fire = () => {
editor.dispatchEvent(new Event("input", { bubbles: true }));
editor.focus();
};
const wrap = (before, after, placeholder) => {
const had = end > start;
const sel = had ? value.slice(start, end) : (placeholder || "");
editor.setRangeText(before + sel + after, start, end, "end");
editor.selectionStart = start + before.length;
editor.selectionEnd = start + before.length + sel.length;
fire();
};
// Range covering every line touched by the selection.
const lineBlock = () => {
const ls = start === 0 ? 0 : value.lastIndexOf("\n", start - 1) + 1;
let le = value.indexOf("\n", end);
if (le === -1) le = value.length;
return { ls, le, block: value.slice(ls, le) };
};
const togglePrefix = (prefix) => {
const { ls, le, block } = lineBlock();
const lines = block.split("\n");
const allHave = lines.every((l) => l.startsWith(prefix));
const newBlock = lines.map((l) => allHave ? l.slice(prefix.length) : prefix + l).join("\n");
editor.setRangeText(newBlock, ls, le, "end");
editor.selectionStart = ls;
editor.selectionEnd = ls + newBlock.length;
fire();
};
switch (action) {
case "bold": wrap("**", "**", "bold"); break;
case "italic": wrap("*", "*", "italic"); break;
case "strike": wrap("~~", "~~", "strikethrough"); break;
case "code": wrap("`", "`", "code"); break;
case "h1": togglePrefix("# "); break;
case "h2": togglePrefix("## "); break;
case "list": togglePrefix("- "); break;
case "numbered": togglePrefix("1. "); break;
case "quote": togglePrefix("> "); break;
case "task": {
const { ls, le, block } = lineBlock();
const marker = block.match(/^-\s*\[([ xX])\]\s+/);
const bullet = block.match(/^[-*+]\s+/);
let newBlock;
if (marker) {
newBlock = block.slice(marker[0].length);
} else if (bullet) {
newBlock = "- [ ] " + block.slice(bullet[0].length);
} else {
newBlock = "- [ ] " + block;
}
editor.setRangeText(newBlock, ls, le, "end");
editor.selectionStart = ls;
editor.selectionEnd = ls + newBlock.length;
fire();
break;
}
case "link": {
const had = end > start;
const sel = had ? value.slice(start, end) : "text";
editor.setRangeText("[" + sel + "](url)", start, end, "end");
const urlStart = start + 1 + sel.length + 2; // after "]("
editor.selectionStart = urlStart;
editor.selectionEnd = urlStart + 3; // select "url"
fire();
break;
}
case "hr": {
const lead = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
editor.setRangeText(lead + "---\n", start, start, "end");
fire();
break;
}
case "codeblock": {
const had = end > start;
const sel = had ? value.slice(start, end) : "code";
const lead = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
editor.setRangeText(lead + "```\n" + sel + "\n```", start, end, "end");
fire();
break;
}
}
}
const DASHBOARD_NOTES_HTML = `
<div class="canvasrefined-notes-toolbar" role="toolbar" aria-label="Format notes">
<button type="button" class="cr-fmt" data-action="bold" title="Bold (Ctrl/Cmd+B)"><strong>B</strong></button>
<button type="button" class="cr-fmt" data-action="italic" title="Italic (Ctrl/Cmd+I)"><em>I</em></button>
<button type="button" class="cr-fmt" data-action="strike" title="Strikethrough"><s>S</s></button>
<button type="button" class="cr-fmt" data-action="code" title="Inline code"><code>&lt;/&gt;</code></button>
<span class="cr-fmt-sep"></span>
<button type="button" class="cr-fmt" data-action="h1" title="Heading 1">H1</button>
<button type="button" class="cr-fmt" data-action="h2" title="Heading 2">H2</button>
<span class="cr-fmt-sep"></span>
<button type="button" class="cr-fmt" data-action="list" title="Bullet list">&bull;</button>
<button type="button" class="cr-fmt" data-action="numbered" title="Numbered list">1.</button>
<button type="button" class="cr-fmt" data-action="task" title="Task list">&#9744;</button>
<button type="button" class="cr-fmt" data-action="quote" title="Quote">&ldquo;</button>
<span class="cr-fmt-sep"></span>
<button type="button" class="cr-fmt" data-action="link" title="Insert link">Link</button>
<button type="button" class="cr-fmt" data-action="hr" title="Horizontal rule">&mdash;</button>
<button type="button" class="cr-fmt" data-action="codeblock" title="Code block">&#96;&#96;&#96;</button>
</div>
<div class="canvasrefined-notes-surface">
<div class="canvasrefined-notes-rendered" tabindex="0" aria-label="Dashboard notes — click to edit" title="Click to edit"></div>
<textarea class="canvasrefined-notes-editor" placeholder="Type Markdown — click away to render" spellcheck="false"></textarea>
</div>
`;
function wireDashboardNotes(notes) {
const editor = notes.querySelector(".canvasrefined-notes-editor");
const rendered = notes.querySelector(".canvasrefined-notes-rendered");
editor.value = options.dashboard_notes_text || "";
renderDashboardNotesPreview(rendered, editor.value);
const enterEdit = () => {
if (notes.classList.contains("is-editing")) return;
notes.classList.add("is-editing");
editor.focus();
const len = editor.value.length;
editor.setSelectionRange(len, len);
};
const exitEdit = () => {
notes.classList.remove("is-editing");
renderDashboardNotesPreview(rendered, editor.value);
};
rendered.addEventListener("click", enterEdit);
rendered.addEventListener("focus", enterEdit);
editor.addEventListener("blur", exitEdit);
editor.addEventListener("input", function () {
options.dashboard_notes_text = this.value;
delayDashboardNotesStorage(this.value);
});
// Toolbar buttons: keep focus in the editor (mousedown preventDefault stops the
// button from stealing focus and collapsing back to the rendered view), then apply
// the formatting. Enters edit mode first if the user formats from the rendered view.
notes.querySelectorAll(".cr-fmt").forEach(btn => {
btn.addEventListener("mousedown", e => e.preventDefault());
btn.addEventListener("click", () => {
if (!notes.classList.contains("is-editing")) enterEdit();
notesApplyFormat(editor, btn.dataset.action);
});
});
editor.addEventListener("keydown", function (e) {
if (e.key === "Escape") { e.preventDefault(); editor.blur(); return; } // Esc: render
const mod = e.ctrlKey || e.metaKey;
if (!mod) return;
const k = e.key.toLowerCase();
if (k === "b") { e.preventDefault(); notesApplyFormat(editor, "bold"); }
else if (k === "i") { e.preventDefault(); notesApplyFormat(editor, "italic"); }
else if (k === "enter") { e.preventDefault(); editor.blur(); } // Ctrl/Cmd+Enter: render
});
}
function loadDashboardNotes() {
const container = document.querySelector("#DashboardCard_Container");
if (options.dashboard_notes === true) {
if (!container) return;
let notes = document.querySelector('.canvasrefined-dashboard-notes');
// Rebuild older (split edit/preview) markup into the new single-surface layout.
if (notes && !notes.querySelector(".canvasrefined-notes-surface")) {
notes.remove();
notes = null;
}
if (!notes) {
notes = document.createElement("textarea");
notes = document.createElement("div");
notes.classList.add("canvasrefined-dashboard-notes");
notes.placeholder = "Enter notes here";
document.querySelector("#DashboardCard_Container").prepend(notes);
notes.value = options.dashboard_notes_text;
notes.style.display = "block";
resizeDashboardNotes(notes);
notes.addEventListener('input', function () {
options.dashboard_notes_text = this.value;
delayDashboardNotesStorage(this.value);
scheduleDashboardNotesResize(this);
});
notes.innerHTML = DASHBOARD_NOTES_HTML;
// Mount as a full-width sibling above the card grid. Prepending inside the
// DashboardCard_Container makes the notes a masonry/grid cell (narrow & broken).
const parent = container.parentNode;
if (parent) parent.insertBefore(notes, container);
else container.prepend(notes);
wireDashboardNotes(notes);
} else {
notes.style.display = "block";
resizeDashboardNotes(notes);
notes.style.display = "";
const editor = notes.querySelector(".canvasrefined-notes-editor");
const rendered = notes.querySelector(".canvasrefined-notes-rendered");
// While editing, the textarea is the source of truth: don't clobber it from
// storage and don't waste a render on the hidden rendered view (which would
// also feed the observer loop). Only sync + render in view mode.
if (!notes.classList.contains("is-editing")) {
if (editor && editor.value !== (options.dashboard_notes_text || "")) {
editor.value = options.dashboard_notes_text || "";
}
renderDashboardNotesPreview(rendered, editor ? editor.value : "");
}
}
} else {
let notes = document.querySelector('.canvasrefined-dashboard-notes');

203
js/markdown.js Normal file
View File

@ -0,0 +1,203 @@
/*
CanvasRefined - lightweight Markdown renderer for dashboard notes.
Renders a small, notes-friendly subset of Markdown to HTML:
headings, bold, italic, strikethrough, inline code, fenced code blocks,
links, images, autolinks, blockquotes, ordered/unordered lists, task
lists, horizontal rules, and paragraphs.
All user-supplied text is HTML-escaped before any formatting is applied,
and links/images are URL-sanitized, so the returned HTML is safe to assign
via innerHTML. Exposes window.renderMarkdown(text) -> html.
*/
(function () {
"use strict";
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function sanitizeUrl(url) {
const u = String(url == null ? "" : url).trim();
if (u === "") return "";
if (/^(https?:|mailto:|ftp:|tel:)/i.test(u)) return u;
if (/^(javascript:|vbscript:|file:|data:)/i.test(u)) return "#";
if (/^[#/?]/.test(u)) return u;
// Block any other explicit protocol we did not whitelist.
if (/^[a-z][a-z0-9+.\-]*:/i.test(u)) return "#";
return u;
}
function renderMarkdown(src) {
if (src == null) return "";
src = String(src).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// Stash protected HTML (code blocks/spans, autolinks) behind NUL-delimited
// tokens so they survive escaping and inline formatting untouched.
const stash = [];
const keep = (html) => {
stash.push(html);
return "\u0000" + (stash.length - 1) + "\u0000";
};
// 1. Fenced code blocks: ```lang\n code ```
src = src.replace(/```([\w-]*)\n?([\s\S]*?)```/g, (m, lang, code) => {
code = code.replace(/^\n/, "").replace(/\n$/, "");
const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : "";
return keep(`<pre><code${langAttr}>${escapeHtml(code)}</code></pre>`);
});
// 2. Inline code: `code`
src = src.replace(/`([^`\n]+)`/g, (m, code) => keep(`<code>${escapeHtml(code)}</code>`));
// 3. Autolinks: <https://example.com>
src = src.replace(/<(https?:\/\/[^\s<>]+)>/g, (m, url) => {
const safe = escapeHtml(sanitizeUrl(url));
return keep(`<a href="${safe}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a>`);
});
// 4. Inline formatting, applied to escaped text so nothing the user
// typed can become live HTML. The URL capture allows one level of
// balanced parentheses, e.g. https://en.wikipedia.org/wiki/Foo_(bar).
const inline = (text) => {
let t = escapeHtml(text);
// images: ![alt](url) or ![alt](url "title")
t = t.replace(/!\[([^\]]*)\]\(((?:[^()\s]|\([^)\s]*\))*)(?:\s+"([^"]*)")?\)/g,
(m, alt, url, title) => {
const tAttr = title ? ` title="${title}"` : "";
return `<img src="${sanitizeUrl(url)}" alt="${alt}"${tAttr}>`;
});
// links: [text](url) or [text](url "title")
t = t.replace(/\[([^\]]+)\]\(((?:[^()\s]|\([^)\s]*\))*)(?:\s+"([^"]*)")?\)/g,
(m, txt, url, title) => {
const tAttr = title ? ` title="${title}"` : "";
return `<a href="${sanitizeUrl(url)}" target="_blank" rel="noopener noreferrer"${tAttr}>${txt}</a>`;
});
// bold: **text** or __text__
t = t.replace(/\*\*([^*]+?)\*\*/g, "<strong>$1</strong>");
t = t.replace(/__([^_]+?)__/g, "<strong>$1</strong>");
// strikethrough: ~~text~~
t = t.replace(/~~([^~]+?)~~/g, "<del>$1</del>");
// italic: *text* (avoid *** by requiring non-star borders)
t = t.replace(/(^|[^*])\*([^*]+?)\*(?!\*)/g, "$1<em>$2</em>");
// italic: _text_ (skip word-internal underscores like file_name)
t = t.replace(/(^|[^_\w])_([^_]+?)_(?!\w)/g, "$1<em>$2</em>");
return t;
};
const lines = src.split("\n");
const out = [];
const n = lines.length;
let i = 0;
const isToken = (s) => /^\u0000\d+\u0000$/.test(s);
while (i < n) {
const line = lines[i];
// Blank line separates blocks.
if (/^\s*$/.test(line)) { i++; continue; }
// A stashed block (e.g. a fenced code block) sitting on its own line.
if (isToken(line)) { out.push(line); i++; continue; }
// ATX heading: # Title (optional closing hashes)
const h = line.match(/^(#{1,6})\s+(.*?)(?:\s+#{1,6})?$/);
if (h) {
const lvl = h[1].length;
out.push("<h" + lvl + ">" + inline(h[2]) + "</h" + lvl + ">");
i++; continue;
}
// Horizontal rule: --- / *** / ___
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
out.push("<hr>");
i++; continue;
}
// Blockquote: > ...
if (/^>{1}\s?/.test(line)) {
const quote = [];
while (i < n && /^>{1}\s?/.test(lines[i])) {
quote.push(inline(lines[i].replace(/^>{1}\s?/, "")));
i++;
}
out.push("<blockquote>" + quote.join("<br>") + "</blockquote>");
continue;
}
// Unordered list: - / * / + (supports task lists: - [ ] / - [x])
if (/^\s*[-*+]\s+/.test(line)) {
const items = [];
while (i < n) {
const m = lines[i].match(/^\s*[-*+]\s+(.*)$/);
if (m) {
const task = m[1].match(/^\[([ xX])\]\s+(.*)$/);
if (task) {
const checked = /x/i.test(task[1]);
items.push(
'<li class="cr-task"><input type="checkbox" disabled' +
(checked ? " checked" : "") + "> " + inline(task[2])
);
} else {
items.push("<li>" + inline(m[1]));
}
i++; continue;
}
// Lazy continuation (indented) of the previous item.
if (/^\s+\S/.test(lines[i]) && items.length) {
items[items.length - 1] += "<br>" + inline(lines[i].trim());
i++; continue;
}
break;
}
out.push("<ul>" + items.map((li) => li + "</li>").join("") + "</ul>");
continue;
}
// Ordered list: 1.
if (/^\s*\d+\.\s+/.test(line)) {
const items = [];
while (i < n) {
const m = lines[i].match(/^\s*\d+\.\s+(.*)$/);
if (m) { items.push("<li>" + inline(m[1])); i++; continue; }
if (/^\s+\S/.test(lines[i]) && items.length) {
items[items.length - 1] += "<br>" + inline(lines[i].trim());
i++; continue;
}
break;
}
out.push("<ol>" + items.map((li) => li + "</li>").join("") + "</ol>");
continue;
}
// Paragraph: gather consecutive lines until a block boundary.
const para = [line];
i++;
while (i < n) {
const l = lines[i];
if (/^\s*$/.test(l)) break;
if (isToken(l)) break;
if (/^#{1,6}\s+/.test(l)) break;
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(l)) break;
if (/^>{1}\s?/.test(l)) break;
if (/^\s*[-*+]\s+/.test(l)) break;
if (/^\s*\d+\.\s+/.test(l)) break;
para.push(l);
i++;
}
out.push("<p>" + para.map(inline).join("<br>") + "</p>");
}
let html = out.join("\n");
// Restore stashed HTML.
html = html.replace(/\u0000(\d+)\u0000/g, (m, idx) => stash[+idx] || "");
return html;
}
window.renderMarkdown = renderMarkdown;
})();

View File

@ -77,6 +77,7 @@ const defaultOptions = {
"assignment_date_format": false,
"dashboard_notes": false,
"dashboard_notes_text": "",
"dashboard_notes_mode": "edit",
"better_todo": false,
"better_sidebar": false,
"sidebar_scale": 100,
@ -433,6 +434,299 @@ function toggleSubOptionsVisibility(option, isOn) {
}
}
function setupFeatureSearch(menu) {
const searchInput = document.querySelector("#feature-search");
const resultsEl = document.querySelector("#feature-search-results");
const headerSearch = document.querySelector("#header-search");
if (!searchInput || !resultsEl || !headerSearch) return;
// Map (reference-keyed) each .tab element -> the button id that opens it.
// A plain object won't work: DOM elements stringify to the same key.
const tabElToBtnId = new Map();
Object.entries(menu.tabs).forEach(([btnId, info]) => {
const tabEl = document.querySelector(info.tab);
if (tabEl) tabElToBtnId.set(tabEl, btnId);
});
function shouldSkip(el) {
// Skip overlays / scrapped containers that aren't real features
if (el.closest('#submit-popup, #browser-settings-popup, #opt-in, #card-edit-menu')) return true;
// Skip anything inside a statically-hidden option-container
// (e.g. the scrapped "remind" block, "Submit your theme").
// Dynamically-hidden sub-options (whose parent toggle is off) are kept.
let node = el;
while (node && node !== document.body) {
if (node.classList && node.classList.contains('option-container') && node.style.display === 'none') return true;
node = node.parentElement;
}
return false;
}
function labelOf(sub) {
const label = sub.querySelector("label");
if (label) return label.textContent.trim();
const st = sub.querySelector(".sub-text");
if (st) return st.textContent.trim();
return "";
}
function keyIdOf(sub) {
const label = sub.querySelector("label");
if (label && label.getAttribute("for")) return label.getAttribute("for");
const input = sub.querySelector("input");
if (input && input.id) return input.id;
return labelOf(sub);
}
function categoryFor(el) {
const tabEl = el.closest(".tab");
if (tabEl) {
const btnId = tabElToBtnId.get(tabEl);
if (btnId) {
const btn = document.getElementById(btnId);
const span = btn && btn.querySelector("span");
if (span) return span.textContent.trim();
}
return "Section";
}
if (el.classList && el.classList.contains("tab-btn")) return "Section";
if (el.classList && el.classList.contains("option")) return "Settings";
const owner = el.closest(".option");
if (owner) {
const name = owner.querySelector(".option-name");
if (name) return name.textContent.trim();
}
return "Settings";
}
function openTab(btnId) {
const btn = document.getElementById(btnId);
if (btn) btn.click();
}
function showMain() {
const back = document.querySelector(".back-btn");
if (back) back.click();
}
// Reveal any hidden sub-option blocks between el and .main so the target
// is visible (purely visual; doesn't change stored settings).
function revealAncestors(el) {
const main = document.querySelector(".main");
let node = el;
while (node && node !== main && node !== document.body) {
if (node.style && node.style.display === "none") node.style.display = "";
node = node.parentElement;
}
}
function highlight(el) {
el.classList.add("search-highlight");
setTimeout(() => el.classList.remove("search-highlight"), 2000);
}
function goToMainElement(el) {
showMain();
revealAncestors(el);
requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "smooth", block: "center" });
highlight(el);
});
}
function goToTabElement(el) {
const tabEl = el.closest(".tab");
const btnId = tabElToBtnId.get(tabEl);
if (btnId) openTab(btnId);
requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "smooth", block: "center" });
highlight(el);
});
}
let featureIndex = null;
let currentMatches = [];
let activeIndex = -1;
function buildIndex() {
const index = [];
const seen = new Set();
function add(entry) {
if (!entry.text || seen.has(entry.key)) return;
seen.add(entry.key);
index.push(entry);
}
// Big section buttons (Edit Dark Mode, Cards, Themes, Styles, GPA Settings, Report issue)
document.querySelectorAll(".more-options-container .tab-btn").forEach(btn => {
const span = btn.querySelector("span");
add({ key: "tab:" + btn.id, text: span ? span.textContent.trim() : "", el: btn, action: () => openTab(btn.id) });
});
// Home: option toggles
document.querySelectorAll(".options .option").forEach(opt => {
if (!opt.id) return;
const name = opt.querySelector(".option-name");
if (!name) return;
if (shouldSkip(opt)) return;
add({ key: "option:" + opt.id, text: name.textContent.trim(), el: opt, action: () => goToMainElement(opt) });
});
// Home: sub-options / timesets / labelled rows (e.g. "Use dd/mm", "Start time", max-items)
document.querySelectorAll(".options .sub-option, .options .timeset, .options .sub-options > div").forEach(sub => {
if (shouldSkip(sub)) return;
// Skip statically-hidden sub-options (e.g. the "hover preview" TODO).
// Dynamically-hidden sub-options have display:none on their parent
// .sub-options, not on themselves, so they stay indexed.
if (sub.classList.contains("sub-option") && sub.style.display === "none") return;
const text = labelOf(sub);
if (!text) return;
add({ key: "sub:" + keyIdOf(sub), text, el: sub, action: () => goToMainElement(sub) });
});
// Home: custom Canvas URL
const customDomain = document.querySelector("#customDomain");
if (customDomain && !shouldSkip(customDomain)) {
const wrap = customDomain.closest(".customDomain");
const label = wrap ? wrap.querySelector("[data-i18n='enter_url']") : null;
add({ key: "custom:customDomain", text: label ? label.textContent.trim() : "Canvas URL", el: wrap || customDomain, action: () => goToMainElement(wrap || customDomain) });
}
// Tabs: section headings (e.g. "Presets", "Custom styles", "Popular Palettes", "Custom Background")
document.querySelectorAll(".tab .header-small").forEach(h => {
if (shouldSkip(h)) return;
const text = h.textContent.trim();
if (!text) return;
const btnId = tabElToBtnId.get(h.closest(".tab"));
add({ key: "heading:" + (btnId || "?") + ":" + text, text, el: h, action: () => goToTabElement(h) });
});
// Tabs: checkbox sub-options (e.g. "Daily Random Image", "Custom Card Styles")
document.querySelectorAll(".tab .sub-option").forEach(sub => {
if (shouldSkip(sub)) return;
const text = labelOf(sub);
if (!text) return;
const btnId = tabElToBtnId.get(sub.closest(".tab"));
add({ key: "tabsub:" + (btnId || "?") + ":" + keyIdOf(sub), text, el: sub, action: () => goToTabElement(sub) });
});
// Dark mode color fields (e.g. "Sidebar Text", "Background Main")
document.querySelectorAll(".tab .color-type-header").forEach(h => {
if (shouldSkip(h)) return;
const text = h.textContent.trim();
if (!text) return;
const btnId = tabElToBtnId.get(h.closest(".tab"));
add({ key: "color:" + (btnId || "?") + ":" + text, text, el: h, action: () => goToTabElement(h) });
});
return index;
}
function filterFeatures(query) {
const q = query.trim().toLowerCase();
if (!q) return [];
if (!featureIndex) featureIndex = buildIndex();
const scored = [];
for (const entry of featureIndex) {
const t = entry.text.toLowerCase();
let score = -1;
if (t === q) score = 1000;
else if (t.startsWith(q)) score = 500 - t.length;
else {
const idx = t.indexOf(q);
if (idx !== -1) score = 200 - idx;
else if (t.split(/\s+/).some(w => w.toLowerCase().startsWith(q))) score = 50;
}
if (score >= 0) scored.push({ entry, score });
}
scored.sort((a, b) => b.score - a.score || a.entry.text.length - b.entry.text.length);
return scored.slice(0, 12).map(s => s.entry);
}
function renderResults(matches) {
currentMatches = matches;
activeIndex = matches.length ? 0 : -1;
resultsEl.innerHTML = "";
if (!matches.length) {
const empty = document.createElement("div");
empty.className = "search-result empty";
empty.textContent = "No features found";
resultsEl.appendChild(empty);
resultsEl.classList.add("open");
return;
}
matches.forEach((entry, i) => {
const item = document.createElement("div");
item.className = "search-result" + (i === activeIndex ? " active" : "");
item.setAttribute("role", "option");
const title = document.createElement("span");
title.className = "search-result-title";
title.textContent = entry.text;
const cat = document.createElement("span");
cat.className = "search-result-category";
cat.textContent = categoryFor(entry.el);
item.appendChild(title);
item.appendChild(cat);
item.addEventListener("mousedown", (e) => {
e.preventDefault();
selectResult(i);
});
resultsEl.appendChild(item);
});
resultsEl.classList.add("open");
}
function setActive(i) {
activeIndex = i;
const items = resultsEl.querySelectorAll(".search-result");
items.forEach((el, idx) => el.classList.toggle("active", idx === i));
const active = resultsEl.querySelector(".search-result.active");
if (active) active.scrollIntoView({ block: "nearest" });
}
function closeResults() {
resultsEl.classList.remove("open");
resultsEl.innerHTML = "";
currentMatches = [];
activeIndex = -1;
}
function selectResult(i) {
const entry = currentMatches[i];
if (!entry) return;
closeResults();
searchInput.value = "";
searchInput.blur();
entry.action();
}
searchInput.addEventListener("input", () => {
if (!searchInput.value.trim()) { closeResults(); return; }
renderResults(filterFeatures(searchInput.value));
});
searchInput.addEventListener("focus", () => {
if (searchInput.value.trim()) renderResults(filterFeatures(searchInput.value));
});
searchInput.addEventListener("keydown", (e) => {
if (!currentMatches.length) {
if (e.key === "Escape") searchInput.blur();
return;
}
if (e.key === "ArrowDown") { e.preventDefault(); setActive((activeIndex + 1) % currentMatches.length); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((activeIndex - 1 + currentMatches.length) % currentMatches.length); }
else if (e.key === "Enter") { e.preventDefault(); if (activeIndex >= 0) selectResult(activeIndex); }
else if (e.key === "Escape") { e.preventDefault(); closeResults(); searchInput.blur(); }
});
searchInput.addEventListener("blur", () => setTimeout(closeResults, 150));
const icon = headerSearch.querySelector(".header-search-icon");
if (icon) icon.addEventListener("click", () => searchInput.focus());
document.addEventListener("click", (e) => {
if (!e.target.closest("#header-search")) closeResults();
});
}
function setup() {
const menu = {
@ -671,6 +965,13 @@ function setup() {
document.querySelectorAll('[data-i18n]').forEach(text => {
text.innerText = chrome.i18n.getMessage(text.dataset.i18n);
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const msg = chrome.i18n.getMessage(el.dataset.i18nPlaceholder);
if (msg) el.placeholder = msg;
});
// header feature search (search bar that jumps to features)
setupFeatureSearch(menu);
// activate dark mode inspector button
document.querySelector("#inspector-btn").addEventListener("click", async function () {

View File

@ -25,7 +25,7 @@
"content_scripts": [
{
"matches": ["https://*/*"],
"js": ["css/darkmodecss.js", "js/backgrounds.js", "js/content.js"],
"js": ["css/darkmodecss.js", "js/backgrounds.js", "js/markdown.js", "js/content.js"],
"css": ["css/content.css"],
"run_at": "document_start"
}