@atom-forge/brickee 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +59 -1
  2. package/README-AI.md +34 -28
  3. package/dist/Editor.svelte +713 -867
  4. package/dist/Editor.svelte.d.ts +0 -3
  5. package/dist/LinkPrompt.svelte +1 -14
  6. package/dist/Preview.svelte +27 -58
  7. package/dist/Preview.svelte.d.ts +2 -7
  8. package/dist/PropsBar.svelte +75 -249
  9. package/dist/PropsBar.svelte.d.ts +0 -3
  10. package/dist/code.d.ts +12 -0
  11. package/dist/code.js +15 -0
  12. package/dist/dom.d.ts +8 -0
  13. package/dist/dom.js +9 -0
  14. package/dist/editor/BlockMenu.svelte +102 -0
  15. package/dist/editor/BlockMenu.svelte.d.ts +32 -0
  16. package/dist/editor/FormatBar.svelte +41 -0
  17. package/dist/editor/FormatBar.svelte.d.ts +16 -0
  18. package/dist/editor/drag.svelte.d.ts +63 -0
  19. package/dist/editor/drag.svelte.js +95 -0
  20. package/dist/editor/history.d.ts +24 -0
  21. package/dist/editor/history.js +86 -0
  22. package/dist/editor/sweep.svelte.d.ts +21 -0
  23. package/dist/editor/sweep.svelte.js +56 -0
  24. package/dist/html.d.ts +12 -0
  25. package/dist/html.js +206 -0
  26. package/dist/i18n.js +1 -1
  27. package/dist/index.d.ts +3 -2
  28. package/dist/index.js +4 -3
  29. package/dist/kit/Empty.svelte +4 -15
  30. package/dist/kit/Frame.svelte +7 -37
  31. package/dist/locales/hu.js +6 -8
  32. package/dist/markdown.d.ts +20 -6
  33. package/dist/markdown.js +380 -85
  34. package/dist/model.js +2 -3
  35. package/dist/plugins/Gap.svelte +5 -8
  36. package/dist/plugins/GapPreview.svelte +4 -6
  37. package/dist/plugins/Map.svelte +2 -17
  38. package/dist/plugins/MapPreview.svelte +8 -10
  39. package/dist/plugins/Table.svelte +233 -282
  40. package/dist/plugins/TablePreview.svelte +28 -28
  41. package/dist/plugins/YouTube.svelte +3 -31
  42. package/dist/plugins/YouTubePreview.svelte +45 -43
  43. package/dist/plugins/index.js +15 -2
  44. package/dist/schema.js +1 -1
  45. package/dist/types.d.ts +27 -10
  46. package/dist/types.js +8 -1
  47. package/dist/view.js +1 -1
  48. package/docs/en/bundled-plugins.md +1 -1
  49. package/docs/en/documents-and-markdown.md +24 -19
  50. package/docs/en/editor-and-preview.md +7 -10
  51. package/docs/en/getting-started.md +14 -2
  52. package/docs/en/host-contexts.md +26 -132
  53. package/docs/en/index.md +5 -5
  54. package/docs/en/recipes-and-pitfalls.md +12 -11
  55. package/docs/en/server-side.md +0 -17
  56. package/docs/en/upgrading.md +34 -4
  57. package/docs/en/writing-plugins.md +147 -21
  58. package/docs/hu/bundled-plugins.md +1 -1
  59. package/docs/hu/documents-and-markdown.md +24 -19
  60. package/docs/hu/editor-and-preview.md +7 -10
  61. package/docs/hu/getting-started.md +14 -2
  62. package/docs/hu/host-contexts.md +26 -132
  63. package/docs/hu/index.md +5 -5
  64. package/docs/hu/recipes-and-pitfalls.md +12 -11
  65. package/docs/hu/server-side.md +0 -17
  66. package/docs/hu/upgrading.md +34 -4
  67. package/docs/hu/writing-plugins.md +147 -21
  68. package/package.json +14 -2
  69. package/dist/ImagePicker.svelte +0 -131
  70. package/dist/ImagePicker.svelte.d.ts +0 -13
  71. package/dist/media.d.ts +0 -17
  72. package/dist/media.js +0 -12
@@ -1,4 +1,3 @@
1
- import { type MediaItem } from './media';
2
1
  import { type Messages } from './i18n';
3
2
  import { type BlockPlugin, type StoredBlock } from './types';
4
3
  interface Props {
@@ -12,8 +11,6 @@ interface Props {
12
11
  plugins?: BlockPlugin[];
13
12
  /** Set by plugins with nested blocks: a compact editor inside another block. */
14
13
  nested?: boolean;
15
- /** Images the blocks can pick from (the page's attachments); nested editors share their parent's. */
16
- media?: MediaItem[];
17
14
  /** UI text in the host's language (English text -> translation); nested editors share their parent's. */
18
15
  messages?: Messages;
19
16
  }
@@ -22,21 +22,8 @@
22
22
  </script>
23
23
 
24
24
  <!-- svelte-ignore a11y_no_static_element_interactions -->
25
- <div class="light link" {onkeydown}>
25
+ <div class="flex w-[340px] gap-1.5 rounded-xl border border-frame bg-surface p-2 shadow-[0_10px_36px_rgb(0_0_0/0.2)]" {onkeydown}>
26
26
  <Input bind:this={input} compact monospace bind:value={url} placeholder="https://…" />
27
27
  <Button compact accent label={tr('OK')} onclick={() => popups.resolve(url.trim())} />
28
28
  {#if href}<Button compact ghost label={tr('Remove')} onclick={() => popups.resolve('')} />{/if}
29
29
  </div>
30
-
31
- <style>
32
- .link {
33
- display: flex;
34
- gap: 6px;
35
- width: 340px;
36
- padding: 8px;
37
- background: #fff;
38
- border: 1px solid #e5e5ea;
39
- border-radius: 12px;
40
- box-shadow: 0 10px 36px rgba(0, 0, 0, 0.2);
41
- }
42
- </style>
@@ -1,8 +1,9 @@
1
- <!-- Styles travel with the markup: the sites show the blocks as server-rendered HTML (see content.server.ts). -->
1
+ <!-- Renders a document the way a site shows it. Styles travel with the markup (injected), so the preview looks the same
2
+ wherever it is mounted. -->
2
3
  <svelte:options css="injected" />
3
4
 
4
5
  <script lang="ts" module>
5
- import type { StoredBlock } from './types';
6
+ import { olStart, type StoredBlock } from './types';
6
7
 
7
8
  const LIST = new Set(['md:ul', 'md:ol', 'md:todo']);
8
9
 
@@ -49,35 +50,24 @@
49
50
  <script lang="ts">
50
51
  import { useText } from './i18n';
51
52
  import { inline } from './markdown';
52
- import { provideMedia, type MediaItem } from './media';
53
53
  import { provideText, type Messages } from './i18n';
54
54
  import type { BlockPlugin } from './types';
55
55
 
56
56
  interface Props {
57
57
  blocks: StoredBlock[];
58
58
  plugins: BlockPlugin[];
59
- /** Set by plugins rendering their nested blocks; only the top level splits at the supporters-only divider. */
59
+ /** Set by plugins rendering their nested blocks (a container's preview). */
60
60
  nested?: boolean;
61
- /** The images blocks refer to by id; nested previews share their parent's. */
62
- media?: MediaItem[];
63
61
  /** UI text in the host's language; nested previews share their parent's. */
64
62
  messages?: Messages;
65
- /** Mark the blocks after a gate (the supporters-only divider) with a label and a dashed line; default: yes. */
66
- markGated?: boolean;
67
63
  }
68
- let { blocks, plugins, nested = false, media, messages, markGated = true }: Props = $props();
64
+ let { blocks, plugins, nested = false, messages }: Props = $props();
69
65
 
70
- provideMedia(() => media);
71
66
  provideText(() => messages);
72
67
  const tr = useText();
73
68
 
74
69
  const pluginOf = (type: string) => plugins.find((p) => p.type === type);
75
70
 
76
- // Everything after a unique supporters-only divider is for supporters: shown here, marked as such.
77
- const lockAt = $derived(nested || !markGated ? -1 : blocks.findIndex((b) => pluginOf(b.type)?.gate));
78
- const open = $derived(lockAt < 0 ? blocks : blocks.slice(0, lockAt + 1));
79
- const locked = $derived(lockAt < 0 ? [] : blocks.slice(lockAt + 1));
80
-
81
71
  // Inline markdown -> html: text is escaped first, links are http(s), mailto, tel or site-relative only (see inline()).
82
72
  const html = (b: StoredBlock) => inline(b.text ?? '');
83
73
  const indent = (b: StoredBlock) => (b.indent ? `${b.indent * 1.5}em` : undefined);
@@ -85,7 +75,8 @@
85
75
 
86
76
  {#snippet list(node: ListNode)}
87
77
  {#if node.type === 'md:ol'}
88
- <ol>{@render items(node)}</ol>
78
+ {@const start = olStart(node.items[0]?.block.props)}
79
+ <ol start={start === 1 ? undefined : start}>{@render items(node)}</ol>
89
80
  {:else}
90
81
  <ul class:todo={node.type === 'md:todo'}>{@render items(node)}</ul>
91
82
  {/if}
@@ -140,47 +131,25 @@
140
131
  {/snippet}
141
132
 
142
133
  <div class="preview" class:nested>
143
- {@render run(open)}
144
- {#if locked.length}
145
- <div class="supporters" aria-label={tr('For supporters only')}>
146
- <p class="supporters-label">{tr('For supporters only')}</p>
147
- {@render run(locked)}
148
- </div>
149
- {/if}
134
+ {@render run(blocks)}
150
135
  </div>
151
136
 
152
- <style>
153
- /* Only what the renderer itself adds; the article typography comes from the page that shows it. */
154
- .supporters {
155
- margin-top: 1.5em;
156
- padding-top: 1em;
157
- border-top: 2px dashed #f96854;
158
- }
159
- .supporters-label {
160
- font: 600 11px/1 system-ui, sans-serif;
161
- letter-spacing: 0.08em;
162
- text-transform: uppercase;
163
- color: #f96854;
164
- }
165
- .preview :global(ul.todo) {
166
- list-style: none;
167
- padding-left: 0.2em;
168
- }
169
- .preview :global(ul.todo input) {
170
- margin-right: 0.5em;
171
- }
172
- .preview :global(img) {
173
- max-width: 100%;
174
- }
175
- /* The content starts at its top edge, whatever its first block is (pages often align it with a sidebar). */
176
- .preview:not(.nested) > :global(:first-child),
177
- .preview:not(.nested) > :global(:first-child > :first-child) {
178
- margin-top: 0;
179
- }
180
- /* Floating images and side boxes stay inside the content. */
181
- .preview:not(.nested)::after {
182
- content: '';
183
- display: block;
184
- clear: both;
185
- }
186
- </style>
137
+ <style>.preview :global(ul.todo) {
138
+ list-style: none;
139
+ padding-left: 0.2em;
140
+ }
141
+ .preview :global(ul.todo input) {
142
+ margin-right: 0.5em;
143
+ }
144
+ .preview :global(img) {
145
+ max-width: 100%;
146
+ }
147
+ .preview:not(.nested) > :global(:first-child),
148
+ .preview:not(.nested) > :global(:first-child > :first-child) {
149
+ margin-top: 0;
150
+ }
151
+ .preview:not(.nested)::after {
152
+ content: "";
153
+ display: block;
154
+ clear: both;
155
+ }</style>
@@ -1,4 +1,4 @@
1
- import type { StoredBlock } from './types';
1
+ import { type StoredBlock } from './types';
2
2
  /** Consecutive list blocks as properly nested lists: an item one level deeper goes into the previous item. */
3
3
  export type ListNode = {
4
4
  type: string;
@@ -15,20 +15,15 @@ export type Part = {
15
15
  lists: ListNode[];
16
16
  };
17
17
  export declare function parts(blocks: StoredBlock[]): Part[];
18
- import { type MediaItem } from './media';
19
18
  import { type Messages } from './i18n';
20
19
  import type { BlockPlugin } from './types';
21
20
  interface Props {
22
21
  blocks: StoredBlock[];
23
22
  plugins: BlockPlugin[];
24
- /** Set by plugins rendering their nested blocks; only the top level splits at the supporters-only divider. */
23
+ /** Set by plugins rendering their nested blocks (a container's preview). */
25
24
  nested?: boolean;
26
- /** The images blocks refer to by id; nested previews share their parent's. */
27
- media?: MediaItem[];
28
25
  /** UI text in the host's language; nested previews share their parent's. */
29
26
  messages?: Messages;
30
- /** Mark the blocks after a gate (the supporters-only divider) with a label and a dashed line; default: yes. */
31
- markGated?: boolean;
32
27
  }
33
28
  declare const Preview: import("svelte").Component<Props, {}, "">;
34
29
  type Preview = ReturnType<typeof Preview>;
@@ -1,31 +1,27 @@
1
- <!-- The fields of a plugin block, on the top of its box. Closed: only a small header (the block's tag); a click on it
2
- opens the fields in place: a wrapping row (label and control side by side), text areas on their own full-width rows. An image field shows the picked image(s); a click
3
- opens the picker (ImagePicker) with all the document's images. -->
1
+ <!-- The fields of a plugin block (or a code block), on the top of its box. Closed: only a small header (the block's
2
+ tag); a click on it opens the fields in place: a wrapping row (label and control side by side), text areas on their
3
+ own full-width rows. -->
4
4
  <script lang="ts">
5
- import { ColorPicker, Input, NativeSelect, Slider, Textarea, getPopupManager } from '@atom-forge/ui';
5
+ import { ColorPicker, Input, NativeSelect, Slider, Textarea } from '@atom-forge/ui';
6
6
  import ChevronDown from '@lucide/svelte/icons/chevron-down';
7
7
  import { slide } from 'svelte/transition';
8
- import ImagePicker from './ImagePicker.svelte';
9
8
  import { useView } from './view';
10
9
  import type { Translate } from './i18n';
11
- import type { MediaItem } from './media';
12
10
  import type { Block, PropField } from './types';
13
11
 
14
12
  interface Props {
15
13
  block: Block;
16
14
  tag: string;
17
15
  fields: PropField[];
18
- media: MediaItem[];
19
16
  tr: Translate;
20
17
  /** Sets one property from its field's text and returns the text as it is now kept. */
21
18
  setProp: (b: Block, f: PropField, raw: string) => string;
22
19
  /** Sets one property to a value as it is ('' or undefined removes it). */
23
20
  setValue: (b: Block, key: string, v: unknown) => void;
24
- pickMedia: (b: Block, f: PropField, id: string) => void;
25
21
  /** Start with the fields open (a block just made). */
26
22
  open?: boolean;
27
23
  }
28
- let { block, tag, fields, media, tr, setProp, setValue, pickMedia, open: startOpen = false }: Props = $props();
24
+ let { block, tag, fields, tr, setProp, setValue, open: startOpen = false }: Props = $props();
29
25
  // svelte-ignore state_referenced_locally
30
26
  let open = $state(startOpen);
31
27
 
@@ -33,10 +29,18 @@
33
29
  const v = block.props?.[f.key];
34
30
  return f.format ? f.format(v) : v == null ? '' : String(v);
35
31
  };
36
- const picked = (f: PropField): string[] => {
32
+ /** A text field's placeholder: its label, and its own placeholder (an example) after it. */
33
+ const hint = (f: PropField) => [tr(f.label), f.placeholder && tr(f.placeholder)].filter(Boolean).join(' · ');
34
+ /** Toggles: the values switched on, kept in the order of the options. */
35
+ const toggled = (f: PropField): string[] => {
37
36
  const v = block.props?.[f.key];
38
- return Array.isArray(v) ? v : v ? [String(v)] : [];
37
+ return Array.isArray(v) ? v.map(String) : [];
39
38
  };
39
+ function toggle(f: PropField, value: string) {
40
+ const on = new Set(toggled(f));
41
+ if (!on.delete(value)) on.add(value);
42
+ setValue(block, f.key, (f.options ?? []).map((o) => o.value).filter((x) => on.has(x)));
43
+ }
40
44
  const optionLabel = (f: PropField, i: number) => {
41
45
  const opt = f.options?.[i];
42
46
  return opt ? tr(opt.label, { n: opt.value }) : '';
@@ -107,57 +111,68 @@
107
111
 
108
112
  const short = $derived(fields.filter((f) => f.kind !== 'textarea'));
109
113
  const long = $derived(fields.filter((f) => f.kind === 'textarea'));
110
-
111
- const popups = getPopupManager();
112
- const urlOf = (id: string) => media.find((m) => m.id === id);
113
- function openPicker(e: MouseEvent, f: PropField) {
114
- popups.open.component(
115
- ImagePicker,
116
- { media, multiple: f.kind === 'images', getPicked: () => picked(f), pick: (id: string) => pickMedia(block, f, id), tr },
117
- { anchor: e, align: 'left' }
118
- );
119
- }
120
114
  </script>
121
115
 
122
- <div class="light bar" class:open>
123
- <button type="button" class="head" aria-expanded={open} title={tr(open ? 'Hide settings' : 'Settings')} onmousedown={(e) => e.preventDefault()} onclick={() => (open = !open)}>
124
- <span class="tag">{tag}</span>
125
- <ChevronDown size={13} class="chev" />
116
+ <div
117
+ data-fields
118
+ class="grid gap-1.5 rounded-lg font-sans text-xs leading-[1.4] text-surface-contrast transition-[padding,background] duration-[180ms] {open
119
+ ? 'mb-2 justify-items-stretch bg-[rgb(120_120_140/0.07)] px-2 pt-1.5 pb-2'
120
+ : 'mb-1 justify-items-start'}"
121
+ >
122
+ <button type="button" data-fields-head class="group flex cursor-pointer items-center gap-0.5 justify-self-start border-0 bg-transparent p-0 text-muted-contrast" aria-expanded={open} title={tr(open ? 'Hide settings' : 'Settings')} onmousedown={(e) => e.preventDefault()} onclick={() => (open = !open)}>
123
+ <span class="rounded-[5px] bg-[rgb(120_120_140/0.12)] px-1.5 py-px font-mono text-[11px] leading-[1.5] font-medium whitespace-nowrap text-muted-contrast select-none group-hover:bg-[rgb(120_120_140/0.2)] group-hover:text-surface-contrast">{tag}</span>
124
+ <ChevronDown size={13} class="transition-[rotate] duration-150 {open ? 'rotate-180' : ''}" />
126
125
  </button>
127
126
  {#if open}
128
- <div class="body" transition:slide={{ duration: 180 }}>
129
- <div class="grid">
127
+ <div transition:slide={{ duration: 180 }}>
128
+ <!-- the fields in columns as wide as the box allows; text fields carry their label as placeholder, the others a short
129
+ label beside the control; the bar's small type in the controls too -->
130
+ <div class="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-x-2.5 gap-y-1.5 pt-1.5 [&_input:not([type=range])]:text-[12px] [&_select]:text-[12px] [&_textarea]:text-[12px]">
130
131
  {#each short as f (f.key)}
131
- {#if f.kind === 'image' || f.kind === 'images'}
132
- {@const ids = picked(f)}
133
- <div class="field">
134
- <span class="name">{tr(f.label)}</span>
135
- <button type="button" class="chosen" title={tr('Choose')} onclick={(e) => openPicker(e, f)}>
136
- {#each ids as id, n (id)}
137
- {@const m = urlOf(id)}
138
- {#if m}<img src={m.url} alt={m.name} />{:else}<span class="missing" title={id}>?</span>{/if}
139
- {#if f.kind === 'images' && n === 5 && ids.length > 6}<span class="more">+{ids.length - 6}</span>{/if}
140
- {:else}
141
- <span class="empty">{tr('Choose…')}</span>
142
- {/each}
143
- </button>
144
- </div>
132
+ {#if f.kind === 'text'}
133
+ <!-- the field shows the value as kept (a YouTube link becomes its id); its label is the placeholder -->
134
+ <label class="grid min-w-0 content-start" title={tr(f.label)}>
135
+ <span class="sr-only">{tr(f.label)}</span>
136
+ <Input small monospace placeholder={hint(f)} bind:value={() => text(f), (v: string) => setProp(block, f, v)} />
137
+ </label>
145
138
  {:else}
146
- <!-- not a label around buttons (a slider's view buttons, a colour's clear): a click on a label goes to its first button -->
147
- <svelte:element this={f.kind === 'slider' || f.kind === 'color' ? 'div' : 'label'} class="field">
148
- <span class="name"
149
- >{tr(f.label)}{#if viewLabel(f)}<span class="view" class:own={current(f).mine}>{viewLabel(f)}</span>{/if}</span
139
+ <!-- not a label around buttons (a slider's view buttons, a colour's clear, toggles): a click on a label goes to its first button -->
140
+ <svelte:element this={f.kind === 'select' ? 'label' : 'div'} class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] content-start items-center gap-2" role={f.kind === 'toggles' ? 'group' : undefined} aria-label={f.kind === 'toggles' ? tr(f.label) : undefined}>
141
+ <span class="text-[11.5px] font-semibold whitespace-nowrap text-muted-contrast select-none"
142
+ >{tr(f.label)}{#if viewLabel(f)}<!-- the view a per-view field edits; dashed while it only takes a wider view's value --><span
143
+ class="ml-1.5 rounded border px-[5px] font-medium {current(f).mine ? 'border-solid border-surface-contrast text-surface-contrast' : 'border-dashed border-frame text-muted-contrast'}"
144
+ >{viewLabel(f)}</span
145
+ >{/if}</span
150
146
  >
151
147
  {#if f.kind === 'select'}
152
148
  <NativeSelect
149
+ small
153
150
  options={(f.options ?? []).map((opt) => ({ value: opt.value, label: tr(opt.label, { n: opt.value }) }))}
154
151
  bind:value={() => text(f), (v: string) => setProp(block, f, v)}
155
152
  />
153
+ {:else if f.kind === 'toggles'}
154
+ {@const on = toggled(f)}
155
+ <span class="inline-flex gap-px justify-self-start rounded-[7px] bg-muted p-0.5">
156
+ {#each f.options ?? [] as opt (opt.value)}
157
+ <button
158
+ type="button"
159
+ class="inline-grid h-6 min-w-[26px] cursor-pointer place-items-center rounded-[5px] border-0 px-1 {on.includes(opt.value)
160
+ ? 'bg-surface text-accent shadow-[0_1px_2px_rgb(0_0_0/0.08)]'
161
+ : 'bg-transparent text-muted-contrast hover:text-surface-contrast'}"
162
+ aria-pressed={on.includes(opt.value)}
163
+ title={tr(opt.label)}
164
+ aria-label={tr(opt.label)}
165
+ onmousedown={(e) => e.preventDefault()}
166
+ onclick={() => toggle(f, opt.value)}
167
+ >{#if opt.icon}<opt.icon size={15} />{:else}{tr(opt.label)}{/if}</button
168
+ >
169
+ {/each}
170
+ </span>
156
171
  {:else if f.kind === 'slider'}
157
- <span class="line">
158
- <span class="slider">
172
+ <span class="flex min-w-0 items-center gap-2">
173
+ <span class="block min-w-20 flex-1">
159
174
  <Slider
160
- compact
175
+ small
161
176
  drawStops={!free(f) && (f.options?.length ?? 0) <= 10}
162
177
  min={free(f) ? low(f) : 0}
163
178
  max={free(f) ? f.max : (f.options?.length ?? 1) - 1}
@@ -165,35 +180,34 @@
165
180
  bind:value={() => pos(f), (v: number) => setPos(f, v)}
166
181
  />
167
182
  </span>
168
- <span class="value">{posLabel(f)}</span>
183
+ <span class="min-w-[4.5em] whitespace-nowrap text-surface-contrast tabular-nums">{posLabel(f)}</span>
169
184
  </span>
170
185
  {:else if f.kind === 'color'}
171
186
  <!-- with `none`: an unset colour shows as "none" (a click sets one); a set one can be cleared -->
172
- <span class="line">
187
+ <span class="flex min-w-0 items-center gap-2">
173
188
  {#if f.none && !text(f)}
174
- <button type="button" class="nocolor" onclick={() => setProp(block, f, f.placeholder || '#000000')}>
175
- <span class="swatch" aria-hidden="true"></span>{tr(f.none)}
189
+ <button type="button" class="flex h-[30px] cursor-pointer items-center gap-1.5 rounded-[7px] border border-dashed border-frame bg-surface pr-2.5 pl-1.5 text-muted-contrast hover:border-accent hover:text-surface-contrast" onclick={() => setProp(block, f, f.placeholder || '#000000')}>
190
+ <!-- an empty swatch, struck through -->
191
+ <span class="size-4 rounded border border-frame bg-[linear-gradient(to_top_right,transparent_calc(50%-1px),#e5484d_calc(50%-1px),#e5484d_calc(50%+1px),transparent_calc(50%+1px))]" aria-hidden="true"></span>{tr(f.none)}
176
192
  </button>
177
193
  {:else}
178
- <ColorPicker bind:value={() => text(f) || '#000000', (v: string) => setProp(block, f, v)} />
194
+ <ColorPicker small bind:value={() => text(f) || '#000000', (v: string) => setProp(block, f, v)} />
179
195
  {#if f.none}
180
- <button type="button" class="clear" title={tr(f.none)} aria-label={tr(f.none)} onclick={() => setProp(block, f, '')}>×</button>
196
+ <button type="button" class="size-[22px] cursor-pointer rounded-md border-0 bg-transparent p-0 text-base leading-none text-muted-contrast hover:bg-[rgb(120_120_140/0.14)] hover:text-surface-contrast" title={tr(f.none)} aria-label={tr(f.none)} onclick={() => setProp(block, f, '')}>×</button>
181
197
  {/if}
182
198
  {/if}
183
199
  </span>
184
- {:else}
185
- <!-- the field shows the value as kept (a YouTube link becomes its id) -->
186
- <Input monospace placeholder={f.placeholder && tr(f.placeholder)} bind:value={() => text(f), (v: string) => setProp(block, f, v)} />
187
200
  {/if}
188
201
  </svelte:element>
189
202
  {/if}
190
203
  {/each}
191
204
  {#each long as f (f.key)}
192
- <label class="field long" onfocusin={() => (editing = { key: f.key, text: text(f) })} onfocusout={() => (editing = null)}>
193
- <span class="name">{tr(f.label)}</span>
205
+ <label class="col-span-full grid min-w-0 content-start" title={tr(f.label)} onfocusin={() => (editing = { key: f.key, text: text(f) })} onfocusout={() => (editing = null)}>
206
+ <span class="sr-only">{tr(f.label)}</span>
194
207
  <Textarea
195
- rows={3}
196
- placeholder={f.placeholder && tr(f.placeholder)}
208
+ small
209
+ rows={2}
210
+ placeholder={hint(f)}
197
211
  bind:value={() => (editing?.key === f.key ? editing.text : text(f)), (v: string) => {
198
212
  if (editing?.key === f.key) editing.text = v;
199
213
  setProp(block, f, v);
@@ -205,191 +219,3 @@
205
219
  </div>
206
220
  {/if}
207
221
  </div>
208
-
209
- <style>
210
- .bar {
211
- display: grid;
212
- justify-items: start;
213
- gap: 6px;
214
- margin-bottom: 4px;
215
- border-radius: 8px;
216
- font: 12px/1.4 system-ui, sans-serif;
217
- color: #1d1d1f;
218
- }
219
- .bar.open {
220
- justify-items: stretch;
221
- margin-bottom: 8px;
222
- padding: 8px 12px 12px;
223
- background: rgba(120, 120, 140, 0.07);
224
- }
225
- .bar {
226
- transition:
227
- padding 0.18s,
228
- background 0.18s;
229
- }
230
- /* the fields in 1–3 columns (as wide as the box allows), each with its label above */
231
- .grid {
232
- display: grid;
233
- grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
234
- gap: 12px 16px;
235
- padding-top: 8px;
236
- }
237
- .head {
238
- display: flex;
239
- align-items: center;
240
- gap: 2px;
241
- padding: 0;
242
- border: 0;
243
- background: none;
244
- color: #8e8e96;
245
- cursor: pointer;
246
- justify-self: start;
247
- }
248
- .head:hover .tag {
249
- background: rgba(120, 120, 140, 0.2);
250
- color: #1d1d1f;
251
- }
252
- .head :global(.chev) {
253
- transition: rotate 0.15s;
254
- }
255
- .open .head :global(.chev) {
256
- rotate: 180deg;
257
- }
258
- .tag {
259
- padding: 1px 6px;
260
- border-radius: 5px;
261
- background: rgba(120, 120, 140, 0.12);
262
- color: #6e6e76;
263
- font: 500 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
264
- white-space: nowrap;
265
- user-select: none;
266
- }
267
- .field {
268
- display: grid;
269
- align-content: start;
270
- gap: 4px;
271
- min-width: 0;
272
- }
273
- /* text areas take a whole row */
274
- .field.long {
275
- grid-column: 1 / -1;
276
- }
277
- .line {
278
- display: flex;
279
- align-items: center;
280
- gap: 8px;
281
- min-width: 0;
282
- }
283
- .name {
284
- font-size: 11.5px;
285
- font-weight: 600;
286
- color: #8e8e96;
287
- white-space: nowrap;
288
- user-select: none;
289
- }
290
- /* the view a per-view field edits (the host's current one); dashed while it only takes a wider view's value */
291
- .view {
292
- margin-left: 6px;
293
- padding: 0 5px;
294
- border: 1px dashed #c8c8d0;
295
- border-radius: 4px;
296
- color: #6e6e76;
297
- font-weight: 500;
298
- }
299
- .view.own {
300
- border-style: solid;
301
- border-color: #1d1d1f;
302
- color: #1d1d1f;
303
- }
304
- .slider {
305
- display: block;
306
- flex: 1;
307
- min-width: 80px;
308
- }
309
- .value {
310
- min-width: 4.5em;
311
- color: #3a3a40;
312
- font-variant-numeric: tabular-nums;
313
- white-space: nowrap;
314
- }
315
- .nocolor {
316
- display: flex;
317
- align-items: center;
318
- gap: 6px;
319
- height: 30px;
320
- padding: 0 10px 0 6px;
321
- border: 1px dashed #c8c8d0;
322
- border-radius: 7px;
323
- background: #fff;
324
- color: #8e8e96;
325
- cursor: pointer;
326
- }
327
- .nocolor:hover {
328
- border-color: var(--color-accent, #ea580c);
329
- color: #3a3a40;
330
- }
331
- /* an empty swatch, struck through */
332
- .swatch {
333
- width: 16px;
334
- height: 16px;
335
- border: 1px solid #c8c8d0;
336
- border-radius: 4px;
337
- background: linear-gradient(to top right, transparent calc(50% - 1px), #e5484d calc(50% - 1px), #e5484d calc(50% + 1px), transparent calc(50% + 1px));
338
- }
339
- .clear {
340
- width: 22px;
341
- height: 22px;
342
- padding: 0;
343
- border: 0;
344
- border-radius: 6px;
345
- background: none;
346
- color: #8e8e96;
347
- font-size: 16px;
348
- line-height: 1;
349
- cursor: pointer;
350
- }
351
- .clear:hover {
352
- background: rgba(120, 120, 140, 0.14);
353
- color: #1d1d1f;
354
- }
355
- .chosen {
356
- display: flex;
357
- align-items: center;
358
- gap: 3px;
359
- min-height: 30px;
360
- padding: 2px;
361
- border: 1px solid #dcdce2;
362
- border-radius: 7px;
363
- background: #fff;
364
- cursor: pointer;
365
- }
366
- .chosen:hover {
367
- border-color: var(--color-accent, #ea580c);
368
- }
369
- /* images after the sixth are a "+n" */
370
- .chosen > img:nth-of-type(n + 7) {
371
- display: none;
372
- }
373
- .chosen img,
374
- .chosen .missing {
375
- width: 40px;
376
- height: 24px;
377
- object-fit: cover;
378
- border-radius: 4px;
379
- }
380
- .chosen .missing {
381
- display: grid;
382
- place-items: center;
383
- background: #eee;
384
- color: #8e8e96;
385
- }
386
- .chosen .more {
387
- padding: 0 4px;
388
- color: #6e6e76;
389
- font-size: 11px;
390
- }
391
- .chosen .empty {
392
- padding: 0 8px;
393
- color: #8e8e96;
394
- }
395
- </style>
@@ -1,17 +1,14 @@
1
1
  import type { Translate } from './i18n';
2
- import type { MediaItem } from './media';
3
2
  import type { Block, PropField } from './types';
4
3
  interface Props {
5
4
  block: Block;
6
5
  tag: string;
7
6
  fields: PropField[];
8
- media: MediaItem[];
9
7
  tr: Translate;
10
8
  /** Sets one property from its field's text and returns the text as it is now kept. */
11
9
  setProp: (b: Block, f: PropField, raw: string) => string;
12
10
  /** Sets one property to a value as it is ('' or undefined removes it). */
13
11
  setValue: (b: Block, key: string, v: unknown) => void;
14
- pickMedia: (b: Block, f: PropField, id: string) => void;
15
12
  /** Start with the fields open (a block just made). */
16
13
  open?: boolean;
17
14
  }
package/dist/code.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ /** A language: its value is stored in the block (`props.lang`, the opening fence's info string). */
2
+ export interface CodeLanguage {
3
+ value: string;
4
+ /** Shown in the dropdown; defaults to the value. */
5
+ label?: string;
6
+ }
7
+ type Get = () => CodeLanguage[];
8
+ /** Call in a component's init (around the editor). Without its own list, a nested editor keeps its parent's. */
9
+ export declare function provideCodeLanguages(own: () => (string | CodeLanguage)[] | undefined): void;
10
+ /** Call in a component's init; the returned function reads the current list (empty: free text). */
11
+ export declare const useCodeLanguages: () => Get;
12
+ export {};
package/dist/code.js ADDED
@@ -0,0 +1,15 @@
1
+ // The languages a code block offers. Without a list (the default) its language is free text; with one, the field is a
2
+ // dropdown of these. Which languages make sense is the host's business (what its site can highlight).
3
+ import { getContext, setContext } from 'svelte';
4
+ const KEY = 'brickee:code-languages';
5
+ const normalize = (list) => list.map((l) => (typeof l === 'string' ? { value: l } : l));
6
+ /** Call in a component's init (around the editor). Without its own list, a nested editor keeps its parent's. */
7
+ export function provideCodeLanguages(own) {
8
+ const parent = getContext(KEY);
9
+ setContext(KEY, () => {
10
+ const list = own();
11
+ return list ? normalize(list) : (parent?.() ?? []);
12
+ });
13
+ }
14
+ /** Call in a component's init; the returned function reads the current list (empty: free text). */
15
+ export const useCodeLanguages = () => getContext(KEY) ?? (() => []);
package/dist/dom.d.ts CHANGED
@@ -2,6 +2,14 @@
2
2
  export declare function rich(node: HTMLElement, value: string): {
3
3
  update(v: string): void;
4
4
  };
5
+ /**
6
+ * Svelte action: moves the element to the end of the body. A floating menu positioned with `position: fixed` is then
7
+ * placed against the viewport even when the editor sits in a transformed parent (an animated drawer, a dialog), where
8
+ * `fixed` would be relative to that parent.
9
+ */
10
+ export declare function portal(node: HTMLElement): {
11
+ destroy: () => void;
12
+ };
5
13
  export declare function serialize(frag: DocumentFragment): string;
6
14
  /** Returns '' for html that has no visible text (e.g. `<b></b>`, `<br>`). */
7
15
  export declare function clean(h: string): string;