You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/web/routes/uix/components/words/+page.svelte

499 lines
14 KiB

<script lang="ts">
// Words editor — F2 foundation: the editor surface renders + edits on
// the new base-`Block` model. Chrome (gutters / bubble toolbar /
// settings drawer) lands in F3.
import Words from '$uix/eidos/components/words';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import { Eye, Pencil, FileText, Check } from '$uix/eidos/components/icon';
import { serializeHtml } from '$soma/components/words';
import type { WordsInspectorMode } from '$uix/eidos/components/words/types';
import type {
WordsDocument,
WordsImageUploadResult,
WordsOnUploadImage
} from '$soma/components/words';
// Local-only image upload — turns the dropped/pasted File into a
// `data:` URL via FileReader. Lets the demo accept drag-drop and
// paste of images without a backend. Real apps would POST the file
// to their storage and resolve with the returned URL instead.
const handleUploadImage: WordsOnUploadImage = (file: File) =>
new Promise<WordsImageUploadResult>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error('FileReader failed'));
reader.onload = () => {
const url = typeof reader.result === 'string' ? reader.result : '';
if (!url) {
reject(new Error('Empty data URL'));
return;
}
resolve({
url,
// Use the filename (sans extension) as a default alt — the
// inspector lets the user refine it afterwards.
alt: file.name.replace(/\.[^/.]+$/, '') || undefined
});
};
reader.readAsDataURL(file);
});
const INSPECTOR_MODES: readonly { v: WordsInspectorMode; label: string }[] = [
{ v: 'none', label: 'None' },
{ v: 'sidebar', label: 'Sidebar' },
{ v: 'drawer', label: 'Drawer' },
{ v: 'popover', label: 'Popover' }
];
let inspector = $state<WordsInspectorMode>('sidebar');
let value = $state<WordsDocument>({
version: '2.0.0',
children: [
{ type: 'heading', level: 1, children: [{ type: 'text', text: 'Words' }] },
{
type: 'paragraph',
children: [
{ type: 'text', text: 'A clean editor rebuilt on a base ' },
{ type: 'text', text: 'Block', marks: ['code'] },
{
type: 'text',
text: ' model — common style props (margins, colors, spacing, borders, align) shared by every block and '
},
{ type: 'text', text: 'extended', marks: ['italic'] },
{ type: 'text', text: ' per type.' }
]
},
{
type: 'paragraph',
children: [
{ type: 'text', text: 'Inline marks: ' },
{ type: 'text', text: 'bold', marks: ['bold'] },
{ type: 'text', text: ', ' },
{ type: 'text', text: 'italic', marks: ['italic'] },
{ type: 'text', text: ', ' },
{ type: 'text', text: 'underline', marks: ['underline'] },
{ type: 'text', text: ', ' },
{ type: 'text', text: 'strike', marks: ['strike'] },
{ type: 'text', text: ', and a ' },
{
type: 'link',
href: 'https://tiptap.dev',
target: '_blank',
children: [{ type: 'text', text: 'link' }]
},
{ type: 'text', text: '.' }
]
},
{
type: 'heading',
level: 2,
children: [{ type: 'text', text: 'Quote & code' }]
},
{
type: 'quote',
children: [
{
type: 'text',
text: 'Design is not just what it looks like and feels like. Design is how it works.'
}
]
},
{
type: 'code',
language: 'ts',
children: [
{
type: 'text',
text: "const greet = (name: string) => `Hello, ${name}!`\nconsole.log(greet('Words'))"
}
]
},
{
type: 'heading',
level: 2,
children: [{ type: 'text', text: 'Lists' }]
},
{
type: 'list',
kind: 'unordered',
items: [
{ children: [{ type: 'text', text: 'Bulleted item one' }] },
{ children: [{ type: 'text', text: 'Bulleted item two' }] }
]
},
{
type: 'list',
kind: 'check',
items: [
{ checked: true, children: [{ type: 'text', text: 'Engine reworked (base Block)' }] },
{
checked: false,
children: [{ type: 'text', text: 'Chrome (gutters + bubble + drawer)' }]
}
]
},
{
type: 'callout',
intent: 'affirm',
title: 'Foundation',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'This surface renders directly from the model. Editing round-trips through the engine.'
}
]
}
]
},
{ type: 'divider' },
{
type: 'heading',
level: 2,
children: [{ type: 'text', text: 'Columns' }]
},
{
type: 'columns',
columns: [
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: '' }] }] },
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: '' }] }] }
]
},
{
type: 'heading',
level: 2,
children: [{ type: 'text', text: 'Table' }]
},
{
type: 'table',
headerRow: true,
rows: [
{
cells: [
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Phase' }] }] },
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Status' }] }] }
]
},
{
cells: [
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Engine' }] }] },
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Done' }] }] }
]
},
{
cells: [
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Visual' }] }] },
{
children: [{ type: 'paragraph', children: [{ type: 'text', text: 'In progress' }] }]
}
]
}
]
}
]
});
// ── Topbar state ──────────────────────────────────────────────────────
// Edit / Preview. Preview renders the document as clean read-only HTML
// via the export-only `serializeHtml` serializer.
let mode = $state<'edit' | 'preview'>('edit');
const EDIT_MODES: readonly { v: 'edit' | 'preview'; label: string }[] = [
{ v: 'edit', label: 'Edit' },
{ v: 'preview', label: 'Preview' }
];
const previewHtml = $derived(mode === 'preview' ? serializeHtml(value) : '');
// Breadcrumb title — the first heading's text, else a placeholder.
const docTitle = $derived.by(() => {
for (const b of value.children) {
if (b.type === 'heading') {
const text = b.children
.map((c) => (c.type === 'text' ? c.text : ''))
.join('')
.trim();
if (text) return text;
}
}
return 'Untitled document';
});
// "Saved" indicator — flips to "Editing…" on any document change, settles
// back to "Saved" after a short idle. Demo-only chrome (a real app would
// debounce a network save here).
let saveState = $state<'saved' | 'editing'>('saved');
let saveTimer: ReturnType<typeof setTimeout> | undefined;
let trackedInitial = false;
$effect(() => {
void value; // re-run whenever the bound document changes
if (!trackedInitial) {
trackedInitial = true;
return;
}
saveState = 'editing';
clearTimeout(saveTimer);
saveTimer = setTimeout(() => (saveState = 'saved'), 600);
return () => clearTimeout(saveTimer);
});
</script>
<header>
<p data-uix-eyebrow>— editors · words</p>
<h1>Words</h1>
<p>
Rebuilt on a clean base-<code>Block</code> model. This is the F2 foundation: the editor surface renders
and edits. Chrome (left gutter menu, right settings gutter, bubble toolbar) is next.
</p>
</header>
<div class="demo-topbar">
<div class="demo-doc">
<FileText size="sm" />
<span class="demo-doc-title">{docTitle}</span>
<span class="demo-save" data-state={saveState}>
{#if saveState === 'saved'}<Check size="sm" />Saved{:else}Editing…{/if}
</span>
</div>
<div class="demo-topbar-actions">
<span class="demo-controls-label">Inspector</span>
<ToggleGroup
type="single"
size="xs"
variant="outline"
attached
value={[inspector]}
onValueChange={(v: string[]) => {
if (v[0]) inspector = v[0] as WordsInspectorMode;
}}
aria-label="Inspector mode"
>
{#each INSPECTOR_MODES as m (m.v)}
<ToggleGroup.Item value={m.v}>{m.label}</ToggleGroup.Item>
{/each}
</ToggleGroup>
<ToggleGroup
type="single"
size="xs"
variant="outline"
attached
value={[mode]}
onValueChange={(v: string[]) => {
if (v[0]) mode = v[0] as 'edit' | 'preview';
}}
aria-label="Edit or preview"
>
{#each EDIT_MODES as m (m.v)}
<ToggleGroup.Item value={m.v}>
{#if m.v === 'edit'}<Pencil size="sm" />{:else}<Eye size="sm" />{/if}
{m.label}
</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
</div>
<div class="demo-canvas" data-mode={mode}>
{#if mode === 'edit'}
<Words
bind:value
{inspector}
placeholder="Write something…"
onUploadImage={handleUploadImage}
/>
{:else}
<!-- Preview: the document rendered as clean, read-only HTML (the
export-only `serializeHtml`). Content is the user's own document,
escaped + URL-sanitized by the serializer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<article class="demo-preview">{@html previewHtml}</article>
{/if}
</div>
<details class="demo-trace">
<summary>document value</summary>
<pre>{JSON.stringify(value, null, 2)}</pre>
</details>
<style>
.demo-canvas {
display: flex;
justify-content: center;
padding: var(--space-8) var(--space-4);
/* Theme-aware surface — `--color-surface-sunken` does not exist, so
the old fallback painted a light panel on the dark theme. */
background: var(--color-surface-default);
border-radius: var(--radius-lg);
}
.demo-canvas :global([data-words]) {
inline-size: 100%;
max-inline-size: 62rem;
}
.demo-topbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
margin-block-end: var(--space-3);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
}
.demo-doc {
display: flex;
align-items: center;
gap: var(--space-2);
min-inline-size: 0;
color: var(--color-content-secondary);
}
.demo-doc-title {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-content-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.demo-save {
display: inline-flex;
align-items: center;
gap: var(--space-1);
margin-inline-start: var(--space-1);
font-size: var(--font-size-xs);
transition: color var(--duration-fast) var(--ease-default);
}
.demo-save[data-state='saved'] {
color: var(--color-affirm-text, var(--color-content-secondary));
}
.demo-save[data-state='editing'] {
color: var(--color-content-muted);
}
.demo-topbar-actions {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.demo-controls-label {
font-size: var(--font-size-sm);
color: var(--color-content-secondary);
}
.demo-trace {
margin-block-start: var(--space-6);
font-size: var(--font-size-xs);
color: var(--color-content-secondary);
}
.demo-trace summary {
cursor: pointer;
user-select: none;
}
.demo-trace pre {
max-block-size: 24rem;
overflow: auto;
padding: var(--space-3);
background: var(--color-surface-overlay);
border-radius: var(--radius-md);
}
/* Preview mode — the document as a clean reading view. The preview is
bare serialized HTML, so it carries its own typography (the editor's
`[data-words]` CSS does not apply here). */
.demo-canvas[data-mode='preview'] {
justify-content: flex-start;
}
.demo-preview {
inline-size: 100%;
max-inline-size: 48rem;
margin-inline: auto;
color: var(--color-content-primary);
font-size: var(--font-size-md);
line-height: 1.7;
}
/* Headings mirror the editor recipe tokens (words `heading-*`): same
sizes (h1 = xl, h2 = lg+4, h3 = lg), semibold weight, heading
line-height + primary color, top-only rhythm — so Preview reads
like Edit mode rather than a louder, bolder variant. */
.demo-preview :global(:first-child) {
margin-block-start: 0;
}
.demo-preview :global(h1),
.demo-preview :global(h2),
.demo-preview :global(h3) {
font-weight: var(--font-weight-semibold);
line-height: var(--leading-heading);
color: var(--color-content-primary);
margin-block: calc(var(--space-3) * 1.5) 0;
}
.demo-preview :global(h1) {
font-size: var(--font-size-xl);
}
.demo-preview :global(h2) {
font-size: calc(var(--font-size-lg) + 4px);
}
.demo-preview :global(h3) {
font-size: var(--font-size-lg);
}
/* Columns serialize to `<div data-words-columns><div data-words-column>` —
plain divs, so without this they stack. Mirror the editor's row flex. */
.demo-preview :global([data-words-columns]) {
display: flex;
flex-direction: row;
gap: var(--space-3);
align-items: stretch;
margin-block: var(--space-3);
}
.demo-preview :global([data-words-column]) {
flex: 1 1 0;
min-inline-size: 0;
}
.demo-preview :global(p) {
margin-block: var(--space-3);
}
.demo-preview :global(ul),
.demo-preview :global(ol) {
margin-block: var(--space-3);
padding-inline-start: var(--space-6);
}
.demo-preview :global(blockquote) {
margin-block: var(--space-3);
padding-inline-start: var(--space-4);
border-inline-start: 3px solid var(--color-border-default);
color: var(--color-content-secondary);
}
.demo-preview :global(pre) {
margin-block: var(--space-3);
padding: var(--space-3);
overflow: auto;
background: var(--color-surface-overlay);
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
}
.demo-preview :global(table) {
inline-size: 100%;
margin-block: var(--space-4);
border-collapse: collapse;
}
.demo-preview :global(th),
.demo-preview :global(td) {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border-subtle);
text-align: start;
vertical-align: top;
}
.demo-preview :global(th) {
background: var(--color-surface-raised);
font-weight: 600;
}
.demo-preview :global(img) {
max-inline-size: 100%;
height: auto;
border-radius: var(--radius-md);
}
.demo-preview :global(a) {
color: var(--color-primary-text, var(--color-primary-element));
text-decoration: underline;
}
</style>

Powered by TurnKey Linux.