From f370b55c2cc488a0484c3fa42fef060e18b55be9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 19 May 2026 21:39:01 +0000 Subject: [PATCH 1/4] Add embedded image and video preview renderer Co-authored-by: Kiya Rose Ren-Miyakari --- cross-platform/src/index.html | 4 + cross-platform/src/main.js | 140 ++++++++++++++++++++++++++++++++-- cross-platform/src/styles.css | 110 +++++++++++++++++++++++++- 3 files changed, 248 insertions(+), 6 deletions(-) diff --git a/cross-platform/src/index.html b/cross-platform/src/index.html index 13a7231..31de049 100644 --- a/cross-platform/src/index.html +++ b/cross-platform/src/index.html @@ -17,6 +17,10 @@
+
+

Embedded media

+
+
diff --git a/cross-platform/src/main.js b/cross-platform/src/main.js index 7d84f1e..edb2a25 100644 --- a/cross-platform/src/main.js +++ b/cross-platform/src/main.js @@ -1,6 +1,13 @@ const pinBtn = document.getElementById("pin-btn"); const statusEl = document.getElementById("status"); const noteEl = document.getElementById("note"); +const mediaPreviewEl = document.getElementById("media-preview"); + +const IMAGE_EXTENSIONS = /\.(avif|bmp|gif|jpe?g|png|svg|webp)(\?.*)?(#.*)?$/i; +const VIDEO_EXTENSIONS = /\.(m4v|mov|mp4|ogg|ogv|webm)(\?.*)?(#.*)?$/i; +const URL_PATTERN = /https?:\/\/[^\s<>"')\]]+/gi; +const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((https?:\/\/[^)\s]+)\)/gi; +const TRAILING_PUNCTUATION_PATTERN = /[),.!?;:]+$/; const TAURI = window.__TAURI__; const invoke = TAURI && TAURI.tauri ? TAURI.tauri.invoke : null; @@ -9,6 +16,123 @@ function setStatus(message) { statusEl.textContent = message; } +function normalizeUrl(rawUrl) { + return rawUrl.replace(TRAILING_PUNCTUATION_PATTERN, ""); +} + +function classifyMediaUrl(url) { + if (IMAGE_EXTENSIONS.test(url)) { + return "image"; + } + if (VIDEO_EXTENSIONS.test(url)) { + return "video"; + } + return null; +} + +function collectMediaUrls(noteText) { + const seen = new Set(); + const mediaItems = []; + + MARKDOWN_IMAGE_PATTERN.lastIndex = 0; + URL_PATTERN.lastIndex = 0; + + let match = MARKDOWN_IMAGE_PATTERN.exec(noteText); + while (match) { + const url = normalizeUrl(match[1]); + const type = classifyMediaUrl(url); + if (type && !seen.has(url)) { + seen.add(url); + mediaItems.push({ url, type }); + } + match = MARKDOWN_IMAGE_PATTERN.exec(noteText); + } + + match = URL_PATTERN.exec(noteText); + while (match) { + const url = normalizeUrl(match[0]); + const type = classifyMediaUrl(url); + if (type && !seen.has(url)) { + seen.add(url); + mediaItems.push({ url, type }); + } + match = URL_PATTERN.exec(noteText); + } + + return mediaItems; +} + +function createMediaCard(mediaItem) { + const cardEl = document.createElement("article"); + cardEl.className = "media-card"; + + const labelEl = document.createElement("p"); + labelEl.className = "media-card-label"; + labelEl.textContent = mediaItem.type === "image" ? "Image" : "Video"; + + const urlEl = document.createElement("p"); + urlEl.className = "media-card-url"; + urlEl.textContent = mediaItem.url; + + const errorEl = document.createElement("p"); + errorEl.className = "media-error"; + errorEl.textContent = + mediaItem.type === "image" + ? "Unable to load this image." + : "Unable to load this video."; + + const mediaEl = document.createElement(mediaItem.type === "image" ? "img" : "video"); + mediaEl.src = mediaItem.url; + mediaEl.referrerPolicy = "no-referrer"; + + if (mediaItem.type === "image") { + mediaEl.alt = "Embedded image preview"; + mediaEl.loading = "lazy"; + } else { + mediaEl.controls = true; + mediaEl.preload = "metadata"; + mediaEl.playsInline = true; + } + + mediaEl.addEventListener( + "error", + () => { + if (!cardEl.contains(errorEl)) { + cardEl.appendChild(errorEl); + } + }, + { once: true }, + ); + + cardEl.appendChild(labelEl); + cardEl.appendChild(mediaEl); + cardEl.appendChild(urlEl); + return cardEl; +} + +function renderMediaPreview(noteText) { + if (!mediaPreviewEl) { + return; + } + + mediaPreviewEl.textContent = ""; + + const mediaItems = collectMediaUrls(noteText); + if (mediaItems.length === 0) { + const emptyEl = document.createElement("p"); + emptyEl.className = "media-empty"; + emptyEl.textContent = "Paste image or video links in your note to preview them here."; + mediaPreviewEl.appendChild(emptyEl); + return; + } + + const fragment = document.createDocumentFragment(); + mediaItems.forEach((mediaItem) => { + fragment.appendChild(createMediaCard(mediaItem)); + }); + mediaPreviewEl.appendChild(fragment); +} + function loadNote() { let saved = ""; try { @@ -20,6 +144,7 @@ function loadNote() { } noteEl.value = saved; setEdited(saved.length > 0); + renderMediaPreview(saved); } function setEdited(isEdited) { @@ -60,17 +185,22 @@ function init() { setStatus("Tauri API unavailable"); } else if (TAURI && TAURI.app) { // Briefly show the app version so users can verify which build is running - TAURI.app.getVersion().then((version) => { - const previous = statusEl.textContent; - setStatus(`v${version}`); - setTimeout(() => setStatus(previous), 2000); - }).catch(() => { /* non-critical */ }); + TAURI.app.getVersion() + .then((version) => { + const previous = statusEl.textContent; + setStatus(`v${version}`); + setTimeout(() => setStatus(previous), 2000); + }) + .catch(() => { + /* non-critical */ + }); } noteEl.addEventListener("input", (e) => { const value = e.target.value; saveNote(value); setEdited(value.length > 0); + renderMediaPreview(value); }); pinBtn.addEventListener("click", togglePin); diff --git a/cross-platform/src/styles.css b/cross-platform/src/styles.css index 4fd2447..c2c47b6 100644 --- a/cross-platform/src/styles.css +++ b/cross-platform/src/styles.css @@ -49,11 +49,16 @@ header button:disabled { main { flex: 1; padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; } textarea { width: 100%; - height: 100%; + flex: 1; + min-height: 120px; resize: none; padding: 10px; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; @@ -69,6 +74,77 @@ textarea { color: #666; } +#media-preview-panel { + border: 1px solid #d0d0d0; + border-radius: 8px; + background: #fff; + padding: 10px; + min-height: 150px; + max-height: 45%; + overflow: auto; +} + +#media-preview-panel h2 { + margin: 0 0 8px 0; + font-size: 12px; + font-weight: 600; + color: #555; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +#media-preview { + display: flex; + flex-direction: column; + gap: 10px; +} + +.media-empty { + margin: 0; + font-size: 13px; + color: #777; +} + +.media-card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; + border-radius: 8px; + border: 1px solid #e2e2e2; + background: #fafafa; +} + +.media-card-label { + margin: 0; + font-size: 11px; + font-weight: 600; + color: #666; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.media-card-url { + margin: 0; + overflow-wrap: anywhere; + font-size: 12px; + color: #666; +} + +.media-card img, +.media-card video { + max-width: 100%; + border-radius: 6px; + border: 1px solid #d7d7d7; + background: #000; +} + +.media-error { + margin: 0; + font-size: 12px; + color: #8f1f1f; +} + @media (prefers-color-scheme: dark) { body { background: #1e1e1e; @@ -95,6 +171,38 @@ textarea { border-color: #3a3a3a; } + #media-preview-panel { + background: #2a2a2a; + border-color: #3a3a3a; + } + + #media-preview-panel h2 { + color: #aaa; + } + + .media-empty { + color: #9d9d9d; + } + + .media-card { + background: #242424; + border-color: #3a3a3a; + } + + .media-card-label, + .media-card-url { + color: #a7a7a7; + } + + .media-card img, + .media-card video { + border-color: #4a4a4a; + } + + .media-error { + color: #f19797; + } + #status { color: #888; } From eed4eb8c42f4833c5834e37b661c2319d0511400 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 19 May 2026 22:01:56 +0000 Subject: [PATCH 2/4] Allow external media sources in Tauri CSP Co-authored-by: Kiya Rose Ren-Miyakari --- cross-platform/src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cross-platform/src-tauri/tauri.conf.json b/cross-platform/src-tauri/tauri.conf.json index 48c197d..d20fabd 100644 --- a/cross-platform/src-tauri/tauri.conf.json +++ b/cross-platform/src-tauri/tauri.conf.json @@ -12,7 +12,7 @@ }, "tauri": { "security": { - "csp": "default-src 'self' tauri: asset: data: blob:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: data: blob:" + "csp": "default-src 'self' tauri: asset: data: blob:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: data: blob: https:; media-src 'self' asset: data: blob: https:" }, "bundle": { "active": true, From 01bbb109bc045bdaf7c0a21ee6924e42c5a07448 Mon Sep 17 00:00:00 2001 From: Kiya Rose Date: Wed, 20 May 2026 00:48:12 -0400 Subject: [PATCH 3/4] Inline Intime --- cross-platform/package-lock.json | 4 +- cross-platform/package.json | 2 +- cross-platform/src-tauri/Cargo.lock | 2 +- cross-platform/src-tauri/Cargo.toml | 2 +- cross-platform/src-tauri/tauri.conf.json | 2 +- cross-platform/src/index.html | 14 +- cross-platform/src/main.js | 234 ++++++++++++++++++----- cross-platform/src/styles.css | 133 +++++-------- 8 files changed, 244 insertions(+), 149 deletions(-) diff --git a/cross-platform/package-lock.json b/cross-platform/package-lock.json index f15eb5e..09a2521 100644 --- a/cross-platform/package-lock.json +++ b/cross-platform/package-lock.json @@ -1,12 +1,12 @@ { "name": "pinstick-cross", - "version": "2.6.0", + "version": "2.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pinstick-cross", - "version": "2.6.0", + "version": "2.7.1", "devDependencies": { "@tauri-apps/cli": "1.5.10" } diff --git a/cross-platform/package.json b/cross-platform/package.json index 49c5c33..d1440bc 100644 --- a/cross-platform/package.json +++ b/cross-platform/package.json @@ -1,6 +1,6 @@ { "name": "pinstick-cross", - "version": "2.6.0", + "version": "2.7.1", "private": true, "type": "module", "scripts": { diff --git a/cross-platform/src-tauri/Cargo.lock b/cross-platform/src-tauri/Cargo.lock index 9b60cdd..28b049b 100644 --- a/cross-platform/src-tauri/Cargo.lock +++ b/cross-platform/src-tauri/Cargo.lock @@ -2000,7 +2000,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pinstick" -version = "2.6.0" +version = "2.7.1" dependencies = [ "serde", "serde_json", diff --git a/cross-platform/src-tauri/Cargo.toml b/cross-platform/src-tauri/Cargo.toml index daa7b77..e6e3dd8 100644 --- a/cross-platform/src-tauri/Cargo.toml +++ b/cross-platform/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pinstick" -version = "2.6.0" +version = "2.7.1" description = "PinStick cross-platform note pinning app" authors = ["SillyLittleTech"] edition = "2021" diff --git a/cross-platform/src-tauri/tauri.conf.json b/cross-platform/src-tauri/tauri.conf.json index d20fabd..63216f9 100644 --- a/cross-platform/src-tauri/tauri.conf.json +++ b/cross-platform/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "PinStick", - "version": "2.6.0" + "version": "2.7.1" }, "tauri": { "security": { diff --git a/cross-platform/src/index.html b/cross-platform/src/index.html index 31de049..0136bf4 100644 --- a/cross-platform/src/index.html +++ b/cross-platform/src/index.html @@ -16,11 +16,15 @@
- -
-

Embedded media

-
-
+
+ + + +
diff --git a/cross-platform/src/main.js b/cross-platform/src/main.js index edb2a25..5cc9499 100644 --- a/cross-platform/src/main.js +++ b/cross-platform/src/main.js @@ -1,13 +1,20 @@ const pinBtn = document.getElementById("pin-btn"); const statusEl = document.getElementById("status"); const noteEl = document.getElementById("note"); -const mediaPreviewEl = document.getElementById("media-preview"); +const noteSurfaceEl = document.getElementById("note-surface"); +const mediaStageEl = document.getElementById("media-stage"); +const mediaFileInputEl = document.getElementById("media-file-input"); + +const NOTE_STORAGE_KEY = "pinstick-note"; +const LOCAL_MEDIA_STORAGE_KEY = "pinstick-local-media"; const IMAGE_EXTENSIONS = /\.(avif|bmp|gif|jpe?g|png|svg|webp)(\?.*)?(#.*)?$/i; const VIDEO_EXTENSIONS = /\.(m4v|mov|mp4|ogg|ogv|webm)(\?.*)?(#.*)?$/i; const URL_PATTERN = /https?:\/\/[^\s<>"')\]]+/gi; const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((https?:\/\/[^)\s]+)\)/gi; const TRAILING_PUNCTUATION_PATTERN = /[),.!?;:]+$/; +const LOCAL_IMAGE_MIME = /^image\//i; +const LOCAL_VIDEO_MIME = /^video\//i; const TAURI = window.__TAURI__; const invoke = TAURI && TAURI.tauri ? TAURI.tauri.invoke : null; @@ -30,6 +37,23 @@ function classifyMediaUrl(url) { return null; } +function classifyLocalFile(file) { + if (LOCAL_IMAGE_MIME.test(file.type)) { + return "image"; + } + if (LOCAL_VIDEO_MIME.test(file.type)) { + return "video"; + } + const name = file.name.toLowerCase(); + if (IMAGE_EXTENSIONS.test(name)) { + return "image"; + } + if (VIDEO_EXTENSIONS.test(name)) { + return "video"; + } + return null; +} + function collectMediaUrls(noteText) { const seen = new Set(); const mediaItems = []; @@ -43,7 +67,7 @@ function collectMediaUrls(noteText) { const type = classifyMediaUrl(url); if (type && !seen.has(url)) { seen.add(url); - mediaItems.push({ url, type }); + mediaItems.push({ source: "remote", url, type }); } match = MARKDOWN_IMAGE_PATTERN.exec(noteText); } @@ -54,7 +78,7 @@ function collectMediaUrls(noteText) { const type = classifyMediaUrl(url); if (type && !seen.has(url)) { seen.add(url); - mediaItems.push({ url, type }); + mediaItems.push({ source: "remote", url, type }); } match = URL_PATTERN.exec(noteText); } @@ -62,31 +86,92 @@ function collectMediaUrls(noteText) { return mediaItems; } -function createMediaCard(mediaItem) { - const cardEl = document.createElement("article"); - cardEl.className = "media-card"; +function loadLocalMedia() { + try { + const raw = localStorage.getItem(LOCAL_MEDIA_STORAGE_KEY); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw); + if ( + parsed && + (parsed.type === "image" || parsed.type === "video") && + typeof parsed.dataUrl === "string" + ) { + return { source: "local", type: parsed.type, dataUrl: parsed.dataUrl }; + } + } catch (err) { + console.warn("Unable to read local media:", err); + } + return null; +} + +function saveLocalMedia(mediaItem) { + // Large videos may exceed localStorage quota; IndexedDB would be a future improvement. + localStorage.setItem( + LOCAL_MEDIA_STORAGE_KEY, + JSON.stringify({ type: mediaItem.type, dataUrl: mediaItem.dataUrl }), + ); +} + +function clearLocalMedia() { + localStorage.removeItem(LOCAL_MEDIA_STORAGE_KEY); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function removeUrlFromNote(noteText, url) { + let updated = noteText; + const markdownPattern = new RegExp( + `!\\[[^\\]]*]\\(${escapeRegExp(url)}\\)\\s*`, + "g", + ); + updated = updated.replace(markdownPattern, ""); + updated = updated.split(url).join(""); + return updated.replace(/\n{3,}/g, "\n\n").trim(); +} - const labelEl = document.createElement("p"); - labelEl.className = "media-card-label"; - labelEl.textContent = mediaItem.type === "image" ? "Image" : "Video"; +function hasActiveMedia(noteText) { + if (loadLocalMedia()) { + return true; + } + return collectMediaUrls(noteText).length > 0; +} - const urlEl = document.createElement("p"); - urlEl.className = "media-card-url"; - urlEl.textContent = mediaItem.url; +function updateEditedState(noteText) { + const edited = noteText.trim().length > 0 || hasActiveMedia(noteText); + document.title = edited ? "PinStick • Edited" : "PinStick"; + setStatus(edited ? "Edited" : "Ready"); +} +function saveNote(value) { + localStorage.setItem(NOTE_STORAGE_KEY, value); +} + +function createMediaEmbed(mediaItem) { + const embedEl = document.createElement("article"); + embedEl.className = "media-embed"; + embedEl.title = "Double-click to remove"; + + const src = mediaItem.source === "local" ? mediaItem.dataUrl : mediaItem.url; const errorEl = document.createElement("p"); errorEl.className = "media-error"; + errorEl.hidden = true; errorEl.textContent = mediaItem.type === "image" ? "Unable to load this image." : "Unable to load this video."; const mediaEl = document.createElement(mediaItem.type === "image" ? "img" : "video"); - mediaEl.src = mediaItem.url; - mediaEl.referrerPolicy = "no-referrer"; + mediaEl.src = src; + if (mediaItem.source === "remote") { + mediaEl.referrerPolicy = "no-referrer"; + } if (mediaItem.type === "image") { - mediaEl.alt = "Embedded image preview"; + mediaEl.alt = "Embedded image"; mediaEl.loading = "lazy"; } else { mediaEl.controls = true; @@ -97,63 +182,99 @@ function createMediaCard(mediaItem) { mediaEl.addEventListener( "error", () => { - if (!cardEl.contains(errorEl)) { - cardEl.appendChild(errorEl); - } + errorEl.hidden = false; }, { once: true }, ); - cardEl.appendChild(labelEl); - cardEl.appendChild(mediaEl); - cardEl.appendChild(urlEl); - return cardEl; + embedEl.addEventListener("dblclick", (event) => { + event.preventDefault(); + if (mediaItem.source === "local") { + clearLocalMedia(); + } else { + const updated = removeUrlFromNote(noteEl.value, mediaItem.url); + noteEl.value = updated; + saveNote(updated); + } + syncNoteView(noteEl.value); + }); + + embedEl.appendChild(mediaEl); + embedEl.appendChild(errorEl); + return embedEl; } -function renderMediaPreview(noteText) { - if (!mediaPreviewEl) { +function syncNoteView(noteText) { + if (!noteSurfaceEl || !mediaStageEl) { return; } - mediaPreviewEl.textContent = ""; + const remoteItems = collectMediaUrls(noteText); + const localItem = loadLocalMedia(); + const mediaItems = localItem ? [localItem, ...remoteItems] : remoteItems; + const inMediaMode = mediaItems.length > 0; - const mediaItems = collectMediaUrls(noteText); - if (mediaItems.length === 0) { - const emptyEl = document.createElement("p"); - emptyEl.className = "media-empty"; - emptyEl.textContent = "Paste image or video links in your note to preview them here."; - mediaPreviewEl.appendChild(emptyEl); - return; + noteSurfaceEl.classList.toggle("media-mode", inMediaMode); + mediaStageEl.hidden = !inMediaMode; + mediaStageEl.textContent = ""; + + if (inMediaMode) { + const fragment = document.createDocumentFragment(); + mediaItems.forEach((item) => { + fragment.appendChild(createMediaEmbed(item)); + }); + mediaStageEl.appendChild(fragment); } - const fragment = document.createDocumentFragment(); - mediaItems.forEach((mediaItem) => { - fragment.appendChild(createMediaCard(mediaItem)); - }); - mediaPreviewEl.appendChild(fragment); + updateEditedState(noteText); } function loadNote() { let saved = ""; try { - const stored = localStorage.getItem("pinstick-note"); + const stored = localStorage.getItem(NOTE_STORAGE_KEY); saved = stored === null ? "" : stored; } catch (err) { console.warn("Unable to read saved note:", err); setStatus("Failed to load note; starting empty"); } noteEl.value = saved; - setEdited(saved.length > 0); - renderMediaPreview(saved); + syncNoteView(saved); } -function setEdited(isEdited) { - document.title = isEdited ? "PinStick • Edited" : "PinStick"; - setStatus(isEdited ? "Edited" : "Ready"); +function readFileAsDataUrl(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); } -function saveNote(value) { - localStorage.setItem("pinstick-note", value); +async function handleLocalFileSelected(file) { + const type = classifyLocalFile(file); + if (!type) { + setStatus("Unsupported file type"); + return; + } + + try { + const dataUrl = await readFileAsDataUrl(file); + saveLocalMedia({ type, dataUrl }); + syncNoteView(noteEl.value); + setStatus("Media added"); + } catch (err) { + console.error(err); + setStatus("Failed to load file"); + } +} + +function openMediaFilePicker() { + if (!mediaFileInputEl) { + return; + } + mediaFileInputEl.value = ""; + mediaFileInputEl.click(); } async function togglePin() { @@ -184,8 +305,8 @@ function init() { pinBtn.disabled = true; setStatus("Tauri API unavailable"); } else if (TAURI && TAURI.app) { - // Briefly show the app version so users can verify which build is running - TAURI.app.getVersion() + TAURI.app + .getVersion() .then((version) => { const previous = statusEl.textContent; setStatus(`v${version}`); @@ -199,10 +320,25 @@ function init() { noteEl.addEventListener("input", (e) => { const value = e.target.value; saveNote(value); - setEdited(value.length > 0); - renderMediaPreview(value); + syncNoteView(value); + }); + + noteEl.addEventListener("dblclick", () => { + if (noteEl.value.trim() !== "" || hasActiveMedia(noteEl.value)) { + return; + } + openMediaFilePicker(); }); + if (mediaFileInputEl) { + mediaFileInputEl.addEventListener("change", () => { + const file = mediaFileInputEl.files && mediaFileInputEl.files[0]; + if (file) { + handleLocalFileSelected(file); + } + }); + } + pinBtn.addEventListener("click", togglePin); } diff --git a/cross-platform/src/styles.css b/cross-platform/src/styles.css index c2c47b6..8fe397a 100644 --- a/cross-platform/src/styles.css +++ b/cross-platform/src/styles.css @@ -51,14 +51,21 @@ main { padding: 8px; display: flex; flex-direction: column; - gap: 8px; min-height: 0; } -textarea { +.note-surface { + position: relative; + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.note-surface textarea { width: 100%; flex: 1; - min-height: 120px; + min-height: 0; resize: none; padding: 10px; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; @@ -69,80 +76,56 @@ textarea { color: #222; } -#status { - font-size: 11px; - color: #666; +.note-surface.media-mode textarea { + display: none; } -#media-preview-panel { +.media-stage { + display: none; + flex: 1; + flex-direction: column; + gap: 8px; + min-height: 0; + overflow: auto; border: 1px solid #d0d0d0; border-radius: 8px; - background: #fff; - padding: 10px; - min-height: 150px; - max-height: 45%; - overflow: auto; -} - -#media-preview-panel h2 { - margin: 0 0 8px 0; - font-size: 12px; - font-weight: 600; - color: #555; - text-transform: uppercase; - letter-spacing: 0.05em; + background: #000; } -#media-preview { +.note-surface.media-mode .media-stage { display: flex; - flex-direction: column; - gap: 10px; } -.media-empty { - margin: 0; - font-size: 13px; - color: #777; -} - -.media-card { +.media-embed { + flex: 1; + min-height: 120px; display: flex; - flex-direction: column; - gap: 8px; - padding: 8px; - border-radius: 8px; - border: 1px solid #e2e2e2; - background: #fafafa; -} - -.media-card-label { - margin: 0; - font-size: 11px; - font-weight: 600; - color: #666; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.media-card-url { - margin: 0; - overflow-wrap: anywhere; - font-size: 12px; - color: #666; + align-items: center; + justify-content: center; + cursor: pointer; + position: relative; } -.media-card img, -.media-card video { +.media-embed img, +.media-embed video { max-width: 100%; - border-radius: 6px; - border: 1px solid #d7d7d7; - background: #000; + max-height: 100%; + width: 100%; + height: 100%; + object-fit: contain; } .media-error { margin: 0; + padding: 8px; font-size: 12px; - color: #8f1f1f; + color: #f19797; + text-align: center; +} + +#status { + font-size: 11px; + color: #666; } @media (prefers-color-scheme: dark) { @@ -165,44 +148,16 @@ textarea { border-color: rgba(255, 255, 255, 0.2); } - textarea { + .note-surface textarea { background: #2a2a2a; color: #e0e0e0; border-color: #3a3a3a; } - #media-preview-panel { - background: #2a2a2a; - border-color: #3a3a3a; - } - - #media-preview-panel h2 { - color: #aaa; - } - - .media-empty { - color: #9d9d9d; - } - - .media-card { - background: #242424; + .media-stage { border-color: #3a3a3a; } - .media-card-label, - .media-card-url { - color: #a7a7a7; - } - - .media-card img, - .media-card video { - border-color: #4a4a4a; - } - - .media-error { - color: #f19797; - } - #status { color: #888; } From 92f01043d5d1963697c5860e45fce3cc7af14f39 Mon Sep 17 00:00:00 2001 From: Kiya Rose Date: Wed, 20 May 2026 01:41:11 -0400 Subject: [PATCH 4/4] Add an overlay mode This overlay mode with slider, resolves #21 --- cross-platform/package-lock.json | 4 +- cross-platform/package.json | 2 +- cross-platform/src-tauri/Cargo.lock | 82 +++++- cross-platform/src-tauri/Cargo.toml | 14 +- cross-platform/src-tauri/src/main.rs | 329 ++++++++++++++++++++++- cross-platform/src-tauri/tauri.conf.json | 6 +- cross-platform/src/index.html | 25 +- cross-platform/src/main.js | 274 +++++++++++++++++++ cross-platform/src/styles.css | 200 ++++++++++---- 9 files changed, 862 insertions(+), 74 deletions(-) diff --git a/cross-platform/package-lock.json b/cross-platform/package-lock.json index 09a2521..6e8b303 100644 --- a/cross-platform/package-lock.json +++ b/cross-platform/package-lock.json @@ -1,12 +1,12 @@ { "name": "pinstick-cross", - "version": "2.7.1", + "version": "2.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pinstick-cross", - "version": "2.7.1", + "version": "2.8.0", "devDependencies": { "@tauri-apps/cli": "1.5.10" } diff --git a/cross-platform/package.json b/cross-platform/package.json index d1440bc..ede4d1a 100644 --- a/cross-platform/package.json +++ b/cross-platform/package.json @@ -1,6 +1,6 @@ { "name": "pinstick-cross", - "version": "2.7.1", + "version": "2.8.0", "private": true, "type": "module", "scripts": { diff --git a/cross-platform/src-tauri/Cargo.lock b/cross-platform/src-tauri/Cargo.lock index 28b049b..b51a6e1 100644 --- a/cross-platform/src-tauri/Cargo.lock +++ b/cross-platform/src-tauri/Cargo.lock @@ -285,8 +285,24 @@ dependencies = [ "block", "cocoa-foundation", "core-foundation", - "core-graphics", - "foreign-types", + "core-graphics 0.22.3", + "foreign-types 0.3.2", + "libc", + "objc", +] + +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation", + "core-graphics 0.23.2", + "foreign-types 0.5.0", "libc", "objc", ] @@ -352,7 +368,20 @@ dependencies = [ "bitflags 1.3.2", "core-foundation", "core-graphics-types", - "foreign-types", + "foreign-types 0.3.2", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types 0.5.0", "libc", ] @@ -722,7 +751,28 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "foreign-types-shared", + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -731,6 +781,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -2000,12 +2056,16 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pinstick" -version = "2.7.1" +version = "2.8.0" dependencies = [ + "cocoa 0.25.0", + "raw-window-handle", "serde", "serde_json", "tauri", "tauri-build", + "windows 0.48.0", + "x11", ] [[package]] @@ -2752,9 +2812,9 @@ dependencies = [ "bitflags 1.3.2", "cairo-rs", "cc", - "cocoa", + "cocoa 0.24.1", "core-foundation", - "core-graphics", + "core-graphics 0.22.3", "crossbeam-channel", "dispatch", "gdk", @@ -2825,7 +2885,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae1f57c291a6ab8e1d2e6b8ad0a35ff769c9925deb8a89de85425ff08762d0c" dependencies = [ "anyhow", - "cocoa", + "cocoa 0.24.1", "dirs-next", "dunce", "embed_plist", @@ -2952,7 +3012,7 @@ version = "0.14.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce361fec1e186705371f1c64ae9dd2a3a6768bc530d0a2d5e75a634bb416ad4d" dependencies = [ - "cocoa", + "cocoa 0.24.1", "gtk", "percent-encoding", "rand 0.8.5", @@ -3949,8 +4009,8 @@ checksum = "4a2a144c3ab5e83e04724bc8e67cea552ffae413185fda459fafdae173fd985d" dependencies = [ "base64 0.13.1", "block", - "cocoa", - "core-graphics", + "cocoa 0.24.1", + "core-graphics 0.22.3", "crossbeam-channel", "dunce", "gdk", diff --git a/cross-platform/src-tauri/Cargo.toml b/cross-platform/src-tauri/Cargo.toml index e6e3dd8..0e06faa 100644 --- a/cross-platform/src-tauri/Cargo.toml +++ b/cross-platform/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pinstick" -version = "2.7.1" +version = "2.8.0" description = "PinStick cross-platform note pinning app" authors = ["SillyLittleTech"] edition = "2021" @@ -8,7 +8,17 @@ edition = "2021" [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -tauri = { version = "1.5", features = ["custom-protocol"] } +tauri = { version = "1.5", features = ["custom-protocol", "macos-private-api"] } + +[target.'cfg(target_os = "macos")'.dependencies] +cocoa = "0.25" + +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.48", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging"] } + +[target.'cfg(target_os = "linux")'.dependencies] +raw-window-handle = "0.5" +x11 = { version = "2", features = ["xlib"] } [features] default = ["custom-protocol"] diff --git a/cross-platform/src-tauri/src/main.rs b/cross-platform/src-tauri/src/main.rs index 3b996da..f4c4169 100644 --- a/cross-platform/src-tauri/src/main.rs +++ b/cross-platform/src-tauri/src/main.rs @@ -10,28 +10,343 @@ struct PinState { pinned: bool, } -/// Stores pin state; Mutex provides thread-safe interior mutability for concurrent access. -/// Poison recovery via `unwrap_or_else` is safe because the bool has no invariants and can be safely overwritten. +#[derive(Serialize)] +struct OverlayStateResponse { + enabled: bool, + opacity: f32, +} + +#[derive(Serialize)] +struct OverlayInputAvailability { + click_through: bool, + platform: String, + message: Option, +} + +#[derive(Clone)] +struct OverlayState { + enabled: bool, + opacity: f32, + saved_pin: bool, +} + +impl Default for OverlayState { + fn default() -> Self { + Self { + enabled: false, + opacity: 0.7, + saved_pin: false, + } + } +} + struct PinStore(Mutex); +struct OverlayStore(Mutex); + +fn clamp_opacity(opacity: f32) -> f32 { + opacity.clamp(0.4, 1.0) +} + +fn platform_name() -> &'static str { + #[cfg(target_os = "macos")] + { + return "macos"; + } + #[cfg(target_os = "windows")] + { + return "windows"; + } + #[cfg(target_os = "linux")] + { + if std::env::var_os("WAYLAND_DISPLAY").is_some() { + return "linux-wayland"; + } + return "linux-x11"; + } + #[allow(unreachable_code)] + "unknown" +} + +fn overlay_input_available() -> OverlayInputAvailability { + #[cfg(target_os = "linux")] + { + if std::env::var_os("WAYLAND_DISPLAY").is_some() { + return OverlayInputAvailability { + click_through: false, + platform: "linux-wayland".into(), + message: Some( + "Full click-through isn't available on Wayland. Use the toolbar to exit or change opacity.".into(), + ), + }; + } + } + + OverlayInputAvailability { + click_through: true, + platform: platform_name().into(), + message: None, + } +} + +#[cfg(target_os = "macos")] +fn set_window_opacity(window: &tauri::Window, opacity: f32) -> Result<(), String> { + use cocoa::appkit::NSWindow; + use cocoa::base::id; + + let ns_window = window.ns_window().map_err(|e| e.to_string())? as id; + unsafe { + ns_window.setAlphaValue_(opacity as _); + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn set_window_opacity(_window: &tauri::Window, _opacity: f32) -> Result<(), String> { + // Opacity is applied via CSS on Windows (avoids HWND API version conflicts with Tauri). + Ok(()) +} + +#[cfg(target_os = "linux")] +fn set_window_opacity(_window: &tauri::Window, _opacity: f32) -> Result<(), String> { + // Opacity is applied via CSS on Linux. + Ok(()) +} + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +fn set_window_opacity(_window: &tauri::Window, _opacity: f32) -> Result<(), String> { + Ok(()) +} + +fn apply_overlay_window( + window: &tauri::Window, + enabled: bool, + opacity: f32, + click_through: bool, +) -> Result<(), String> { + if enabled { + window + .set_always_on_top(true) + .map_err(|e| format!("Failed to set always on top: {e}"))?; + set_window_opacity(window, opacity)?; + if click_through { + window + .set_ignore_cursor_events(true) + .map_err(|e| format!("Failed to set click-through: {e}"))?; + } else { + window + .set_ignore_cursor_events(false) + .map_err(|e| format!("Failed to disable click-through: {e}"))?; + } + } else { + set_window_opacity(window, 1.0)?; + window + .set_ignore_cursor_events(false) + .map_err(|e| format!("Failed to restore click-through: {e}"))?; + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn get_cursor_position_impl(window: &tauri::Window) -> Result<(f64, f64), String> { + use cocoa::appkit::{NSEvent, NSWindow}; + use cocoa::base::{id, nil}; + use cocoa::foundation::NSRect; + + let ns_window = window.ns_window().map_err(|e| e.to_string())? as id; + let scale = window.scale_factor().map_err(|e| e.to_string())?; + + unsafe { + let mouse = NSEvent::mouseLocation(nil); + let frame: NSRect = ns_window.frame(); + let x = (mouse.x - frame.origin.x) / scale; + let y = (frame.origin.y + frame.size.height - mouse.y) / scale; + Ok((x, y)) + } +} + +#[cfg(target_os = "windows")] +fn get_cursor_position_impl(window: &tauri::Window) -> Result<(f64, f64), String> { + use windows::Win32::Foundation::POINT; + use windows::Win32::UI::WindowsAndMessaging::GetCursorPos; + + let outer = window + .outer_position() + .map_err(|e| format!("Failed to get window position: {e}"))?; + let scale = window.scale_factor().map_err(|e| e.to_string())?; + + unsafe { + let mut point = POINT::default(); + GetCursorPos(&mut point) + .as_bool() + .then_some(()) + .ok_or_else(|| "GetCursorPos failed".to_string())?; + let x = (point.x as f64 - outer.x as f64) / scale; + let y = (point.y as f64 - outer.y as f64) / scale; + Ok((x, y)) + } +} + +#[cfg(target_os = "linux")] +fn get_cursor_position_impl(window: &tauri::Window) -> Result<(f64, f64), String> { + if std::env::var_os("WAYLAND_DISPLAY").is_some() { + return Err("wayland_cursor_unavailable".into()); + } + + use raw_window_handle::{HasRawWindowHandle, RawWindowHandle}; // trait for raw_window_handle() + use x11::xlib; + + let handle = window + .raw_window_handle() + .map_err(|e| format!("raw_window_handle failed: {e}"))?; + + match handle { + RawWindowHandle::Xlib(xlib_handle) => unsafe { + let display = xlib_handle.display as *mut xlib::Display; + let mut root_return: xlib::Window = 0; + let mut child_return: xlib::Window = 0; + let mut root_x: i32 = 0; + let mut root_y: i32 = 0; + let mut win_x: i32 = 0; + let mut win_y: i32 = 0; + let mut mask: u32 = 0; + + let ok = xlib::XQueryPointer( + display, + xlib_handle.window as u64, + &mut root_return, + &mut child_return, + &mut root_x, + &mut root_y, + &mut win_x, + &mut win_y, + &mut mask, + ); + if ok == 0 { + return Err("XQueryPointer failed".into()); + } + + let scale = window.scale_factor().map_err(|e| e.to_string())?; + Ok((win_x as f64 / scale, win_y as f64 / scale)) + }, + RawWindowHandle::Wayland(_) => Err("wayland_cursor_unavailable".into()), + _ => Err("unsupported_linux_display".into()), + } +} + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +fn get_cursor_position_impl(_window: &tauri::Window) -> Result<(f64, f64), String> { + Err("cursor_position_unavailable".into()) +} #[tauri::command] -fn toggle_pin(window: tauri::Window, store: State) -> Result { - // Recover from a poisoned mutex by taking the inner value. - let mut pinned = store.0.lock().unwrap_or_else(|e| e.into_inner()); +fn toggle_pin( + window: tauri::Window, + pin_store: State, + overlay_store: State, +) -> Result { + let overlay = overlay_store.0.lock().unwrap_or_else(|e| e.into_inner()); + if overlay.enabled { + return Err("Disable overlay before changing pin state.".into()); + } + drop(overlay); + let mut pinned = pin_store.0.lock().unwrap_or_else(|e| e.into_inner()); let next = !*pinned; window .set_always_on_top(next) .map_err(|e| format!("Failed to toggle pin: {e}"))?; - *pinned = next; Ok(PinState { pinned: next }) } +#[tauri::command] +fn toggle_overlay( + window: tauri::Window, + pin_store: State, + overlay_store: State, +) -> Result { + let availability = overlay_input_available(); + let mut overlay = overlay_store.0.lock().unwrap_or_else(|e| e.into_inner()); + let mut pinned = pin_store.0.lock().unwrap_or_else(|e| e.into_inner()); + + let next = !overlay.enabled; + if next { + overlay.saved_pin = *pinned; + overlay.enabled = true; + apply_overlay_window(&window, true, overlay.opacity, availability.click_through)?; + } else { + overlay.enabled = false; + apply_overlay_window(&window, false, overlay.opacity, availability.click_through)?; + window + .set_always_on_top(overlay.saved_pin) + .map_err(|e| format!("Failed to restore pin state: {e}"))?; + *pinned = overlay.saved_pin; + } + + Ok(OverlayStateResponse { + enabled: overlay.enabled, + opacity: overlay.opacity, + }) +} + +#[tauri::command] +fn set_overlay_opacity( + opacity: f32, + window: tauri::Window, + overlay_store: State, +) -> Result { + let mut overlay = overlay_store.0.lock().unwrap_or_else(|e| e.into_inner()); + overlay.opacity = clamp_opacity(opacity); + + if overlay.enabled { + set_window_opacity(&window, overlay.opacity)?; + } + + Ok(OverlayStateResponse { + enabled: overlay.enabled, + opacity: overlay.opacity, + }) +} + +#[tauri::command] +fn set_ignore_cursor_events(ignore: bool, window: tauri::Window) -> Result<(), String> { + window + .set_ignore_cursor_events(ignore) + .map_err(|e| format!("Failed to set ignore cursor events: {e}")) +} + +#[tauri::command] +fn get_cursor_position(window: tauri::Window) -> Result<(f64, f64), String> { + get_cursor_position_impl(&window) +} + +#[tauri::command] +fn check_overlay_input_available() -> OverlayInputAvailability { + overlay_input_available() +} + +#[tauri::command] +fn get_overlay_state(overlay_store: State) -> Result { + let overlay = overlay_store.0.lock().unwrap_or_else(|e| e.into_inner()); + Ok(OverlayStateResponse { + enabled: overlay.enabled, + opacity: overlay.opacity, + }) +} + fn main() { tauri::Builder::default() .manage(PinStore(Mutex::new(false))) - .invoke_handler(tauri::generate_handler![toggle_pin]) + .manage(OverlayStore(Mutex::new(OverlayState::default()))) + .invoke_handler(tauri::generate_handler![ + toggle_pin, + toggle_overlay, + set_overlay_opacity, + set_ignore_cursor_events, + get_cursor_position, + check_overlay_input_available, + get_overlay_state, + ]) .setup(|app| { if let Some(window) = app.get_window("main") { window.set_title("PinStick")?; diff --git a/cross-platform/src-tauri/tauri.conf.json b/cross-platform/src-tauri/tauri.conf.json index 63216f9..d417f76 100644 --- a/cross-platform/src-tauri/tauri.conf.json +++ b/cross-platform/src-tauri/tauri.conf.json @@ -8,9 +8,10 @@ }, "package": { "productName": "PinStick", - "version": "2.7.1" + "version": "2.8.0" }, "tauri": { + "macOSPrivateApi": true, "security": { "csp": "default-src 'self' tauri: asset: data: blob:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: data: blob: https:; media-src 'self' asset: data: blob: https:" }, @@ -33,7 +34,8 @@ "height": 320, "resizable": true, "fullscreen": false, - "center": true + "center": true, + "transparent": true } ] } diff --git a/cross-platform/src/index.html b/cross-platform/src/index.html index 0136bf4..1b264f7 100644 --- a/cross-platform/src/index.html +++ b/cross-platform/src/index.html @@ -7,14 +7,37 @@ -
+
+ +
+
+ + + 70%
Ready
+