Phase 2
This commit is contained in:
@@ -1,27 +1,230 @@
|
||||
<script lang="ts">
|
||||
// Center pane: live transcript + Markdown notes (Phase 1/2, FR-TRX-2/FR-NOTE-*).
|
||||
// Center pane: live transcript while recording (FR-TRX-2), or — once a past
|
||||
// meeting is selected — raw transcript + editable/rendered notes side by
|
||||
// side (FR-NOTE-1/2/6) with export (FR-NOTE-3, T2.6).
|
||||
import { recording } from "../stores/recording.svelte";
|
||||
import { meetings } from "../stores/meetings.svelte";
|
||||
import { api, type SpeakerInfo } from "../api";
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { save, open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
function speakerName(label: string): string {
|
||||
return label; // Phase 4: resolve to display name from mapping.
|
||||
// Notes can contain pasted/typed Markdown from anywhere; this webview has an
|
||||
// IPC bridge to the Rust backend, so unsanitized `{@html}` here would be a
|
||||
// real local-privilege risk, not just a cosmetic one — sanitize before render.
|
||||
function renderNotes(markdown: string): string {
|
||||
return DOMPurify.sanitize(marked.parse(markdown || "") as string);
|
||||
}
|
||||
|
||||
function speakerName(label: string, speakers: SpeakerInfo[] = []): string {
|
||||
return speakers.find((s) => s.label === label)?.display_name ?? label;
|
||||
}
|
||||
|
||||
let notesText = $state("");
|
||||
let editorEl: HTMLTextAreaElement | undefined = $state();
|
||||
let saveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let loadedForId: string | null = null;
|
||||
|
||||
// Sync the editor buffer whenever a different meeting is selected.
|
||||
$effect(() => {
|
||||
const m = meetings.selected;
|
||||
if (m && m.id !== loadedForId) {
|
||||
notesText = m.notes_markdown;
|
||||
loadedForId = m.id;
|
||||
} else if (!m) {
|
||||
loadedForId = null;
|
||||
}
|
||||
});
|
||||
|
||||
function scheduleSave() {
|
||||
const id = meetings.selected?.id;
|
||||
if (!id) return;
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => meetings.saveNotes(id, notesText), 500);
|
||||
}
|
||||
|
||||
function wrapSelection(before: string, after: string = before) {
|
||||
const el = editorEl;
|
||||
if (!el) return;
|
||||
const { selectionStart: s, selectionEnd: e, value } = el;
|
||||
const selected = value.slice(s, e);
|
||||
notesText = value.slice(0, s) + before + selected + after + value.slice(e);
|
||||
queueMicrotask(() => {
|
||||
el.focus();
|
||||
el.selectionStart = s + before.length;
|
||||
el.selectionEnd = s + before.length + selected.length;
|
||||
});
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function insertLinePrefix(prefix: string) {
|
||||
const el = editorEl;
|
||||
if (!el) return;
|
||||
const { selectionStart: s, value } = el;
|
||||
const lineStart = value.lastIndexOf("\n", s - 1) + 1;
|
||||
notesText = value.slice(0, lineStart) + prefix + value.slice(lineStart);
|
||||
queueMicrotask(() => el.focus());
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
async function exportMd() {
|
||||
const m = meetings.selected;
|
||||
if (!m) return;
|
||||
const path = await save({
|
||||
defaultPath: `${m.title}.md`,
|
||||
filters: [{ name: "Markdown", extensions: ["md"] }],
|
||||
});
|
||||
if (path) await api.exportMeeting(m.id, path, "md");
|
||||
}
|
||||
|
||||
async function exportBundle() {
|
||||
const m = meetings.selected;
|
||||
if (!m) return;
|
||||
const dir = await open({ directory: true, defaultPath: m.title });
|
||||
if (typeof dir === "string") await api.exportMeeting(m.id, dir, "bundle");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wrap">
|
||||
{#if recording.segments.length === 0}
|
||||
<p class="muted">Transcript will appear here as you record.</p>
|
||||
{#if meetings.selected}
|
||||
{@const m = meetings.selected}
|
||||
<div class="split">
|
||||
<div class="pane transcript">
|
||||
<h4>Transcript</h4>
|
||||
{#if m.segments.length === 0}
|
||||
<p class="muted">No transcript for this meeting.</p>
|
||||
{:else}
|
||||
{#each m.segments as s (s.id)}
|
||||
<p><strong>{speakerName(s.speaker, m.speakers)}:</strong> {s.text}</p>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="pane notes">
|
||||
<div class="toolbar" role="toolbar" aria-label="Notes formatting">
|
||||
<button onclick={() => wrapSelection("**")} title="Bold"><b>B</b></button>
|
||||
<button onclick={() => wrapSelection("_")} title="Italic"><i>I</i></button>
|
||||
<button onclick={() => insertLinePrefix("# ")} title="Heading 1">H1</button>
|
||||
<button onclick={() => insertLinePrefix("## ")} title="Heading 2">H2</button>
|
||||
<button onclick={() => insertLinePrefix("- ")} title="Bullet list">•</button>
|
||||
<button onclick={() => insertLinePrefix("- [ ] ")} title="Checkbox">☑</button>
|
||||
<span class="spacer"></span>
|
||||
<button onclick={exportMd} title="Export notes as .md">Export .md</button>
|
||||
<button onclick={exportBundle} title="Export audio + transcript + notes to a folder">
|
||||
Export bundle
|
||||
</button>
|
||||
</div>
|
||||
<div class="editor-preview">
|
||||
<textarea
|
||||
bind:this={editorEl}
|
||||
bind:value={notesText}
|
||||
oninput={scheduleSave}
|
||||
placeholder="Notes…"
|
||||
></textarea>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via DOMPurify in renderNotes() -->
|
||||
<div class="preview">{@html renderNotes(notesText)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if recording.segments.length === 0}
|
||||
<p class="muted pad">Transcript will appear here as you record.</p>
|
||||
{:else}
|
||||
{#each recording.segments as s (s.id)}
|
||||
<p class:interim={s.interim}>
|
||||
<strong>{speakerName(s.speaker)}:</strong> {s.text}
|
||||
</p>
|
||||
{/each}
|
||||
<div class="pad">
|
||||
{#each recording.segments as s (s.id)}
|
||||
<p class:interim={s.interim}>
|
||||
<strong>{speakerName(s.speaker)}:</strong>
|
||||
{s.text}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wrap { padding: 1rem; max-width: 760px; }
|
||||
.muted { color: var(--muted); }
|
||||
.interim { opacity: 0.55; font-style: italic; }
|
||||
p { margin: 0.35rem 0; line-height: 1.5; }
|
||||
.wrap {
|
||||
height: 100%;
|
||||
}
|
||||
.pad {
|
||||
padding: 1rem;
|
||||
max-width: 760px;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
.interim {
|
||||
opacity: 0.55;
|
||||
font-style: italic;
|
||||
}
|
||||
p {
|
||||
margin: 0.35rem 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
height: 100%;
|
||||
}
|
||||
.pane {
|
||||
overflow: auto;
|
||||
padding: 0.75rem 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.pane.transcript {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.pane h4 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar button {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
border-radius: 4px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toolbar .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.editor-preview {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
height: calc(100% - 2.5rem);
|
||||
}
|
||||
textarea {
|
||||
resize: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
}
|
||||
.preview {
|
||||
overflow: auto;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.preview :global(h1),
|
||||
.preview :global(h2),
|
||||
.preview :global(h3) {
|
||||
margin: 0.6rem 0 0.3rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user