@openpresentation/opf-editor 0.24.1 → 0.24.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.
@@ -59,6 +59,8 @@ export interface SourcePaneOptions {
59
59
  /** Accessible name of the code editor (default `OPF source`). */
60
60
  label?: string;
61
61
  catalogs?: JsonCatalogContext;
62
+ /** Live completions while typing JSON: property names and values (enums, booleans, catalog references) at the cursor's JSON path, from the same data as the field menu. YAML and Markdown keep the menu only. Default true. */
63
+ completions?: boolean;
62
64
  onError?(error: unknown): void;
63
65
  }
64
66
 
@@ -4,6 +4,7 @@ import { fromMarkdown, toMarkdown } from "@openpresentation/opf/markdown";
4
4
  import { mountJsonCodeEditor } from "./json-editor.js";
5
5
  import { createSourceMemory, findDuplicateKey, updateJsonSource } from "./exact-source.js";
6
6
  import { lineForPath, lineOfOffset, offsetOfLine } from "./source-lines.js";
7
+ import { errorText, normalizeFinding, rejectionFindings as findingsOfError } from "./source-findings.js";
7
8
 
8
9
  // The source pane (RR-98): the OPF document as JSON, YAML or Markdown, with the file name, the format menu, Format, Copy
9
10
  // and Download, and the findings under the code. JSON is canonical and keeps its authored bytes (exact-source); YAML and
@@ -65,20 +66,6 @@ function same(a, b) {
65
66
  }
66
67
  const SEVERITY = { error: 0, warning: 1, info: 2 };
67
68
  const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
68
- const errorText = (error) => error?.issues?.[0]?.message ?? error?.message ?? String(error);
69
-
70
- /** Normalize one finding: core `Finding` objects, `{ path, message, severity, line? }` objects and plain strings. */
71
- function normalizeFinding(input, kind) {
72
- const finding = typeof input === "string" ? { message: input } : input ?? {};
73
- const severity = finding.severity === "error" || finding.severity === "info" ? finding.severity : finding.severity === "warning" || finding.severity === "warn" ? "warning" : "error";
74
- const out = { path: String(finding.path ?? ""), message: String(finding.message ?? ""), severity };
75
- const line = finding.line ?? finding.location?.line;
76
- if (Number.isInteger(line) && line > 0) out.line = line;
77
- const own = finding.kind ?? kind;
78
- if (own) out.kind = own;
79
- if (finding.ruleId) out.ruleId = finding.ruleId;
80
- return out;
81
- }
82
69
 
83
70
  const JSON_ERRORS = { InvalidSymbol: "Invalid symbol", InvalidNumberFormat: "Invalid number", PropertyNameExpected: "Property name expected", ValueExpected: "Value expected", ColonExpected: "Colon expected", CommaExpected: "Comma expected", CloseBraceExpected: "Closing brace expected", CloseBracketExpected: "Closing bracket expected", EndOfFileExpected: "End of file expected", InvalidCommentToken: "Comments are not allowed in JSON", UnexpectedEndOfComment: "Unexpected end of comment", UnexpectedEndOfString: "Unexpected end of string", UnexpectedEndOfNumber: "Unexpected end of number", InvalidUnicode: "Invalid unicode escape", InvalidEscapeCharacter: "Invalid escape character", InvalidCharacter: "Invalid character" };
84
71
 
@@ -137,6 +124,8 @@ export function createSourcePane(container, options = {}) {
137
124
  let collapsed = false;
138
125
  let copyTimer = null;
139
126
  let renderedKey = "";
127
+ // Whether the findings include an error, and what the editor's `aria-invalid` currently says (null: not yet set).
128
+ let invalid = false, shownInvalid = null;
140
129
  const lineCache = { key: "", values: new Map() };
141
130
 
142
131
  // ---- DOM ----------------------------------------------------------------------------------------------------------
@@ -232,6 +221,7 @@ export function createSourcePane(container, options = {}) {
232
221
  lineNumbers: true,
233
222
  language: formatOf(format).language,
234
223
  readOnly,
224
+ completions: options.completions !== false,
235
225
  catalogs: options.catalogs ?? editor.catalogs,
236
226
  describedBy: `${id}-findings`,
237
227
  onChange: () => schedule(),
@@ -275,7 +265,8 @@ export function createSourcePane(container, options = {}) {
275
265
  editor.applyPatch([{ op: "replace", path: "", value: read.deck }], { source: "source-pane", format });
276
266
  } catch (error) {
277
267
  dirty = true;
278
- rejectionFindings = read.findings.length ? read.findings.map((finding) => ({ ...finding, kind: "rejected" })) : [normalizeFinding({ message: errorText(error), kind: "rejected" })];
268
+ // Every issue of the refusal is its own finding, with its path; a format's own findings stand in when the error names none.
269
+ rejectionFindings = error?.issues?.length || !read.findings.length ? findingsOfError(error) : read.findings.map((finding) => ({ ...finding, kind: "rejected" }));
279
270
  applying = false;
280
271
  renderFindings();
281
272
  return;
@@ -292,13 +283,16 @@ export function createSourcePane(container, options = {}) {
292
283
  function refreshNotes() {
293
284
  try { noteFindings = markdownNotes(toMarkdown(deck()).report); } catch { noteFindings = []; }
294
285
  }
286
+ /** `aria-invalid` follows the findings: set while any finding is an error (syntax, a refused draft, the session's or the host's), cleared with them. */
295
287
  function syncAttributes() {
296
- control.update(control.api.getValue(), syntaxFindings.length > 0 || rejectionFindings.length > 0, null, `${id}-findings`);
288
+ shownInvalid = invalid;
289
+ control.update(control.api.getValue(), invalid, null, `${id}-findings`);
297
290
  }
298
291
 
299
292
  /** Show `value` as the text of the current format, as an external change. */
300
293
  function show(value) {
301
294
  baseline = value;
295
+ shownInvalid = false;
302
296
  control.update(value, false, null, `${id}-findings`);
303
297
  }
304
298
  function fromSession(notice) {
@@ -332,7 +326,7 @@ export function createSourcePane(container, options = {}) {
332
326
 
333
327
  const unsubscribe = editor.subscribe((event) => {
334
328
  if (destroyed) return;
335
- if (event.type === "catalogs") { renderFindings(); return; }
329
+ if (event.type === "catalogs") { control.setCatalogs(options.catalogs ?? editor.catalogs); renderFindings(); return; }
336
330
  if (applying) return;
337
331
  version++;
338
332
  fromSession();
@@ -539,13 +533,19 @@ export function createSourcePane(container, options = {}) {
539
533
  catch (error) { options.onError?.(error); host = []; }
540
534
  } else if (hostFindings) host = hostFindings.map((finding) => normalizeFinding(finding));
541
535
  else host = sessionFindings();
536
+ // A finding repeated by an earlier group is dropped; repeats inside one group stay, so a refusal lists every issue the validator found.
542
537
  const seen = new Set();
543
- const all = [...syntaxFindings, ...rejectionFindings, ...transientNotes, ...noteFindings, ...host].filter((finding) => {
544
- const key = `${finding.severity}|${finding.path}|${finding.message}`;
545
- if (seen.has(key)) return false;
546
- seen.add(key);
547
- return true;
548
- });
538
+ const all = [];
539
+ for (const group of [syntaxFindings, rejectionFindings, transientNotes, noteFindings, host]) {
540
+ const keys = [];
541
+ for (const finding of group) {
542
+ const key = `${finding.severity}|${finding.path}|${finding.message}`;
543
+ if (seen.has(key)) continue;
544
+ keys.push(key);
545
+ all.push(finding);
546
+ }
547
+ for (const key of keys) seen.add(key);
548
+ }
549
549
  return all.map((finding, index) => ({ finding, index })).sort((a, b) => SEVERITY[a.finding.severity] - SEVERITY[b.finding.severity] || a.index - b.index).map((entry) => entry.finding);
550
550
  }
551
551
  function lineOf(finding) {
@@ -558,9 +558,14 @@ export function createSourcePane(container, options = {}) {
558
558
  return lineCache.values.get(cacheKey) ?? explicit ?? null;
559
559
  }
560
560
  function renderFindings() {
561
+ paintFindings();
562
+ if (!destroyed && invalid !== shownInvalid) syncAttributes();
563
+ }
564
+ function paintFindings() {
561
565
  if (destroyed) return;
562
566
  const all = collect();
563
567
  const rows = all.map((finding) => ({ finding, line: lineOf(finding) }));
568
+ invalid = rows.some((row) => row.finding.severity === "error");
564
569
  const key = JSON.stringify([format, collapsed, rows.map(({ finding, line }) => [finding.severity, finding.path, finding.message, finding.kind, line])]);
565
570
  if (key === renderedKey) return;
566
571
  renderedKey = key;
package/dist/styles.css CHANGED
@@ -2807,6 +2807,49 @@
2807
2807
  box-shadow: var(--opf-ui-shadow-pop);
2808
2808
  }
2809
2809
 
2810
+ :root :is(.opf-ui, .opf-json, .opf-code) .cm-tooltip.cm-tooltip-autocomplete > ul {
2811
+ font-family: var(--opf-ui-mono);
2812
+ font-size: 12px;
2813
+ }
2814
+
2815
+ :root :is(.opf-ui, .opf-json, .opf-code) .cm-tooltip.cm-tooltip-autocomplete > ul > li {
2816
+ padding: 2px 8px;
2817
+ color: var(--opf-ui-fg);
2818
+ }
2819
+
2820
+ :root :is(.opf-ui, .opf-json, .opf-code) .cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] {
2821
+ color: var(--opf-ui-accent-fg);
2822
+ background: var(--opf-ui-accent);
2823
+ }
2824
+
2825
+ :root :is(.opf-ui, .opf-json, .opf-code) .cm-tooltip-autocomplete .cm-completionDetail {
2826
+ margin-left: 12px;
2827
+ font-family: var(--opf-ui-font);
2828
+ font-style: normal;
2829
+ color: var(--opf-ui-muted-fg);
2830
+ }
2831
+
2832
+ :root :is(.opf-ui, .opf-json, .opf-code) .cm-tooltip-autocomplete > ul > li[aria-selected] .cm-completionDetail {
2833
+ color: inherit;
2834
+ opacity: 0.85;
2835
+ }
2836
+
2837
+ :root :is(.opf-ui, .opf-json, .opf-code) .cm-tooltip-autocomplete .cm-completionMatchedText {
2838
+ font-weight: 600;
2839
+ text-decoration: none;
2840
+ }
2841
+
2842
+ :root :is(.opf-ui, .opf-json, .opf-code) .cm-tooltip .cm-completionInfo {
2843
+ max-width: 280px;
2844
+ padding: 6px 8px;
2845
+ font: 12px/1.45 var(--opf-ui-font);
2846
+ white-space: pre-wrap;
2847
+ color: var(--opf-ui-fg);
2848
+ background: var(--opf-ui-surface);
2849
+ border: 1px solid var(--opf-ui-border);
2850
+ border-radius: var(--opf-ui-radius-md);
2851
+ }
2852
+
2810
2853
  :is(.opf-ui, .opf-json, .opf-code) .cm-diagnostic-error {
2811
2854
  border-left-color: var(--opf-ui-danger);
2812
2855
  }
@@ -2938,6 +2981,163 @@
2938
2981
  }
2939
2982
  }
2940
2983
 
2984
+ /* presenter.css */
2985
+ /* RR-98: the presenter and the Present button (src/presenter.js). Only --opf-ui-* tokens, no color literals (test/styles.mjs). */
2986
+
2987
+ /* ---- the presenter -------------------------------------------------------------------------------------------------------------------- */
2988
+ .opf-presenter,
2989
+ .opf-presenter *,
2990
+ .opf-presenter *::before,
2991
+ .opf-presenter *::after { box-sizing: border-box; }
2992
+ .opf-presenter[hidden] { display: none !important; }
2993
+
2994
+ .opf-presenter {
2995
+ position: fixed;
2996
+ inset: 0;
2997
+ z-index: 2000;
2998
+ display: flex;
2999
+ align-items: center;
3000
+ justify-content: center;
3001
+ overflow: hidden;
3002
+ /* Slides are letterboxed on black in every theme, so the bars never take a token's light or dark value. */
3003
+ background: black;
3004
+ font-family: var(--opf-ui-font);
3005
+ font-size: 13px;
3006
+ line-height: 1.4;
3007
+ color: var(--opf-ui-fg);
3008
+ user-select: none;
3009
+ -webkit-user-select: none;
3010
+ touch-action: none;
3011
+ outline: none;
3012
+ }
3013
+ .opf-presenter[data-chrome="hidden"] { cursor: none; }
3014
+
3015
+ .opf-presenter .opf-presenter-slide {
3016
+ width: 100%;
3017
+ height: 100%;
3018
+ display: flex;
3019
+ align-items: center;
3020
+ justify-content: center;
3021
+ }
3022
+ /* The slide's own width and height attributes give its shape (viewBox); this makes it as large as the screen allows. */
3023
+ .opf-presenter .opf-presenter-slide svg {
3024
+ display: block;
3025
+ width: 100%;
3026
+ height: 100%;
3027
+ max-width: 100%;
3028
+ max-height: 100%;
3029
+ }
3030
+ .opf-presenter .opf-presenter-message {
3031
+ position: absolute;
3032
+ margin: 0;
3033
+ max-width: 40ch;
3034
+ padding: 12px 16px;
3035
+ border: 1px solid var(--opf-ui-border);
3036
+ border-radius: var(--opf-ui-radius-md);
3037
+ background: var(--opf-ui-surface);
3038
+ color: var(--opf-ui-muted-fg);
3039
+ text-align: center;
3040
+ font: 400 13px/1.5 var(--opf-ui-font);
3041
+ }
3042
+ .opf-presenter .opf-presenter-message[hidden] { display: none; }
3043
+ .opf-presenter .opf-presenter-live {
3044
+ position: absolute;
3045
+ width: 1px;
3046
+ height: 1px;
3047
+ margin: -1px;
3048
+ padding: 0;
3049
+ overflow: hidden;
3050
+ clip-path: inset(50%);
3051
+ white-space: nowrap;
3052
+ border: 0;
3053
+ }
3054
+
3055
+ /* ---- the counter and the exit button (they appear on pointer move) ---------------------------------------------------------------------- */
3056
+ .opf-presenter .opf-presenter-counter,
3057
+ .opf-presenter .opf-presenter-close {
3058
+ position: absolute;
3059
+ background: var(--opf-ui-surface);
3060
+ color: var(--opf-ui-muted-fg);
3061
+ border: 1px solid var(--opf-ui-border);
3062
+ opacity: 0;
3063
+ transition: opacity var(--opf-ui-ease), color var(--opf-ui-ease), border-color var(--opf-ui-ease);
3064
+ }
3065
+ .opf-presenter[data-chrome="visible"] .opf-presenter-counter,
3066
+ .opf-presenter[data-chrome="visible"] .opf-presenter-close,
3067
+ .opf-presenter .opf-presenter-close:focus-visible { opacity: 1; }
3068
+
3069
+ .opf-presenter .opf-presenter-counter {
3070
+ bottom: 16px;
3071
+ left: 0;
3072
+ right: 0;
3073
+ width: max-content;
3074
+ margin: 0 auto;
3075
+ padding: 4px 10px;
3076
+ border-radius: var(--opf-ui-radius);
3077
+ font: 500 11.5px/1.4 var(--opf-ui-mono);
3078
+ letter-spacing: 0.04em;
3079
+ pointer-events: none;
3080
+ }
3081
+ .opf-presenter .opf-presenter-close {
3082
+ top: 16px;
3083
+ right: 16px;
3084
+ display: inline-flex;
3085
+ align-items: center;
3086
+ justify-content: center;
3087
+ width: var(--opf-ui-control-h);
3088
+ height: var(--opf-ui-control-h);
3089
+ padding: 0;
3090
+ margin: 0;
3091
+ border-radius: var(--opf-ui-radius);
3092
+ font: inherit;
3093
+ cursor: pointer;
3094
+ }
3095
+ .opf-presenter .opf-presenter-close:hover { color: var(--opf-ui-fg); background: var(--opf-ui-muted); }
3096
+ .opf-presenter .opf-presenter-close:focus-visible {
3097
+ outline: 2px solid var(--opf-ui-ring);
3098
+ outline-offset: 2px;
3099
+ }
3100
+ .opf-presenter .opf-icon { flex: none; }
3101
+
3102
+ /* ---- the Present button (styled like the Export trigger) ------------------------------------------------------------------------------- */
3103
+ .opf-present-button,
3104
+ .opf-present-button *,
3105
+ .opf-present-button *::before,
3106
+ .opf-present-button *::after { box-sizing: border-box; }
3107
+ .opf-present-button {
3108
+ display: inline-flex;
3109
+ align-items: center;
3110
+ justify-content: center;
3111
+ flex: none;
3112
+ width: var(--opf-ui-control-h);
3113
+ height: var(--opf-ui-control-h);
3114
+ margin: 0;
3115
+ padding: 0;
3116
+ border: 1px solid var(--opf-ui-accent);
3117
+ border-radius: var(--opf-ui-radius);
3118
+ background: var(--opf-ui-accent);
3119
+ color: var(--opf-ui-accent-fg);
3120
+ font: inherit;
3121
+ cursor: pointer;
3122
+ transition: opacity var(--opf-ui-ease);
3123
+ }
3124
+ .opf-present-button:hover { opacity: 0.92; }
3125
+ .opf-present-button:focus-visible {
3126
+ outline: 2px solid var(--opf-ui-ring);
3127
+ outline-offset: 2px;
3128
+ }
3129
+ .opf-present-button[aria-disabled="true"] {
3130
+ opacity: 0.5;
3131
+ cursor: not-allowed;
3132
+ }
3133
+ .opf-present-button .opf-icon { flex: none; }
3134
+
3135
+ @media (prefers-reduced-motion: reduce) {
3136
+ .opf-presenter .opf-presenter-counter,
3137
+ .opf-presenter .opf-presenter-close,
3138
+ .opf-present-button { transition: none; }
3139
+ }
3140
+
2941
3141
  /* source-pane.css */
2942
3142
  /* Source pane (RR-98). Only --opf-ui-* tokens (src/styles/tokens.css). */
2943
3143
  .opf-source {
@@ -3087,7 +3287,8 @@
3087
3287
 
3088
3288
  /* Code */
3089
3289
  .opf-source-code { position: relative; flex: 1; min-height: 0; overflow: hidden; display: flex; }
3090
- .opf-source-code > .cm-editor { flex: 1; min-width: 0; height: 100%; background: transparent; color: var(--opf-ui-fg); }
3290
+ /* The pane frame draws the edge: the editor inside it has no border or radius of its own (panels.css gives a standalone editor both). */
3291
+ .opf-source-code > .cm-editor { flex: 1; min-width: 0; height: 100%; background: transparent; color: var(--opf-ui-fg); border: 0; border-radius: 0; }
3091
3292
  .opf-source-code .cm-editor.cm-focused { outline: none; }
3092
3293
  .opf-source-code .cm-scroller { overflow: auto; font: 400 13.5px/22px var(--opf-ui-mono); }
3093
3294
  .opf-source[data-density="touch"] .opf-source-code .cm-scroller { font-size: 13px; line-height: 21px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-editor",
3
- "version": "0.24.1",
3
+ "version": "0.24.3",
4
4
  "description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -230,6 +230,14 @@
230
230
  "types": "./dist/source-pane.d.ts",
231
231
  "default": "./dist/source-pane.js"
232
232
  },
233
+ "./presenter": {
234
+ "types": "./dist/presenter.d.ts",
235
+ "default": "./dist/presenter.js"
236
+ },
237
+ "./read-deck-file": {
238
+ "types": "./dist/read-deck-file.d.ts",
239
+ "default": "./dist/read-deck-file.js"
240
+ },
233
241
  "./export-menu": {
234
242
  "types": "./dist/export-menu.d.ts",
235
243
  "default": "./dist/export-menu.js"
@@ -270,7 +278,7 @@
270
278
  "check:font-hotlinks": "node --test scripts/check-font-hotlinks.test.mjs && node scripts/check-font-hotlinks.mjs",
271
279
  "check:font-hotlinks:built": "node scripts/check-font-hotlinks.mjs --built dist artifacts/playground",
272
280
  "build:playground": "node scripts/build-playground.mjs",
273
- "test:playground": "npm run build:playground && npm run check:font-hotlinks:built && node test/playground-pptx.mjs && node test/playground-script-fonts.mjs artifacts/playground-script-fonts && node test/playground-lazy-fonts.mjs artifacts/playground-lazy-fonts && node test/playground-base-fonts.mjs artifacts/playground-base-fonts && node test/playground-source.mjs && node test/design-controls-browser.mjs && node test/design-gaps-browser.mjs && node test/chart-options-browser.mjs && node test/persistence-browser.mjs && node test/slides-browser.mjs && node test/slide-navigator-browser.mjs && node test/layout-templates-browser.mjs && node test/template-panel-browser.mjs && node test/numbering-panel-browser.mjs && node test/playground-ensure-fonts.mjs artifacts/playground-ensure-fonts && node test/canvas-fonts-browser.mjs artifacts/canvas-fonts && node test/find-replace-browser.mjs && node test/image-crop-browser.mjs && node test/mobile-browser.mjs && node test/review-panel-browser.mjs && node test/review-hook-browser.mjs && node test/source-pane-browser.mjs && node test/playground-download.mjs artifacts/playground-download && node test/export-menu-browser.mjs && node test/agent-handoff-browser.mjs",
281
+ "test:playground": "npm run build:playground && npm run check:font-hotlinks:built && node test/playground-pptx.mjs && node test/playground-script-fonts.mjs artifacts/playground-script-fonts && node test/playground-lazy-fonts.mjs artifacts/playground-lazy-fonts && node test/playground-base-fonts.mjs artifacts/playground-base-fonts && node test/playground-source.mjs && node test/design-controls-browser.mjs && node test/design-gaps-browser.mjs && node test/chart-options-browser.mjs && node test/persistence-browser.mjs && node test/slides-browser.mjs && node test/slide-navigator-browser.mjs && node test/layout-templates-browser.mjs && node test/template-panel-browser.mjs && node test/numbering-panel-browser.mjs && node test/playground-ensure-fonts.mjs artifacts/playground-ensure-fonts && node test/canvas-fonts-browser.mjs artifacts/canvas-fonts && node test/find-replace-browser.mjs && node test/image-crop-browser.mjs && node test/mobile-browser.mjs && node test/review-panel-browser.mjs && node test/review-hook-browser.mjs && node test/source-pane-browser.mjs && node test/playground-download.mjs artifacts/playground-download && node test/export-menu-browser.mjs && node test/agent-handoff-browser.mjs && node test/presenter-browser.mjs && node test/present-button-browser.mjs",
274
282
  "test:code-browser": "node test/code-browser.mjs",
275
283
  "test:selection-browser": "node test/selection-browser.mjs",
276
284
  "test:click-entry-browser": "node test/click-entry-browser.mjs",
@@ -297,6 +305,8 @@
297
305
  "test:export-license-browser": "node test/export-license-browser.mjs",
298
306
  "test:chart-options-browser": "node test/chart-options-browser.mjs",
299
307
  "test:numbering-panel-browser": "node test/numbering-panel-browser.mjs",
308
+ "test:presenter-browser": "node test/presenter-browser.mjs",
309
+ "test:present-button-browser": "node test/present-button-browser.mjs",
300
310
  "test:export-menu-browser": "node test/export-menu-browser.mjs",
301
311
  "test:agent-handoff-browser": "node test/agent-handoff-browser.mjs"
302
312
  },