Remove Memos Clipper and all associated resources to decommission the extension.
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
// background.js - service worker
|
|
||||||
|
|
||||||
chrome.runtime.onInstalled.addListener(() => {
|
|
||||||
console.log("Memos Clipper installed.");
|
|
||||||
});
|
|
||||||
266
content.js
266
content.js
@@ -1,266 +0,0 @@
|
|||||||
// content.js - Extracts page content and converts to Markdown
|
|
||||||
// Guard against double-injection (MV3 scripting.executeScript can fire multiple times)
|
|
||||||
if (window.__memosClipperLoaded) { /* skip */ } else {
|
|
||||||
window.__memosClipperLoaded = true;
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
// ── Turndown-lite: a minimal but solid HTML→Markdown converter ──────────────
|
|
||||||
function htmlToMarkdown(element) {
|
|
||||||
const clone = element.cloneNode(true);
|
|
||||||
|
|
||||||
// Remove unwanted elements — comprehensive list covering real-world sites
|
|
||||||
const removeSelectors = [
|
|
||||||
// Semantic structural chrome
|
|
||||||
'script', 'style', 'noscript', 'template',
|
|
||||||
'nav', 'header', 'footer', 'aside',
|
|
||||||
// ARIA roles for chrome
|
|
||||||
'[role="navigation"]', '[role="banner"]', '[role="complementary"]',
|
|
||||||
'[role="contentinfo"]', '[role="search"]', '[role="toolbar"]',
|
|
||||||
'[role="menubar"]', '[role="menu"]', '[role="dialog"]',
|
|
||||||
// Common class/id patterns for site chrome
|
|
||||||
'[class*="navbar"]', '[class*="nav-bar"]', '[class*="site-nav"]',
|
|
||||||
'[class*="site-header"]', '[class*="site-footer"]',
|
|
||||||
'[class*="page-header"]', '[class*="page-footer"]',
|
|
||||||
'[id*="navbar"]', '[id*="site-nav"]', '[id*="site-header"]', '[id*="site-footer"]',
|
|
||||||
// Ads and tracking
|
|
||||||
'[class*="advertisement"]', '[class*="advert"]', '[class*=" ad-"]',
|
|
||||||
'[class*="google-ad"]', '[class*="sponsored"]',
|
|
||||||
'[id*="advertisement"]', '[id*="google_ad"]',
|
|
||||||
// Cookie banners, popups, overlays
|
|
||||||
'[class*="cookie"]', '[id*="cookie"]',
|
|
||||||
'[class*="consent"]', '[id*="consent"]',
|
|
||||||
'[class*="gdpr"]', '[id*="gdpr"]',
|
|
||||||
'[class*="popup"]', '[class*="modal"]', '[class*="overlay"]',
|
|
||||||
'[class*="banner"]', '[id*="banner"]',
|
|
||||||
// Social / share widgets
|
|
||||||
'[class*="share-bar"]', '[class*="social-bar"]', '[class*="share-buttons"]',
|
|
||||||
'[class*="sharing"]',
|
|
||||||
// Subscription / newsletter prompts
|
|
||||||
'[class*="newsletter"]', '[class*="subscribe"]',
|
|
||||||
// Comments sections
|
|
||||||
'[id="comments"]', '[class*="comments-section"]', '[id*="disqus"]',
|
|
||||||
// Related / recommended articles
|
|
||||||
'[class*="related-posts"]', '[class*="recommended"]', '[class*="more-articles"]',
|
|
||||||
// Sidebar
|
|
||||||
'[class*="sidebar"]', '[id*="sidebar"]',
|
|
||||||
// Print/hidden
|
|
||||||
'[hidden]', '[aria-hidden="true"]',
|
|
||||||
].join(', ');
|
|
||||||
|
|
||||||
clone.querySelectorAll(removeSelectors).forEach((el) => el.remove());
|
|
||||||
|
|
||||||
// Also remove elements that are visually hidden via inline style
|
|
||||||
clone.querySelectorAll('[style*="display:none"],[style*="display: none"],[style*="visibility:hidden"]')
|
|
||||||
.forEach((el) => el.remove());
|
|
||||||
|
|
||||||
return nodeToMd(clone).replace(/\n{3,}/g, "\n\n").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeToMd(node, ctx = { listDepth: 0, ordered: false, index: 0 }) {
|
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
|
||||||
return node.textContent.replace(/\s+/g, " ");
|
|
||||||
}
|
|
||||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
|
||||||
|
|
||||||
const tag = node.tagName.toLowerCase();
|
|
||||||
const children = () =>
|
|
||||||
Array.from(node.childNodes)
|
|
||||||
.map((c) => nodeToMd(c, ctx))
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
switch (tag) {
|
|
||||||
case "h1": return `\n\n# ${children().trim()}\n\n`;
|
|
||||||
case "h2": return `\n\n## ${children().trim()}\n\n`;
|
|
||||||
case "h3": return `\n\n### ${children().trim()}\n\n`;
|
|
||||||
case "h4": return `\n\n#### ${children().trim()}\n\n`;
|
|
||||||
case "h5": return `\n\n##### ${children().trim()}\n\n`;
|
|
||||||
case "h6": return `\n\n###### ${children().trim()}\n\n`;
|
|
||||||
case "p": return `\n\n${children().trim()}\n\n`;
|
|
||||||
case "br": return " \n";
|
|
||||||
case "hr": return "\n\n---\n\n";
|
|
||||||
|
|
||||||
case "strong":
|
|
||||||
case "b": return `**${children()}**`;
|
|
||||||
case "em":
|
|
||||||
case "i": return `_${children()}_`;
|
|
||||||
case "s":
|
|
||||||
case "del": return `~~${children()}~~`;
|
|
||||||
case "code": {
|
|
||||||
const text = node.textContent;
|
|
||||||
return text.includes("`") ? `\`\`${text}\`\`` : `\`${text}\``;
|
|
||||||
}
|
|
||||||
case "pre": {
|
|
||||||
const codeEl = node.querySelector("code");
|
|
||||||
const lang = codeEl
|
|
||||||
? (codeEl.className.match(/language-(\S+)/) || [])[1] || ""
|
|
||||||
: "";
|
|
||||||
const text = (codeEl || node).textContent;
|
|
||||||
return `\n\n\`\`\`${lang}\n${text}\n\`\`\`\n\n`;
|
|
||||||
}
|
|
||||||
case "blockquote": return `\n\n${children()
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => `> ${l}`)
|
|
||||||
.join("\n")}\n\n`;
|
|
||||||
|
|
||||||
case "a": {
|
|
||||||
const href = node.getAttribute("href") || "";
|
|
||||||
const text = children().trim();
|
|
||||||
if (!text) return href;
|
|
||||||
try {
|
|
||||||
const abs = new URL(href, location.href).href;
|
|
||||||
return `[${text}](${abs})`;
|
|
||||||
} catch {
|
|
||||||
return `[${text}](${href})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "img": {
|
|
||||||
const src = node.getAttribute("src") || "";
|
|
||||||
const alt = node.getAttribute("alt") || "";
|
|
||||||
try {
|
|
||||||
const abs = new URL(src, location.href).href;
|
|
||||||
return ``;
|
|
||||||
} catch {
|
|
||||||
return src ? `` : "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "ul": {
|
|
||||||
const lines = Array.from(node.children)
|
|
||||||
.map((li) => `${" ".repeat(ctx.listDepth)}- ${nodeToMd(li, { ...ctx, listDepth: ctx.listDepth + 1 }).trim()}`)
|
|
||||||
.join("\n");
|
|
||||||
return `\n\n${lines}\n\n`;
|
|
||||||
}
|
|
||||||
case "ol": {
|
|
||||||
const lines = Array.from(node.children)
|
|
||||||
.map((li, i) => `${" ".repeat(ctx.listDepth)}${i + 1}. ${nodeToMd(li, { ...ctx, listDepth: ctx.listDepth + 1 }).trim()}`)
|
|
||||||
.join("\n");
|
|
||||||
return `\n\n${lines}\n\n`;
|
|
||||||
}
|
|
||||||
case "li": return children();
|
|
||||||
|
|
||||||
case "table": return convertTable(node);
|
|
||||||
|
|
||||||
case "figure": {
|
|
||||||
const img = node.querySelector("img");
|
|
||||||
const caption = node.querySelector("figcaption");
|
|
||||||
let md = img ? nodeToMd(img, ctx) : children();
|
|
||||||
if (caption) md += `\n*${caption.textContent.trim()}*`;
|
|
||||||
return `\n\n${md}\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// skip presentational / hidden
|
|
||||||
case "svg":
|
|
||||||
case "canvas":
|
|
||||||
case "video":
|
|
||||||
case "audio":
|
|
||||||
case "iframe":
|
|
||||||
case "button":
|
|
||||||
case "input":
|
|
||||||
case "select":
|
|
||||||
case "textarea":
|
|
||||||
case "form":
|
|
||||||
case "nav":
|
|
||||||
case "header":
|
|
||||||
case "footer":
|
|
||||||
case "aside":
|
|
||||||
return "";
|
|
||||||
|
|
||||||
default:
|
|
||||||
return children();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertTable(table) {
|
|
||||||
const rows = Array.from(table.querySelectorAll("tr"));
|
|
||||||
if (!rows.length) return "";
|
|
||||||
const toRow = (tr) =>
|
|
||||||
"| " +
|
|
||||||
Array.from(tr.querySelectorAll("th,td"))
|
|
||||||
.map((c) => c.textContent.trim().replace(/\|/g, "\\|"))
|
|
||||||
.join(" | ") +
|
|
||||||
" |";
|
|
||||||
const header = toRow(rows[0]);
|
|
||||||
const sep =
|
|
||||||
"| " +
|
|
||||||
Array.from(rows[0].querySelectorAll("th,td"))
|
|
||||||
.map(() => "---")
|
|
||||||
.join(" | ") +
|
|
||||||
" |";
|
|
||||||
const body = rows.slice(1).map(toRow).join("\n");
|
|
||||||
return `\n\n${header}\n${sep}\n${body}\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Extract images from HTML ────────────────────────────────────────────────
|
|
||||||
function extractImages(element) {
|
|
||||||
const seen = new Set();
|
|
||||||
const imgs = Array.from(element.querySelectorAll("img"));
|
|
||||||
return imgs
|
|
||||||
.filter((img) => {
|
|
||||||
const src = img.getAttribute("src") || "";
|
|
||||||
if (!src) return false;
|
|
||||||
// skip tiny icons / tracking pixels by rendered size
|
|
||||||
const w = img.naturalWidth || img.width || 0;
|
|
||||||
const h = img.naturalHeight || img.height || 0;
|
|
||||||
if (w > 0 && w < 32 && h > 0 && h < 32) return false;
|
|
||||||
// skip 1x1 gif trackers
|
|
||||||
if (src.startsWith("data:image/gif")) return false;
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.map((img) => {
|
|
||||||
const src = img.getAttribute("src") || "";
|
|
||||||
let abs = src;
|
|
||||||
try { abs = new URL(src, location.href).href; } catch {}
|
|
||||||
return { src: abs, alt: img.getAttribute("alt") || "" };
|
|
||||||
})
|
|
||||||
.filter((img) => {
|
|
||||||
if (seen.has(img.src)) return false;
|
|
||||||
seen.add(img.src);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Message handler ─────────────────────────────────────────────────────────
|
|
||||||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
||||||
if (msg.action === "getContent") {
|
|
||||||
try {
|
|
||||||
let markdown = "";
|
|
||||||
let images = [];
|
|
||||||
let title = document.title || location.href;
|
|
||||||
|
|
||||||
if (msg.mode === "selection") {
|
|
||||||
const sel = window.getSelection();
|
|
||||||
if (sel && sel.rangeCount > 0) {
|
|
||||||
const frag = sel.getRangeAt(0).cloneContents();
|
|
||||||
const div = document.createElement("div");
|
|
||||||
div.appendChild(frag);
|
|
||||||
markdown = htmlToMarkdown(div);
|
|
||||||
images = extractImages(div);
|
|
||||||
} else {
|
|
||||||
markdown = "";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// full page — prefer article/main, fall back to body
|
|
||||||
const root =
|
|
||||||
document.querySelector("article") ||
|
|
||||||
document.querySelector("main") ||
|
|
||||||
document.querySelector('[role="main"]') ||
|
|
||||||
document.body;
|
|
||||||
markdown = htmlToMarkdown(root);
|
|
||||||
images = extractImages(root);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepend source line
|
|
||||||
const sourceNote = `> Source: [${title}](${location.href})\n\n`;
|
|
||||||
markdown = sourceNote + markdown;
|
|
||||||
|
|
||||||
sendResponse({ ok: true, markdown, images, title, url: location.href });
|
|
||||||
} catch (e) {
|
|
||||||
sendResponse({ ok: false, error: e.message });
|
|
||||||
}
|
|
||||||
return true; // async
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
} // end guard
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 341 B |
BIN
icons/icon16.png
BIN
icons/icon16.png
Binary file not shown.
|
Before Width: | Height: | Size: 99 B |
BIN
icons/icon48.png
BIN
icons/icon48.png
Binary file not shown.
|
Before Width: | Height: | Size: 145 B |
@@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
"manifest_version": 3,
|
|
||||||
"name": "Memos Clipper",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Clip web pages or selections as Markdown to your usememos instance",
|
|
||||||
"permissions": [
|
|
||||||
"activeTab",
|
|
||||||
"scripting",
|
|
||||||
"storage"
|
|
||||||
],
|
|
||||||
"host_permissions": [
|
|
||||||
"<all_urls>"
|
|
||||||
],
|
|
||||||
"action": {
|
|
||||||
"default_popup": "popup.html",
|
|
||||||
"default_icon": {
|
|
||||||
"16": "icons/icon16.png",
|
|
||||||
"48": "icons/icon48.png",
|
|
||||||
"128": "icons/icon128.png"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"icons": {
|
|
||||||
"16": "icons/icon16.png",
|
|
||||||
"48": "icons/icon48.png",
|
|
||||||
"128": "icons/icon128.png"
|
|
||||||
},
|
|
||||||
"background": {
|
|
||||||
"service_worker": "background.js"
|
|
||||||
},
|
|
||||||
"options_page": "settings.html",
|
|
||||||
"content_scripts": [
|
|
||||||
{
|
|
||||||
"matches": ["<all_urls>"],
|
|
||||||
"js": ["content.js"],
|
|
||||||
"run_at": "document_idle"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
146
marked.min.js
vendored
146
marked.min.js
vendored
@@ -1,146 +0,0 @@
|
|||||||
/*!
|
|
||||||
* marked-mini - minimal Markdown renderer for Memos Clipper
|
|
||||||
* Supports: headings, bold, italic, strike, code, pre, blockquote,
|
|
||||||
* links, images, ul, ol, hr, tables, line breaks
|
|
||||||
*/
|
|
||||||
(function(global){
|
|
||||||
"use strict";
|
|
||||||
function escape(s){return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");}
|
|
||||||
|
|
||||||
function parseInline(s){
|
|
||||||
// code spans
|
|
||||||
s = s.replace(/`([^`]+)`/g,(_,c)=>`<code>${escape(c)}</code>`);
|
|
||||||
// bold+italic
|
|
||||||
s = s.replace(/\*\*\*(.+?)\*\*\*/g,"<strong><em>$1</em></strong>");
|
|
||||||
// bold
|
|
||||||
s = s.replace(/\*\*(.+?)\*\*/g,"<strong>$1</strong>");
|
|
||||||
s = s.replace(/__(.+?)__/g,"<strong>$1</strong>");
|
|
||||||
// italic
|
|
||||||
s = s.replace(/\*(.+?)\*/g,"<em>$1</em>");
|
|
||||||
s = s.replace(/_([^_]+)_/g,"<em>$1</em>");
|
|
||||||
// strikethrough
|
|
||||||
s = s.replace(/~~(.+?)~~/g,"<del>$1</del>");
|
|
||||||
// images
|
|
||||||
s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g,(_,alt,src)=>`<img src="${escape(src)}" alt="${escape(alt)}">`);
|
|
||||||
// links
|
|
||||||
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g,(_,txt,href)=>`<a href="${escape(href)}" target="_blank">${txt}</a>`);
|
|
||||||
// auto-link
|
|
||||||
s = s.replace(/(?<!["\(])(https?:\/\/[^\s<>)]+)/g,url=>`<a href="${escape(url)}" target="_blank">${escape(url)}</a>`);
|
|
||||||
// hard break
|
|
||||||
s = s.replace(/ \n/g,"<br>");
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parse(src){
|
|
||||||
const lines = src.split("\n");
|
|
||||||
const out = [];
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while(i < lines.length){
|
|
||||||
const line = lines[i];
|
|
||||||
|
|
||||||
// Fenced code block
|
|
||||||
const fenceM = line.match(/^```(\w*)/);
|
|
||||||
if(fenceM){
|
|
||||||
const lang = fenceM[1]||"";
|
|
||||||
const code = [];
|
|
||||||
i++;
|
|
||||||
while(i<lines.length && !lines[i].startsWith("```")){
|
|
||||||
code.push(lines[i]);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
i++; // skip closing ```
|
|
||||||
out.push(`<pre><code class="language-${escape(lang)}">${escape(code.join("\n"))}</code></pre>`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Headings
|
|
||||||
const hM = line.match(/^(#{1,6})\s+(.*)/);
|
|
||||||
if(hM){
|
|
||||||
const lvl = hM[1].length;
|
|
||||||
out.push(`<h${lvl}>${parseInline(hM[2])}</h${lvl}>`);
|
|
||||||
i++; continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// HR
|
|
||||||
if(/^[-*_]{3,}\s*$/.test(line)){
|
|
||||||
out.push("<hr>");
|
|
||||||
i++; continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blockquote
|
|
||||||
if(line.startsWith("> ")){
|
|
||||||
const bq = [];
|
|
||||||
while(i<lines.length && lines[i].startsWith("> ")){
|
|
||||||
bq.push(lines[i].slice(2));
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
out.push(`<blockquote>${parse(bq.join("\n"))}</blockquote>`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unordered list
|
|
||||||
if(/^[-*+]\s/.test(line)){
|
|
||||||
const items = [];
|
|
||||||
while(i<lines.length && /^[-*+]\s/.test(lines[i])){
|
|
||||||
items.push(`<li>${parseInline(lines[i].replace(/^[-*+]\s/,""))}</li>`);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
out.push(`<ul>${items.join("")}</ul>`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ordered list
|
|
||||||
if(/^\d+\.\s/.test(line)){
|
|
||||||
const items = [];
|
|
||||||
while(i<lines.length && /^\d+\.\s/.test(lines[i])){
|
|
||||||
items.push(`<li>${parseInline(lines[i].replace(/^\d+\.\s/,""))}</li>`);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
out.push(`<ol>${items.join("")}</ol>`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Table (| col | col |)
|
|
||||||
if(line.startsWith("|") && i+1<lines.length && /^\|[-| :]+\|/.test(lines[i+1])){
|
|
||||||
const headers = line.split("|").filter((_,idx,a)=>idx>0&&idx<a.length-1).map(h=>`<th>${parseInline(h.trim())}</th>`);
|
|
||||||
i+=2; // skip header + separator
|
|
||||||
const rows = [];
|
|
||||||
while(i<lines.length && lines[i].startsWith("|")){
|
|
||||||
const cells = lines[i].split("|").filter((_,idx,a)=>idx>0&&idx<a.length-1).map(c=>`<td>${parseInline(c.trim())}</td>`);
|
|
||||||
rows.push(`<tr>${cells.join("")}</tr>`);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
out.push(`<table><thead><tr>${headers.join("")}</tr></thead><tbody>${rows.join("")}</tbody></table>`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty line
|
|
||||||
if(line.trim()===""){
|
|
||||||
i++; continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paragraph — collect until blank line
|
|
||||||
const para = [];
|
|
||||||
while(i<lines.length && lines[i].trim()!==""){
|
|
||||||
// stop on block elements
|
|
||||||
if(/^(#{1,6}\s|```|>|[-*+]\s|\d+\.\s|[-*_]{3,}\s*$)/.test(lines[i])) break;
|
|
||||||
para.push(lines[i]);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
if(para.length) out.push(`<p>${parseInline(para.join(" "))}</p>`);
|
|
||||||
}
|
|
||||||
return out.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
global.marked = {
|
|
||||||
parse: function(src, opts) {
|
|
||||||
const breaks = opts && opts.breaks;
|
|
||||||
// With breaks:true, single newlines in paragraphs become <br>
|
|
||||||
if (breaks) {
|
|
||||||
// Pre-process: single \n inside paragraph text → two spaces + \n (markdown hard break)
|
|
||||||
src = src.replace(/([^\n])\n([^\n])/g, "$1 \n$2");
|
|
||||||
}
|
|
||||||
return parse(src);
|
|
||||||
}
|
|
||||||
};})(window);
|
|
||||||
404
popup.css
404
popup.css
@@ -1,404 +0,0 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap');
|
|
||||||
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg: #0f0f11;
|
|
||||||
--surface: #1a1a1f;
|
|
||||||
--surface2: #22222a;
|
|
||||||
--border: #2a2a35;
|
|
||||||
--accent: #7c6af7;
|
|
||||||
--accent-dim: #7c6af720;
|
|
||||||
--accent-hover: #9585fa;
|
|
||||||
--text: #e8e8f0;
|
|
||||||
--text-dim: #777788;
|
|
||||||
--text-muted: #444455;
|
|
||||||
--success: #4ade80;
|
|
||||||
--error: #f87171;
|
|
||||||
--radius: 6px;
|
|
||||||
--font: 'IBM Plex Sans', sans-serif;
|
|
||||||
--mono: 'IBM Plex Mono', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
width: 440px;
|
|
||||||
min-height: 200px;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Views ── */
|
|
||||||
.view { display: flex; flex-direction: column; }
|
|
||||||
.view.hidden { display: none !important; }
|
|
||||||
|
|
||||||
/* ── Loading ── */
|
|
||||||
#view-loading {
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 48px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.spinner {
|
|
||||||
width: 22px; height: 22px;
|
|
||||||
border: 2px solid var(--border);
|
|
||||||
border-top-color: var(--accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin .7s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
|
|
||||||
/* ── Setup / error / success ── */
|
|
||||||
.setup-box, .success-box {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 40px 32px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.setup-icon {
|
|
||||||
width: 44px; height: 44px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.setup-icon svg { stroke: var(--text-dim); }
|
|
||||||
.setup-icon.err { color: var(--error); font-size: 20px; font-weight: 700; }
|
|
||||||
|
|
||||||
.success-icon {
|
|
||||||
width: 44px; height: 44px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #4ade8015;
|
|
||||||
border: 1px solid #4ade8040;
|
|
||||||
color: var(--success);
|
|
||||||
font-size: 22px;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
}
|
|
||||||
.success-box p { font-size: 14px; font-weight: 600; color: var(--text); }
|
|
||||||
.success-box a { color: var(--accent); text-decoration: none; font-size: 12px; }
|
|
||||||
.success-box a:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
/* ── Header ── */
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 12px 14px 10px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
.logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.logo svg { stroke: var(--accent); flex-shrink: 0; }
|
|
||||||
.page-title {
|
|
||||||
max-width: 220px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
.header-actions { display: flex; gap: 4px; }
|
|
||||||
.icon-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 5px 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-dim);
|
|
||||||
display: flex; align-items: center;
|
|
||||||
transition: all .12s;
|
|
||||||
}
|
|
||||||
.icon-btn:hover { border-color: var(--border); color: var(--text); background: var(--surface2); }
|
|
||||||
.icon-btn svg { display: block; }
|
|
||||||
|
|
||||||
/* ── Tabs ── */
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
position: relative;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--surface);
|
|
||||||
padding: 0 14px;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
.tab {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
transition: color .12s;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
margin-bottom: -1px;
|
|
||||||
}
|
|
||||||
.tab:hover { color: var(--text); }
|
|
||||||
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
|
||||||
|
|
||||||
/* ── Tab panels ── */
|
|
||||||
.tab-panel { flex: 1; }
|
|
||||||
.tab-panel.hidden { display: none; }
|
|
||||||
|
|
||||||
#md-editor {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 260px;
|
|
||||||
background: var(--bg);
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.7;
|
|
||||||
padding: 14px;
|
|
||||||
resize: none;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
#md-editor::placeholder { color: var(--text-muted); }
|
|
||||||
|
|
||||||
.preview-body {
|
|
||||||
height: 260px;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--bg);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* preview markdown styles */
|
|
||||||
.preview-body h1 { font-size: 18px; margin: 0 0 12px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
|
||||||
.preview-body h2 { font-size: 15px; margin: 16px 0 8px; }
|
|
||||||
.preview-body h3 { font-size: 13px; font-weight: 600; margin: 14px 0 6px; }
|
|
||||||
.preview-body p { margin: 0 0 10px; }
|
|
||||||
.preview-body blockquote {
|
|
||||||
border-left: 3px solid var(--accent);
|
|
||||||
margin: 10px 0;
|
|
||||||
padding: 4px 12px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
background: var(--accent-dim);
|
|
||||||
border-radius: 0 4px 4px 0;
|
|
||||||
}
|
|
||||||
.preview-body code {
|
|
||||||
background: var(--surface2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 1px 5px;
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.preview-body pre {
|
|
||||||
background: var(--surface2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 10px 12px;
|
|
||||||
overflow-x: auto;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
.preview-body pre code { background: none; border: none; padding: 0; }
|
|
||||||
.preview-body a { color: var(--accent); }
|
|
||||||
.preview-body img { max-width: 100%; border-radius: 4px; margin: 8px 0; }
|
|
||||||
.preview-body table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 12px; }
|
|
||||||
.preview-body th, .preview-body td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; }
|
|
||||||
.preview-body th { background: var(--surface2); }
|
|
||||||
.preview-body hr { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
|
|
||||||
.preview-body ul, .preview-body ol { padding-left: 20px; margin: 0 0 10px; }
|
|
||||||
|
|
||||||
/* ── Images section ── */
|
|
||||||
#images-section {
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
.images-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 8px 14px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
.badge {
|
|
||||||
display: inline-block;
|
|
||||||
background: var(--accent-dim);
|
|
||||||
color: var(--accent);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
.toggle-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.toggle-label input { accent-color: var(--accent); cursor: pointer; }
|
|
||||||
|
|
||||||
#images-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 0 14px 10px;
|
|
||||||
max-height: 90px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.img-chip {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
background: var(--surface2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 3px 8px 3px 5px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
max-width: 160px;
|
|
||||||
}
|
|
||||||
.img-chip img {
|
|
||||||
width: 20px; height: 20px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.img-chip span {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.img-chip .remove-img {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.img-chip .remove-img:hover { color: var(--error); }
|
|
||||||
.img-chip.skipped { opacity: .4; }
|
|
||||||
|
|
||||||
/* ── Tags row ── */
|
|
||||||
#tags-row {
|
|
||||||
padding: 6px 14px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
#tags-input {
|
|
||||||
width: 100%;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
color: var(--accent);
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 2px 0;
|
|
||||||
}
|
|
||||||
#tags-input::placeholder { color: var(--text-muted); font-family: var(--font); }
|
|
||||||
|
|
||||||
/* ── Char counter ── */
|
|
||||||
.char-counter {
|
|
||||||
padding: 3px 14px;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: var(--mono);
|
|
||||||
text-align: right;
|
|
||||||
background: var(--bg);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Footer ── */
|
|
||||||
footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: var(--surface);
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.footer-left, .footer-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
background: var(--surface2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 5px 8px;
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color .12s;
|
|
||||||
}
|
|
||||||
select:focus { border-color: var(--accent); }
|
|
||||||
select option { background: var(--surface); }
|
|
||||||
|
|
||||||
.secondary-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all .12s;
|
|
||||||
}
|
|
||||||
.secondary-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
||||||
|
|
||||||
.send-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
background: var(--accent);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
color: #fff;
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 7px 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background .12s, transform .08s;
|
|
||||||
}
|
|
||||||
.send-btn:hover { background: var(--accent-hover); }
|
|
||||||
.send-btn:active { transform: scale(.97); }
|
|
||||||
.send-btn:disabled { opacity: .5; cursor: default; }
|
|
||||||
.send-btn svg { flex-shrink: 0; }
|
|
||||||
|
|
||||||
/* scrollbar */
|
|
||||||
::-webkit-scrollbar { width: 4px; height: 4px; }
|
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
|
||||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
|
||||||
|
|
||||||
/* mode badge */
|
|
||||||
#mode-toggle.mode-selection { color: var(--accent); background: var(--accent-dim); border-color: var(--accent); }
|
|
||||||
134
popup.html
134
popup.html
@@ -1,134 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<title>Memos Clipper</title>
|
|
||||||
<link rel="stylesheet" href="popup.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- ── Loading ── -->
|
|
||||||
<div id="view-loading" class="view">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<span>Extracting content…</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── No settings ── -->
|
|
||||||
<div id="view-setup" class="view hidden">
|
|
||||||
<div class="setup-box">
|
|
||||||
<div class="setup-icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p>Configure your Memos instance first.</p>
|
|
||||||
<button id="open-settings-btn">Open Settings</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Main editor ── -->
|
|
||||||
<div id="view-main" class="view hidden">
|
|
||||||
<header>
|
|
||||||
<div class="logo">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
|
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
||||||
<polyline points="14 2 14 8 20 8"/>
|
|
||||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
|
||||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
|
||||||
</svg>
|
|
||||||
<span id="page-title" class="page-title">Clip to Memos</span>
|
|
||||||
</div>
|
|
||||||
<div class="header-actions">
|
|
||||||
<button id="mode-toggle" class="icon-btn" title="Switch clip mode">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
|
||||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button id="settings-btn" class="icon-btn" title="Settings">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
|
||||||
<circle cx="12" cy="12" r="3"/>
|
|
||||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- tab bar -->
|
|
||||||
<div class="tabs">
|
|
||||||
<button class="tab active" data-tab="edit">Edit</button>
|
|
||||||
<button class="tab" data-tab="preview">Preview</button>
|
|
||||||
<div class="tab-indicator"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- edit panel -->
|
|
||||||
<div id="tab-edit" class="tab-panel">
|
|
||||||
<textarea id="md-editor" spellcheck="false" placeholder="Markdown content…"></textarea>
|
|
||||||
<div id="char-counter" class="char-counter">0 chars</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- preview panel -->
|
|
||||||
<div id="tab-preview" class="tab-panel hidden">
|
|
||||||
<div id="md-preview" class="preview-body"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- images section -->
|
|
||||||
<div id="images-section">
|
|
||||||
<div class="images-header">
|
|
||||||
<span id="images-label">Images <span id="img-count" class="badge">0</span></span>
|
|
||||||
<label class="toggle-label">
|
|
||||||
<input type="checkbox" id="attach-images" checked />
|
|
||||||
upload as attachments
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div id="images-list"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- tag input -->
|
|
||||||
<div id="tags-row">
|
|
||||||
<input id="tags-input" type="text" placeholder="#tag1 #tag2 …" spellcheck="false" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- footer -->
|
|
||||||
<footer>
|
|
||||||
<div class="footer-left">
|
|
||||||
<select id="visibility-select">
|
|
||||||
<option value="PRIVATE">🔒 Private</option>
|
|
||||||
<option value="PROTECTED">🔗 Protected</option>
|
|
||||||
<option value="PUBLIC">🌐 Public</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="footer-right">
|
|
||||||
<button id="reload-btn" class="secondary-btn" title="Re-clip page">↺</button>
|
|
||||||
<button id="send-btn" class="send-btn">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
|
|
||||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
|
||||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
|
||||||
</svg>
|
|
||||||
Send to Memos
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Success ── -->
|
|
||||||
<div id="view-success" class="view hidden">
|
|
||||||
<div class="success-box">
|
|
||||||
<div class="success-icon">✓</div>
|
|
||||||
<p>Memo saved!</p>
|
|
||||||
<a id="memo-link" href="#" target="_blank">Open in Memos →</a>
|
|
||||||
<button id="new-clip-btn" class="secondary-btn" style="margin-top:8px">Clip another</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Error ── -->
|
|
||||||
<div id="view-error" class="view hidden">
|
|
||||||
<div class="setup-box">
|
|
||||||
<div class="setup-icon err">✗</div>
|
|
||||||
<p id="error-msg">Something went wrong.</p>
|
|
||||||
<button id="retry-btn">Retry</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="marked.min.js"></script>
|
|
||||||
<script src="popup.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
384
popup.js
384
popup.js
@@ -1,384 +0,0 @@
|
|||||||
// popup.js
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
// ── State ───────────────────────────────────────────────────────────────────
|
|
||||||
const state = {
|
|
||||||
markdown: "",
|
|
||||||
images: [], // {src, alt, keep: bool}
|
|
||||||
mode: "page", // "page" | "selection"
|
|
||||||
settings: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── DOM refs ─────────────────────────────────────────────────────────────────
|
|
||||||
const views = {
|
|
||||||
loading: document.getElementById("view-loading"),
|
|
||||||
setup: document.getElementById("view-setup"),
|
|
||||||
main: document.getElementById("view-main"),
|
|
||||||
success: document.getElementById("view-success"),
|
|
||||||
error: document.getElementById("view-error"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mdEditor = document.getElementById("md-editor");
|
|
||||||
const mdPreview = document.getElementById("md-preview");
|
|
||||||
const imagesList = document.getElementById("images-list");
|
|
||||||
const imgCount = document.getElementById("img-count");
|
|
||||||
const pageTitle = document.getElementById("page-title");
|
|
||||||
const visibilityEl = document.getElementById("visibility-select");
|
|
||||||
const attachCheck = document.getElementById("attach-images");
|
|
||||||
const modeToggle = document.getElementById("mode-toggle");
|
|
||||||
const sendBtn = document.getElementById("send-btn");
|
|
||||||
const reloadBtn = document.getElementById("reload-btn");
|
|
||||||
const errorMsg = document.getElementById("error-msg");
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
function showView(name) {
|
|
||||||
Object.values(views).forEach((v) => v.classList.add("hidden"));
|
|
||||||
views[name].classList.remove("hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSettings() {
|
|
||||||
return chrome.storage.sync.get([
|
|
||||||
"memosUrl", "apiToken", "visibility", "clipMode", "includeImages", "includeTags"
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getActiveTab() {
|
|
||||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
||||||
return tab;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Boot ──────────────────────────────────────────────────────────────────────
|
|
||||||
(async () => {
|
|
||||||
showView("loading");
|
|
||||||
state.settings = await getSettings();
|
|
||||||
|
|
||||||
if (!state.settings.memosUrl || !state.settings.apiToken) {
|
|
||||||
showView("setup");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply saved defaults
|
|
||||||
state.mode = state.settings.clipMode || "page";
|
|
||||||
if (state.settings.visibility) visibilityEl.value = state.settings.visibility;
|
|
||||||
if (state.settings.includeImages === false) attachCheck.checked = false;
|
|
||||||
|
|
||||||
updateModeButton();
|
|
||||||
await clip();
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ── Clip page ─────────────────────────────────────────────────────────────────
|
|
||||||
async function clip() {
|
|
||||||
showView("loading");
|
|
||||||
try {
|
|
||||||
const tab = await getActiveTab();
|
|
||||||
pageTitle.textContent = tab.title || tab.url;
|
|
||||||
|
|
||||||
// Ensure content script is available (MV3 — inject on demand)
|
|
||||||
try {
|
|
||||||
await chrome.scripting.executeScript({
|
|
||||||
target: { tabId: tab.id },
|
|
||||||
files: ["content.js"],
|
|
||||||
});
|
|
||||||
} catch (_) { /* already injected or restricted page */ }
|
|
||||||
|
|
||||||
const response = await new Promise((resolve, reject) => {
|
|
||||||
chrome.tabs.sendMessage(tab.id, { action: "getContent", mode: state.mode }, (res) => {
|
|
||||||
if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message));
|
|
||||||
else resolve(res);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response || !response.ok) {
|
|
||||||
throw new Error(response?.error || "Content script did not respond. Try reloading the page.");
|
|
||||||
}
|
|
||||||
|
|
||||||
state.markdown = response.markdown;
|
|
||||||
// Warn if selection mode returned nothing
|
|
||||||
if (state.mode === "selection" && !response.markdown.replace(/^>.*\n\n/, "").trim()) {
|
|
||||||
state.markdown = response.markdown + "\n\n> ⚠️ No text was selected. Select some text on the page first, or switch to full-page mode.";
|
|
||||||
}
|
|
||||||
state.images = response.images.map((img) => ({ ...img, keep: true }));
|
|
||||||
|
|
||||||
mdEditor.value = state.markdown;
|
|
||||||
updateCharCounter();
|
|
||||||
renderImages();
|
|
||||||
showView("main");
|
|
||||||
switchTab("edit");
|
|
||||||
} catch (e) {
|
|
||||||
errorMsg.textContent = e.message;
|
|
||||||
showView("error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
|
||||||
function switchTab(name) {
|
|
||||||
document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
|
|
||||||
document.querySelectorAll(".tab-panel").forEach((p) => p.classList.add("hidden"));
|
|
||||||
document.querySelector(`.tab[data-tab="${name}"]`).classList.add("active");
|
|
||||||
document.getElementById(`tab-${name}`).classList.remove("hidden");
|
|
||||||
if (name === "preview") renderPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelectorAll(".tab").forEach((btn) => {
|
|
||||||
btn.addEventListener("click", () => switchTab(btn.dataset.tab));
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Preview ───────────────────────────────────────────────────────────────────
|
|
||||||
function renderPreview() {
|
|
||||||
state.markdown = mdEditor.value;
|
|
||||||
if (typeof marked !== "undefined") {
|
|
||||||
mdPreview.innerHTML = marked.parse(state.markdown, { breaks: true });
|
|
||||||
} else {
|
|
||||||
// Fallback: basic escaping
|
|
||||||
mdPreview.innerHTML = `<pre style="white-space:pre-wrap">${escHtml(state.markdown)}</pre>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escHtml(s) {
|
|
||||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Images ─────────────────────────────────────────────────────────────────────
|
|
||||||
function renderImages() {
|
|
||||||
// Deduplicate by src, exclude oversized data: URIs
|
|
||||||
const seen = new Set();
|
|
||||||
const visible = state.images.filter((img) => {
|
|
||||||
if (seen.has(img.src)) return false;
|
|
||||||
seen.add(img.src);
|
|
||||||
if (img.src.startsWith("data:") && img.src.length >= 500_000) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync keep flags back (dedup may have removed some)
|
|
||||||
state.images = visible;
|
|
||||||
|
|
||||||
const uploadable = visible.filter((img) => !img.src.startsWith("data:")).length;
|
|
||||||
imgCount.textContent = uploadable;
|
|
||||||
imagesList.innerHTML = "";
|
|
||||||
|
|
||||||
if (!visible.length) {
|
|
||||||
document.getElementById("images-section").style.display = "none";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
document.getElementById("images-section").style.display = "";
|
|
||||||
|
|
||||||
visible.forEach((img, i) => {
|
|
||||||
const isDataUri = img.src.startsWith("data:");
|
|
||||||
const chip = document.createElement("div");
|
|
||||||
chip.className = "img-chip" + (img.keep ? "" : " skipped");
|
|
||||||
|
|
||||||
const thumb = document.createElement("img");
|
|
||||||
thumb.src = img.src;
|
|
||||||
thumb.onerror = () => { thumb.style.display = "none"; };
|
|
||||||
|
|
||||||
const label = document.createElement("span");
|
|
||||||
const name = isDataUri
|
|
||||||
? `inline-image-${i + 1}`
|
|
||||||
: (img.src.split("/").pop().split("?")[0].slice(0, 28) || `image-${i + 1}`);
|
|
||||||
label.textContent = name;
|
|
||||||
if (isDataUri) label.title = "Embedded data URI — cannot be uploaded";
|
|
||||||
|
|
||||||
const remove = document.createElement("button");
|
|
||||||
remove.className = "remove-img";
|
|
||||||
remove.textContent = "×";
|
|
||||||
remove.title = img.keep ? "Exclude this image" : "Include this image";
|
|
||||||
if (isDataUri) {
|
|
||||||
remove.style.display = "none"; // data URIs can't be uploaded anyway
|
|
||||||
}
|
|
||||||
remove.addEventListener("click", () => {
|
|
||||||
img.keep = !img.keep;
|
|
||||||
chip.classList.toggle("skipped", !img.keep);
|
|
||||||
remove.title = img.keep ? "Exclude this image" : "Include this image";
|
|
||||||
const kept = state.images.filter((im) => im.keep && !im.src.startsWith("data:")).length;
|
|
||||||
imgCount.textContent = kept;
|
|
||||||
});
|
|
||||||
|
|
||||||
chip.append(thumb, label, remove);
|
|
||||||
imagesList.appendChild(chip);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Char counter ─────────────────────────────────────────────────────────────
|
|
||||||
const charCounter = document.getElementById("char-counter");
|
|
||||||
function updateCharCounter() {
|
|
||||||
const n = mdEditor.value.length;
|
|
||||||
charCounter.textContent = `${n.toLocaleString()} char${n !== 1 ? "s" : ""}`;
|
|
||||||
}
|
|
||||||
mdEditor.addEventListener("input", updateCharCounter);
|
|
||||||
|
|
||||||
// ── Tags input ────────────────────────────────────────────────────────────────
|
|
||||||
const tagsInput = document.getElementById("tags-input");
|
|
||||||
|
|
||||||
// Load saved tags default
|
|
||||||
getSettings().then((s) => {
|
|
||||||
if (s.includeTags) tagsInput.value = "#clipped";
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Mode toggle ───────────────────────────────────────────────────────────────
|
|
||||||
function updateModeButton() {
|
|
||||||
modeToggle.title = state.mode === "page"
|
|
||||||
? "Currently: full page — click for selection mode"
|
|
||||||
: "Currently: selection — click for full page mode";
|
|
||||||
modeToggle.classList.toggle("mode-selection", state.mode === "selection");
|
|
||||||
}
|
|
||||||
|
|
||||||
modeToggle.addEventListener("click", async () => {
|
|
||||||
state.mode = state.mode === "page" ? "selection" : "page";
|
|
||||||
updateModeButton();
|
|
||||||
await clip();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Reload ────────────────────────────────────────────────────────────────────
|
|
||||||
reloadBtn.addEventListener("click", () => clip());
|
|
||||||
|
|
||||||
// ── Settings buttons ───────────────────────────────────────────────────────────
|
|
||||||
document.getElementById("settings-btn").addEventListener("click", () => {
|
|
||||||
chrome.runtime.openOptionsPage();
|
|
||||||
});
|
|
||||||
document.getElementById("open-settings-btn").addEventListener("click", () => {
|
|
||||||
chrome.runtime.openOptionsPage();
|
|
||||||
});
|
|
||||||
document.getElementById("retry-btn").addEventListener("click", () => clip());
|
|
||||||
document.getElementById("new-clip-btn").addEventListener("click", () => clip());
|
|
||||||
|
|
||||||
// ── Send to Memos ─────────────────────────────────────────────────────────────
|
|
||||||
sendBtn.addEventListener("click", async () => {
|
|
||||||
state.markdown = mdEditor.value;
|
|
||||||
// Append tags from tag bar if any
|
|
||||||
const tagStr = tagsInput.value.trim();
|
|
||||||
const finalContent = tagStr ? `${state.markdown}\n\n${tagStr}` : state.markdown;
|
|
||||||
const settings = await getSettings();
|
|
||||||
const baseUrl = settings.memosUrl.replace(/\/$/, "");
|
|
||||||
const token = settings.apiToken;
|
|
||||||
const visibility = visibilityEl.value;
|
|
||||||
|
|
||||||
sendBtn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
let resourceNames = [];
|
|
||||||
|
|
||||||
// 1. Upload images if requested
|
|
||||||
if (attachCheck.checked) {
|
|
||||||
const toUpload = state.images.filter((img) => img.keep && !img.src.startsWith("data:"));
|
|
||||||
let uploaded = 0;
|
|
||||||
for (const img of toUpload) {
|
|
||||||
sendBtn.textContent = `Uploading image ${uploaded + 1}/${toUpload.length}…`;
|
|
||||||
try {
|
|
||||||
const resource = await uploadImage(baseUrl, token, img);
|
|
||||||
if (resource) {
|
|
||||||
// v0.22+ uses resource.name; older APIs return resource.id
|
|
||||||
resourceNames.push(resource.name || `resources/${resource.id}`);
|
|
||||||
}
|
|
||||||
uploaded++;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Failed to upload image:", img.src, e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sendBtn.textContent = "Creating memo…";
|
|
||||||
|
|
||||||
// 2. Create memo
|
|
||||||
// Build content: append uploaded image markdown links so they appear inline
|
|
||||||
// even on instances where resource attachment display is not supported.
|
|
||||||
let contentWithImages = finalContent;
|
|
||||||
if (resourceNames.length) {
|
|
||||||
const imgLinks = resourceNames
|
|
||||||
.map((name, i) => {
|
|
||||||
// v0.22+ resource URL pattern: /file/{name}/filename
|
|
||||||
const urlName = name.startsWith("resources/") ? name : `resources/${name}`;
|
|
||||||
return `})`;
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
contentWithImages = finalContent + "\n\n" + imgLinks;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = {
|
|
||||||
content: contentWithImages,
|
|
||||||
visibility,
|
|
||||||
};
|
|
||||||
// Also set resources array for native attachment display (v0.22+)
|
|
||||||
if (resourceNames.length) {
|
|
||||||
body.resources = resourceNames.map((name) => {
|
|
||||||
// Normalise: API wants just "resources/abc123" not a full path
|
|
||||||
const norm = name.startsWith("resources/") ? name : `resources/${name}`;
|
|
||||||
return { name: norm };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}/api/v1/memos`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const txt = await res.text();
|
|
||||||
throw new Error(`API error ${res.status}: ${txt.slice(0, 200)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const memo = await res.json();
|
|
||||||
// Handle both v0.22+ (memo.name = "memos/123") and older (memo.id = "123" or memo.uid)
|
|
||||||
let memoId = "";
|
|
||||||
if (memo.name) {
|
|
||||||
memoId = memo.name.replace(/^memos\//, "");
|
|
||||||
} else if (memo.uid) {
|
|
||||||
memoId = memo.uid;
|
|
||||||
} else if (memo.id) {
|
|
||||||
memoId = memo.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById("memo-link").href = memoId ? `${baseUrl}/memos/${memoId}` : baseUrl;
|
|
||||||
showView("success");
|
|
||||||
} catch (e) {
|
|
||||||
errorMsg.textContent = e.message;
|
|
||||||
showView("error");
|
|
||||||
} finally {
|
|
||||||
sendBtn.disabled = false;
|
|
||||||
sendBtn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> Send to Memos`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Upload a single image resource ────────────────────────────────────────────
|
|
||||||
async function uploadImage(baseUrl, token, img) {
|
|
||||||
const response = await fetch(img.src);
|
|
||||||
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
|
|
||||||
const blob = await response.blob();
|
|
||||||
|
|
||||||
// Derive a filename with a valid extension
|
|
||||||
let filename = img.src.split("/").pop().split("?")[0].split("#")[0];
|
|
||||||
// Strip non-filename characters
|
|
||||||
filename = filename.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80);
|
|
||||||
// If no extension, infer from MIME type
|
|
||||||
if (!/\.\w{2,5}$/.test(filename)) {
|
|
||||||
const mimeToExt = {
|
|
||||||
"image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png",
|
|
||||||
"image/gif": "gif", "image/webp": "webp", "image/svg+xml": "svg",
|
|
||||||
"image/avif": "avif","image/tiff": "tiff",
|
|
||||||
};
|
|
||||||
const ext = mimeToExt[blob.type] || "jpg";
|
|
||||||
filename = (filename || "image") + "." + ext;
|
|
||||||
}
|
|
||||||
if (!filename || filename === ".") filename = "image.jpg";
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", blob, filename);
|
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}/api/v1/resources`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const txt = await res.text();
|
|
||||||
throw new Error(`Resource upload failed ${res.status}: ${txt.slice(0, 120)}`);
|
|
||||||
}
|
|
||||||
const resource = await res.json();
|
|
||||||
// Normalise the returned name — could be "resources/abc", "abc", or an int id
|
|
||||||
if (resource.name) return resource;
|
|
||||||
if (resource.id) return { ...resource, name: `resources/${resource.id}` };
|
|
||||||
throw new Error("Resource upload returned unexpected shape: " + JSON.stringify(resource).slice(0, 80));
|
|
||||||
}
|
|
||||||
157
settings.css
157
settings.css
@@ -1,157 +0,0 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap');
|
|
||||||
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg: #0f0f11;
|
|
||||||
--surface: #1a1a1f;
|
|
||||||
--border: #2a2a35;
|
|
||||||
--accent: #7c6af7;
|
|
||||||
--accent-dim: #7c6af730;
|
|
||||||
--text: #e8e8f0;
|
|
||||||
--text-dim: #888899;
|
|
||||||
--success: #4ade80;
|
|
||||||
--error: #f87171;
|
|
||||||
--radius: 8px;
|
|
||||||
--font: 'IBM Plex Sans', sans-serif;
|
|
||||||
--mono: 'IBM Plex Mono', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: var(--font);
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
min-height: 100vh;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page { max-width: 560px; margin: 0 auto; padding: 32px 24px; }
|
|
||||||
|
|
||||||
header {
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.logo svg { width: 22px; height: 22px; stroke: var(--accent); }
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card h2 {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: .08em;
|
|
||||||
color: var(--text-dim);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
label > span {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="url"],
|
|
||||||
input[type="password"],
|
|
||||||
input[type="text"],
|
|
||||||
select {
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 9px 12px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color .15s;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus, select:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
select option { background: var(--surface); }
|
|
||||||
|
|
||||||
small {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-family: var(--font);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label {
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.checkbox-label input[type="checkbox"] {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
accent-color: var(--accent);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.checkbox-label span { font-size: 13px; font-weight: 400; }
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 9px 18px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all .15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:not(.secondary) {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
button:not(.secondary):hover { filter: brightness(1.15); }
|
|
||||||
|
|
||||||
button.secondary {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
button.secondary:hover { border-color: var(--accent); color: var(--accent); }
|
|
||||||
|
|
||||||
.status {
|
|
||||||
margin-top: 12px;
|
|
||||||
padding: 9px 12px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: var(--mono);
|
|
||||||
}
|
|
||||||
.status.hidden { display: none; }
|
|
||||||
.status.ok { background: #4ade8015; border: 1px solid #4ade8040; color: var(--success); }
|
|
||||||
.status.err { background: #f8717115; border: 1px solid #f8717140; color: var(--error); }
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<title>Memos Clipper — Settings</title>
|
|
||||||
<link rel="stylesheet" href="settings.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="page">
|
|
||||||
<header>
|
|
||||||
<div class="logo">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
||||||
<polyline points="14 2 14 8 20 8"/>
|
|
||||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
|
||||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
|
||||||
<polyline points="10 9 9 9 8 9"/>
|
|
||||||
</svg>
|
|
||||||
Memos Clipper Settings
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<div class="card">
|
|
||||||
<h2>Connection</h2>
|
|
||||||
<label>
|
|
||||||
<span>Memos Instance URL</span>
|
|
||||||
<input type="url" id="memos-url" placeholder="https://memos.example.com" />
|
|
||||||
<small>The base URL of your usememos instance (no trailing slash)</small>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>API Token</span>
|
|
||||||
<input type="password" id="api-token" placeholder="Your access token" />
|
|
||||||
<small>Settings → Account → Access Tokens in your Memos instance</small>
|
|
||||||
</label>
|
|
||||||
<div class="actions">
|
|
||||||
<button id="test-btn" class="secondary">Test Connection</button>
|
|
||||||
<button id="save-btn">Save Settings</button>
|
|
||||||
</div>
|
|
||||||
<div id="status" class="status hidden"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Defaults</h2>
|
|
||||||
<label>
|
|
||||||
<span>Default visibility</span>
|
|
||||||
<select id="visibility">
|
|
||||||
<option value="PRIVATE">Private</option>
|
|
||||||
<option value="PROTECTED">Protected</option>
|
|
||||||
<option value="PUBLIC">Public</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Default clip mode</span>
|
|
||||||
<select id="clip-mode">
|
|
||||||
<option value="page">Full page (article extraction)</option>
|
|
||||||
<option value="selection">Selection only</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" id="include-images" checked />
|
|
||||||
<span>Upload page images as attachments</span>
|
|
||||||
</label>
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" id="include-tags" />
|
|
||||||
<span>Automatically add #clipped tag</span>
|
|
||||||
</label>
|
|
||||||
<div class="actions">
|
|
||||||
<button id="save-defaults-btn">Save Defaults</button>
|
|
||||||
</div>
|
|
||||||
<div id="defaults-status" class="status hidden"></div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
<script src="settings.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
63
settings.js
63
settings.js
@@ -1,63 +0,0 @@
|
|||||||
// settings.js
|
|
||||||
const $ = (id) => document.getElementById(id);
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
const s = await chrome.storage.sync.get([
|
|
||||||
"memosUrl", "apiToken", "visibility", "clipMode", "includeImages", "includeTags"
|
|
||||||
]);
|
|
||||||
if (s.memosUrl) $("memos-url").value = s.memosUrl;
|
|
||||||
if (s.apiToken) $("api-token").value = s.apiToken;
|
|
||||||
if (s.visibility) $("visibility").value = s.visibility;
|
|
||||||
if (s.clipMode) $("clip-mode").value = s.clipMode;
|
|
||||||
if (s.includeImages !== undefined) $("include-images").checked = s.includeImages;
|
|
||||||
if (s.includeTags !== undefined) $("include-tags").checked = s.includeTags;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showStatus(el, ok, msg) {
|
|
||||||
el.textContent = msg;
|
|
||||||
el.className = `status ${ok ? "ok" : "err"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
$("save-btn").addEventListener("click", async () => {
|
|
||||||
const url = $("memos-url").value.trim().replace(/\/$/, "");
|
|
||||||
const token = $("api-token").value.trim();
|
|
||||||
if (!url || !token) {
|
|
||||||
showStatus($("status"), false, "URL and token are required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await chrome.storage.sync.set({ memosUrl: url, apiToken: token });
|
|
||||||
showStatus($("status"), true, "✓ Saved.");
|
|
||||||
});
|
|
||||||
|
|
||||||
$("save-defaults-btn").addEventListener("click", async () => {
|
|
||||||
await chrome.storage.sync.set({
|
|
||||||
visibility: $("visibility").value,
|
|
||||||
clipMode: $("clip-mode").value,
|
|
||||||
includeImages: $("include-images").checked,
|
|
||||||
includeTags: $("include-tags").checked,
|
|
||||||
});
|
|
||||||
showStatus($("defaults-status"), true, "✓ Defaults saved.");
|
|
||||||
});
|
|
||||||
|
|
||||||
$("test-btn").addEventListener("click", async () => {
|
|
||||||
const url = $("memos-url").value.trim().replace(/\/$/, "");
|
|
||||||
const token = $("api-token").value.trim();
|
|
||||||
$("test-btn").textContent = "Testing…";
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${url}/api/v1/memos?pageSize=1`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
showStatus($("status"), true, `✓ Connected! (HTTP ${res.status})`);
|
|
||||||
} else {
|
|
||||||
const txt = await res.text();
|
|
||||||
showStatus($("status"), false, `✗ HTTP ${res.status}: ${txt.slice(0, 120)}`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showStatus($("status"), false, `✗ ${e.message}`);
|
|
||||||
} finally {
|
|
||||||
$("test-btn").textContent = "Test Connection";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
load();
|
|
||||||
Reference in New Issue
Block a user