Add Memos Clipper extension with Markdown content extraction and browser integration.

This commit is contained in:
2026-03-15 09:34:59 +01:00
parent 2acd843b2b
commit a2f0a0e262
18 changed files with 3774 additions and 0 deletions

2006
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "memos-extension",
"version": "1.0.0",
"description": "Clip any web page or selection as Markdown directly to your [usememos](https://usememos.com) instance. Images are uploaded as attachments. Preview and edit before sending.",
"main": "background.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.27",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"vite": "^8.0.0",
"vite-plugin-static-copy": "^3.3.0"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

5
src/background.js Normal file
View File

@@ -0,0 +1,5 @@
// background.js - service worker
chrome.runtime.onInstalled.addListener(() => {
console.log("Memos Clipper installed.");
});

272
src/content.js Normal file
View File

@@ -0,0 +1,272 @@
// 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, isSelection = false) {
const clone = element.cloneNode(true);
// Remove unwanted elements — comprehensive list covering real-world sites
// Skip this if we're in selection mode, because the user explicitly picked this content
if (!isSelection) {
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());
} else {
// In selection mode, we still want to remove script/style tags if any
clone.querySelectorAll('script, style, noscript, template').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 `![${alt}](${abs})`;
} catch {
return src ? `![${alt}](${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, true);
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

BIN
src/icons/icon128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 B

BIN
src/icons/icon16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

BIN
src/icons/icon48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 B

38
src/manifest.json Normal file
View File

@@ -0,0 +1,38 @@
{
"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
src/marked.min.js vendored Normal file
View File

@@ -0,0 +1,146 @@
/*!
* 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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");}
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
src/popup.css Normal file
View File

@@ -0,0 +1,404 @@
@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); }

135
src/popup.html Normal file
View File

@@ -0,0 +1,135 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Memos Clipper</title>
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<!-- ── Loading ── -->
<div id="view-loading" class="view flex flex-col items-center justify-center p-8 space-y-4">
<div class="spinner animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<span class="text-gray-600">Extracting content…</span>
</div>
<!-- ── No settings ── -->
<div id="view-setup" class="view hidden p-6 text-center">
<div class="setup-box border border-dashed border-gray-300 rounded-lg p-6 flex flex-col items-center">
<div class="setup-icon text-gray-400 mb-4">
<svg class="w-12 h-12" 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 class="text-gray-700 mb-4 font-medium">Configure your Memos instance first.</p>
<button id="open-settings-btn" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded shadow transition">Open Settings</button>
</div>
</div>
<!-- ── Main editor ── -->
<div id="view-main" class="view hidden">
<header class="flex items-center justify-between p-3 border-b border-gray-100 bg-white sticky top-0 z-10">
<div class="logo flex items-center space-x-2 text-blue-600">
<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 font-semibold text-gray-800 truncate max-w-[180px]">Clip to Memos</span>
</div>
<div class="header-actions flex space-x-1">
<button id="mode-toggle" class="icon-btn p-1.5 rounded hover:bg-gray-100 text-gray-500 transition" 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 p-1.5 rounded hover:bg-gray-100 text-gray-500 transition" 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 flex flex-col h-96 border-b border-gray-100">
<textarea rows="14" id="md-editor" spellcheck="false" placeholder="Markdown content…" class="flex-1 w-full p-3 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-blue-100"></textarea>
<div id="char-counter" class="char-counter text-right px-3 py-1 text-[10px] text-gray-400">0 chars</div>
</div>
<!-- preview panel -->
<div id="tab-preview" class="tab-panel hidden h-64 overflow-y-auto p-3 border-b border-gray-100">
<div id="md-preview" class="preview-body prose prose-sm max-w-none"></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 class="flex items-center justify-between p-3 bg-gray-50">
<div class="footer-left">
<select id="visibility-select" class="text-xs border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 transition">
<option value="PRIVATE">🔒 Private</option>
<option value="PROTECTED">🔗 Protected</option>
<option value="PUBLIC">🌐 Public</option>
</select>
</div>
<div class="footer-right flex space-x-2">
<button id="reload-btn" class="secondary-btn p-1.5 border border-gray-200 rounded hover:bg-white transition text-gray-500" title="Re-clip page"></button>
<button id="send-btn" class="send-btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-1.5 rounded shadow flex items-center space-x-2 transition">
<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>
<span class="text-sm font-medium">Send to Memos</span>
</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" type="module"></script>
<script src="/popup.js" type="module"></script>
</body>
</html>

398
src/popup.js Normal file
View File

@@ -0,0 +1,398 @@
// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// ── 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
const imageMap = new Map(); // originalUrl -> resourceName
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
const name = resource.name || `resources/${resource.id}`;
resourceNames.push(name);
imageMap.set(img.src, name);
}
uploaded++;
} catch (e) {
console.warn("Failed to upload image:", img.src, e.message);
}
}
}
sendBtn.textContent = "Creating memo…";
// 2. Create memo
// Try to replace original image URLs with local attachment URLs in the markdown
let contentWithImages = finalContent;
for (const [origUrl, resName] of imageMap.entries()) {
const urlPart = resName.startsWith("resources/") ? resName : `resources/${resName}`;
const attachmentUrl = `${baseUrl}/file/${urlPart}`;
// Escape for regex
const escapedUrl = origUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`!\\[(.*?)\\]\\(${escapedUrl}\\)`, 'g');
contentWithImages = contentWithImages.replace(re, `![$1](${attachmentUrl})`);
}
// Append any uploaded images that weren't already in the markdown (e.g. from gallery)
for (const resName of imageMap.values()) {
const urlPart = resName.startsWith("resources/") ? resName : `resources/${resName}`;
if (!contentWithImages.includes(`/file/${urlPart}`)) {
contentWithImages += `\n\n![attachment](${baseUrl}/file/${urlPart})`;
}
}
const body = {
content: contentWithImages,
visibility,
};
// Also set resources array for native attachment display (v0.22+)
if (resourceNames.length) {
body.resources = resourceNames.map((name) => {
const norm = name.startsWith("resources/") ? name : `resources/${name}`;
return { name: norm };
});
// Backward compatibility: some versions use resourceIdList (array of ints)
const resourceIds = resourceNames
.map(n => parseInt(n.replace("resources/", "")))
.filter(id => !isNaN(id));
if (resourceIds.length === resourceNames.length) {
body.resourceIdList = resourceIds;
}
}
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
src/settings.css Normal file
View File

@@ -0,0 +1,157 @@
@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); }

80
src/settings.html Normal file
View File

@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Memos Clipper — Settings</title>
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="settings.css" />
</head>
<body>
<div class="page">
<header class="p-6 border-b border-gray-100 bg-white">
<div class="logo flex items-center space-x-3 text-2xl font-bold text-blue-600">
<svg class="w-8 h-8" 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>
<span>Memos Clipper Settings</span>
</div>
</header>
<main class="max-w-3xl mx-auto p-6 space-y-8">
<div class="card bg-white rounded-xl shadow-sm border border-gray-100 p-6 space-y-6">
<h2 class="text-xl font-semibold text-gray-800 border-b pb-2">Connection</h2>
<label class="block space-y-2">
<span class="text-sm font-medium text-gray-700">Memos Instance URL</span>
<input type="url" id="memos-url" placeholder="https://memos.example.com" class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-400 outline-none transition" />
<small class="block text-xs text-gray-400">The base URL of your usememos instance (no trailing slash)</small>
</label>
<label class="block space-y-2">
<span class="text-sm font-medium text-gray-700">API Token</span>
<input type="password" id="api-token" placeholder="Your access token" class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-400 outline-none transition" />
<small class="block text-xs text-gray-400">Settings → Account → Access Tokens in your Memos instance</small>
</label>
<div class="actions flex space-x-3 pt-2">
<button id="test-btn" class="secondary flex-1 bg-gray-50 hover:bg-gray-100 text-gray-700 font-medium py-2 px-4 rounded-lg border border-gray-200 transition">Test Connection</button>
<button id="save-btn" class="flex-1 bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg shadow-sm transition">Save Settings</button>
</div>
<div id="status" class="status hidden p-3 rounded-lg text-sm"></div>
</div>
<div class="card bg-white rounded-xl shadow-sm border border-gray-100 p-6 space-y-6">
<h2 class="text-xl font-semibold text-gray-800 border-b pb-2">Defaults</h2>
<label class="block space-y-2">
<span class="text-sm font-medium text-gray-700">Default visibility</span>
<select id="visibility" class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-400 outline-none bg-white transition">
<option value="PRIVATE">Private</option>
<option value="PROTECTED">Protected</option>
<option value="PUBLIC">Public</option>
</select>
</label>
<label class="block space-y-2">
<span class="text-sm font-medium text-gray-700">Default clip mode</span>
<select id="clip-mode" class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-400 outline-none bg-white transition">
<option value="page">Full page (article extraction)</option>
<option value="selection">Selection only</option>
</select>
</label>
<div class="space-y-3 pt-2">
<label class="flex items-center space-x-3 cursor-pointer group">
<input type="checkbox" id="include-images" checked class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 transition" />
<span class="text-sm text-gray-700 group-hover:text-gray-900 transition">Upload page images as attachments</span>
</label>
<label class="flex items-center space-x-3 cursor-pointer group">
<input type="checkbox" id="include-tags" class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 transition" />
<span class="text-sm text-gray-700 group-hover:text-gray-900 transition">Automatically add #clipped tag</span>
</label>
</div>
<div class="actions pt-4">
<button id="save-defaults-btn" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg shadow-sm transition">Save Defaults</button>
</div>
<div id="defaults-status" class="status hidden p-3 rounded-lg text-sm"></div>
</div>
</main>
</div>
<script src="/settings.js" type="module"></script>
</body>
</html>

63
src/settings.js Normal file
View File

@@ -0,0 +1,63 @@
// 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();

9
src/style.css Normal file
View File

@@ -0,0 +1,9 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@source "./**/*.{html,js}";
@theme {
--font-sans: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}

32
vite.config.js Normal file
View File

@@ -0,0 +1,32 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import { viteStaticCopy } from 'vite-plugin-static-copy';
export default defineConfig({
root: 'src',
build: {
outDir: '../dist',
emptyOutDir: true,
rollupOptions: {
input: {
popup: resolve(__dirname, 'src/popup.html'),
settings: resolve(__dirname, 'src/settings.html'),
background: resolve(__dirname, 'src/background.js'),
content: resolve(__dirname, 'src/content.js'),
},
output: {
entryFileNames: '[name].js',
assetFileNames: 'assets/[name].[ext]',
},
},
},
plugins: [
viteStaticCopy({
targets: [
{ src: 'manifest.json', dest: '.' },
{ src: 'icons/*', dest: 'icons' },
{ src: 'marked.min.js', dest: '.' }
]
})
]
});