Compare commits
15 Commits
feat/62-do
...
dd360ade8b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd360ade8b | ||
|
|
f71712ab4b | ||
|
|
10783fdb55 | ||
|
|
5ea5590c89 | ||
|
|
142f296255 | ||
|
|
c19f7b3b1a | ||
|
|
db9d8ed457 | ||
|
|
65457a5650 | ||
|
|
1eb2659ba0 | ||
|
|
f18649fb79 | ||
|
|
a392e85f43 | ||
|
|
c9b4e6dad4 | ||
|
|
8519fbb48a | ||
|
|
ee85ce4668 | ||
|
|
ecfd80bf9a |
@@ -257,5 +257,8 @@
|
||||
"doc_panel_tab_history": "Verlauf",
|
||||
"doc_panel_annotate": "Annotieren",
|
||||
"doc_panel_annotate_stop": "Fertig",
|
||||
"doc_panel_annotation_thread_title": "Annotation"
|
||||
"doc_panel_annotation_thread_title": "Annotation",
|
||||
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
|
||||
"pdf_annotations_show": "Annotierungen anzeigen",
|
||||
"pdf_annotations_hide": "Annotierungen verbergen"
|
||||
}
|
||||
|
||||
@@ -257,5 +257,8 @@
|
||||
"doc_panel_tab_history": "History",
|
||||
"doc_panel_annotate": "Annotate",
|
||||
"doc_panel_annotate_stop": "Done",
|
||||
"doc_panel_annotation_thread_title": "Annotation"
|
||||
"doc_panel_annotation_thread_title": "Annotation",
|
||||
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
|
||||
"pdf_annotations_show": "Show annotations",
|
||||
"pdf_annotations_hide": "Hide annotations"
|
||||
}
|
||||
|
||||
@@ -257,5 +257,8 @@
|
||||
"doc_panel_tab_history": "Historial",
|
||||
"doc_panel_annotate": "Anotar",
|
||||
"doc_panel_annotate_stop": "Listo",
|
||||
"doc_panel_annotation_thread_title": "Anotación"
|
||||
"doc_panel_annotation_thread_title": "Anotación",
|
||||
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
|
||||
"pdf_annotations_show": "Mostrar anotaciones",
|
||||
"pdf_annotations_hide": "Ocultar anotaciones"
|
||||
}
|
||||
|
||||
65
frontend/src/lib/components/AnnotationSidePanel.svelte
Normal file
65
frontend/src/lib/components/AnnotationSidePanel.svelte
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import CommentThread from './CommentThread.svelte';
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
activeAnnotationId: string | null;
|
||||
activeAnnotationPage: number | null;
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
canAdmin: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let {
|
||||
documentId,
|
||||
activeAnnotationId,
|
||||
activeAnnotationPage,
|
||||
canComment,
|
||||
currentUserId,
|
||||
canAdmin,
|
||||
onClose
|
||||
}: Props = $props();
|
||||
|
||||
const visible = $derived(activeAnnotationId !== null);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 z-10 flex w-80 flex-col border-l border-brand-sand bg-white shadow-[-4px_0_16px_rgba(0,0,0,0.08)] transition-transform duration-200 {visible
|
||||
? 'translate-x-0'
|
||||
: 'pointer-events-none translate-x-full'}"
|
||||
data-testid="annotation-side-panel"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-brand-sand px-4 py-3">
|
||||
<span class="font-sans text-xs font-medium text-brand-navy">
|
||||
{m.doc_panel_discussion_annotation_tab({ page: String(activeAnnotationPage ?? '?') })}
|
||||
</span>
|
||||
<button
|
||||
onclick={onClose}
|
||||
aria-label={m.comment_panel_close()}
|
||||
class="rounded p-1 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Comment thread -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
{#if activeAnnotationId}
|
||||
{#key activeAnnotationId}
|
||||
<CommentThread
|
||||
documentId={documentId}
|
||||
annotationId={activeAnnotationId}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
loadOnMount={true}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,8 +48,6 @@ type Props = {
|
||||
open: boolean;
|
||||
height: number;
|
||||
activeTab: Tab;
|
||||
activeAnnotationId: string | null;
|
||||
onAnnotationCommentCountChange?: (annotationId: string, count: number) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -60,23 +58,25 @@ let {
|
||||
canAdmin,
|
||||
open = $bindable(),
|
||||
height = $bindable(),
|
||||
activeTab = $bindable(),
|
||||
activeAnnotationId,
|
||||
onAnnotationCommentCountChange
|
||||
activeTab = $bindable()
|
||||
}: Props = $props();
|
||||
|
||||
const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px)
|
||||
const DEFAULT_HEIGHT = 320;
|
||||
|
||||
let isDragging = $state(false);
|
||||
let dragStartY = 0;
|
||||
let dragStartHeight = 0;
|
||||
|
||||
function fullHeight() {
|
||||
const topbar = document.querySelector('[data-topbar]');
|
||||
return window.innerHeight - (topbar?.getBoundingClientRect().bottom ?? 0);
|
||||
}
|
||||
|
||||
function openTab(tab: Tab) {
|
||||
activeTab = tab;
|
||||
if (!open) {
|
||||
open = true;
|
||||
if (height <= MIN_HEIGHT) height = DEFAULT_HEIGHT;
|
||||
if (height <= MIN_HEIGHT) height = fullHeight();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,14 +95,14 @@ function onDragMove(e: PointerEvent) {
|
||||
if (!isDragging) return;
|
||||
const delta = dragStartY - e.clientY; // positive = dragging up = bigger panel
|
||||
const newHeight = dragStartHeight + delta;
|
||||
const maxHeight = Math.floor(window.innerHeight * 0.8);
|
||||
const maxHeight = fullHeight();
|
||||
|
||||
if (newHeight <= MIN_HEIGHT + 20) {
|
||||
// collapsed past threshold → close
|
||||
open = false;
|
||||
} else {
|
||||
open = true;
|
||||
height = Math.max(DEFAULT_HEIGHT / 4, Math.min(newHeight, maxHeight));
|
||||
height = Math.max(80, Math.min(newHeight, maxHeight));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,12 +181,10 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
|
||||
{:else if activeTab === 'discussion'}
|
||||
<PanelDiscussion
|
||||
documentId={doc.id}
|
||||
activeAnnotationId={activeAnnotationId}
|
||||
initialComments={comments}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
onAnnotationCommentCountChange={onAnnotationCommentCountChange}
|
||||
/>
|
||||
{:else if activeTab === 'history'}
|
||||
<PanelHistory documentId={doc.id} />
|
||||
|
||||
@@ -59,6 +59,7 @@ const compactMeta = $derived.by(() => {
|
||||
|
||||
<div
|
||||
class="z-20 flex shrink-0 items-center justify-between border-b border-brand-sand bg-white px-6 py-3 shadow-sm"
|
||||
data-topbar
|
||||
>
|
||||
<!-- Left: back + title -->
|
||||
<div class="flex min-w-0 items-center gap-4 overflow-hidden">
|
||||
@@ -100,10 +101,16 @@ const compactMeta = $derived.by(() => {
|
||||
<button
|
||||
onclick={() => (annotateMode = !annotateMode)}
|
||||
aria-label={annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
|
||||
class="rounded px-3 py-1.5 font-sans text-xs font-medium transition {annotateMode
|
||||
class="flex items-center gap-1.5 rounded px-3 py-1.5 font-sans text-xs font-medium transition {annotateMode
|
||||
? 'bg-brand-navy text-white'
|
||||
: 'border border-brand-navy text-brand-navy hover:bg-brand-navy hover:text-white'}"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 {annotateMode ? 'invert' : ''}"
|
||||
/>
|
||||
{annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -15,6 +15,7 @@ type Props = {
|
||||
error: string;
|
||||
annotateMode: boolean;
|
||||
activeAnnotationId: string | null;
|
||||
activeAnnotationPage: number | null;
|
||||
onAnnotationClick: (id: string) => void;
|
||||
};
|
||||
|
||||
@@ -25,6 +26,7 @@ let {
|
||||
error,
|
||||
annotateMode = $bindable(),
|
||||
activeAnnotationId = $bindable(),
|
||||
activeAnnotationPage = $bindable(),
|
||||
onAnnotationClick
|
||||
}: Props = $props();
|
||||
</script>
|
||||
@@ -79,6 +81,7 @@ let {
|
||||
documentId={doc.id}
|
||||
bind:annotateMode={annotateMode}
|
||||
bind:activeAnnotationId={activeAnnotationId}
|
||||
bind:activeAnnotationPage={activeAnnotationPage}
|
||||
onAnnotationClick={onAnnotationClick}
|
||||
/>
|
||||
{:else if fileUrl}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import CommentThread from './CommentThread.svelte';
|
||||
|
||||
type CommentReply = {
|
||||
@@ -23,63 +22,21 @@ type Comment = {
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
activeAnnotationId: string | null;
|
||||
initialComments: Comment[];
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
canAdmin: boolean;
|
||||
onAnnotationCommentCountChange?: (annotationId: string, count: number) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
documentId,
|
||||
activeAnnotationId,
|
||||
initialComments,
|
||||
canComment,
|
||||
currentUserId,
|
||||
canAdmin,
|
||||
onAnnotationCommentCountChange
|
||||
}: Props = $props();
|
||||
let { documentId, initialComments, canComment, currentUserId, canAdmin }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-8 p-6">
|
||||
<!-- Annotation thread (shown when an annotation is active) -->
|
||||
{#if activeAnnotationId}
|
||||
<div>
|
||||
<h4
|
||||
class="mb-3 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
||||
>
|
||||
{m.doc_panel_annotation_thread_title()}
|
||||
</h4>
|
||||
{#key activeAnnotationId}
|
||||
<CommentThread
|
||||
documentId={documentId}
|
||||
annotationId={activeAnnotationId}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
loadOnMount={true}
|
||||
onCountChange={(count) => onAnnotationCommentCountChange?.(activeAnnotationId, count)}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- General document discussion -->
|
||||
<div>
|
||||
{#if activeAnnotationId}
|
||||
<h4
|
||||
class="mb-3 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
||||
>
|
||||
{m.comment_section_title()}
|
||||
</h4>
|
||||
{/if}
|
||||
<CommentThread
|
||||
documentId={documentId}
|
||||
initialComments={initialComments}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<CommentThread
|
||||
documentId={documentId}
|
||||
initialComments={initialComments}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -333,8 +333,79 @@ $effect(() => {
|
||||
{m.history_compare_apply()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Diff panel for compare mode -->
|
||||
{#if diffLoading}
|
||||
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
|
||||
{:else if noDiff}
|
||||
<div
|
||||
data-testid="history-diff"
|
||||
class="rounded-sm border border-brand-sand bg-white p-4 font-serif text-sm text-gray-400 italic"
|
||||
>
|
||||
{m.history_diff_no_changes()}
|
||||
</div>
|
||||
{:else if diffEntries.length > 0}
|
||||
<div
|
||||
data-testid="history-diff"
|
||||
class="space-y-4 rounded-sm border border-brand-sand bg-white p-4"
|
||||
>
|
||||
{#each diffEntries as entry (entry.field)}
|
||||
<div>
|
||||
<span
|
||||
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-gray-400 uppercase"
|
||||
>{entry.label}</span
|
||||
>
|
||||
{#if entry.kind === 'text'}
|
||||
<p class="font-serif text-sm leading-relaxed">
|
||||
{#each entry.parts as part, partIdx (partIdx)}
|
||||
{#if part.added}
|
||||
<span class="bg-green-50 text-green-700">{part.value}</span>
|
||||
{:else if part.removed}
|
||||
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
|
||||
{:else}
|
||||
<span>{part.value}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</p>
|
||||
{:else if entry.kind === 'scalar'}
|
||||
<div class="flex items-center gap-2 font-serif text-sm">
|
||||
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
|
||||
<svg
|
||||
class="h-3 w-3 flex-shrink-0 text-gray-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-green-700">{entry.newVal || '—'}</span>
|
||||
</div>
|
||||
{:else if entry.kind === 'relation'}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each entry.removed as item (item)}
|
||||
<span
|
||||
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
|
||||
>{item}</span
|
||||
>
|
||||
{/each}
|
||||
{#each entry.added as item (item)}
|
||||
<span
|
||||
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
|
||||
>{item}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Version list -->
|
||||
<!-- Version list with inline diff below each selected item -->
|
||||
<ul class="divide-y divide-brand-sand">
|
||||
{#each versions as v, i (v.id)}
|
||||
<li>
|
||||
@@ -367,80 +438,82 @@ $effect(() => {
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Diff shown inline below the selected version -->
|
||||
{#if selectedVersionId === v.id}
|
||||
{#if diffLoading}
|
||||
<p class="pb-3 pl-2 font-sans text-xs text-gray-400">{m.history_loading()}</p>
|
||||
{:else if noDiff}
|
||||
<div
|
||||
data-testid="history-diff"
|
||||
class="mb-2 rounded-sm border border-brand-sand bg-white p-4 font-serif text-sm text-gray-400 italic"
|
||||
>
|
||||
{m.history_diff_no_changes()}
|
||||
</div>
|
||||
{:else if diffEntries.length > 0}
|
||||
<div
|
||||
data-testid="history-diff"
|
||||
class="mb-2 space-y-4 rounded-sm border border-brand-sand bg-white p-4"
|
||||
>
|
||||
{#each diffEntries as entry (entry.field)}
|
||||
<div>
|
||||
<span
|
||||
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-gray-400 uppercase"
|
||||
>{entry.label}</span
|
||||
>
|
||||
{#if entry.kind === 'text'}
|
||||
<p class="font-serif text-sm leading-relaxed">
|
||||
{#each entry.parts as part, partIdx (partIdx)}
|
||||
{#if part.added}
|
||||
<span class="bg-green-50 text-green-700">{part.value}</span>
|
||||
{:else if part.removed}
|
||||
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
|
||||
{:else}
|
||||
<span>{part.value}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</p>
|
||||
{:else if entry.kind === 'scalar'}
|
||||
<div class="flex items-center gap-2 font-serif text-sm">
|
||||
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
|
||||
<svg
|
||||
class="h-3 w-3 flex-shrink-0 text-gray-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-green-700">{entry.newVal || '—'}</span>
|
||||
</div>
|
||||
{:else if entry.kind === 'relation'}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each entry.removed as item (item)}
|
||||
<span
|
||||
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
|
||||
>{item}</span
|
||||
>
|
||||
{/each}
|
||||
{#each entry.added as item (item)}
|
||||
<span
|
||||
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
|
||||
>{item}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- Diff panel -->
|
||||
{#if diffLoading}
|
||||
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
|
||||
{:else if noDiff}
|
||||
<div
|
||||
data-testid="history-diff"
|
||||
class="rounded-sm border border-brand-sand bg-white p-4 font-serif text-sm text-gray-400 italic"
|
||||
>
|
||||
{m.history_diff_no_changes()}
|
||||
</div>
|
||||
{:else if diffEntries.length > 0}
|
||||
<div
|
||||
data-testid="history-diff"
|
||||
class="space-y-4 rounded-sm border border-brand-sand bg-white p-4"
|
||||
>
|
||||
{#each diffEntries as entry (entry.field)}
|
||||
<div>
|
||||
<span
|
||||
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-gray-400 uppercase"
|
||||
>{entry.label}</span
|
||||
>
|
||||
{#if entry.kind === 'text'}
|
||||
<p class="font-serif text-sm leading-relaxed">
|
||||
{#each entry.parts as part, partIdx (partIdx)}
|
||||
{#if part.added}
|
||||
<span class="bg-green-50 text-green-700">{part.value}</span>
|
||||
{:else if part.removed}
|
||||
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
|
||||
{:else}
|
||||
<span>{part.value}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</p>
|
||||
{:else if entry.kind === 'scalar'}
|
||||
<div class="flex items-center gap-2 font-serif text-sm">
|
||||
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
|
||||
<svg
|
||||
class="h-3 w-3 flex-shrink-0 text-gray-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-green-700">{entry.newVal || '—'}</span>
|
||||
</div>
|
||||
{:else if entry.kind === 'relation'}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each entry.removed as item (item)}
|
||||
<span
|
||||
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
|
||||
>{item}</span
|
||||
>
|
||||
{/each}
|
||||
{#each entry.added as item (item)}
|
||||
<span
|
||||
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
|
||||
>{item}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -3,18 +3,21 @@ import { onMount } from 'svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
||||
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
url,
|
||||
documentId = '',
|
||||
annotateMode = $bindable(false),
|
||||
activeAnnotationId = $bindable<string | null>(null),
|
||||
activeAnnotationPage = $bindable<number | null>(null),
|
||||
onAnnotationClick
|
||||
}: {
|
||||
url: string;
|
||||
documentId?: string;
|
||||
annotateMode?: boolean;
|
||||
activeAnnotationId?: string | null;
|
||||
activeAnnotationPage?: number | null;
|
||||
onAnnotationClick?: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
@@ -53,6 +56,7 @@ type Annotation = {
|
||||
let annotations = $state<Annotation[]>([]);
|
||||
let annotateColor = $state('#ffff00');
|
||||
let commentCounts = new SvelteMap<string, number>();
|
||||
let showAnnotations = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
// Dynamic import keeps pdfjs out of the SSR bundle entirely
|
||||
@@ -213,6 +217,7 @@ async function handleAnnotationDraw(rect: { x: number; y: number; width: number;
|
||||
const created: Annotation = await res.json();
|
||||
annotations = [...annotations, created];
|
||||
activeAnnotationId = created.id;
|
||||
activeAnnotationPage = created.pageNumber;
|
||||
onAnnotationClick?.(created.id);
|
||||
}
|
||||
} catch {
|
||||
@@ -236,6 +241,8 @@ async function handleAnnotationDelete(annotationId: string) {
|
||||
|
||||
function handleAnnotationClick(id: string) {
|
||||
activeAnnotationId = id;
|
||||
const ann = annotations.find((a) => a.id === id);
|
||||
activeAnnotationPage = ann?.pageNumber ?? null;
|
||||
onAnnotationClick?.(id);
|
||||
}
|
||||
|
||||
@@ -396,6 +403,44 @@ function zoomOut() {
|
||||
title="Farbe wählen"
|
||||
/>
|
||||
{/if}
|
||||
<!-- Annotation visibility toggle (shown when annotations exist) -->
|
||||
{#if annotations.length > 0}
|
||||
<button
|
||||
onclick={() => (showAnnotations = !showAnnotations)}
|
||||
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
|
||||
? 'text-gray-300 hover:bg-white/10'
|
||||
: 'bg-white/10 text-brand-mint'}"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
{#if showAnnotations}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
{showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- PDF canvas area -->
|
||||
@@ -419,15 +464,17 @@ function zoomOut() {
|
||||
class="textLayer"
|
||||
style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;"
|
||||
></div>
|
||||
<AnnotationLayer
|
||||
annotations={annotations.filter((a) => a.pageNumber === currentPage)}
|
||||
canAnnotate={annotateMode}
|
||||
color={annotateColor}
|
||||
onDraw={handleAnnotationDraw}
|
||||
onDelete={handleAnnotationDelete}
|
||||
commentCounts={Object.fromEntries(commentCounts)}
|
||||
onAnnotationClick={handleAnnotationClick}
|
||||
/>
|
||||
{#if showAnnotations}
|
||||
<AnnotationLayer
|
||||
annotations={annotations.filter((a) => a.pageNumber === currentPage)}
|
||||
canAnnotate={annotateMode}
|
||||
color={annotateColor}
|
||||
onDraw={handleAnnotationDraw}
|
||||
onDelete={handleAnnotationDelete}
|
||||
commentCounts={Object.fromEntries(commentCounts)}
|
||||
onAnnotationClick={handleAnnotationClick}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { onMount } from 'svelte';
|
||||
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||
import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte';
|
||||
import AnnotationSidePanel from '$lib/components/AnnotationSidePanel.svelte';
|
||||
|
||||
type Tab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
||||
|
||||
@@ -56,53 +57,59 @@ async function loadFile(id: string) {
|
||||
|
||||
let annotateMode = $state(false);
|
||||
let activeAnnotationId = $state<string | null>(null);
|
||||
let activeAnnotationPage = $state<number | null>(null);
|
||||
|
||||
// When an annotation is clicked, open the Diskussion tab.
|
||||
// Close the panel when entering annotate mode so the PDF is fully visible.
|
||||
$effect(() => {
|
||||
if (activeAnnotationId) {
|
||||
activeTab = 'discussion';
|
||||
panelOpen = true;
|
||||
}
|
||||
if (annotateMode) panelOpen = false;
|
||||
});
|
||||
|
||||
// ── Bottom panel state ────────────────────────────────────────────────────────
|
||||
|
||||
const LS_KEY_OPEN = 'doc-panel-open';
|
||||
const LS_KEY_HEIGHT = 'doc-panel-height';
|
||||
const LS_KEY_TAB = 'doc-panel-tab';
|
||||
|
||||
let panelOpen = $state(false);
|
||||
let panelHeight = $state(320);
|
||||
let panelHeight = $state(0); // set to full height on mount
|
||||
let navHeight = $state(0);
|
||||
let activeTab = $state<Tab>('metadata');
|
||||
let localStorageRestored = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
const savedOpen = localStorage.getItem(LS_KEY_OPEN);
|
||||
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
|
||||
|
||||
const savedHeight = localStorage.getItem(LS_KEY_HEIGHT);
|
||||
const savedTab = localStorage.getItem(LS_KEY_TAB);
|
||||
|
||||
if (savedTab && ['metadata', 'transcription', 'discussion', 'history'].includes(savedTab)) {
|
||||
activeTab = savedTab as Tab;
|
||||
}
|
||||
const topbar = document.querySelector('[data-topbar]');
|
||||
panelHeight = window.innerHeight - navHeight - (topbar?.getBoundingClientRect().height ?? 0);
|
||||
if (savedHeight) {
|
||||
const h = parseInt(savedHeight, 10);
|
||||
if (!isNaN(h) && h >= 80) panelHeight = h;
|
||||
}
|
||||
if (savedOpen !== null) {
|
||||
panelOpen = savedOpen === 'true';
|
||||
} else if (!doc.filePath) {
|
||||
// No previous state and no file → open to Metadaten by default
|
||||
panelOpen = true;
|
||||
activeTab = 'metadata';
|
||||
}
|
||||
|
||||
localStorageRestored = true;
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (activeAnnotationId) {
|
||||
activeAnnotationId = null;
|
||||
activeAnnotationPage = null;
|
||||
} else if (panelOpen) {
|
||||
panelOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
});
|
||||
|
||||
// Persist panel state whenever it changes (after initial restore).
|
||||
$effect(() => {
|
||||
if (!localStorageRestored) return;
|
||||
localStorage.setItem(LS_KEY_OPEN, String(panelOpen));
|
||||
localStorage.setItem(LS_KEY_HEIGHT, String(panelHeight));
|
||||
localStorage.setItem(LS_KEY_TAB, activeTab);
|
||||
});
|
||||
@@ -112,7 +119,11 @@ $effect(() => {
|
||||
<title>{doc.title || doc.originalFilename || 'Dokument'}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex h-screen flex-col overflow-hidden bg-white" data-hydrated>
|
||||
<div
|
||||
class="fixed right-0 bottom-0 left-0 z-40 flex flex-col overflow-hidden bg-white"
|
||||
style="top: {navHeight}px"
|
||||
data-hydrated
|
||||
>
|
||||
<DocumentTopBar
|
||||
doc={doc}
|
||||
canWrite={data.canWrite ?? false}
|
||||
@@ -129,21 +140,33 @@ $effect(() => {
|
||||
error={fileError}
|
||||
bind:annotateMode={annotateMode}
|
||||
bind:activeAnnotationId={activeAnnotationId}
|
||||
bind:activeAnnotationPage={activeAnnotationPage}
|
||||
onAnnotationClick={(id) => {
|
||||
activeAnnotationId = id;
|
||||
}}
|
||||
/>
|
||||
<AnnotationSidePanel
|
||||
documentId={doc.id}
|
||||
activeAnnotationId={activeAnnotationId}
|
||||
activeAnnotationPage={activeAnnotationPage}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
onClose={() => {
|
||||
activeAnnotationId = null;
|
||||
activeAnnotationPage = null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DocumentBottomPanel
|
||||
doc={doc}
|
||||
comments={(data.comments ?? []) as never[]}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
bind:open={panelOpen}
|
||||
bind:height={panelHeight}
|
||||
bind:activeTab={activeTab}
|
||||
activeAnnotationId={activeAnnotationId}
|
||||
/>
|
||||
<DocumentBottomPanel
|
||||
doc={doc}
|
||||
comments={(data.comments ?? []) as never[]}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
bind:open={panelOpen}
|
||||
bind:height={panelHeight}
|
||||
bind:activeTab={activeTab}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user