diff --git a/README.md b/README.md index c1043c1..ea7922b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 6aa66f7..4e8859a 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2,6 +2,9 @@ "Canvas_Refined": { "message": "Canvas Refined" }, + "search_features": { + "message": "Search features..." + }, "updates": { "message": "Updates" }, diff --git a/css/content.css b/css/content.css index c7d2b7f..82db7e2 100644 --- a/css/content.css +++ b/css/content.css @@ -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; } -} \ No newline at end of file +} + +/* ===== 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;} \ No newline at end of file diff --git a/css/popup.css b/css/popup.css index 56c8881..1555187 100644 --- a/css/popup.css +++ b/css/popup.css @@ -252,4 +252,21 @@ input[type="checkbox"]:checked {background: #8dd28d;} 100% { opacity: 1; } -} \ No newline at end of file +} + +/* ===== 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; } } \ No newline at end of file diff --git a/html/popup.html b/html/popup.html index 59530e5..110a3ae 100644 --- a/html/popup.html +++ b/html/popup.html @@ -24,6 +24,11 @@

Canvas Refined

+
+ + + + + + + + + + + + + + + +
+
+
+ +
+`; + +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'); diff --git a/js/markdown.js b/js/markdown.js new file mode 100644 index 0000000..7a15a1b --- /dev/null +++ b/js/markdown.js @@ -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, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + 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(`
${escapeHtml(code)}
`); + }); + + // 2. Inline code: `code` + src = src.replace(/`([^`\n]+)`/g, (m, code) => keep(`${escapeHtml(code)}`)); + + // 3. Autolinks: + src = src.replace(/<(https?:\/\/[^\s<>]+)>/g, (m, url) => { + const safe = escapeHtml(sanitizeUrl(url)); + return keep(`${escapeHtml(url)}`); + }); + + // 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 `${alt}`; + }); + // links: [text](url) or [text](url "title") + t = t.replace(/\[([^\]]+)\]\(((?:[^()\s]|\([^)\s]*\))*)(?:\s+"([^"]*)")?\)/g, + (m, txt, url, title) => { + const tAttr = title ? ` title="${title}"` : ""; + return `${txt}`; + }); + // bold: **text** or __text__ + t = t.replace(/\*\*([^*]+?)\*\*/g, "$1"); + t = t.replace(/__([^_]+?)__/g, "$1"); + // strikethrough: ~~text~~ + t = t.replace(/~~([^~]+?)~~/g, "$1"); + // italic: *text* (avoid *** by requiring non-star borders) + t = t.replace(/(^|[^*])\*([^*]+?)\*(?!\*)/g, "$1$2"); + // italic: _text_ (skip word-internal underscores like file_name) + t = t.replace(/(^|[^_\w])_([^_]+?)_(?!\w)/g, "$1$2"); + 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("" + inline(h[2]) + ""); + i++; continue; + } + + // Horizontal rule: --- / *** / ___ + if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { + out.push("
"); + 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("
" + quote.join("
") + "
"); + 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( + '
  • " + inline(task[2]) + ); + } else { + items.push("
  • " + inline(m[1])); + } + i++; continue; + } + // Lazy continuation (indented) of the previous item. + if (/^\s+\S/.test(lines[i]) && items.length) { + items[items.length - 1] += "
    " + inline(lines[i].trim()); + i++; continue; + } + break; + } + out.push(""); + 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("
  • " + inline(m[1])); i++; continue; } + if (/^\s+\S/.test(lines[i]) && items.length) { + items[items.length - 1] += "
    " + inline(lines[i].trim()); + i++; continue; + } + break; + } + out.push("
      " + items.map((li) => li + "").join("") + "
    "); + 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("

    " + para.map(inline).join("
    ") + "

    "); + } + + let html = out.join("\n"); + // Restore stashed HTML. + html = html.replace(/\u0000(\d+)\u0000/g, (m, idx) => stash[+idx] || ""); + return html; + } + + window.renderMarkdown = renderMarkdown; +})(); diff --git a/js/popup.js b/js/popup.js index dd7d206..139e37f 100644 --- a/js/popup.js +++ b/js/popup.js @@ -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 () { diff --git a/manifest.json b/manifest.json index 4eb77c1..2c8d571 100644 --- a/manifest.json +++ b/manifest.json @@ -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" }