@quillmark/svelte 0.4.0 → 0.4.1

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.
@@ -4,10 +4,6 @@ import type { Content } from '@quillmark/wasm';
4
4
  export declare function codePoints(s: string): string[];
5
5
  /** USV length of `s` (code points, not UTF-16 units). */
6
6
  export declare function usvLength(s: string): number;
7
- interface DecodeOpts {
8
- /** Strip all marks (a `plaintext` field). */
9
- plaintext?: boolean;
10
- }
11
7
  /**
12
8
  * A `Content` as read-only DOM: decode under `schema`, then the nodes' own `toDOM`.
13
9
  * The rendering half of the codec's job with no editing attached (no PM view, no
@@ -18,7 +14,6 @@ interface DecodeOpts {
18
14
  * `DOMSerializer.fromSchema` memoizes on the schema, so repeat calls build no
19
15
  * serializer.
20
16
  */
21
- export declare function renderContent(rt: Content, schema: Schema, opts?: DecodeOpts): Node;
17
+ export declare function renderContent(rt: Content, schema: Schema): Node;
22
18
  /** Decode a `Content` to a PM document under `schema`. */
23
- export declare function decode(rt: Content, schema: Schema, opts?: DecodeOpts): PMNode;
24
- export {};
19
+ export declare function decode(rt: Content, schema: Schema): PMNode;
@@ -10,7 +10,7 @@
10
10
  import { DOMSerializer } from 'prosemirror-model';
11
11
  import { ISLAND_SLOT } from './islands.js';
12
12
  import { descriptorOf, markKey, pmMarkFromContent } from './marks.js';
13
- import { isInlineSchema } from './schema.js';
13
+ import { hasMarks, isInlineSchema } from './schema.js';
14
14
  import { isAnchorMark, isCodeLine, isHeadingLine, isListItemContainer } from '@quillmark/wasm';
15
15
  /** Code points of `s` (USV units): the iteration granularity the content speaks. */
16
16
  export function codePoints(s) {
@@ -33,11 +33,11 @@ export function usvLength(s) {
33
33
  * `DOMSerializer.fromSchema` memoizes on the schema, so repeat calls build no
34
34
  * serializer.
35
35
  */
36
- export function renderContent(rt, schema, opts = {}) {
37
- return DOMSerializer.fromSchema(schema).serializeFragment(decode(rt, schema, opts).content);
36
+ export function renderContent(rt, schema) {
37
+ return DOMSerializer.fromSchema(schema).serializeFragment(decode(rt, schema).content);
38
38
  }
39
39
  /** Decode a `Content` to a PM document under `schema`. */
40
- export function decode(rt, schema, opts = {}) {
40
+ export function decode(rt, schema) {
41
41
  const lineTexts = rt.text.split('\n');
42
42
  // Per-line USV start: line i begins after all earlier lines and their `\n`s.
43
43
  const starts = [];
@@ -46,7 +46,11 @@ export function decode(rt, schema, opts = {}) {
46
46
  starts.push(acc);
47
47
  acc += usvLength(lineTexts[i]) + 1; // +1 for the `\n` boundary
48
48
  }
49
- const marks = opts.plaintext ? [] : rt.marks.filter((m) => !isAnchorMark(m));
49
+ // A mark-free schema (`plaintextSchema`) has nothing to apply them with, and a
50
+ // plaintext field that acquired one anyway — an older build's popover, a hand-
51
+ // written document — is exactly the content that must open and then heal on the
52
+ // next commit, not throw here.
53
+ const marks = hasMarks(schema) ? rt.marks.filter((m) => !isAnchorMark(m)) : [];
50
54
  const cursor = { i: 0, rt };
51
55
  if (isInlineSchema(schema)) {
52
56
  return decodeInline(rt, schema, lineTexts, starts, marks, cursor);
@@ -16,7 +16,8 @@ export interface CreateFieldOpts {
16
16
  container: HTMLElement;
17
17
  /** Constrained single-textblock schema (a `richtext(inline)` field). */
18
18
  inline?: boolean;
19
- /** Inline + marks/islands stripped (a `plaintext` field). Implies `inline`. */
19
+ /** The mark-free inline schema (a `plaintext` field): literal text, no formatting
20
+ * and no anchors. Implies `inline`. */
20
21
  plaintext?: boolean;
21
22
  /** Suppress the markdown-shorthand input rules. */
22
23
  noInputRules?: boolean;
@@ -145,15 +146,13 @@ export declare function createField(opts: CreateFieldOpts): FieldController;
145
146
  * last the two that answer to a caret or a selection beside a block: the gap cursor
146
147
  * and {@link pastAtomPlugin}.
147
148
  *
148
- * A `plaintext` field carries no marks (decode strips them, the keymap suppresses
149
- * Mod-b/i/u), so it also skips the input rules: `inlineSchema` still declares the
150
- * mark types, so a `**bold**` rule would apply a strong mark and eat the literal
151
- * delimiters. The inline schema has no block nodes, so those rules are the only
152
- * ones it would add: skip all.
149
+ * Every mark-shaped plugin reads the schema rather than a flag: over
150
+ * `plaintextSchema` the shorthand rules build nothing (each is guarded on its mark
151
+ * type) and the toggles bind no key, so a `**bold**` keeps the delimiters its author
152
+ * typed without a second rule saying so.
153
153
  */
154
154
  export declare function proseLeafPlugins(schema: Schema, opts: {
155
155
  inline: boolean;
156
- plaintext: boolean;
157
156
  /** The slash menu's report channel; absent mounts no menu and leaves
158
157
  * Enter/Escape/the arrows to the links below (`keymap.ts`). */
159
158
  slash?: (state: SlashState | undefined) => void;
@@ -27,7 +27,7 @@ import { anchorsFromContent } from './marks.js';
27
27
  import { createReconciler } from './reconcile.js';
28
28
  import { inputRulesPlugin } from './inputrules.js';
29
29
  import { bodyKeymap } from './keymap.js';
30
- import { blockSchema, inlineSchema } from './schema.js';
30
+ import { blockSchema, inlineSchema, plaintextSchema } from './schema.js';
31
31
  import { DEFAULT_TABLE_STRINGS, tableNodeView } from './table-view.js';
32
32
  import { focusSlashItem, runSlashItem, slashPlugin } from './slash.js';
33
33
  const anchorKey = new PluginKey('quill-anchors');
@@ -108,7 +108,10 @@ export function createField(opts) {
108
108
  const { doc, addr, container } = opts;
109
109
  const inline = !!opts.inline || !!opts.plaintext;
110
110
  const plaintext = !!opts.plaintext;
111
- const schema = inline ? inlineSchema : blockSchema;
111
+ // The declared type picks the schema, and the schema is the whole of what
112
+ // `plaintext` suppresses: no mark types to toggle, to paste in, or to mint a
113
+ // shorthand with (`schema.ts`).
114
+ const schema = plaintext ? plaintextSchema : inline ? inlineSchema : blockSchema;
112
115
  // Bound once and held: the reader is a `{quill, doc}` pair that reads live, so
113
116
  // every read below sees the commit before it.
114
117
  const reader = opts.quill.reader(doc);
@@ -176,7 +179,7 @@ export function createField(opts) {
176
179
  }
177
180
  });
178
181
  function buildState(rt) {
179
- const pmDoc = decode(rt, schema, { plaintext });
182
+ const pmDoc = decode(rt, schema);
180
183
  const anchors = plaintext ? [] : anchorsFromContent(rt);
181
184
  // Seed anchor plugin positions in PM coords via a fresh index over pmDoc.
182
185
  const seedIndex = buildLineIndex(pmDoc);
@@ -188,7 +191,6 @@ export function createField(opts) {
188
191
  doc: pmDoc,
189
192
  plugins: proseLeafPlugins(schema, {
190
193
  inline,
191
- plaintext,
192
194
  slash,
193
195
  noInputRules: opts.noInputRules,
194
196
  // Always installed, so a leaf that mounts without a ghost can still be
@@ -368,19 +370,18 @@ export function createField(opts) {
368
370
  * last the two that answer to a caret or a selection beside a block: the gap cursor
369
371
  * and {@link pastAtomPlugin}.
370
372
  *
371
- * A `plaintext` field carries no marks (decode strips them, the keymap suppresses
372
- * Mod-b/i/u), so it also skips the input rules: `inlineSchema` still declares the
373
- * mark types, so a `**bold**` rule would apply a strong mark and eat the literal
374
- * delimiters. The inline schema has no block nodes, so those rules are the only
375
- * ones it would add: skip all.
373
+ * Every mark-shaped plugin reads the schema rather than a flag: over
374
+ * `plaintextSchema` the shorthand rules build nothing (each is guarded on its mark
375
+ * type) and the toggles bind no key, so a `**bold**` keeps the delimiters its author
376
+ * typed without a second rule saying so.
376
377
  */
377
378
  export function proseLeafPlugins(schema, opts) {
378
379
  const list = [history(), ...(opts.afterHistory ?? [])];
379
380
  if (opts.slash)
380
381
  list.push(slashPlugin(opts.slash));
381
- if (!opts.noInputRules && !opts.plaintext)
382
+ if (!opts.noInputRules)
382
383
  list.push(inputRulesPlugin(schema));
383
- list.push(keymap(editorKeymap(schema, opts.inline, opts.plaintext, !!opts.slash)));
384
+ list.push(keymap(editorKeymap(schema, opts.inline, !!opts.slash)));
384
385
  list.push(keymap(baseKeymap));
385
386
  // All three answer to something only a block leaf holds: an inline leaf is one
386
387
  // textblock with no island, no gap to put a cursor in, and nowhere for a pasted
@@ -460,7 +461,8 @@ function placeholderPlugin(read) {
460
461
  }
461
462
  /**
462
463
  * The field's keymap: history, mark toggles, and the body's structural keys; Enter
463
- * suppressed inline.
464
+ * suppressed inline. The toggles are bound per mark type the schema declares, so a
465
+ * plaintext leaf leaves Mod-b/i/u to the browser.
464
466
  *
465
467
  * Tab forks on the leaf's role, not on the caret's position. An inline/plaintext
466
468
  * leaf is a form field: Tab stays unbound, so field navigation is open for a shell
@@ -468,20 +470,18 @@ function placeholderPlugin(read) {
468
470
  * is structural, a chain each nested surface prepends to (`keymap.ts`). One key
469
471
  * never means two things within one surface.
470
472
  */
471
- function editorKeymap(schema, inline, plaintext, slash) {
473
+ function editorKeymap(schema, inline, slash) {
472
474
  const map = {
473
475
  'Mod-z': undo,
474
476
  'Mod-y': redo,
475
477
  'Shift-Mod-z': redo
476
478
  };
477
- if (!plaintext) {
478
- if (schema.marks.strong)
479
- map['Mod-b'] = toggleMark(schema.marks.strong);
480
- if (schema.marks.em)
481
- map['Mod-i'] = toggleMark(schema.marks.em);
482
- if (schema.marks.underline)
483
- map['Mod-u'] = toggleMark(schema.marks.underline);
484
- }
479
+ if (schema.marks.strong)
480
+ map['Mod-b'] = toggleMark(schema.marks.strong);
481
+ if (schema.marks.em)
482
+ map['Mod-i'] = toggleMark(schema.marks.em);
483
+ if (schema.marks.underline)
484
+ map['Mod-u'] = toggleMark(schema.marks.underline);
485
485
  if (inline) {
486
486
  // One textblock only: swallow Enter so no second block is attempted.
487
487
  map['Enter'] = () => true;
@@ -9,7 +9,7 @@ export { DEFAULT_TABLE_STRINGS } from './table-view.js';
9
9
  export type { TableChromeStrings } from './table-view.js';
10
10
  export { DEFAULT_SLASH_STRINGS } from './slash.js';
11
11
  export type { SlashState, SlashStrings } from './slash.js';
12
- export { blockSchema, inlineSchema } from './schema.js';
12
+ export { blockSchema, inlineSchema, plaintextSchema, hasMarks } from './schema.js';
13
13
  export { decode, renderContent, usvLength } from './decode.js';
14
14
  export { pmToContent, contentEdit, lower } from './encode.js';
15
15
  export type { ContentEdit } from './encode.js';
@@ -20,8 +20,9 @@ export { DEFAULT_TABLE_STRINGS } from './table-view.js';
20
20
  // The slash menu: the codec owns the model and the picks, the chrome the words and
21
21
  // the pixels (`visual/SlashMenu.svelte` is the only off-barrel caller).
22
22
  export { DEFAULT_SLASH_STRINGS } from './slash.js';
23
- // Schemas (the decode/encode target; the VisualEditor mounts them).
24
- export { blockSchema, inlineSchema } from './schema.js';
23
+ // Schemas (the decode/encode target; the VisualEditor mounts them). `hasMarks` is
24
+ // what a mark surface asks before offering itself over a leaf.
25
+ export { blockSchema, inlineSchema, plaintextSchema, hasMarks } from './schema.js';
25
26
  // Decode / encode / positions (tests + VisualEditor).
26
27
  //
27
28
  // The barrel carries what has an off-barrel caller, and nothing else. A symbol
@@ -5,6 +5,13 @@
5
5
  // block rule with no guard of its own reuses `wrappingInputRule`, and the rest mirror
6
6
  // an upstream body around one. These produce transactions the codec already lowers:
7
7
  // they add no new lowering surface.
8
+ //
9
+ // A rule declines only where what it would mint is unrepresentable, or is a shape
10
+ // another gesture owns — never on how a quill renders it. What a quill typesets is not
11
+ // knowable here (a quill this build has not seen is the open-sets case, CODEC §"Open
12
+ // sets"), and a construct that arrives through `importMarkdown` must stay authorable or
13
+ // a document opens carrying a shape the editor refuses to make. So one guard survives:
14
+ // `- ` / `1. ` at the head of an existing item, where Tab owns the nesting.
8
15
  import { InputRule, inputRules, wrappingInputRule } from 'prosemirror-inputrules';
9
16
  import { canJoin, findWrapping } from 'prosemirror-transform';
10
17
  import { Selection } from 'prosemirror-state';
@@ -43,11 +50,10 @@ function markInputRule(regexp, markType, delimLen) {
43
50
  * `wrappingInputRule` (whose body this mirrors), plus one normalization: a
44
51
  * `heading` being wrapped becomes a `paragraph` first.
45
52
  *
46
- * `list_item` is `block+`, so `list_item > heading` is *representable* and a bare
47
- * wrap mints it — a shape markdown has no spelling for, so nothing downstream is
48
- * obliged to typeset it and the gesture that made it cannot be read back. This is
49
- * the wrap-side route into it; the `# ` rule guards the other, in
50
- * {@link markdownInputRules}.
53
+ * `list_item` is `block+`, so `list_item > heading` is *representable*, and `# `
54
+ * inside the item is the gesture that mints it ({@link markdownInputRules}). A wrap
55
+ * keeping the heading would be a second door onto that shape, so `- ` / `1. ` mint a
56
+ * paragraph item wherever they fire.
51
57
  *
52
58
  * `item` is the second guard, and it is about the gesture rather than the shape: the
53
59
  * rule declines at the start of an item's own first block ({@link openingAnItem}).
@@ -114,16 +120,13 @@ export function markdownInputRules(schema) {
114
120
  // Block shorthands (only where the schema has the node; the inline schema
115
121
  // omits them, so this stays a no-op there).
116
122
  if (schema.nodes.heading) {
117
- // `textblockTypeInputRule`'s body plus one guard: inside a list item, `# `
118
- // declines (null) and stays literal text, rather than minting the unrenderable
119
- // `list_item > heading` {@link listWrappingRule} normalizes away on the wrap side.
123
+ // `textblockTypeInputRule`'s body. It fires inside a list item like anywhere else:
124
+ // `list_item > heading` is a shape the content holds and `importMarkdown` produces
125
+ // from `- # title`, so a rule that declined there would refuse to author what a
126
+ // document can arrive carrying.
120
127
  const heading = schema.nodes.heading;
121
128
  rules.push(new InputRule(/^(#{1,6})\s$/, (state, match, start, end) => {
122
129
  const $start = state.doc.resolve(start);
123
- for (let d = $start.depth; d > 0; d--) {
124
- if (item && $start.node(d).type === item)
125
- return null;
126
- }
127
130
  if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), heading)) {
128
131
  return null;
129
132
  }
@@ -165,12 +168,6 @@ export function markdownInputRules(schema) {
165
168
  if ($start.parentOffset !== 0 || $start.parent.content.size !== end - start) {
166
169
  return null;
167
170
  }
168
- // The `# ` rule's guard, for its reason: `list_item` is `block+`, so a
169
- // divider inside an item is representable and renders to nothing.
170
- for (let d = $start.depth; d > 0; d--) {
171
- if (item && $start.node(d).type === item)
172
- return null;
173
- }
174
171
  if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), rule)) {
175
172
  return null;
176
173
  }
@@ -194,6 +191,8 @@ export function markdownInputRules(schema) {
194
191
  if (schema.nodes.ordered_list && schema.nodes.paragraph) {
195
192
  rules.push(listWrappingRule(/^(\d+)\.\s$/, schema.nodes.ordered_list, schema.nodes.paragraph, item, (match) => ({ start: +match[1] }), (match, node) => node.childCount + node.attrs.start === +match[1]));
196
193
  }
194
+ // No item guard: a quote wrapping an item's own first block is a container the
195
+ // content holds, where a list wrapping one is an item whose only content is an item.
197
196
  if (schema.nodes.blockquote) {
198
197
  rules.push(wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote));
199
198
  }
@@ -1,13 +1,22 @@
1
1
  import { Schema } from 'prosemirror-model';
2
2
  /** The full field schema: every block kind, container, mark, and island. */
3
3
  export declare const blockSchema: Schema<string, string>;
4
+ /** The constrained inline schema: one paragraph, no block splitting, no
5
+ * containers, no islands, and the full mark set (a `richtext(inline)` field, and
6
+ * a table cell, which is the same content unit). */
7
+ export declare const inlineSchema: Schema<string, string>;
4
8
  /**
5
- * The constrained inline schema: one paragraph, no block splitting, no
6
- * containers, no islands. `doc: "paragraph"` (exactly one child) is what makes an
7
- * Enter a no-op at the model level: there is no second block for a split to land.
8
- * Marks are present; `plaintext` fields strip them at decode and mount no
9
- * mark input (a schema-level distinction the field draws, per CODEC §Inline mode).
9
+ * The inline schema with no mark types at all: a `plaintext` field, whose value is
10
+ * literal text the boundary refuses to coerce back once it carries a mark.
11
+ *
12
+ * Declaring none is what makes that structural rather than a rule each path
13
+ * restates: `toggleMark` has no type to apply, the mark input rules build nothing,
14
+ * a paste parses its marks away, and `decode` drops any a stray content arrives
15
+ * carrying (CODEC §Inline mode).
10
16
  */
11
- export declare const inlineSchema: Schema<"text" | "doc" | "paragraph", string>;
17
+ export declare const plaintextSchema: Schema<string, never>;
18
+ /** Whether `schema` can carry formatting at all: false for {@link plaintextSchema}
19
+ * alone. What a mark command asks before it offers itself. */
20
+ export declare function hasMarks(schema: Schema): boolean;
12
21
  /** True for the constrained inline schema (no block containers); decode branches on it. */
13
22
  export declare function isInlineSchema(schema: Schema): boolean;
@@ -4,12 +4,13 @@
4
4
  // Each of the three open sets gets an inert carrier so an unrecognized value
5
5
  // survives a round-trip: the `unknown` mark, the paragraph's `unknown` attribute,
6
6
  // and the `unknown_container` node. `blockSchema` is the full field; `inlineSchema`
7
- // is the constrained single-textblock form for `richtext(inline)` / `plaintext`
8
- // (one paragraph, no block split, no containers, no islands): same decode/lower/
9
- // position machinery, narrower shape. Anchors are not marks here (decorations).
7
+ // is the constrained single-textblock form for `richtext(inline)` (one paragraph,
8
+ // no block split, no containers, no islands) and `plaintextSchema` is that one
9
+ // without marks: same decode/lower/position machinery, narrower shape. Anchors are
10
+ // not marks here (decorations).
10
11
  import { Schema } from 'prosemirror-model';
11
12
  import { islandBlockSpec, islandInlineSpec } from './islands.js';
12
- // ── Marks (shared by both schemas; plaintext mounts none) ───────────────────
13
+ // ── Marks (the block and inline schemas share them; plaintext declares none) ─
13
14
  const marks = {
14
15
  // Order matters: it fixes mark-set sort order and parse precedence. `link`
15
16
  // last so it wraps outermost; `unknown` after it, non-exclusive so several
@@ -134,21 +135,33 @@ export const blockSchema = new Schema({
134
135
  nodes: { ...blockNodes, ...inlineLeafNodes },
135
136
  marks
136
137
  });
138
+ // ── The constrained inline nodes (both inline schemas) ──────────────────────
139
+ // `doc: "paragraph"` (exactly one child) is what makes an Enter a no-op at the
140
+ // model level: there is no second block for a split to land.
141
+ const inlineNodes = {
142
+ doc: { content: 'paragraph' },
143
+ paragraph: { content: 'inline*', toDOM: () => ['p', 0] },
144
+ text: { group: 'inline' }
145
+ };
146
+ /** The constrained inline schema: one paragraph, no block splitting, no
147
+ * containers, no islands, and the full mark set (a `richtext(inline)` field, and
148
+ * a table cell, which is the same content unit). */
149
+ export const inlineSchema = new Schema({ nodes: inlineNodes, marks });
137
150
  /**
138
- * The constrained inline schema: one paragraph, no block splitting, no
139
- * containers, no islands. `doc: "paragraph"` (exactly one child) is what makes an
140
- * Enter a no-op at the model level: there is no second block for a split to land.
141
- * Marks are present; `plaintext` fields strip them at decode and mount no
142
- * mark input (a schema-level distinction the field draws, per CODEC §Inline mode).
151
+ * The inline schema with no mark types at all: a `plaintext` field, whose value is
152
+ * literal text the boundary refuses to coerce back once it carries a mark.
153
+ *
154
+ * Declaring none is what makes that structural rather than a rule each path
155
+ * restates: `toggleMark` has no type to apply, the mark input rules build nothing,
156
+ * a paste parses its marks away, and `decode` drops any a stray content arrives
157
+ * carrying (CODEC §Inline mode).
143
158
  */
144
- export const inlineSchema = new Schema({
145
- nodes: {
146
- doc: { content: 'paragraph' },
147
- paragraph: { content: 'inline*', toDOM: () => ['p', 0] },
148
- text: { group: 'inline' }
149
- },
150
- marks
151
- });
159
+ export const plaintextSchema = new Schema({ nodes: inlineNodes, marks: {} });
160
+ /** Whether `schema` can carry formatting at all: false for {@link plaintextSchema}
161
+ * alone. What a mark command asks before it offers itself. */
162
+ export function hasMarks(schema) {
163
+ return Object.keys(schema.marks).length > 0;
164
+ }
152
165
  /** True for the constrained inline schema (no block containers); decode branches on it. */
153
166
  export function isInlineSchema(schema) {
154
167
  return !schema.nodes.blockquote;
@@ -185,9 +185,13 @@
185
185
  }
186
186
  </script>
187
187
 
188
+ <!-- `data-qm-card` names the island for a floating surface that has to stay inside one
189
+ ({@link FieldHint}). A surface portals to `[data-qm-root]`, so the card is not its
190
+ ancestor and nothing else marks the box its guidance belongs in. -->
188
191
  <section
189
192
  bind:this={el}
190
193
  class="qm-card"
194
+ data-qm-card
191
195
  class:qm-main={card.isMain}
192
196
  class:qm-unschemable={card.unschemable}
193
197
  >
@@ -46,6 +46,13 @@
46
46
  * along with the editor's subtree. `undefined` falls back to bits-ui's default. */
47
47
  const portalTarget = $derived(triggerEl?.closest<HTMLElement>('[data-qm-root]') ?? undefined);
48
48
 
49
+ /** What the surface stays inside: the field's own card and the root it portals into.
50
+ * floating-ui intersects the two, so a flip answers to whichever is tighter. Empty
51
+ * falls back to the primitive's default, the surface's own clipping ancestors. */
52
+ const bounds = $derived(
53
+ [triggerEl?.closest<HTMLElement>('[data-qm-card]'), portalTarget].filter((el) => el != null)
54
+ );
55
+
49
56
  /** Placement, re-measured on each opening: the surface rides the label rung beside
50
57
  * the glyph, or stands over the field. */
51
58
  let beside = $state(false);
@@ -132,11 +139,18 @@
132
139
  neighbour's control instead, which reaching for it dismisses. -->
133
140
  <!-- `align="center"` for the rung, `"start"` over the field: centred, a surface taller
134
141
  than the glyph's line box would hang half its height onto the field's own control. -->
142
+ <!-- Both are preferences, and the card is what refuses them: its first field has no room
143
+ above its label inside one, so standing over that field puts the surface on the ground
144
+ above the card, reading as the neighbour's rather than as this field's. Refused, the
145
+ placement reverses and the surface goes under the field, over the control it describes:
146
+ the overlap the fork above spends a probe to avoid, and the only one left where there is
147
+ nothing above to stand over. -->
135
148
 
136
149
  <Popover.Root bind:open>
137
150
  <Popover.Portal to={portalTarget}>
138
151
  <Popover.Content
139
152
  customAnchor={triggerEl ?? null}
153
+ collisionBoundary={bounds}
140
154
  side={beside ? 'right' : 'top'}
141
155
  align={beside ? 'center' : 'start'}
142
156
  sideOffset={6}
@@ -65,6 +65,7 @@
65
65
  import Icon from './icons/Icon.svelte';
66
66
  import type { IconName } from './icons/nodes.js';
67
67
  import {
68
+ hasMarks,
68
69
  rangeAnchor,
69
70
  type FieldController,
70
71
  type LeafViews,
@@ -140,7 +141,17 @@
140
141
  // Escape out of a table cell lands.
141
142
  const selection = view?.state.selection;
142
143
  const formattable = selection instanceof TextSelection && !selection.empty;
143
- if (!view || !view.hasFocus() || !formattable || !hasText(view)) {
144
+ // …in a leaf that has formatting at all. A `plaintext` leaf declares no mark
145
+ // types (`plaintextSchema`), so this surface withholds itself over one entirely,
146
+ // the way it never rises over the text input a `string` field draws: the value is
147
+ // literal text, and a mark on it is a value the boundary refuses to coerce back.
148
+ if (
149
+ !view ||
150
+ !view.hasFocus() ||
151
+ !formattable ||
152
+ !hasMarks(view.state.schema) ||
153
+ !hasText(view)
154
+ ) {
144
155
  open = false;
145
156
  linkPromptOpen = false;
146
157
  return;
@@ -47,7 +47,7 @@
47
47
  const pmDoc = decode(value, inlineSchema);
48
48
  const state = EditorState.create({
49
49
  doc: pmDoc,
50
- plugins: proseLeafPlugins(inlineSchema, { inline: true, plaintext: false })
50
+ plugins: proseLeafPlugins(inlineSchema, { inline: true })
51
51
  });
52
52
  const mounted = new EditorView(containerEl, {
53
53
  state,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quillmark/svelte",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Editor, live-preview and source surfaces over a Quillmark WASM session.",
5
5
  "repository": {
6
6
  "type": "git",