agent: dogfood polish 2026/04/19 — Linux native chrome + History redesign + mic picker cleanup
Second dogfood sprint. Headline fix: Linux now uses native KWin/Mutter decorations instead of fragile frameless `startResizeDragging`, which collapsed diagonal corner resize to a single axis and made drag feel laggy. macOS / Windows keep custom chrome via `useCustomChrome` gate. Other changes: - Cross-window preferences sync via `kon:preferences-changed` Tauri event — theme and font changes propagate live to float/viewer. - Hotkey recorder rewritten to use capture-phase document listener gated by $effect; button focus was unreliable in webkit2gtk. - History page redesigned for cognitive-load hygiene: title-first compact row, inline title input, Edit popout opening /viewer in edit mode, clipboard export as .md with YAML frontmatter, manual tag chips + + Add tag input, header tag filter (cap 7), global Starred filter, `tag:xyz` search syntax. - `deriveAutoTags` kept as empty hook for post-Task-7 LLM topic tags; research found all previous auto-tag chips redundant with row UI. - Viewer window adds edit mode with debounced-save textarea; native title renamed to "Kon - Transcription Editor". - Window minimums updated per GNOME HIG + WCAG reflow research: main 960x600, float 360x480, editor 560x520. - Microphone picker filters raw ALSA strings (hw:, plughw:, front:, sysdefault:, null) and dedupes by CARD=X. New `description` field on DeviceInfo reads /proc/asound/cards so Blue Yeti shows as "Blue Microphones" instead of the short "Microphones" card name. - GPU reporting fixed: get_runtime_capabilities now returns accelerators=[cpu,vulkan] and whisper.supports_gpu=true, matching the transcribe-rs whisper-vulkan feature linked unconditionally. - ResizeHandles kept for macOS/Windows frameless: 12px edges, 20px corners via CSS vars, pointerdown + setPointerCapture, corners above edges in z-order, rendered as sibling (not child) of the animated layout root so `position: fixed` is viewport-relative. - Dueling drag-region handlers removed — `data-tauri-drag-region` and manual `startDragging()` were stacked on the same elements; kept the manual handler which has the button/input early-return logic. See HANDOVER.md for the full session log and deferred items. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -86,6 +86,11 @@
|
||||
/* Motion */
|
||||
--duration-ui: 150ms;
|
||||
--duration-decorative: 300ms;
|
||||
|
||||
/* Window resize hit zones — consumed by ResizeHandles.svelte. One source
|
||||
of truth so every Kon window feels identical. */
|
||||
--kon-resize-edge: 12px;
|
||||
--kon-resize-corner: 20px;
|
||||
}
|
||||
|
||||
/* === Button Component Classes === */
|
||||
|
||||
@@ -6,16 +6,17 @@
|
||||
|
||||
const modifierKeys = new Set(["Control", "Shift", "Alt", "Meta"]);
|
||||
|
||||
function startRecording() {
|
||||
recording = true;
|
||||
captured = false;
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
// Capture-phase listener; guard still belts-and-braces.
|
||||
if (!recording) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === "Escape") {
|
||||
recording = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for a non-modifier key
|
||||
if (modifierKeys.has(e.key)) return;
|
||||
|
||||
@@ -39,8 +40,21 @@
|
||||
setTimeout(() => { captured = false; }, 1500);
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
recording = false;
|
||||
// Register the listener only while recording, at the capture phase so no
|
||||
// descendant handler (or the parent layout's svelte:window keydown) can
|
||||
// swallow the event first. Button-level onkeydown would require the
|
||||
// button to hold keyboard focus after a click, which webkit2gtk on Linux
|
||||
// does not guarantee.
|
||||
$effect(() => {
|
||||
if (!recording) return;
|
||||
const handler = handleKeyDown;
|
||||
document.addEventListener("keydown", handler, { capture: true });
|
||||
return () => document.removeEventListener("keydown", handler, { capture: true });
|
||||
});
|
||||
|
||||
function startRecording() {
|
||||
recording = true;
|
||||
captured = false;
|
||||
}
|
||||
|
||||
let chips = $derived(settings.globalHotkey.split("+"));
|
||||
@@ -55,8 +69,6 @@
|
||||
: 'bg-bg-input border-border hover:border-border'}
|
||||
border transition-all"
|
||||
onclick={startRecording}
|
||||
onkeydown={handleKeyDown}
|
||||
onblur={handleBlur}
|
||||
aria-label="Record hotkey"
|
||||
>
|
||||
{#if recording}
|
||||
|
||||
141
src/lib/components/ResizeHandles.svelte
Normal file
141
src/lib/components/ResizeHandles.svelte
Normal file
@@ -0,0 +1,141 @@
|
||||
<script>
|
||||
// Invisible resize-handle overlays around each Kon window edge.
|
||||
// Needed because Kon runs with `decorations: false`, so the WM
|
||||
// provides no resize affordance on KDE/GNOME Wayland.
|
||||
//
|
||||
// Architecture: eight fixed-position divs are the click target for
|
||||
// resize. Because the divs themselves are the event target (not any
|
||||
// ancestor), Tauri's `data-tauri-drag-region` delegated handler walks
|
||||
// the div's ancestors, finds no drag-region, and does not start a
|
||||
// window move. This gives us a clean separation: resize wins on the
|
||||
// edges, drag wins on the titlebar.
|
||||
//
|
||||
// Hit zone sizes come from CSS custom properties so every Kon window
|
||||
// across the app stays identical. See `--kon-resize-edge` and
|
||||
// `--kon-resize-corner` in app.css.
|
||||
//
|
||||
// Placement requirement: instances of this component MUST be mounted
|
||||
// as a sibling of the animated/transformed layout root (not a child),
|
||||
// or `position: fixed` becomes relative to that transformed ancestor
|
||||
// instead of the viewport.
|
||||
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { hasTauriRuntime } from "$lib/utils/runtime.js";
|
||||
|
||||
const enabled = hasTauriRuntime();
|
||||
|
||||
async function startResize(e, direction) {
|
||||
if (!enabled) return;
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Lock the pointer to this handle BEFORE asking Wayland to begin a
|
||||
// resize. Without capture, KWin's xdg_toplevel.resize grab arrives
|
||||
// between mousedown synthesis and pointer movement, often losing the
|
||||
// diagonal component and collapsing a corner drag to a single axis.
|
||||
// pointerdown + setPointerCapture keeps the direction pinned.
|
||||
try {
|
||||
e.currentTarget?.setPointerCapture?.(e.pointerId);
|
||||
} catch {}
|
||||
try {
|
||||
await getCurrentWindow().startResizeDragging(direction);
|
||||
} catch {}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if enabled}
|
||||
<!-- Edge strips. Inset by corner size so edge and corner zones don't overlap. -->
|
||||
<div class="kon-rh kon-rh-n" onpointerdown={(e) => startResize(e,"North")}></div>
|
||||
<div class="kon-rh kon-rh-s" onpointerdown={(e) => startResize(e,"South")}></div>
|
||||
<div class="kon-rh kon-rh-w" onpointerdown={(e) => startResize(e,"West")}></div>
|
||||
<div class="kon-rh kon-rh-e" onpointerdown={(e) => startResize(e,"East")}></div>
|
||||
|
||||
<!-- Corner hit zones, larger for easier diagonal targeting. -->
|
||||
<div class="kon-rh kon-rh-nw" onpointerdown={(e) => startResize(e,"NorthWest")}></div>
|
||||
<div class="kon-rh kon-rh-ne" onpointerdown={(e) => startResize(e,"NorthEast")}></div>
|
||||
<div class="kon-rh kon-rh-sw" onpointerdown={(e) => startResize(e,"SouthWest")}></div>
|
||||
<div class="kon-rh kon-rh-se" onpointerdown={(e) => startResize(e,"SouthEast")}></div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.kon-rh {
|
||||
position: fixed;
|
||||
z-index: 2147483646;
|
||||
background: transparent;
|
||||
pointer-events: auto;
|
||||
/* Prevent the browser from consuming the pointerdown for its own
|
||||
defaults (text selection, drag scroll) before we capture it. */
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* Corners stack above edges so, at the 1-2 px of overlap on some
|
||||
compositors, a corner click is never mistaken for an edge click. */
|
||||
.kon-rh-nw,
|
||||
.kon-rh-ne,
|
||||
.kon-rh-sw,
|
||||
.kon-rh-se {
|
||||
z-index: 2147483647;
|
||||
}
|
||||
|
||||
/* Edges */
|
||||
.kon-rh-n {
|
||||
top: 0;
|
||||
left: var(--kon-resize-corner);
|
||||
right: var(--kon-resize-corner);
|
||||
height: var(--kon-resize-edge);
|
||||
cursor: n-resize;
|
||||
}
|
||||
.kon-rh-s {
|
||||
bottom: 0;
|
||||
left: var(--kon-resize-corner);
|
||||
right: var(--kon-resize-corner);
|
||||
height: var(--kon-resize-edge);
|
||||
cursor: s-resize;
|
||||
}
|
||||
.kon-rh-w {
|
||||
top: var(--kon-resize-corner);
|
||||
bottom: var(--kon-resize-corner);
|
||||
left: 0;
|
||||
width: var(--kon-resize-edge);
|
||||
cursor: w-resize;
|
||||
}
|
||||
.kon-rh-e {
|
||||
top: var(--kon-resize-corner);
|
||||
bottom: var(--kon-resize-corner);
|
||||
right: 0;
|
||||
width: var(--kon-resize-edge);
|
||||
cursor: e-resize;
|
||||
}
|
||||
|
||||
/* Corners */
|
||||
.kon-rh-nw {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--kon-resize-corner);
|
||||
height: var(--kon-resize-corner);
|
||||
cursor: nw-resize;
|
||||
}
|
||||
.kon-rh-ne {
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: var(--kon-resize-corner);
|
||||
height: var(--kon-resize-corner);
|
||||
cursor: ne-resize;
|
||||
}
|
||||
.kon-rh-sw {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: var(--kon-resize-corner);
|
||||
height: var(--kon-resize-corner);
|
||||
cursor: sw-resize;
|
||||
}
|
||||
.kon-rh-se {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: var(--kon-resize-corner);
|
||||
height: var(--kon-resize-corner);
|
||||
cursor: se-resize;
|
||||
}
|
||||
</style>
|
||||
@@ -22,6 +22,7 @@
|
||||
function handleDragStart(e) {
|
||||
if (e.button !== 0) return;
|
||||
if (e.target.closest("button")) return;
|
||||
try { e.currentTarget?.setPointerCapture?.(e.pointerId); } catch {}
|
||||
getCurrentWindow().startDragging();
|
||||
}
|
||||
|
||||
@@ -38,20 +39,18 @@
|
||||
<div
|
||||
class="flex items-center select-none bg-sidebar border-b border-border-subtle
|
||||
{compact ? 'h-[28px]' : 'h-[32px]'}"
|
||||
onmousedown={handleDragStart}
|
||||
data-tauri-drag-region
|
||||
onpointerdown={handleDragStart}
|
||||
>
|
||||
{#if !compact}
|
||||
<!-- Left spacer: aligns with sidebar width -->
|
||||
<div
|
||||
class="transition-all {settings.sidebarCollapsed ? 'w-[48px] min-w-[48px]' : 'w-[210px] min-w-[210px]'}"
|
||||
style="transition-duration: var(--duration-ui)"
|
||||
data-tauri-drag-region
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Centre: drag area -->
|
||||
<div class="flex-1" data-tauri-drag-region></div>
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Window controls -->
|
||||
<div class="flex items-center h-full">
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
import { history, saveHistory, deleteFromHistory, renameHistoryEntry } from "$lib/stores/page.svelte.js";
|
||||
import { toasts } from "$lib/stores/toasts.svelte.js";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import {
|
||||
deriveAutoTags, buildFrontmatter, buildMarkdown, normaliseTag,
|
||||
} from "$lib/utils/frontmatter.js";
|
||||
import { getPreferences } from "$lib/stores/preferences.svelte.js";
|
||||
import { clampTextLines, measurePreWrap } from "$lib/utils/textMeasure.js";
|
||||
import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js";
|
||||
@@ -12,12 +15,14 @@
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import { formatTime, formatDuration } from "$lib/utils/time.js";
|
||||
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
|
||||
import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown } from 'lucide-svelte';
|
||||
import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown, ExternalLink, Star } from 'lucide-svelte';
|
||||
|
||||
const prefs = getPreferences();
|
||||
const COLLAPSED_ROW_MIN_HEIGHT = 54;
|
||||
const COLLAPSED_ROW_VERTICAL_PADDING = 24;
|
||||
const EXPANDED_BASE_HEIGHT = 92;
|
||||
const EXPANDED_TITLE_INPUT_HEIGHT = 48;
|
||||
const EXPANDED_TAGS_ROW_HEIGHT = 40;
|
||||
const AUDIO_PLAYER_HEIGHT = 54;
|
||||
const HISTORY_PREVIEW_LINES = 2;
|
||||
const HISTORY_DURATION_WIDTH = 48;
|
||||
@@ -31,6 +36,8 @@
|
||||
const BUFFER_COUNT = 6;
|
||||
|
||||
let searchQuery = $state("");
|
||||
let showStarredOnly = $state(false);
|
||||
let activeTagFilter = $state(null); // null = all; string = tag value
|
||||
let expandedId = $state(null);
|
||||
let playingId = $state(null);
|
||||
let audioEl = $state(null);
|
||||
@@ -47,25 +54,70 @@
|
||||
stopPlayback();
|
||||
});
|
||||
|
||||
let filtered = $derived(
|
||||
searchQuery
|
||||
? history.filter((h) => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
return (
|
||||
(h.text && h.text.toLowerCase().includes(q)) ||
|
||||
(h.preview && h.preview.toLowerCase().includes(q)) ||
|
||||
(h.source && h.source.toLowerCase().includes(q)) ||
|
||||
(h.title && h.title.toLowerCase().includes(q))
|
||||
);
|
||||
})
|
||||
: history
|
||||
);
|
||||
function itemHasStar(h) {
|
||||
if (Array.isArray(h?.segments)) {
|
||||
return h.segments.some((s) => s?.starred);
|
||||
}
|
||||
return Boolean(h?.starred);
|
||||
}
|
||||
|
||||
function itemAllTags(h) {
|
||||
const auto = deriveAutoTags(h);
|
||||
const manual = Array.isArray(h?.manualTags) ? h.manualTags : [];
|
||||
return [...auto, ...manual];
|
||||
}
|
||||
|
||||
function parseTagFilter(q) {
|
||||
// Matches `tag:value` anywhere in the query; returns { tag, rest }.
|
||||
const match = q.match(/(?:^|\s)tag:([^\s]+)/i);
|
||||
if (!match) return { tag: null, rest: q };
|
||||
const rest = (q.slice(0, match.index) + " " + q.slice(match.index + match[0].length)).trim();
|
||||
return { tag: match[1].toLowerCase(), rest };
|
||||
}
|
||||
|
||||
let searchParsed = $derived(parseTagFilter(searchQuery || ""));
|
||||
|
||||
let allTags = $derived.by(() => {
|
||||
const counts = new Map();
|
||||
for (const h of history) {
|
||||
for (const tag of itemAllTags(h)) {
|
||||
const t = tag.toLowerCase();
|
||||
counts.set(t, (counts.get(t) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.map(([tag, count]) => ({ tag, count }));
|
||||
});
|
||||
|
||||
let filtered = $derived.by(() => {
|
||||
let items = history;
|
||||
if (showStarredOnly) items = items.filter(itemHasStar);
|
||||
if (activeTagFilter) {
|
||||
items = items.filter((h) => itemAllTags(h).some((t) => t.toLowerCase() === activeTagFilter));
|
||||
}
|
||||
if (searchParsed.tag) {
|
||||
items = items.filter((h) => itemAllTags(h).some((t) => t.toLowerCase() === searchParsed.tag));
|
||||
}
|
||||
const q = searchParsed.rest.trim().toLowerCase();
|
||||
if (q) {
|
||||
items = items.filter((h) => (
|
||||
(h.text && h.text.toLowerCase().includes(q)) ||
|
||||
(h.preview && h.preview.toLowerCase().includes(q)) ||
|
||||
(h.source && h.source.toLowerCase().includes(q)) ||
|
||||
(h.title && h.title.toLowerCase().includes(q))
|
||||
));
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
let historyTextFont = $derived(pretextFontShorthand(prefs.accessibility, 13));
|
||||
let historyLineHeight = $derived(bodyPretextLineHeight(prefs.accessibility, 13));
|
||||
|
||||
function compactPreviewText(item) {
|
||||
return item.title || item.preview || item.text || "";
|
||||
// The compact row shows the title (or a placeholder). The transcript
|
||||
// body lives in the expanded drawer so the two are visually distinct.
|
||||
return item.title?.trim() || "Untitled";
|
||||
}
|
||||
|
||||
let compactPreviews = $derived.by(() => {
|
||||
@@ -123,10 +175,11 @@
|
||||
);
|
||||
let height = compactHeight;
|
||||
if (expandedId === item.id) {
|
||||
height += EXPANDED_BASE_HEIGHT + EXPANDED_TITLE_INPUT_HEIGHT + EXPANDED_TAGS_ROW_HEIGHT;
|
||||
const transcriptHeight = item.text && textWidth > 0
|
||||
? measurePreWrap(item.text, historyTextFont, textWidth, historyLineHeight).height
|
||||
: historyLineHeight;
|
||||
height += transcriptHeight + EXPANDED_BASE_HEIGHT;
|
||||
height += transcriptHeight;
|
||||
if (item.audioPath && playingId === item.id) {
|
||||
height += AUDIO_PLAYER_HEIGHT;
|
||||
}
|
||||
@@ -192,27 +245,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function renameItem(item) {
|
||||
const name = prompt("Name this transcript:", item.title || "");
|
||||
if (name === null) return;
|
||||
|
||||
const trimmed = name.trim();
|
||||
item.title = trimmed;
|
||||
item.preview = trimmed ? `${trimmed} — ${item.text.slice(0, 80)}` : item.text.slice(0, 120);
|
||||
|
||||
// Persist via the dual-write helper. Updates SQLite + localStorage and
|
||||
// surfaces a toast on failure (Day 4 of the upgrade plan, closes
|
||||
// architecture-review.md §13).
|
||||
try {
|
||||
await renameHistoryEntry(item.id, { title: trimmed });
|
||||
} catch (err) {
|
||||
toasts.warn(
|
||||
"Rename did not persist",
|
||||
"Your change is visible now but did not save. It may revert on next launch."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlay(item) {
|
||||
if (playingId === item.id) {
|
||||
if (audioEl && !audioEl.paused) {
|
||||
@@ -276,9 +308,57 @@
|
||||
async function openViewer(item) {
|
||||
try {
|
||||
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
|
||||
localStorage.setItem("kon_viewer_mode", "view");
|
||||
await invoke("open_viewer_window");
|
||||
} catch {
|
||||
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
|
||||
localStorage.setItem("kon_viewer_mode", "view");
|
||||
window.open("/viewer", "_blank", "width=600,height=700");
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddTagKey(e, item) {
|
||||
if (e.key !== "Enter" && e.key !== ",") return;
|
||||
e.preventDefault();
|
||||
const raw = e.target.value || "";
|
||||
const next = normaliseTag(raw);
|
||||
if (!next) return;
|
||||
const existing = new Set((item.manualTags || []).map((t) => normaliseTag(t)));
|
||||
if (existing.has(next)) {
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
item.manualTags = [...(item.manualTags || []), next];
|
||||
saveHistory();
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
function removeManualTag(item, tag) {
|
||||
const t = normaliseTag(tag);
|
||||
item.manualTags = (item.manualTags || []).filter((x) => normaliseTag(x) !== t);
|
||||
saveHistory();
|
||||
}
|
||||
|
||||
async function exportMarkdown(item) {
|
||||
const md = buildMarkdown(item);
|
||||
try {
|
||||
await navigator.clipboard.writeText(md);
|
||||
} catch {
|
||||
try {
|
||||
await invoke("copy_to_clipboard", { text: md });
|
||||
} catch {}
|
||||
}
|
||||
toasts.info("Markdown copied to clipboard — paste into Obsidian or save as .md");
|
||||
}
|
||||
|
||||
async function openEditor(item) {
|
||||
try {
|
||||
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
|
||||
localStorage.setItem("kon_viewer_mode", "edit");
|
||||
await invoke("open_viewer_window");
|
||||
} catch {
|
||||
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
|
||||
localStorage.setItem("kon_viewer_mode", "edit");
|
||||
window.open("/viewer", "_blank", "width=600,height=700");
|
||||
}
|
||||
}
|
||||
@@ -308,6 +388,16 @@
|
||||
<h2 class="font-display text-[26px] italic text-text">History</h2>
|
||||
<span class="text-[11px] text-text-tertiary mt-1">{history.length} saved</span>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 btn-md rounded-lg
|
||||
{showStarredOnly ? 'text-accent bg-accent/10' : 'text-text-tertiary hover:text-text-secondary hover:bg-hover'}"
|
||||
onclick={() => (showStarredOnly = !showStarredOnly)}
|
||||
aria-pressed={showStarredOnly}
|
||||
title={showStarredOnly ? "Showing starred only (click to show all)" : "Show starred only"}
|
||||
>
|
||||
<Star size={14} aria-hidden="true" />
|
||||
<span class="text-[11px]">Starred</span>
|
||||
</button>
|
||||
{#if history.length > 0}
|
||||
<button
|
||||
class="btn-md rounded-lg text-text-tertiary hover:text-danger hover:bg-hover"
|
||||
@@ -319,13 +409,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="px-7 pb-4">
|
||||
<div class="px-7 pb-3">
|
||||
<Card>
|
||||
<div class="flex items-center gap-3 px-4 py-2.5">
|
||||
<Search size={16} class="text-text-tertiary flex-shrink-0" aria-hidden="true" />
|
||||
<input
|
||||
class="flex-1 bg-transparent text-text text-[13px] placeholder:text-text-tertiary focus:outline-none"
|
||||
placeholder="Search all transcripts..."
|
||||
placeholder="Search all transcripts... (try tag:meetings)"
|
||||
bind:value={searchQuery}
|
||||
data-no-transition
|
||||
/>
|
||||
@@ -340,6 +430,33 @@
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Tag chip filter bar -->
|
||||
{#if allTags.length > 0}
|
||||
<div class="px-7 pb-3 flex items-center gap-1.5 flex-wrap">
|
||||
<span class="text-[10px] uppercase tracking-wider text-text-tertiary mr-1">Tags</span>
|
||||
{#if activeTagFilter}
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-accent text-[10px] text-accent bg-accent/10"
|
||||
onclick={() => (activeTagFilter = null)}
|
||||
title="Clear tag filter"
|
||||
>
|
||||
{activeTagFilter}
|
||||
<span class="text-[12px] leading-none">×</span>
|
||||
</button>
|
||||
{:else}
|
||||
{#each allTags.slice(0, 7) as t (t.tag)}
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border-subtle text-[10px] text-text-secondary hover:border-accent hover:text-accent"
|
||||
onclick={() => (activeTagFilter = t.tag)}
|
||||
>
|
||||
{t.tag}
|
||||
<span class="text-[9px] text-text-tertiary">{t.count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- History list -->
|
||||
<div class="flex-1 px-7 pb-4 min-h-0">
|
||||
<Card classes="h-full flex flex-col overflow-hidden">
|
||||
@@ -456,6 +573,48 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Inline title input -->
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-bg-input border border-border rounded-lg px-3 py-2 text-[12px]
|
||||
text-text placeholder:text-text-tertiary focus:outline-none focus:border-accent mb-3"
|
||||
placeholder="Name this transcript..."
|
||||
value={item.title || ""}
|
||||
oninput={(e) => { item.title = e.target.value; }}
|
||||
onblur={() => renameHistoryEntry(item.id, { title: (item.title || "").trim() }).catch(() => {})}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
data-no-transition
|
||||
/>
|
||||
|
||||
<!-- Tags: auto + manual -->
|
||||
<div class="flex items-center gap-1.5 flex-wrap mb-3" onclick={(e) => e.stopPropagation()} role="presentation">
|
||||
{#each deriveAutoTags(item) as t (t)}
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] bg-bg-input text-text-tertiary border border-border-subtle"
|
||||
title="Auto-generated tag"
|
||||
>{t}</span>
|
||||
{/each}
|
||||
{#each (item.manualTags || []) as t (t)}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] bg-accent/10 text-accent border border-accent/30">
|
||||
{t}
|
||||
<button
|
||||
class="text-[12px] leading-none hover:text-danger"
|
||||
onclick={() => removeManualTag(item, t)}
|
||||
title="Remove tag"
|
||||
aria-label="Remove tag {t}"
|
||||
>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
<input
|
||||
type="text"
|
||||
class="bg-bg-input border border-border rounded-full px-2 py-0.5 text-[10px]
|
||||
text-text placeholder:text-text-tertiary focus:outline-none focus:border-accent w-[110px]"
|
||||
placeholder="+ add tag"
|
||||
onkeydown={(e) => handleAddTagKey(e, item)}
|
||||
data-no-transition
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Full transcript -->
|
||||
<p class="text-[13px] text-text whitespace-pre-wrap mb-4" style="line-height: {historyLineHeight}px">{item.text}</p>
|
||||
|
||||
@@ -464,13 +623,23 @@
|
||||
<button
|
||||
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text"
|
||||
style="transition-duration: var(--duration-ui)"
|
||||
onclick={(e) => { e.stopPropagation(); renameItem(item); }}
|
||||
>Rename</button>
|
||||
onclick={(e) => { e.stopPropagation(); copyItem(item); }}
|
||||
>Copy</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text"
|
||||
style="transition-duration: var(--duration-ui)"
|
||||
onclick={(e) => { e.stopPropagation(); openEditor(item); }}
|
||||
title="Open transcript in a popout editor"
|
||||
>
|
||||
Edit
|
||||
<ExternalLink size={11} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text"
|
||||
style="transition-duration: var(--duration-ui)"
|
||||
onclick={(e) => { e.stopPropagation(); copyItem(item); }}
|
||||
>Copy</button>
|
||||
onclick={(e) => { e.stopPropagation(); exportMarkdown(item); }}
|
||||
title="Export this transcript as a Markdown file with YAML frontmatter"
|
||||
>Export .md</button>
|
||||
{#if item.audioPath && item.segments && item.segments.length > 0}
|
||||
<button
|
||||
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-accent hover:text-accent-hover"
|
||||
|
||||
@@ -34,6 +34,64 @@
|
||||
let audioDevices = $state([]);
|
||||
let audioDevicesError = $state(null);
|
||||
|
||||
// ALSA enumeration leaks raw device strings (hw:, plughw:, front:,
|
||||
// sysdefault:, back:, surround:, iec958:, dmix:, usbstream:, plus a
|
||||
// bogus "null" device). These are kernel-level aliases — no user
|
||||
// needs to pick between "hw:CARD=2,DEV=0" and "plughw:CARD=2,DEV=0".
|
||||
//
|
||||
// The strategy: keep a small set of well-known sentinel devices
|
||||
// (default/pipewire/pulse), then pull a single entry per unique
|
||||
// sound card by parsing CARD=X from the sysdefault: alias. That
|
||||
// gives us "Microphones" / "C920" / "Generic" as friendly labels
|
||||
// while mapping them to a reliable ALSA path.
|
||||
const SENTINEL_DEVICES = new Set(["default", "pipewire", "pulse"]);
|
||||
|
||||
function parseCardName(name) {
|
||||
const match = String(name || "").match(/CARD=([^,]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function buildVisibleDevices(devices) {
|
||||
const out = [];
|
||||
const seenCards = new Set();
|
||||
|
||||
for (const dev of devices) {
|
||||
const name = dev?.name || "";
|
||||
if (!name || name === "null") continue;
|
||||
if (SENTINEL_DEVICES.has(name)) {
|
||||
out.push(dev);
|
||||
continue;
|
||||
}
|
||||
if (name.startsWith("sysdefault:CARD=")) {
|
||||
const card = parseCardName(name);
|
||||
if (card && !seenCards.has(card)) {
|
||||
seenCards.add(card);
|
||||
out.push(dev);
|
||||
}
|
||||
}
|
||||
// Everything else (hw:, plughw:, front:, dmix:, etc.) is silently
|
||||
// dropped — cpal will resolve the sysdefault:CARD= form fine.
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function friendlyLabel(dev) {
|
||||
const name = dev?.name || "";
|
||||
if (name === "default") return "System default";
|
||||
if (name === "pipewire") return "PipeWire";
|
||||
if (name === "pulse") return "PulseAudio";
|
||||
// Prefer the rich product description from /proc/asound/cards
|
||||
// (e.g. "Blue Microphones" for the Yeti). Falls back to the raw
|
||||
// CARD=X short name if we couldn't load descriptions.
|
||||
const desc = (dev?.description || "").trim();
|
||||
if (desc) return desc;
|
||||
const card = parseCardName(name);
|
||||
if (card) return card;
|
||||
return name;
|
||||
}
|
||||
|
||||
let visibleAudioDevices = $derived(buildVisibleDevices(audioDevices));
|
||||
|
||||
async function refreshAudioDevices() {
|
||||
audioDevicesError = null;
|
||||
try {
|
||||
@@ -448,11 +506,9 @@
|
||||
onfocus={refreshAudioDevices}
|
||||
>
|
||||
<option value="">Auto (recommended) — let Kon pick the working mic</option>
|
||||
{#each audioDevices as dev}
|
||||
{#each visibleAudioDevices as dev}
|
||||
<option value={dev.name} disabled={dev.is_likely_monitor}>
|
||||
{dev.name}
|
||||
{dev.is_default ? " (system default)" : ""}
|
||||
{dev.is_likely_monitor ? " — speaker monitor, skip" : ""}
|
||||
{friendlyLabel(dev)}{#if dev.is_default && dev.name !== "default"} (system default){/if}{#if dev.is_likely_monitor} — speaker monitor, skip{/if}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
@@ -464,7 +520,7 @@
|
||||
</div>
|
||||
{#if audioDevicesError}
|
||||
<p class="text-[11px] text-error mt-2">{audioDevicesError}</p>
|
||||
{:else if audioDevices.length === 0}
|
||||
{:else if visibleAudioDevices.length === 0}
|
||||
<p class="text-[11px] text-text-tertiary mt-2">No input devices detected. Check that a microphone is connected and PulseAudio/PipeWire is running.</p>
|
||||
{:else}
|
||||
<p class="text-[11px] text-text-tertiary mt-2">
|
||||
|
||||
@@ -99,6 +99,20 @@ function loadHistory() {
|
||||
|
||||
export const history = $state(loadHistory());
|
||||
|
||||
// Keep the in-memory history in sync with edits made by sibling windows
|
||||
// (e.g. the viewer saving a cleaned-up transcript). Storage events fire
|
||||
// only on *other* windows than the writer, so we won't re-enter our own
|
||||
// writes.
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key !== HISTORY_KEY || !e.newValue) return;
|
||||
try {
|
||||
const next = JSON.parse(e.newValue);
|
||||
history.splice(0, history.length, ...next);
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
|
||||
export function saveHistory() {
|
||||
try {
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
// src/lib/stores/preferences.svelte.js
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { toasts } from './toasts.svelte.js';
|
||||
|
||||
export const PREFERENCES_CHANGED_EVENT = 'kon:preferences-changed';
|
||||
|
||||
function currentWindowLabel() {
|
||||
try {
|
||||
return getCurrentWindow().label;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastPreferences(prefs) {
|
||||
const source = currentWindowLabel();
|
||||
if (source === null) return;
|
||||
// Fire-and-forget — cross-window sync must never block the local apply path.
|
||||
emit(PREFERENCES_CHANGED_EVENT, { source, prefs: JSON.parse(JSON.stringify(prefs)) })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
const DEFAULTS = {
|
||||
theme: 'dark',
|
||||
zone: 'default',
|
||||
@@ -117,12 +137,25 @@ export function updatePreferences(updates) {
|
||||
Object.assign(preferences, updates);
|
||||
applyToDOM(preferences);
|
||||
persistToSQLite(preferences);
|
||||
broadcastPreferences(preferences);
|
||||
}
|
||||
|
||||
export function updateAccessibility(updates) {
|
||||
Object.assign(preferences.accessibility, updates);
|
||||
applyToDOM(preferences);
|
||||
persistToSQLite(preferences);
|
||||
broadcastPreferences(preferences);
|
||||
}
|
||||
|
||||
// Apply preferences received from another Tauri window. Mutates local state
|
||||
// and DOM only — never persists or re-broadcasts, so there is no echo loop.
|
||||
export function applyExternalPreferences(prefs) {
|
||||
if (!prefs || typeof prefs !== 'object') return;
|
||||
Object.assign(preferences, prefs);
|
||||
if (prefs.accessibility) {
|
||||
Object.assign(preferences.accessibility, prefs.accessibility);
|
||||
}
|
||||
applyToDOM(preferences);
|
||||
}
|
||||
|
||||
// Re-resolve when OS preferences change
|
||||
|
||||
142
src/lib/utils/frontmatter.js
Normal file
142
src/lib/utils/frontmatter.js
Normal file
@@ -0,0 +1,142 @@
|
||||
// Transcript frontmatter + auto-tag derivation.
|
||||
//
|
||||
// A transcript's "frontmatter" is a flat object of metadata that can be
|
||||
// exported as YAML for Obsidian or other Markdown consumers. Auto-tags are
|
||||
// derived deterministically from existing fields (date, duration, source,
|
||||
// text length) so they stay in sync without migration.
|
||||
//
|
||||
// Storage model:
|
||||
// - Source of truth is the existing transcript fields (id, title, date,
|
||||
// duration, source, text, segments).
|
||||
// - Manual tags live on `item.manualTags: string[]`.
|
||||
// - Auto-tags are never stored — derived on demand.
|
||||
|
||||
const DURATION_BUCKETS = [
|
||||
{ max: 60, tag: "duration:short" }, // < 1 minute
|
||||
{ max: 300, tag: "duration:medium" }, // < 5 minutes
|
||||
{ max: 1800, tag: "duration:long" }, // < 30 minutes
|
||||
{ max: Infinity, tag: "duration:very-long" },
|
||||
];
|
||||
|
||||
const WORD_BUCKETS = [
|
||||
{ max: 50, tag: "words:short" },
|
||||
{ max: 300, tag: "words:medium" },
|
||||
{ max: 1500, tag: "words:long" },
|
||||
{ max: Infinity, tag: "words:very-long" },
|
||||
];
|
||||
|
||||
function durationTag(seconds) {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
||||
return DURATION_BUCKETS.find((b) => seconds < b.max)?.tag ?? null;
|
||||
}
|
||||
|
||||
function wordCountTag(text) {
|
||||
if (!text || typeof text !== "string") return null;
|
||||
const count = text.trim().split(/\s+/).filter(Boolean).length;
|
||||
return WORD_BUCKETS.find((b) => count < b.max)?.tag ?? null;
|
||||
}
|
||||
|
||||
// Resolve time-of-day bucket from an ISO date or a legacy string like
|
||||
// "19/04/2026, 11:37:23". Thresholds are fixed and local to the user's
|
||||
// machine — hour 6-11 morning, 12-17 afternoon, 18-21 evening, else night.
|
||||
function timeOfDayTag(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
let ts = Date.parse(dateStr);
|
||||
if (Number.isNaN(ts)) {
|
||||
// Try DD/MM/YYYY, HH:MM:SS (UK local format used by Kon history rows).
|
||||
const match = String(dateStr).match(
|
||||
/(\d{1,2})\/(\d{1,2})\/(\d{4})[,\s]+(\d{1,2}):(\d{2})(?::(\d{2}))?/,
|
||||
);
|
||||
if (!match) return null;
|
||||
const [, dd, mm, yyyy, hh, min, ss] = match;
|
||||
const d = new Date(
|
||||
Number(yyyy), Number(mm) - 1, Number(dd),
|
||||
Number(hh), Number(min), Number(ss || 0),
|
||||
);
|
||||
ts = d.getTime();
|
||||
}
|
||||
const hour = new Date(ts).getHours();
|
||||
if (hour >= 6 && hour < 12) return "time:morning";
|
||||
if (hour >= 12 && hour < 18) return "time:afternoon";
|
||||
if (hour >= 18 && hour < 22) return "time:evening";
|
||||
return "time:night";
|
||||
}
|
||||
|
||||
function sourceTag(source) {
|
||||
if (!source) return null;
|
||||
const s = String(source).toLowerCase();
|
||||
if (s.includes("file")) return "source:file";
|
||||
if (s.includes("live") || s.includes("mic")) return "source:live";
|
||||
return `source:${s.replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "")}`;
|
||||
}
|
||||
|
||||
// Returns tags to display as chips. Intentionally empty by default: the
|
||||
// metadata these tags used to encode (duration, date, source, starred) is
|
||||
// already shown elsewhere in the History row, so chips would duplicate
|
||||
// information and add cognitive load without improving retrieval. The
|
||||
// function is kept as a hook for one future AI-derived content tag
|
||||
// (`topic:*`) once kon-llm wires up real llama-cpp-2 in Phase 3.
|
||||
export function deriveAutoTags(_item) {
|
||||
return [];
|
||||
}
|
||||
|
||||
export function normaliseTag(raw) {
|
||||
return String(raw || "").trim().toLowerCase().replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
// Build the flat frontmatter object that represents a transcript's metadata.
|
||||
// Shown in the expanded History row and serialised when exporting to .md.
|
||||
export function buildFrontmatter(item) {
|
||||
if (!item) return {};
|
||||
const auto = deriveAutoTags(item);
|
||||
const manual = Array.isArray(item.manualTags) ? item.manualTags : [];
|
||||
const tags = Array.from(new Set([...auto, ...manual.map(normaliseTag)])).filter(Boolean);
|
||||
const wordCount = item.text ? item.text.trim().split(/\s+/).filter(Boolean).length : 0;
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title || null,
|
||||
date: item.createdAt || item.date || null,
|
||||
duration_s: Number.isFinite(item.duration) ? item.duration : null,
|
||||
source: item.source || null,
|
||||
word_count: wordCount,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
// Escape a YAML scalar. Keeps things simple — quote if it contains any
|
||||
// character that would otherwise need escaping in plain scalars.
|
||||
function yamlScalar(value) {
|
||||
if (value === null || value === undefined) return "null";
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
const s = String(value);
|
||||
if (s === "") return '""';
|
||||
if (/^[A-Za-z0-9._/:\- ]+$/.test(s) && !/^\s|\s$/.test(s)) return s;
|
||||
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
export function serialiseFrontmatter(fm) {
|
||||
const lines = ["---"];
|
||||
for (const [key, value] of Object.entries(fm)) {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
lines.push(`${key}: []`);
|
||||
} else {
|
||||
lines.push(`${key}:`);
|
||||
for (const v of value) lines.push(` - ${yamlScalar(v)}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`${key}: ${yamlScalar(value)}`);
|
||||
}
|
||||
}
|
||||
lines.push("---");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// Produce an Obsidian-flavoured markdown document for a transcript.
|
||||
export function buildMarkdown(item) {
|
||||
const fm = buildFrontmatter(item);
|
||||
const header = serialiseFrontmatter(fm);
|
||||
const title = fm.title || "Transcript";
|
||||
const body = item?.text || "";
|
||||
return `${header}\n\n# ${title}\n\n${body}\n`;
|
||||
}
|
||||
@@ -6,10 +6,18 @@
|
||||
import TaskSidebar from "$lib/components/TaskSidebar.svelte";
|
||||
import Titlebar from "$lib/components/Titlebar.svelte";
|
||||
import ToastViewport from "$lib/components/ToastViewport.svelte";
|
||||
import ResizeHandles from "$lib/components/ResizeHandles.svelte";
|
||||
import { hasTauriRuntime } from "$lib/utils/runtime.js";
|
||||
import { loadOsInfo } from "$lib/utils/osInfo.js";
|
||||
import { loadOsInfo, isLinux } from "$lib/utils/osInfo.js";
|
||||
import { page, settings, saveSettings } from "$lib/stores/page.svelte.js";
|
||||
import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js";
|
||||
import {
|
||||
getPreferences,
|
||||
updatePreferences,
|
||||
applyExternalPreferences,
|
||||
PREFERENCES_CHANGED_EVENT,
|
||||
} from "$lib/stores/preferences.svelte.js";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { toasts } from "$lib/stores/toasts.svelte.js";
|
||||
|
||||
import { page as sveltePage } from "$app/stores";
|
||||
@@ -19,6 +27,12 @@
|
||||
const prefs = getPreferences();
|
||||
const tauriRuntimeAvailable = hasTauriRuntime();
|
||||
|
||||
// On Linux Kon uses native KWin/Mutter decorations (see
|
||||
// src-tauri/tauri.linux.conf.json and windows.rs). Frameless custom
|
||||
// chrome stays for macOS and Windows. Default to false so Linux users
|
||||
// don't see a flash of custom titlebar before loadOsInfo resolves.
|
||||
let useCustomChrome = $state(false);
|
||||
|
||||
|
||||
// Detect secondary windows (float, viewer) — they use +layout@.svelte
|
||||
// but as a fallback, hide chrome if the URL matches
|
||||
@@ -158,6 +172,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-window preference sync: apply updates broadcast by any other
|
||||
// window (float, viewer) while skipping our own echoes.
|
||||
let unlistenPrefs = null;
|
||||
async function setupPreferencesSync() {
|
||||
if (!tauriRuntimeAvailable) return;
|
||||
let ownLabel = null;
|
||||
try { ownLabel = getCurrentWindow().label; } catch {}
|
||||
unlistenPrefs = await listen(PREFERENCES_CHANGED_EVENT, (event) => {
|
||||
const payload = event?.payload;
|
||||
if (!payload || payload.source === ownLabel) return;
|
||||
applyExternalPreferences(payload.prefs);
|
||||
});
|
||||
}
|
||||
|
||||
// Capture global frontend errors and forward to the Rust error_log via
|
||||
// log_frontend_error. Best-effort: never let the error handler itself
|
||||
// throw, never crash the app over a logging failure.
|
||||
@@ -198,12 +226,19 @@
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
// Cross-window preference sync (no-op outside Tauri).
|
||||
setupPreferencesSync();
|
||||
|
||||
// Diagnostics: capture every uncaught frontend error to error_log.
|
||||
installGlobalErrorCapture();
|
||||
|
||||
// OS detection: warm the cache so components can use modKeyLabel() /
|
||||
// isMac() / isWayland() synchronously after first render.
|
||||
loadOsInfo().catch(() => { /* fallback already populated */ });
|
||||
// isMac() / isWayland() synchronously after first render. We also
|
||||
// use the result to decide whether to render the custom Titlebar +
|
||||
// ResizeHandles (non-Linux) or rely on native decorations (Linux).
|
||||
loadOsInfo()
|
||||
.then(() => { useCustomChrome = !isLinux(); })
|
||||
.catch(() => { /* fallback already populated */ });
|
||||
|
||||
if (!tauriRuntimeAvailable) {
|
||||
hotkeyBackend = "unavailable";
|
||||
@@ -250,6 +285,9 @@
|
||||
if (unlistenEvdev) {
|
||||
unlistenEvdev();
|
||||
}
|
||||
if (unlistenPrefs) {
|
||||
unlistenPrefs();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -261,7 +299,9 @@
|
||||
{@render children()}
|
||||
{:else}
|
||||
<div class="flex flex-col h-screen w-screen overflow-hidden grain">
|
||||
<Titlebar />
|
||||
{#if useCustomChrome}
|
||||
<Titlebar />
|
||||
{/if}
|
||||
<div class="flex flex-1 min-h-0 relative">
|
||||
{#if page.current !== "first-run"}
|
||||
<Sidebar />
|
||||
@@ -282,3 +322,10 @@
|
||||
can call toasts.error(...), toasts.success(...) etc and have it render
|
||||
in the bottom-right of the viewport. (Day 3 of the upgrade plan) -->
|
||||
<ToastViewport />
|
||||
|
||||
<!-- Invisible resize margins for frameless (macOS/Windows). On Linux we
|
||||
use native decorations, so ResizeHandles would compete with the
|
||||
compositor's own resize and is suppressed. -->
|
||||
{#if useCustomChrome}
|
||||
<ResizeHandles />
|
||||
{/if}
|
||||
|
||||
@@ -4,12 +4,20 @@
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { settings } from "$lib/stores/page.svelte.js";
|
||||
import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js";
|
||||
import {
|
||||
getPreferences,
|
||||
updatePreferences,
|
||||
applyExternalPreferences,
|
||||
PREFERENCES_CHANGED_EVENT,
|
||||
} from "$lib/stores/preferences.svelte.js";
|
||||
import Titlebar from "$lib/components/Titlebar.svelte";
|
||||
import { loadOsInfo, isLinux } from "$lib/utils/osInfo.js";
|
||||
|
||||
let { children } = $props();
|
||||
let glowing = $state(false);
|
||||
let unlistenFocus = null;
|
||||
let unlistenPrefs = null;
|
||||
let useCustomChrome = $state(false);
|
||||
|
||||
const prefs = getPreferences();
|
||||
|
||||
@@ -34,6 +42,9 @@
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
loadOsInfo()
|
||||
.then(() => { useCustomChrome = !isLinux(); })
|
||||
.catch(() => {});
|
||||
try {
|
||||
unlistenFocus = await listen("task-window-focus", () => {
|
||||
glowing = true;
|
||||
@@ -43,10 +54,21 @@
|
||||
if (input) input.focus();
|
||||
});
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
let ownLabel = null;
|
||||
try { ownLabel = getCurrentWindow().label; } catch {}
|
||||
unlistenPrefs = await listen(PREFERENCES_CHANGED_EVENT, (event) => {
|
||||
const payload = event?.payload;
|
||||
if (!payload || payload.source === ownLabel) return;
|
||||
applyExternalPreferences(payload.prefs);
|
||||
});
|
||||
} catch {}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unlistenFocus) unlistenFocus();
|
||||
if (unlistenPrefs) unlistenPrefs();
|
||||
});
|
||||
|
||||
// Escape to close
|
||||
@@ -59,7 +81,11 @@
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="h-screen w-screen overflow-hidden grain rounded-lg border border-border shadow-xl animate-float-enter {glowing ? 'animate-glow-pulse' : ''}">
|
||||
<Titlebar compact />
|
||||
{@render children()}
|
||||
<div class="h-screen w-screen overflow-hidden grain border border-border shadow-xl animate-float-enter flex flex-col {glowing ? 'animate-glow-pulse' : ''}">
|
||||
{#if useCustomChrome}
|
||||
<Titlebar compact />
|
||||
{/if}
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
if (e.button !== 0) return;
|
||||
if (e.target.closest("button")) return;
|
||||
if (e.target.closest("input")) return;
|
||||
try { e.currentTarget?.setPointerCapture?.(e.pointerId); } catch {}
|
||||
getCurrentWindow().startDragging();
|
||||
}
|
||||
|
||||
@@ -164,14 +165,13 @@
|
||||
<!-- Drag handle with title -->
|
||||
<div
|
||||
class="flex items-center h-[36px] bg-bg-elevated select-none px-3"
|
||||
onmousedown={handleDragStart}
|
||||
data-tauri-drag-region
|
||||
onpointerdown={handleDragStart}
|
||||
>
|
||||
<span class="text-[12px] font-medium text-text-secondary tracking-wide" data-tauri-drag-region>
|
||||
<span class="text-[12px] font-medium text-text-secondary tracking-wide">
|
||||
Kon - To-do
|
||||
</span>
|
||||
|
||||
<div class="flex-1" data-tauri-drag-region></div>
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Pin button -->
|
||||
<button
|
||||
@@ -206,7 +206,9 @@
|
||||
<!-- Single-column content -->
|
||||
<div class="flex flex-col flex-1 min-h-0">
|
||||
<!-- List selector pills -->
|
||||
<div class="flex items-center gap-1 px-3 py-2 border-b border-border-subtle overflow-x-auto">
|
||||
<!-- Outer row does not scroll, so the sort dropdown below is not clipped. -->
|
||||
<div class="flex items-center gap-1 px-3 py-2 border-b border-border-subtle">
|
||||
<div class="flex items-center gap-1 overflow-x-auto flex-1 min-w-0">
|
||||
{#each builtInLists as list (list.id)}
|
||||
<button
|
||||
class="flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] whitespace-nowrap flex-shrink-0
|
||||
@@ -336,9 +338,10 @@
|
||||
onclick={(e) => { e.stopPropagation(); showNewList = true; }}
|
||||
>+</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sort -->
|
||||
<div class="relative flex-shrink-0 ml-auto">
|
||||
<!-- Sort — sibling of the scroll container so its dropdown is not clipped -->
|
||||
<div class="relative flex-shrink-0 pl-1">
|
||||
<button
|
||||
class="px-1.5 py-1 rounded text-[10px] text-text-tertiary hover:text-text-secondary"
|
||||
onclick={(e) => { e.stopPropagation(); showSortMenu = !showSortMenu; }}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
<script>
|
||||
import "../../app.css";
|
||||
import { onDestroy } from "svelte";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { settings } from "$lib/stores/page.svelte.js";
|
||||
import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js";
|
||||
import {
|
||||
getPreferences,
|
||||
updatePreferences,
|
||||
applyExternalPreferences,
|
||||
PREFERENCES_CHANGED_EVENT,
|
||||
} from "$lib/stores/preferences.svelte.js";
|
||||
import Titlebar from "$lib/components/Titlebar.svelte";
|
||||
import { loadOsInfo, isLinux } from "$lib/utils/osInfo.js";
|
||||
|
||||
let { children } = $props();
|
||||
let unlistenPrefs = null;
|
||||
let useCustomChrome = $state(false);
|
||||
|
||||
const prefs = getPreferences();
|
||||
|
||||
@@ -28,6 +37,25 @@
|
||||
});
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
loadOsInfo()
|
||||
.then(() => { useCustomChrome = !isLinux(); })
|
||||
.catch(() => {});
|
||||
try {
|
||||
let ownLabel = null;
|
||||
try { ownLabel = getCurrentWindow().label; } catch {}
|
||||
unlistenPrefs = await listen(PREFERENCES_CHANGED_EVENT, (event) => {
|
||||
const payload = event?.payload;
|
||||
if (!payload || payload.source === ownLabel) return;
|
||||
applyExternalPreferences(payload.prefs);
|
||||
});
|
||||
} catch {}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unlistenPrefs) unlistenPrefs();
|
||||
});
|
||||
|
||||
// Escape to close
|
||||
function handleKeydown(e) {
|
||||
if (e.key === "Escape") {
|
||||
@@ -38,7 +66,11 @@
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="h-screen w-screen overflow-hidden grain rounded-lg border border-border shadow-xl animate-float-enter">
|
||||
<Titlebar compact />
|
||||
{@render children()}
|
||||
<div class="h-screen w-screen overflow-hidden grain border border-border shadow-xl animate-float-enter flex flex-col">
|
||||
{#if useCustomChrome}
|
||||
<Titlebar compact />
|
||||
{/if}
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
let showStarredOnly = $state(false);
|
||||
let animFrameId = null;
|
||||
let segmentRefs = [];
|
||||
let viewerMode = $state("view"); // "view" | "edit"
|
||||
let textDraft = $state("");
|
||||
let textDirty = $state(false);
|
||||
let textSaveTimer = null;
|
||||
|
||||
|
||||
// Load item data from localStorage (set by HistoryPage before opening this window)
|
||||
@@ -27,6 +31,7 @@
|
||||
const raw = localStorage.getItem("kon_viewer_item");
|
||||
if (raw) {
|
||||
item = JSON.parse(raw);
|
||||
textDraft = item?.text || "";
|
||||
if (item.audioPath) {
|
||||
const src = convertFileSrc(item.audioPath);
|
||||
const audio = new Audio(src);
|
||||
@@ -36,6 +41,8 @@
|
||||
audioEl = audio;
|
||||
}
|
||||
}
|
||||
const mode = localStorage.getItem("kon_viewer_mode");
|
||||
if (mode === "edit" || mode === "view") viewerMode = mode;
|
||||
} catch {}
|
||||
|
||||
// Listen for new items via storage events
|
||||
@@ -46,6 +53,12 @@
|
||||
if (audioEl) audioEl.pause();
|
||||
cancelAnimationFrame(animFrameId);
|
||||
window.removeEventListener("storage", handleStorageChange);
|
||||
// Flush any pending text edit before the window tears down.
|
||||
if (textSaveTimer) {
|
||||
clearTimeout(textSaveTimer);
|
||||
textSaveTimer = null;
|
||||
}
|
||||
if (textDirty) commitTextEdit();
|
||||
});
|
||||
|
||||
function handleStorageChange(e) {
|
||||
@@ -218,6 +231,29 @@
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function scheduleTextSave() {
|
||||
textDirty = true;
|
||||
clearTimeout(textSaveTimer);
|
||||
textSaveTimer = setTimeout(() => {
|
||||
commitTextEdit();
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function commitTextEdit() {
|
||||
if (!item) return;
|
||||
const next = textDraft;
|
||||
if (next === item.text) {
|
||||
textDirty = false;
|
||||
return;
|
||||
}
|
||||
item.text = next;
|
||||
// The compact preview in History is derived from `preview`; keep it
|
||||
// roughly in sync so the list does not show stale copy after an edit.
|
||||
item.preview = next.slice(0, 120);
|
||||
saveItemToHistory();
|
||||
textDirty = false;
|
||||
}
|
||||
|
||||
// Filtered segments (starred filter + search)
|
||||
let visibleSegments = $derived.by(() => {
|
||||
if (!item?.segments) return [];
|
||||
@@ -228,39 +264,28 @@
|
||||
return segs;
|
||||
});
|
||||
|
||||
// Window drag
|
||||
// Window drag — pointerdown + setPointerCapture avoids the mousedown
|
||||
// latency that makes KWin's initial grab feel draggy.
|
||||
function handleDragStart(e) {
|
||||
if (e.button !== 0) return;
|
||||
if (e.target.closest("button")) return;
|
||||
if (e.target.closest("input")) return;
|
||||
try { e.currentTarget?.setPointerCapture?.(e.pointerId); } catch {}
|
||||
getCurrentWindow().startDragging();
|
||||
}
|
||||
|
||||
function closeWindow() {
|
||||
getCurrentWindow().close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full bg-bg">
|
||||
<!-- Drag handle -->
|
||||
<div
|
||||
class="flex items-center h-[36px] bg-bg-elevated select-none px-3"
|
||||
onmousedown={handleDragStart}
|
||||
data-tauri-drag-region
|
||||
onpointerdown={handleDragStart}
|
||||
>
|
||||
<span class="text-[12px] font-medium text-text-secondary tracking-wide" data-tauri-drag-region>
|
||||
Kon - Viewer
|
||||
<span class="text-[12px] font-medium text-text-secondary tracking-wide">
|
||||
Kon - {viewerMode === "edit" ? "Editor" : "Viewer"}
|
||||
</span>
|
||||
<div class="flex-1" data-tauri-drag-region></div>
|
||||
<button
|
||||
class="w-7 h-7 flex items-center justify-center text-text-tertiary hover:text-danger rounded-md hover:bg-hover"
|
||||
onclick={closeWindow}
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
</div>
|
||||
|
||||
{#if item}
|
||||
@@ -328,7 +353,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search bar + view controls -->
|
||||
<!-- Search bar + view controls (segment-specific; hidden in edit mode) -->
|
||||
{#if viewerMode !== "edit"}
|
||||
<div class="flex items-center gap-2 px-5 py-2 border-b border-border-subtle">
|
||||
<div class="flex items-center gap-2 bg-bg-input border border-border rounded-lg px-3 py-1.5 focus-within:border-accent flex-1">
|
||||
<svg class="w-3.5 h-3.5 text-text-tertiary flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -362,10 +388,20 @@
|
||||
title={showStarredOnly ? "Show all segments" : "Show starred only"}
|
||||
>Starred</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Transcript segments -->
|
||||
<!-- Transcript segments / editor -->
|
||||
<div class="flex-1 overflow-y-auto px-5 py-3 min-h-0">
|
||||
{#if item.segments && item.segments.length > 0}
|
||||
{#if viewerMode === "edit"}
|
||||
<textarea
|
||||
class="w-full h-full bg-bg-input border border-border rounded-lg px-3 py-2 text-[13px]
|
||||
text-text leading-relaxed resize-none focus:outline-none focus:border-accent"
|
||||
bind:value={textDraft}
|
||||
oninput={scheduleTextSave}
|
||||
data-no-transition
|
||||
placeholder="Edit the transcript..."
|
||||
></textarea>
|
||||
{:else if item.segments && item.segments.length > 0}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each visibleSegments as seg (seg._idx)}
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user