@openpresentation/opf-editor 0.26.4 → 0.26.5

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.
@@ -76,6 +76,18 @@ export const SNIPPETS = Object.freeze([
76
76
  { prefix: "notes", where: "markdown", key: "notes", description: "Speaker notes for the slide", fragment: ph(1, "Speaker notes") },
77
77
  ]);
78
78
 
79
+ /**
80
+ * Snippets a KEY of the deck is accepted as (SPEC 5: `design`, `organization`, `speaker`): not items of the list on their own (`SNIPPETS` holds
81
+ * those), but what accepting the property writes, as VS Code does for a property with default snippets. The fragment is the value of the key. They hold
82
+ * the required members of the object, each a tab stop (SPEC 5 lists only `name` for the speaker; the Speaker schema requires an id too), and `design`
83
+ * opens the list of the catalog at each stop.
84
+ */
85
+ export const KEY_SNIPPETS = Object.freeze([
86
+ { prefix: "design", where: "deck-key", key: "design", description: "The design of the deck: a theme, a color scheme and a font scheme", fragment: { theme: ph(1, "", { choice: true }), colorScheme: ph(2, "", { choice: true }), fontScheme: ph(3, "", { choice: true }) } },
87
+ { prefix: "organization", where: "deck-key", key: "organization", description: "The organization presenting the deck: an id and a name", fragment: { id: ph(1, "acme"), name: ph(2, "Acme Inc.") } },
88
+ { prefix: "speaker", where: "deck-key", key: "speaker", description: "The person presenting the deck: an id and a name", fragment: { id: ph(1, "alice"), name: ph(2, "Name") } },
89
+ ]);
90
+
79
91
  /** The snippets that fit where the caret is: `where` and the path of the node (see source-json.js). */
80
92
  export function snippetsFor(mode, path) {
81
93
  const last = path.at(-1);
@@ -88,6 +100,14 @@ export function snippetsFor(mode, path) {
88
100
  });
89
101
  }
90
102
 
103
+ /**
104
+ * The snippets a key of the object at `path` is accepted as (the deck's `design`, `organization`, `speaker`): not items of their own in the list,
105
+ * but what accepting that property writes, as VS Code does for a property with default snippets.
106
+ */
107
+ export function keySnippetsFor(path) {
108
+ return path.length === 0 ? KEY_SNIPPETS : [];
109
+ }
110
+
91
111
  /** The JSON text of a snippet for insertion: `{ text, fields }`; a dataset is a `"name": { ... }` entry. */
92
112
  export function snippetJson(snippet, { comma = false } = {}) {
93
113
  if (snippet.key !== undefined) {
@@ -0,0 +1,46 @@
1
+ // One undo step for a suggestion and the value list it opened (artifacts/source-editor/SPEC.md, section 4.6, item 4; TEST-PLAN J-INS-017). Accepting a key
2
+ // of a closed set of values writes `"key": ""` and opens the list of the values; accepting a value is a second edit. Each is an edit of its own in
3
+ // CodeMirror's history, as every accepted suggestion is, so the pair is recorded here and Undo and Redo step over both: one Cmd+Z puts back the text
4
+ // the author had typed before accepting the key (VS Code: "accept is one step with its typed prefix undoable"). Anything typed in between, or any
5
+ // other edit after the pair, makes the pair void, so Undo is the plain one again. Plain functions over an editor view, no DOM.
6
+ import { redo, undo, undoDepth, redoDepth } from "@codemirror/commands";
7
+
8
+ const pending = new WeakMap();
9
+ const pairs = new WeakMap();
10
+
11
+ /** A suggestion that opens a list of values was accepted: the text before it and after it. */
12
+ export function beginUndoGroup(view, before) {
13
+ pending.set(view, { before, after: view.state.doc });
14
+ }
15
+
16
+ /** A suggestion is about to be accepted: the pair it completes (the key accepted just before, with nothing typed since), or null. */
17
+ export function takeUndoGroup(view) {
18
+ const link = pending.get(view);
19
+ pending.delete(view);
20
+ pairs.delete(view);
21
+ return link && link.after.eq(view.state.doc) ? link : null;
22
+ }
23
+
24
+ /** The value was accepted (and the history closed after it): the key and the value are one undo step from now on. */
25
+ export function finishUndoGroup(view, link) {
26
+ pairs.set(view, { before: link.before, after: view.state.doc, depth: undoDepth(view.state), undone: false });
27
+ }
28
+
29
+ /** Whether Undo should step over the last pair; does it when so (returns true). Otherwise the caller does the plain undo. */
30
+ export function undoPair(view) {
31
+ const pair = pairs.get(view);
32
+ if (!pair || pair.undone || undoDepth(view.state) !== pair.depth || !view.state.doc.eq(pair.after)) return false;
33
+ for (let step = 0; step < 3 && !view.state.doc.eq(pair.before); step++) if (!undo(view)) break;
34
+ pair.undone = view.state.doc.eq(pair.before);
35
+ pair.redoDepth = redoDepth(view.state);
36
+ return true;
37
+ }
38
+
39
+ /** The same for Redo: after the pair was undone, Redo puts both back. */
40
+ export function redoPair(view) {
41
+ const pair = pairs.get(view);
42
+ if (!pair || !pair.undone || redoDepth(view.state) !== pair.redoDepth || !view.state.doc.eq(pair.before)) return false;
43
+ for (let step = 0; step < 3 && !view.state.doc.eq(pair.after); step++) if (!redo(view)) break;
44
+ pair.undone = !view.state.doc.eq(pair.after);
45
+ return true;
46
+ }
@@ -220,16 +220,40 @@ function inComment(line, column) {
220
220
  return false;
221
221
  }
222
222
 
223
+ /** The quote character of a quoted scalar that `value` opens and does not close, or null (a quoted scalar may run over several lines). */
224
+ export function openQuote(value) {
225
+ const text = value.trimStart();
226
+ const quote = text[0];
227
+ if (quote !== '"' && quote !== "'") return null;
228
+ return closesQuote(text.slice(1), quote) ? null : quote;
229
+ }
230
+
231
+ /** Whether `text` (the rest of a quoted scalar) holds its closing quote. */
232
+ function closesQuote(text, quote) {
233
+ for (let index = 0; index < text.length; index++) {
234
+ if (quote === '"' && text[index] === "\\") { index++; continue; }
235
+ if (text[index] === quote) {
236
+ if (quote === "'" && text[index + 1] === "'") { index++; continue; }
237
+ return true;
238
+ }
239
+ }
240
+ return false;
241
+ }
242
+
223
243
  /**
224
- * Walk the lines before `stop` (a line index), applying each to `stack`. Lines inside a block scalar or a flow collection that spans lines, blank
225
- * lines, comments and document markers change nothing. Returns `{ block, flow }`: the column a block scalar's text must be indented past (or -1) and
226
- * the depth of an unclosed flow collection (0 when none) at line `stop`, so the caller knows whether that line is inside either.
244
+ * Walk the lines before `stop` (a line index), applying each to `stack`. Lines inside a block scalar, a flow collection or a quoted scalar that span
245
+ * lines, blank lines, comments and document markers change nothing. Returns `{ block, flow, quote, open }`: the column a block scalar's text must be
246
+ * indented past (or -1), the depth of an unclosed flow collection (0 when none), the quote of an unclosed quoted scalar (or null) at line `stop`, so
247
+ * the caller knows whether that line is inside any of them, and for a flow collection `open`: the line it began on and where it sits in the stack.
227
248
  */
228
249
  function walk(lines, stop, stack) {
229
250
  let block = -1;
230
251
  let flow = 0;
252
+ let quote = null;
253
+ let open = null;
231
254
  for (let number = 0; number < stop; number++) {
232
255
  const text = lines[number].text;
256
+ if (quote) { if (closesQuote(text, quote)) quote = null; continue; }
233
257
  if (text.trim() === "") continue;
234
258
  const indent = text.length - text.trimStart().length;
235
259
  if (block >= 0) {
@@ -244,11 +268,14 @@ function walk(lines, stop, stack) {
244
268
  const value = entry ? parsed.rest.slice(entry.valueStart) : parsed.rest;
245
269
  const shape = entry ? shapeOfValue(value) : (parsed.markers.length ? shapeOfValue(value) : undefined);
246
270
  if (!entry && !parsed.markers.length) continue;
247
- enter(stack, parsed, entry, shape);
271
+ const placed = enter(stack, parsed, entry, shape);
248
272
  if (shape?.block) block = entry ? parsed.restStart + entry.keyStart : parsed.markers.at(-1);
249
- else if (shape?.flow) flow = flowDepth(value, 0);
273
+ else if (shape?.flow) {
274
+ flow = flowDepth(value, 0);
275
+ open = flow > 0 ? { number, parsed, entry, value, placed } : null;
276
+ } else if (shape && "scalar" in shape) quote = openQuote(value);
250
277
  }
251
- return { block, flow };
278
+ return { block, flow, quote, open };
252
279
  }
253
280
 
254
281
  // ---- flow collections on one line --------------------------------------------------------------------------------------------------
@@ -401,7 +428,13 @@ function keyToken(text, line, restStart, position) {
401
428
  * colon yet. Options: `explicit` (Ctrl+Space also takes an empty place), `prefix` (the path of the node the text is the body of, for YAML in a fence of
402
429
  * a Markdown slide), `exclude` (keys never offered, the `slides` of front matter).
403
430
  */
404
- export function yamlContextAt(text, position, { explicit = false, prefix = [], exclude = [] } = {}) {
431
+ export function yamlContextAt(text, position, options = {}) {
432
+ const context = readContext(text, position, options);
433
+ // The text the context was read from, and the path it is the body of: the references the value lists offer are looked up in it.
434
+ return context ? { ...context, sourceText: text, prefix: options.prefix ?? [] } : null;
435
+ }
436
+
437
+ function readContext(text, position, { explicit = false, prefix = [], exclude = [] }) {
405
438
  const lines = linesOf(text);
406
439
  const number = lineIndex(lines, position);
407
440
  const line = lines[number];
@@ -414,8 +447,10 @@ export function yamlContextAt(text, position, { explicit = false, prefix = [], e
414
447
  const state = walk(lines, number, stack);
415
448
  const lineIndent = lineText.length - lineText.trimStart().length;
416
449
  if (state.block >= 0 && (trimmedLine === "" ? column > state.block : lineIndent > state.block)) return null;
417
- if (state.flow > 0) return null;
418
450
  const before = lineText.slice(0, column);
451
+ // A line inside a quoted scalar that began above: text, in which only `{{` of a variable is completed.
452
+ if (state.quote) return continuationContext({ text, line, position, before, explicit });
453
+ if (state.flow > 0) return flowContinuation({ text, lines, number, line, position, column, explicit, prefix, exclude, open: state.open });
419
454
  const parsed = readMarkers(before);
420
455
  // The key is read from the whole line: a colon after the caret that ends the key (`top:le|ft: x`) is not the colon before it.
421
456
  const whole = readMarkers(lineText);
@@ -425,6 +460,53 @@ export function yamlContextAt(text, position, { explicit = false, prefix = [], e
425
460
  return entry ? valueContext({ ...shared, entry }) : keyOrItemContext(shared);
426
461
  }
427
462
 
463
+ const VARIABLE_OPEN = /\{\{[A-Za-z0-9._-]*$/;
464
+
465
+ /**
466
+ * A line of text that continues a scalar begun on a line above (a quoted scalar left open, or a sentence that runs on): nothing to complete in it
467
+ * except the name of a variable after `{{`. The context is a value in the text sense (`multiline`), with no path.
468
+ */
469
+ function continuationContext({ text, line, position, before, explicit }) {
470
+ if (!VARIABLE_OPEN.test(before)) return null;
471
+ let end = position;
472
+ while (end < line.end && /[A-Za-z0-9._-]/.test(text[end])) end++;
473
+ const start = line.start + (line.text.length - line.text.trimStart().length);
474
+ return { format: "yaml", mode: "value", multiline: true, token: { quoted: true, closed: false, start, end, from: start, to: end }, path: [], key: undefined, existing: new Set(), document: {}, itemStart: false, flow: false, needsSpace: false, column: 0, indent: 0, explicit };
475
+ }
476
+
477
+ // The text of a line without its comment (a `#` after a space, outside quotes).
478
+ function withoutComment(text) {
479
+ let quote = null;
480
+ for (let index = 0; index < text.length; index++) {
481
+ const character = text[index];
482
+ if (quote) {
483
+ if (quote === '"' && character === "\\") index++;
484
+ else if (character === quote) quote = null;
485
+ } else if (character === '"' || character === "'") {
486
+ if (index === 0 || /[\s[{,:-]/.test(text[index - 1])) quote = character;
487
+ } else if (character === "#" && (index === 0 || isSpace(text[index - 1]))) return text.slice(0, index);
488
+ }
489
+ return text;
490
+ }
491
+
492
+ /**
493
+ * A caret on a line inside a flow collection that began on an earlier line (`tags: [a,` / ` b, |]`, `{theme: minimal,` / ` |}`): the lines from
494
+ * the opening one to the caret are read as one text, so the containers open at the caret and the keys and items before it are known as on one line.
495
+ */
496
+ function flowContinuation({ text, lines, number, line, position, column, explicit, prefix, exclude, open }) {
497
+ if (!open) return null;
498
+ const first = lines[open.number].text;
499
+ const start = open.entry ? open.parsed.restStart + open.entry.valueStart : open.parsed.restStart;
500
+ const middle = lines.slice(open.number + 1, number).map((item) => withoutComment(item.text).trim());
501
+ const joined = [withoutComment(first.slice(start)).trimStart(), ...middle, line.text.slice(0, column).trimStart()].join(" ");
502
+ const flow = flowAt(joined, joined.length);
503
+ if (!flow) return null;
504
+ const { map, seq } = open.placed;
505
+ const frame = open.entry ? map : seq;
506
+ const base = open.entry ? [...map.path, open.entry.key] : [...seq.path, seq.count - 1];
507
+ return flowContext({ text, line, position, flow, base, frame, lines, explicit, prefix, exclude });
508
+ }
509
+
428
510
  function valueContext({ text, lines, line, position, parsed, entry, explicit, prefix, stack, lineIndent, exclude }) {
429
511
  const lineText = line.text;
430
512
  let valueFrom = line.start + parsed.restStart + entry.valueStart;
@@ -465,7 +547,10 @@ function keyOrItemContext({ text, lines, number, line, position, parsed, explici
465
547
  }
466
548
  const quoteOpen = rest[0] === '"' || rest[0] === "'";
467
549
  // The word before the caret is a plain key (or the start of a scalar item) or an unfinished quoted key; anything else (a sentence) is prose.
468
- if (!quoteOpen && rest !== "" && !/^[A-Za-z0-9_.$+:-]+$/.test(rest)) return null;
550
+ if (!quoteOpen && rest !== "" && !/^[A-Za-z0-9_.$+:-]+$/.test(rest)) {
551
+ // A sentence that runs on from a scalar above: only a variable (`{{`) is completed in it.
552
+ return parsed.markers.length ? null : continuationContext({ text, line, position, before: lineText.slice(0, position - line.start), explicit });
553
+ }
469
554
  let seq = null;
470
555
  for (const col of parsed.markers) { seq = stack.seqAt(col); seq.items[seq.count - 1] = { open: true }; }
471
556
  const frame = stack.mapAt(parsed.restStart);
package/dist/styles.css CHANGED
@@ -3639,6 +3639,13 @@ input.opf-lx-input[data-field="max"] { font-family: var(--opf-ui-mono); font-siz
3639
3639
  opacity: 0.85;
3640
3640
  }
3641
3641
 
3642
+ :root :is(.opf-ui, .opf-json, .opf-code) .cm-tooltip-autocomplete > .opf-completion-footer {
3643
+ padding: 4px 8px;
3644
+ font: 12px/1.4 var(--opf-ui-font);
3645
+ color: var(--opf-ui-muted-fg);
3646
+ border-top: 1px solid var(--opf-ui-border);
3647
+ }
3648
+
3642
3649
  :root :is(.opf-ui, .opf-json, .opf-code) .cm-tooltip-autocomplete .cm-completionMatchedText {
3643
3650
  font-weight: 600;
3644
3651
  text-decoration: none;
@@ -4268,6 +4275,25 @@ input.opf-lx-input[data-field="max"] { font-family: var(--opf-ui-mono); font-siz
4268
4275
  cursor: pointer;
4269
4276
  }
4270
4277
  .opf-source-finding-fix:hover { background: var(--opf-ui-muted); }
4278
+ .opf-source-finding-details {
4279
+ min-height: 22px;
4280
+ padding: 0 8px;
4281
+ border: 1px solid transparent;
4282
+ border-radius: var(--opf-ui-radius-sm);
4283
+ background: transparent;
4284
+ color: var(--opf-ui-muted-fg);
4285
+ font-size: 12px;
4286
+ text-decoration: underline;
4287
+ cursor: pointer;
4288
+ }
4289
+ .opf-source-finding-details:hover { background: var(--opf-ui-muted); }
4290
+ .opf-source-finding-details[aria-expanded="true"] { text-decoration: none; background: var(--opf-ui-muted); }
4291
+ .opf-source-finding-explain { display: flex; flex-direction: column; gap: 4px; margin: 0 6px 6px 28px; padding: 6px 8px; border-left: 2px solid var(--opf-ui-border); color: var(--opf-ui-fg); font-size: 12px; }
4292
+ .opf-source-finding-explain[hidden] { display: none; }
4293
+ .opf-source-finding-explain-line { margin: 0; overflow-wrap: anywhere; }
4294
+ .opf-source-finding-explain-example { font: 11px var(--opf-ui-mono); overflow-wrap: anywhere; white-space: pre-wrap; }
4295
+ .opf-source-finding-explain-example code { padding: 1px 4px; border-radius: var(--opf-ui-radius-sm); background: var(--opf-ui-muted); }
4296
+ .opf-source-finding-explain-link { color: var(--opf-ui-muted-fg); }
4271
4297
  .opf-source-finding-more { padding: 3px 6px; color: var(--opf-ui-muted-fg); font-size: 12px; }
4272
4298
 
4273
4299
  .opf-source-status { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-editor",
3
- "version": "0.26.4",
3
+ "version": "0.26.5",
4
4
  "description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -283,10 +283,10 @@
283
283
  "@lezer/highlight": "1.2.5",
284
284
  "jsonc-parser": "3.3.1",
285
285
  "yaml": "^2.9.1",
286
- "@openpresentation/opf": "^0.26.4"
286
+ "@openpresentation/opf": "^0.26.5"
287
287
  },
288
288
  "peerDependencies": {
289
- "@openpresentation/opf-render": "^0.26.4"
289
+ "@openpresentation/opf-render": "^0.26.5"
290
290
  },
291
291
  "peerDependenciesMeta": {
292
292
  "@openpresentation/opf-render": {
@@ -345,9 +345,9 @@
345
345
  "pdf-lib": "1.17.1",
346
346
  "pdfjs-dist": "6.4.299",
347
347
  "playwright": "1.64.0",
348
- "@openpresentation/gallery": "0.26.4",
349
- "@openpresentation/opf-pptx": "0.26.4",
350
- "@openpresentation/opf-render": "0.26.4"
348
+ "@openpresentation/gallery": "0.26.5",
349
+ "@openpresentation/opf-render": "0.26.5",
350
+ "@openpresentation/opf-pptx": "0.26.5"
351
351
  },
352
352
  "scripts": {
353
353
  "build": "node scripts/build.mjs",
@@ -388,6 +388,7 @@
388
388
  "test:source-keyboard-json-browser": "node test/source-keyboard-json-browser.mjs",
389
389
  "test:source-keyboard-yaml-browser": "node test/source-keyboard-yaml-browser.mjs",
390
390
  "test:source-keyboard-markdown-browser": "node test/source-keyboard-markdown-browser.mjs",
391
+ "test:source-diagnostics-browser": "node test/source-diagnostics-browser.mjs",
391
392
  "test:layout-editor-browser": "node test/layout-editor-browser.mjs",
392
393
  "test:export-license-browser": "node test/export-license-browser.mjs",
393
394
  "test:chart-options-browser": "node test/chart-options-browser.mjs",
@@ -397,5 +398,5 @@
397
398
  "test:export-menu-browser": "node test/export-menu-browser.mjs",
398
399
  "test:agent-handoff-browser": "node test/agent-handoff-browser.mjs"
399
400
  },
400
- "gitHead": "8809122a74a43aa99cdd383a856839ad6f018af6"
401
+ "gitHead": "750d120e715b6c4930906aa71322d092d057c58d"
401
402
  }