507 lines
26 KiB
Svelte
507 lines
26 KiB
Svelte
<script lang="ts">
|
|
import { goto } from '$app/navigation';
|
|
import Icon from '@iconify/svelte';
|
|
import { undo, redo } from '@codemirror/commands';
|
|
import {
|
|
connectedUsers,
|
|
editorViewStore,
|
|
documentZoomStore,
|
|
previewOpenStore
|
|
} from '../../ts/store';
|
|
import { exportProject } from '../../ts/typst-api';
|
|
import PageSettingsModal from '../PageSettingsModal.svelte';
|
|
import PresentationMode from '../PresentationMode.svelte';
|
|
import Modal from '../Modal.svelte';
|
|
import PromptModal from '../PromptModal.svelte';
|
|
import ConfirmModal from '../ConfirmModal.svelte';
|
|
|
|
let {
|
|
projectName = 'Project',
|
|
projectId,
|
|
entrypoint = 'main.typ',
|
|
role = 'owner',
|
|
activeText = null,
|
|
activePath = '',
|
|
getAllText,
|
|
onPublish,
|
|
onFilesChanged
|
|
}: {
|
|
projectName?: string;
|
|
projectId: string;
|
|
entrypoint?: string;
|
|
role?: string;
|
|
activeText?: any;
|
|
activePath?: string;
|
|
getAllText: () => Record<string, string>;
|
|
onPublish: () => void;
|
|
onFilesChanged: () => void;
|
|
} = $props();
|
|
|
|
let isViewer = $derived(role === 'viewer');
|
|
|
|
let uploadedFonts = $state<string[]>([]);
|
|
$effect(() => {
|
|
fetch('/api/fonts')
|
|
.then((res) => res.json())
|
|
.then((data) => { uploadedFonts = Array.isArray(data) ? data : []; })
|
|
.catch(() => {});
|
|
});
|
|
|
|
let isPageSettingsOpen = $state(false);
|
|
let isPresentationOpen = $state(false);
|
|
let fileInput = $state<HTMLInputElement | null>(null);
|
|
let activeMenu = $state<string | null>(null);
|
|
let showInfoModal = $state(false);
|
|
let showRenameModal = $state(false);
|
|
let showDeleteModal = $state(false);
|
|
let renameName = $state('');
|
|
|
|
$effect(() => { renameName = projectName; });
|
|
|
|
function safeName() {
|
|
return projectName.replace(/[^a-z0-9_-]/gi, '_');
|
|
}
|
|
|
|
function handleExport(format: 'pdf' | 'png' | 'svg' | 'typ') {
|
|
if (format === 'typ') {
|
|
const content = activeText ? activeText.toString() : '';
|
|
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${(activePath || 'main').replace(/\.[^.]+$/, '')}.typ`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
return;
|
|
}
|
|
exportProject(projectId, getAllText(), format, safeName()).catch((e) => {
|
|
console.error(`Export to ${format} failed:`, e);
|
|
alert(`Failed to export as ${format.toUpperCase()}`);
|
|
});
|
|
}
|
|
|
|
function handlePrint() {
|
|
fetch(`/api/export/pdf`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ project_id: projectId, files: getAllText() })
|
|
})
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error('Print failed');
|
|
return res.blob();
|
|
})
|
|
.then((blob) => {
|
|
const url = URL.createObjectURL(blob);
|
|
const iframe = document.createElement('iframe');
|
|
iframe.style.position = 'fixed';
|
|
iframe.style.right = '0';
|
|
iframe.style.bottom = '0';
|
|
iframe.style.width = '0';
|
|
iframe.style.height = '0';
|
|
iframe.style.border = '0';
|
|
iframe.src = url;
|
|
document.body.appendChild(iframe);
|
|
iframe.onload = () => setTimeout(() => iframe.contentWindow?.print(), 100);
|
|
})
|
|
.catch((e) => {
|
|
console.error('Print failed:', e);
|
|
alert('Failed to print');
|
|
});
|
|
}
|
|
|
|
function handlePandocExport(format: string) {
|
|
const files = getAllText();
|
|
const content = files[entrypoint] ?? (activeText ? activeText.toString() : '');
|
|
fetch(`/api/export/pandoc/${format}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text: content })
|
|
})
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error('Export failed');
|
|
return res.blob();
|
|
})
|
|
.then((blob) => {
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
let ext = format;
|
|
if (format === 'latex') ext = 'tex';
|
|
if (format === 'markdown') ext = 'md';
|
|
a.download = `${safeName()}.${ext}`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
})
|
|
.catch((err) => {
|
|
console.error(err);
|
|
alert(`Failed to export as ${format}`);
|
|
});
|
|
}
|
|
|
|
function applyFormat(prefix: string, suffix: string, defaultText: string = '') {
|
|
const view = $editorViewStore;
|
|
if (!view) return;
|
|
const selection = view.state.selection.main;
|
|
const selectedText = view.state.doc.sliceString(selection.from, selection.to);
|
|
const replacement = prefix + (selectedText || defaultText) + suffix;
|
|
view.dispatch({
|
|
changes: { from: selection.from, to: selection.to, insert: replacement },
|
|
selection: { anchor: selection.from + prefix.length, head: selection.from + prefix.length + (selectedText || defaultText).length }
|
|
});
|
|
view.focus();
|
|
}
|
|
|
|
function insertTypstConfig(setting: string, value: string) {
|
|
if (!activeText) return;
|
|
const content = activeText.toString();
|
|
const regex = new RegExp(`^#set\\s+${setting}\\s*\\(([^)]*)\\)`, 'm');
|
|
const match = content.match(regex);
|
|
const [propKey, ...propValParts] = value.split(':');
|
|
const propKeyTrimmed = propKey.trim();
|
|
const propValTrimmed = propValParts.join(':').trim();
|
|
if (match) {
|
|
const index = match.index!;
|
|
const oldArgs = match[1];
|
|
const propRegex = new RegExp(`${propKeyTrimmed}\\s*:\\s*(?:\\([^)]*\\)|"[^"]*"|[^,)]+)`);
|
|
let newArgs;
|
|
if (propRegex.test(oldArgs)) {
|
|
newArgs = oldArgs.replace(propRegex, `${propKeyTrimmed}: ${propValTrimmed}`);
|
|
} else {
|
|
newArgs = oldArgs.trim() ? `${oldArgs}, ${propKeyTrimmed}: ${propValTrimmed}` : `${propKeyTrimmed}: ${propValTrimmed}`;
|
|
}
|
|
activeText.delete(index, match[0].length);
|
|
activeText.insert(index, `#set ${setting}(${newArgs})`);
|
|
} else {
|
|
activeText.insert(0, `#set ${setting}(${value})\n`);
|
|
}
|
|
}
|
|
|
|
function handlePageSettings(settings: Record<string, string>, docSettings: Record<string, string>) {
|
|
if (!activeText) return;
|
|
if (Object.keys(settings).length > 0) {
|
|
const args = Object.entries(settings).map(([k, v]) => `${k}: ${v}`).join(', ');
|
|
const regex = new RegExp(`^#set\\s+page\\s*\\(([^)]*)\\)`, 'm');
|
|
const match = activeText.toString().match(regex);
|
|
if (match) {
|
|
activeText.delete(match.index!, match[0].length);
|
|
activeText.insert(match.index!, `#set page(${args})`);
|
|
} else {
|
|
activeText.insert(0, `#set page(${args})\n`);
|
|
}
|
|
}
|
|
if (Object.keys(docSettings).length > 0) {
|
|
const docArgs = Object.entries(docSettings).map(([k, v]) => `${k}: ${v}`).join(', ');
|
|
const docRegex = new RegExp(`^#set\\s+document\\s*\\(([^)]*)\\)`, 'm');
|
|
const docMatch = activeText.toString().match(docRegex);
|
|
if (docMatch) {
|
|
activeText.delete(docMatch.index!, docMatch[0].length);
|
|
activeText.insert(docMatch.index!, `#set document(${docArgs})`);
|
|
} else {
|
|
activeText.insert(0, `#set document(${docArgs})\n`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseSettings() {
|
|
if (!activeText) return {};
|
|
const content = activeText.toString();
|
|
const settings: Record<string, string> = {};
|
|
const pageMatch = content.match(/^#set\s+page\s*\(([^)]*)\)/m);
|
|
if (pageMatch) {
|
|
for (const arg of pageMatch[1].split(',').map((s: string) => s.trim())) {
|
|
const [k, ...vParts] = arg.split(':').map((s: string) => s.trim());
|
|
if (k && vParts.length) {
|
|
let v = vParts.join(':').trim();
|
|
if (v.startsWith('"') && v.endsWith('"')) v = v.substring(1, v.length - 1);
|
|
settings[k] = v;
|
|
}
|
|
}
|
|
}
|
|
return settings;
|
|
}
|
|
|
|
function handleUpload(e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
if (!target.files || target.files.length === 0) return;
|
|
const file = target.files[0];
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
fetch(`/api/projects/${projectId}/files/upload`, { method: 'POST', body: form })
|
|
.then((res) => res.json())
|
|
.then(() => {
|
|
const lower = file.name.toLowerCase();
|
|
const view = $editorViewStore;
|
|
if (view) {
|
|
const selection = view.state.selection.main;
|
|
let replacement = '';
|
|
if (lower.endsWith('.ttf') || lower.endsWith('.otf')) {
|
|
replacement = `// Font ${file.name} uploaded — use #set text(font: "Family Name")\n`;
|
|
} else {
|
|
replacement = `#image("${file.name}")\n`;
|
|
}
|
|
view.dispatch({
|
|
changes: { from: selection.from, to: selection.to, insert: replacement },
|
|
selection: { anchor: selection.from + replacement.length }
|
|
});
|
|
view.focus();
|
|
}
|
|
onFilesChanged();
|
|
})
|
|
.catch((err) => {
|
|
console.error(err);
|
|
alert('Failed to upload file');
|
|
});
|
|
target.value = '';
|
|
}
|
|
|
|
function getInitials(name: string) {
|
|
return name.substring(0, 2).toUpperCase();
|
|
}
|
|
|
|
function submitRename(name: string) {
|
|
if (name && name !== projectName) {
|
|
fetch(`/api/projects/${projectId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name })
|
|
}).then((res) => { if (res.ok) window.location.reload(); });
|
|
}
|
|
showRenameModal = false;
|
|
}
|
|
|
|
function confirmDelete() {
|
|
fetch(`/api/projects/${projectId}`, { method: 'DELETE' }).then((res) => {
|
|
if (res.ok) goto('/projects');
|
|
});
|
|
}
|
|
|
|
function handleWindowClick(e: MouseEvent) {
|
|
const target = e.target as HTMLElement;
|
|
if (!target.closest('.action-menu-container')) activeMenu = null;
|
|
}
|
|
|
|
function handleUndo() { activeMenu = null; if ($editorViewStore) undo($editorViewStore); }
|
|
function handleRedo() { activeMenu = null; if ($editorViewStore) redo($editorViewStore); }
|
|
</script>
|
|
|
|
<svelte:window onclick={handleWindowClick} />
|
|
|
|
<header class="flex flex-col border-b border-[var(--theme-border)] bg-[var(--theme-bg)] text-[var(--theme-text)] select-none w-full relative z-[70]">
|
|
<div class="flex items-center justify-between px-4 py-2.5">
|
|
<div class="flex items-center gap-3">
|
|
<button onclick={() => goto('/projects')} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] rounded-md hover:bg-[var(--color-surface-sunken)] transition-colors" title="Projects">
|
|
<Icon icon="mdi:arrow-left" class="text-xl" />
|
|
</button>
|
|
|
|
<div class="flex flex-col gap-0.5">
|
|
<div class="flex items-center gap-2">
|
|
<Icon icon="mdi:folder-multiple-outline" class="text-blue-500 text-base" />
|
|
<h1 class="text-[16px] font-semibold text-[var(--color-ink)] tracking-tight truncate max-w-[200px] md:max-w-xs" title={projectName}>{projectName}</h1>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-0.5 text-[13px] font-medium text-[var(--color-ink-muted)] -ml-1 action-menu-container">
|
|
<div class="relative">
|
|
<button onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'file' ? null : 'file'; }} class="px-2 py-0.5 rounded transition-colors {activeMenu === 'file' ? 'bg-[var(--theme-border)]' : 'hover:bg-[var(--theme-border)]'}">File</button>
|
|
{#if activeMenu === 'file'}
|
|
<div class="absolute left-0 top-full mt-1 w-48 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100] max-h-[calc(100vh-8rem)] overflow-y-auto">
|
|
<button onclick={() => { activeMenu = null; goto('/projects'); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">All Projects</button>
|
|
<button onclick={() => { activeMenu = null; showInfoModal = true; }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Project Info</button>
|
|
{#if !isViewer}
|
|
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
|
<button onclick={() => { activeMenu = null; renameName = projectName; showRenameModal = true; }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Rename</button>
|
|
<button onclick={() => { activeMenu = null; isPageSettingsOpen = true; }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Page Settings</button>
|
|
{#if role === 'owner'}
|
|
<button onclick={() => { activeMenu = null; onPublish(); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Publish as Package</button>
|
|
{/if}
|
|
{/if}
|
|
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
|
<div class="px-4 py-1.5 text-xs font-semibold uppercase tracking-wider opacity-60">Download</div>
|
|
<button onclick={() => { activeMenu = null; handlePrint(); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)] flex items-center gap-1.5"><Icon icon="mdi:printer" /> Print</button>
|
|
<button onclick={() => { activeMenu = null; handleExport('typ'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)] flex items-center gap-1.5"><Icon icon="mdi:code-braces" /> active .typ</button>
|
|
<button onclick={() => { activeMenu = null; handleExport('pdf'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)] flex items-center gap-1.5"><Icon icon="mdi:file-pdf-box" /> .pdf document</button>
|
|
<button onclick={() => { activeMenu = null; handleExport('png'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)] flex items-center gap-1.5"><Icon icon="mdi:image" /> .png image</button>
|
|
<button onclick={() => { activeMenu = null; handleExport('svg'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)] flex items-center gap-1.5"><Icon icon="mdi:svg" /> .svg graphics</button>
|
|
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
|
<div class="px-4 py-1.5 text-xs font-semibold uppercase tracking-wider opacity-60">Export (Pandoc)</div>
|
|
<button onclick={() => { activeMenu = null; handlePandocExport('docx'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)]">Word (.docx)</button>
|
|
<button onclick={() => { activeMenu = null; handlePandocExport('latex'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)]">LaTeX (.tex)</button>
|
|
<button onclick={() => { activeMenu = null; handlePandocExport('markdown'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)]">Markdown (.md)</button>
|
|
<button onclick={() => { activeMenu = null; handlePandocExport('html'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)]">HTML (.html)</button>
|
|
{#if role === 'owner'}
|
|
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
|
<button onclick={() => { activeMenu = null; showDeleteModal = true; }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--color-danger)] hover:bg-[var(--color-danger)]/10">Delete Project</button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if !isViewer}
|
|
<div class="relative">
|
|
<button onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'edit' ? null : 'edit'; }} class="px-2 py-0.5 rounded transition-colors {activeMenu === 'edit' ? 'bg-[var(--theme-border)]' : 'hover:bg-[var(--theme-border)]'}">Edit</button>
|
|
{#if activeMenu === 'edit'}
|
|
<div class="absolute left-0 top-full mt-1 w-48 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100]">
|
|
<button onclick={handleUndo} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Undo (Ctrl+Z)</button>
|
|
<button onclick={handleRedo} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Redo (Ctrl+Y)</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="relative">
|
|
<button onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'view' ? null : 'view'; }} class="px-2 py-0.5 rounded transition-colors {activeMenu === 'view' ? 'bg-[var(--theme-border)]' : 'hover:bg-[var(--theme-border)]'}">View</button>
|
|
{#if activeMenu === 'view'}
|
|
<div class="absolute left-0 top-full mt-1 w-48 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100]">
|
|
<button onclick={() => { activeMenu = null; $previewOpenStore = !$previewOpenStore; }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)] flex items-center justify-between">Preview<Icon icon={$previewOpenStore ? 'mdi:check' : ''} class="text-sm" /></button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3">
|
|
{#if $connectedUsers.length > 0}
|
|
<div class="flex items-center -space-x-2 mr-2">
|
|
{#each $connectedUsers as user}
|
|
<div class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold text-white border-2 border-[var(--color-surface)] shadow-sm" style="background-color: {user.color}; z-index: {user.isLocal ? 10 : 1};" title={user.name + (user.isLocal ? ' (You)' : '')}>{getInitials(user.name)}</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="w-px h-5 bg-[var(--color-line)]"></div>
|
|
|
|
{#if !isViewer}
|
|
<button onclick={() => (isPresentationOpen = true)} class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-[var(--color-ink-muted)] bg-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)] rounded-md transition-colors">
|
|
<Icon icon="mdi:presentation-play" class="text-[16px]" /> Present
|
|
</button>
|
|
{#if role === 'owner'}
|
|
<button onclick={onPublish} class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-white bg-[var(--color-accent)] hover:opacity-90 rounded-md transition-colors">
|
|
<Icon icon="mdi:package-variant-closed" class="text-[16px]" /> Publish
|
|
</button>
|
|
{/if}
|
|
{/if}
|
|
|
|
<div class="w-px h-5 bg-[var(--color-line)]"></div>
|
|
|
|
<button onclick={() => ($previewOpenStore = !$previewOpenStore)} class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium transition-colors rounded-md {$previewOpenStore ? 'text-[var(--color-ink-muted)] bg-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)]' : 'text-[var(--color-accent)] bg-[var(--color-accent-soft)] hover:opacity-90'}" title={$previewOpenStore ? 'Hide preview' : 'Show preview'}>
|
|
<Icon icon={$previewOpenStore ? 'mdi:eye-off-outline' : 'mdi:eye-outline'} class="text-[16px]" /> Preview
|
|
</button>
|
|
|
|
<button onclick={handlePrint} class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-[var(--color-ink-muted)] bg-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)] rounded-md transition-colors" title="Print">
|
|
<Icon icon="mdi:printer" class="text-[16px]" /> Print
|
|
</button>
|
|
|
|
<div class="relative action-menu-container">
|
|
<button onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'export' ? null : 'export'; }} class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-[var(--color-accent)] bg-[var(--color-accent-soft)] hover:opacity-90 rounded-md transition-colors {activeMenu === 'export' ? 'ring-2 ring-[var(--color-accent)]/30' : ''}">
|
|
<Icon icon="mdi:export-variant" class="text-[16px]" /> Export <Icon icon="mdi:chevron-down" class="text-sm opacity-70" />
|
|
</button>
|
|
{#if activeMenu === 'export'}
|
|
<div class="absolute right-0 top-full mt-1 w-44 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100]">
|
|
<button onclick={() => { activeMenu = null; handleExport('pdf'); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)] flex items-center gap-2"><Icon icon="mdi:file-pdf-box" class="text-base text-red-500" /> PDF document</button>
|
|
<button onclick={() => { activeMenu = null; handleExport('typ'); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)] flex items-center gap-2"><Icon icon="mdi:code-braces" class="text-base text-purple-500" /> active .typ</button>
|
|
<button onclick={() => { activeMenu = null; handleExport('svg'); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)] flex items-center gap-2"><Icon icon="mdi:svg" class="text-base text-orange-500" /> SVG graphics</button>
|
|
<button onclick={() => { activeMenu = null; handleExport('png'); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)] flex items-center gap-2"><Icon icon="mdi:image" class="text-base text-blue-500" /> PNG image</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center px-4 py-1.5 bg-[var(--color-surface-muted)] border-t border-[var(--color-line)] gap-4 overflow-x-auto no-scrollbar">
|
|
<div class="flex items-center gap-1">
|
|
<button onclick={() => applyFormat('*', '*', 'bold')} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded transition-colors" title="Bold"><Icon icon="mdi:format-bold" class="text-lg" /></button>
|
|
<button onclick={() => applyFormat('_', '_', 'italic')} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded transition-colors" title="Italic"><Icon icon="mdi:format-italic" class="text-lg" /></button>
|
|
<button onclick={() => applyFormat('`', '`', 'code')} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded transition-colors" title="Code"><Icon icon="mdi:code-tags" class="text-lg" /></button>
|
|
<div class="w-px h-4 mx-1 bg-[var(--color-line)]"></div>
|
|
<button onclick={() => applyFormat('$ ', ' $', 'x = y')} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded transition-colors" title="Math (Inline)"><Icon icon="mdi:sigma" class="text-lg" /></button>
|
|
<button onclick={() => applyFormat('- ', '', 'List item')} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded transition-colors" title="Bullet List"><Icon icon="mdi:format-list-bulleted" class="text-lg" /></button>
|
|
<button onclick={() => applyFormat('+ ', '', 'Numbered item')} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded transition-colors" title="Numbered List"><Icon icon="mdi:format-list-numbered" class="text-lg" /></button>
|
|
<div class="w-px h-4 mx-1 bg-[var(--color-line)]"></div>
|
|
<input type="file" bind:this={fileInput} onchange={handleUpload} class="hidden" accept="image/*,.ttf,.otf" />
|
|
{#if !isViewer}
|
|
<button onclick={() => fileInput?.click()} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded transition-colors" title="Upload Image / Font"><Icon icon="mdi:image-plus" class="text-lg" /></button>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="w-px h-4 bg-[var(--color-line)]"></div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<label for="project-font-select" class="text-[11px] font-semibold uppercase tracking-wider opacity-60">Font</label>
|
|
<select id="project-font-select" onchange={(e) => insertTypstConfig('text', `font: "${e.currentTarget.value}"`)} class="bg-[var(--theme-bg)] text-[var(--theme-text)] border border-[var(--theme-border)] text-xs rounded shadow-sm block py-1 pl-2 pr-6 appearance-none cursor-pointer">
|
|
<option value="New Computer Modern">Default (New CM)</option>
|
|
<option value="Libertinus Serif">Libertinus Serif</option>
|
|
<option value="PT Sans">PT Sans</option>
|
|
<option value="Roboto">Roboto</option>
|
|
{#if uploadedFonts.length > 0}
|
|
<optgroup label="Uploaded Fonts">
|
|
{#each uploadedFonts as font}
|
|
<option value={font}>{font}</option>
|
|
{/each}
|
|
</optgroup>
|
|
{/if}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="w-px h-4 bg-[var(--color-line)]"></div>
|
|
|
|
{#if !isViewer}
|
|
<button onclick={() => (isPageSettingsOpen = true)} class="flex items-center gap-1.5 px-3 py-1 text-[11px] font-semibold bg-[var(--theme-bg)] text-[var(--theme-text)] border border-[var(--theme-border)] rounded shadow-sm transition-colors opacity-90 hover:opacity-100">
|
|
<Icon icon="mdi:file-document-edit-outline" class="text-sm" /> Page Settings
|
|
</button>
|
|
<div class="w-px h-4 bg-[var(--color-line)]"></div>
|
|
{/if}
|
|
|
|
<div class="flex items-center gap-1 bg-[var(--color-surface)] border border-[var(--color-line)] rounded shadow-sm overflow-hidden">
|
|
<button onclick={() => $documentZoomStore = Math.max(10, $documentZoomStore - 10)} class="px-2 py-1 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] transition-colors" title="Zoom Out"><Icon icon="mdi:minus" class="text-sm" /></button>
|
|
<span role="button" tabindex="0" onkeydown={(e) => { if (e.key === 'Enter') $documentZoomStore = 100; }} class="text-[11px] font-semibold text-[var(--color-ink-muted)] min-w-[3rem] text-center select-none" ondblclick={() => $documentZoomStore = 100}>{$documentZoomStore}%</span>
|
|
<button onclick={() => $documentZoomStore = Math.min(500, $documentZoomStore + 10)} class="px-2 py-1 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] transition-colors" title="Zoom In"><Icon icon="mdi:plus" class="text-sm" /></button>
|
|
</div>
|
|
|
|
<div class="flex-grow"></div>
|
|
</div>
|
|
</header>
|
|
|
|
{#if isPageSettingsOpen}
|
|
<PageSettingsModal onClose={() => (isPageSettingsOpen = false)} onApply={handlePageSettings} currentSettings={parseSettings()} />
|
|
{/if}
|
|
|
|
{#if isPresentationOpen}
|
|
<PresentationMode onClose={() => (isPresentationOpen = false)} />
|
|
{/if}
|
|
|
|
{#if showInfoModal}
|
|
<Modal title={projectName} icon="ph:folder-star" onclose={() => showInfoModal = false}>
|
|
<div class="flex flex-col gap-4 text-xs">
|
|
<div><p class="mb-1 font-medium text-[var(--color-ink-muted)]">Type</p><p class="text-sm text-[var(--color-ink)]">Project (multi-file)</p></div>
|
|
<div><p class="mb-1 font-medium text-[var(--color-ink-muted)]">Entrypoint</p><p class="font-mono text-sm text-[var(--color-ink)]">{entrypoint}</p></div>
|
|
<div><p class="mb-1 font-medium text-[var(--color-ink-muted)]">Your role</p><p class="text-sm text-[var(--color-ink)] capitalize">{role}</p></div>
|
|
</div>
|
|
|
|
{#snippet footer()}
|
|
<button onclick={() => showInfoModal = false} class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90">Close</button>
|
|
{/snippet}
|
|
</Modal>
|
|
{/if}
|
|
|
|
{#if showRenameModal}
|
|
<PromptModal
|
|
title="Rename project"
|
|
label="Project name"
|
|
icon="ph:pencil-simple"
|
|
value={renameName}
|
|
confirmLabel="Save"
|
|
onsubmit={submitRename}
|
|
onclose={() => showRenameModal = false}
|
|
/>
|
|
{/if}
|
|
|
|
{#if showDeleteModal}
|
|
<ConfirmModal
|
|
title="Delete project"
|
|
message="Delete this project and all its files? This cannot be undone."
|
|
confirmLabel="Delete"
|
|
onconfirm={confirmDelete}
|
|
onclose={() => showDeleteModal = false}
|
|
/>
|
|
{/if}
|