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.
365 lines
10 KiB
365 lines
10 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 { FileText, Check } from '$uix/eidos/components/icon';
|
|
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 now lives INSIDE the editor's menu bar (the eidos
|
|
// `<Words>` owns the read-only `preview` mode), so the demo no longer
|
|
// toggles it here — it just keeps the breadcrumb + Saved + inspector picker.
|
|
|
|
// 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>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="demo-canvas">
|
|
<!-- Edit / Preview lives in the editor's own menu bar now (read-only
|
|
preview). The demo just hosts the live editor. -->
|
|
<Words bind:value {inspector} placeholder="Write something…" onUploadImage={handleUploadImage} />
|
|
</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);
|
|
}
|
|
|
|
</style>
|