@openpresentation/opf-editor 0.26.0 → 0.26.2

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.
@@ -3,22 +3,7 @@ import { listCatalogRecords } from "./catalogs.js";
3
3
  import { resolveSchema, schemaAtPath, schemaVariants } from "./schema.js";
4
4
  import { findNodeAtOffset, getNodePath, parse as parseJsonc, parseTree } from "jsonc-parser";
5
5
  import { LAYOUT_INTENT_GROUPS, layoutRegionSummary } from "./layout-templates.js";
6
- // The catalog kind a field references (OPF 0.15: every content kind is `id` or `name:id`), read from where the field sits.
7
- const ROOT_REFERENCES = { narrative: "narratives", audience: "audiences", purpose: "purposes", tone: "tones" };
8
- const DESIGN_REFERENCES = { theme: "themes", colorScheme: "colorSchemes", fontScheme: "fontSchemes" };
9
- function catalogAtPath(path) {
10
- const parts = path.map(String);
11
- // A reference's object form names its record with `id`; an audience array's entries are references too.
12
- const field = parts.at(-1) === "id" ? parts.slice(0, -1) : parts;
13
- const tail = /^\d+$/.test(field.at(-1) ?? "") && field.at(-2) === "audience" ? field.slice(0, -1) : field;
14
- const key = tail.at(-1);
15
- if (tail.length === 3 && tail[0] === "slides" && key === "layout") return "layouts";
16
- if (tail.length === 1 && ROOT_REFERENCES[key]) return ROOT_REFERENCES[key];
17
- if (DESIGN_REFERENCES[key] && ((tail.length === 2 && tail[0] === "design") || (tail.length === 4 && tail[0] === "slides" && tail[2] === "design"))) return DESIGN_REFERENCES[key];
18
- // Inside an embedded record: a theme's colour or font scheme.
19
- if (tail.length === 5 && tail[0] === "catalogs" && tail[2] === "themes" && (key === "colorScheme" || key === "fontScheme")) return DESIGN_REFERENCES[key];
20
- return undefined;
21
- }
6
+ import { catalogKindAt } from "@openpresentation/opf/schema";
22
7
  const sourceDetails = {
23
8
  "Loaded catalog": ["Provided by app", "Records from a catalog this app registered; nothing is fetched from a URL. Saving embeds the record in the document."],
24
9
  "Document catalog": ["In this document", "Embedded in this document’s catalogs. Takes precedence over the app’s records with the same reference."],
@@ -44,7 +29,8 @@ function describeOptions(choices, records, current, catalog) {
44
29
  function fieldOptions(presentation, path, loadedCatalogs, { string, boolean }) {
45
30
  const schema = schemaAtPath(presentation, path.map(String));
46
31
  const variants = schemaVariants(schema);
47
- const catalog = catalogAtPath(path);
32
+ // A reference's object form names its record with `id`: the field is the reference.
33
+ const segments = path.map(String), catalog = catalogKindAt(segments.at(-1) === "id" ? segments.slice(0, -1) : segments, presentation);
48
34
  const options = new Map();
49
35
  const records = new Map();
50
36
  // A document group whose source no registered catalog matches: its records beyond the embedded ones are not available here.
@@ -136,13 +122,15 @@ export function fieldOptionEdit(context, value) {
136
122
  const PLACEHOLDER = "__opf_completion__";
137
123
  const WORD = /[A-Za-z0-9_.-]/;
138
124
  // The token being typed: inside a string (a quote opened earlier on this line) or a bare word. JSON strings never span lines.
139
- function tokenAt(text, position, explicit) {
125
+ export function tokenAt(text, position, explicit) {
140
126
  const lineStart = Math.max(text.lastIndexOf("\n", position - 1), text.lastIndexOf("\r", position - 1)) + 1;
141
- let open = -1;
127
+ let open = -1, last = null;
142
128
  for (let index = lineStart; index < position; index++) {
143
- if (open >= 0) { if (text[index] === "\\") index++; else if (text[index] === '"') open = -1; }
129
+ if (open >= 0) { if (text[index] === "\\") index++; else if (text[index] === '"') { last = { start: open, end: index }; open = -1; } }
144
130
  else if (text[index] === '"') open = index;
145
131
  }
132
+ // Asked for explicitly (Ctrl+Space) right after a closing quote: the string just ended is the token, as in VS Code.
133
+ if (open < 0 && explicit && last?.end === position - 1) return { quoted: true, closed: true, start: last.start, end: position, from: last.start + 1, to: position - 1 };
146
134
  if (open >= 0) {
147
135
  let close = -1;
148
136
  for (let index = position; index < text.length && text[index] !== "\n" && text[index] !== "\r"; index++) {
package/dist/presenter.js CHANGED
@@ -19,6 +19,29 @@ const CLICK_GUARD_MS = 450;
19
19
  const NEXT_KEYS = new Set(["ArrowRight", " ", "PageDown", "Enter"]);
20
20
  const PREVIOUS_KEYS = new Set(["ArrowLeft", "PageUp", "Backspace"]);
21
21
 
22
+ // Full screen belongs to the document, not to one presenter: the browser ends whatever is full screen when an exit it was asked for runs, and tells
23
+ // every listener. A presenter that closed early asks for its exit late (when its pending full screen request resolves), possibly after a newer
24
+ // presenter took full screen. So the exits the presenters asked for are counted per document, and any presenter reads a `fullscreenchange` that
25
+ // answers one of them as ours, not as the user leaving full screen.
26
+ const exitRequests = new WeakMap(); // Document -> Set of exits asked for and not yet reported
27
+ const attributedEvents = new WeakMap(); // fullscreenchange Event -> whether it answered an exit a presenter asked for
28
+ function noteExit(doc, win) {
29
+ let requests = exitRequests.get(doc);
30
+ if (!requests) exitRequests.set(doc, requests = new Set());
31
+ const request = {};
32
+ requests.add(request);
33
+ // The browser reports an exit with the event; when it does not (the exit was refused, or two exits became one event), stop counting it soon.
34
+ return { forget: () => requests.delete(request), forgetSoon: () => win.setTimeout(() => requests.delete(request), 500) };
35
+ }
36
+ function answersAnExit(doc, event) {
37
+ if (attributedEvents.has(event)) return attributedEvents.get(event);
38
+ const requests = exitRequests.get(doc);
39
+ const ours = Boolean(requests?.size) && !doc.fullscreenElement;
40
+ if (ours) requests.delete(requests.values().next().value);
41
+ attributedEvents.set(event, ours);
42
+ return ours;
43
+ }
44
+
22
45
  const DEFAULT_LABELS = {
23
46
  /** The accessible name of the presenter dialog. */
24
47
  dialog: "Presentation",
@@ -69,7 +92,7 @@ export function createPresenter(container, options = {}) {
69
92
  // Drawn slides by deck index, for the deck object they were drawn from: a new snapshot (an edit) or a new start() draws again.
70
93
  let drawn = new Map(), drawnFor;
71
94
 
72
- let active = false, destroyed = false, current = -1, opener = null, enteredFullscreen = false, leaving = false, returnTo = null;
95
+ let active = false, destroyed = false, current = -1, opener = null, enteredFullscreen = false, returnTo = null;
73
96
  let counterTimer, preloadTimer, renderToken = 0, swipe = null, clickGuardUntil = 0;
74
97
 
75
98
  // ---- the DOM ----------------------------------------------------------------------------------------------------------------------------
@@ -208,11 +231,12 @@ export function createPresenter(container, options = {}) {
208
231
  () => "overlay",
209
232
  );
210
233
  }
211
- // Ask the browser to leave full screen; onFullscreenChange then sees `leaving` and does not read the exit as the user's. Focus goes back to the
212
- // element that opened the presentation once the exit is done, because the page behind a full screen element takes no focus until then.
234
+ // Ask the browser to leave full screen; onFullscreenChange then sees that the event answers an exit asked for (by this presenter or an earlier
235
+ // one) and does not read it as the user's. Focus goes back to the element that opened the presentation once the exit is done, because the page
236
+ // behind a full screen element takes no focus until then.
213
237
  function leaveFullscreen() {
214
- leaving = true;
215
- Promise.resolve(doc.exitFullscreen?.()).then(restoreFocus, () => { leaving = false; });
238
+ const exit = noteExit(doc, win);
239
+ Promise.resolve(doc.exitFullscreen?.()).then(() => { exit.forgetSoon(); restoreFocus(); }, exit.forget);
216
240
  }
217
241
  // Only when nobody took focus since: the host may move it in `onExit`.
218
242
  function restoreFocus() {
@@ -220,11 +244,11 @@ export function createPresenter(container, options = {}) {
220
244
  if (returnTo?.isConnected && typeof returnTo.focus === "function" && (!now || now === doc.body || now === root)) returnTo.focus({ preventScroll: true });
221
245
  }
222
246
  // The browser takes full screen away with Esc (that key never reaches the page) or the host exits it: the presentation ends with it.
223
- function onFullscreenChange() {
247
+ function onFullscreenChange(event) {
224
248
  if (doc.fullscreenElement === root) return;
225
- if (leaving) {
226
- // The exit that stop() asked for finished. If the host started again meanwhile, full screen is gone and the overlay carries on.
227
- leaving = false;
249
+ if (answersAnExit(doc, event)) {
250
+ // An exit that a presenter asked for finished: ours from stop(), or an earlier presenter's that ran after this one took full screen. A
251
+ // presentation still running carries on as the overlay.
228
252
  if (active && enteredFullscreen) { enteredFullscreen = false; root.dataset.mode = "overlay"; }
229
253
  return;
230
254
  }
package/dist/schema.js CHANGED
@@ -1,122 +1,5 @@
1
- import { schemas, catalogSchemaNames } from '@openpresentation/opf';
2
- import { getValueAtPath, splitOpfPath, opfPathToJsonPointer } from './index.js';
1
+ // The schema helpers moved to core (`@openpresentation/opf/schema`), where the CLI, agents and every editor read the schemas one way. This module
2
+ // keeps the editor's exports.
3
+ import { schemas } from '@openpresentation/opf';
4
+ export { activeSchema, createSchemaValue, listSchemaFields, resolveSchema, schemaAtPath, schemaLabel, schemaType, schemaVariants } from '@openpresentation/opf/schema';
3
5
  export const opfSchemas = schemas;
4
- const documents = new Map(Object.values(schemas).map(schema => [schema.$id, schema]));
5
- const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
6
- // An allOf of shaping members (the SlideDesign definition is Design plus a `not` constraint) reads as one schema: members are
7
- // merged, properties included; constraint-only members (`not`, `required`) shape nothing a picker can show and are dropped.
8
- function mergeAllOf(schema, root, seen) {
9
- if (!Array.isArray(schema.allOf)) return schema;
10
- const {allOf, ...base} = schema;
11
- let merged = base;
12
- for (const member of allOf) {
13
- const resolved = resolveSchema(member, root, new Set(seen));
14
- if (!resolved.properties && !resolved.type && !resolved.oneOf && !resolved.anyOf) continue;
15
- merged = {...resolved, ...merged, ...(resolved.properties || merged.properties ? {properties: {...resolved.properties, ...merged.properties}} : {})};
16
- }
17
- return merged;
18
- }
19
- export function resolveSchema(schema, root = schemas.presentation, seen = new Set()) {
20
- if (!schema || typeof schema !== 'object') return {};
21
- if (!schema.$ref) return mergeAllOf(schema, root, seen);
22
- const ref = schema.$ref;
23
- if (seen.has(ref)) return {};
24
- seen.add(ref);
25
- const [id, fragment = ''] = ref.split('#');
26
- const target = id ? documents.get(id) : root;
27
- if (!target) throw new Error(`Unresolved OPF schema: ${ref}`);
28
- const resolved = fragment ? getValueAtPath(target, fragment) : target;
29
- if (!resolved) throw new Error(`Unresolved OPF schema: ${ref}`);
30
- const { $ref, ...rest } = schema;
31
- return mergeAllOf({...resolveSchema(resolved, target, seen), ...rest}, target, seen);
32
- }
33
- export function schemaVariants(schema, root = schemas.presentation) {
34
- const resolved = resolveSchema(schema, root);
35
- if (Array.isArray(resolved.type)) return resolved.type.flatMap(type=>schemaVariants({...resolved,type},root));
36
- const variants = resolved.oneOf ?? resolved.anyOf;
37
- if (!variants) return [resolved];
38
- const {$ref, oneOf, anyOf, ...base} = resolved;
39
- return variants.flatMap(child => schemaVariants(child, root).map(variant => {
40
- // A branch that only names required fields (Watermark: exactly one of src and text) adds to the base's required list and is
41
- // labelled by them, so the generic form offers "src" and "text" as two forms of one object.
42
- const requiredOnly = !variant.type && !variant.properties && !variant.$ref && Array.isArray(variant.required);
43
- return {...base, ...variant, properties: base.properties || variant.properties ? {...base.properties,...variant.properties} : undefined,
44
- ...(base.required || variant.required ? {required: [...new Set([...(base.required ?? []), ...(variant.required ?? [])])]} : {}),
45
- ...(requiredOnly && !variant.title ? {title: variant.required.join(' + ')} : {})};
46
- }));
47
- }
48
- export function schemaType(schema, value) {
49
- return schema.type ?? (schema.properties || schema.additionalProperties ? 'object' : schema.items ? 'array' : schema.const !== undefined ? typeof schema.const : value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value === 'undefined' ? 'string' : typeof value);
50
- }
51
- function score(schema, value) {
52
- const type=schemaType(schema,value);
53
- if (type === 'integer' ? !Number.isInteger(value) : type === 'array' ? !Array.isArray(value) : type === 'null' ? value !== null : typeof value !== type || (type === 'object' && (value === null || Array.isArray(value)))) return -100;
54
- if (own(schema,'const')) return JSON.stringify(schema.const)===JSON.stringify(value)?100:-100;
55
- if(schema.enum && !schema.enum.includes(value))return -100;
56
- let result=1;
57
- for(const [key,child] of Object.entries(schema.properties??{})) {
58
- if(!value || !own(value,key))continue;
59
- if(own(child,'const'))result+=value[key]===child.const?100:-1000;
60
- else result+=1;
61
- }
62
- for(const key of schema.required??[])if(!value || !own(value,key))result-=2;
63
- return result;
64
- }
65
- export function activeSchema(schema, value, root=schemas.presentation) {
66
- return schemaVariants(schema,root).sort((a,b)=>score(b,value)-score(a,value))[0] ?? {};
67
- }
68
- export function schemaAtPath(presentation, path, root = schemas.presentation) {
69
- const parts=splitOpfPath(path);let current=root,value=presentation;
70
- for(let index=0; index<parts.length;index++) {
71
- const key=parts[index], resolved=activeSchema(current,value,root);
72
- if(parts[0]==='catalogs' && index===3 && catalogSchemaNames[parts[2]]) current=schemas[catalogSchemaNames[parts[2]]]??{};
73
- else current=Array.isArray(value)||resolved.type==='array' ? resolved.items??{} : resolved.properties?.[key] ?? Object.entries(resolved.patternProperties??{}).find(([pattern])=>new RegExp(pattern).test(key))?.[1] ?? (typeof resolved.additionalProperties==='object'?resolved.additionalProperties:{});
74
- value=value?.[key];
75
- }
76
- return resolveSchema(current,root);
77
- }
78
- export function schemaLabel(schema, index=0) {
79
- const type=schemaType(schema);
80
- return schema.title ?? schema.properties?.type?.const ?? (schema.const!==undefined?String(schema.const):`${type}${schema.properties ? ` · ${Object.keys(schema.properties).slice(0,3).join(', ')}` : ''}`) ?? `Option ${index+1}`;
81
- }
82
- export function createSchemaValue(schema, root=schemas.presentation, depth=0) {
83
- if(depth>20)throw new Error('Choose a shallower initial value for this recursive structure.');
84
- const variants=schemaVariants(schema,root), s=variants[0]??{};
85
- if(own(s,'const'))return structuredClone(s.const);
86
- if(own(s,'default'))return structuredClone(s.default);
87
- if(s.enum?.length)return structuredClone(s.enum[0]);
88
- const type=schemaType(s);
89
- if(type==='object'){
90
- const value={};
91
- for(const key of new Set([...(s.required??[]),...Object.keys(s.properties??{}).filter(key=>own(s.properties[key],'const'))])) value[key]=createSchemaValue(s.properties?.[key]??{},root,depth+1);
92
- if(s.anyOf || s.oneOf){ /* Variants have already been expanded. */ }
93
- return value;
94
- }
95
- if(type==='array')return Array.from({length:Math.min(s.minItems??0,100)},()=>createSchemaValue(s.items??{},root,depth+1));
96
- if(type==='boolean')return false;
97
- if(type==='null')return null;
98
- if(type==='number'||type==='integer')return s.minimum??(s.exclusiveMinimum!==undefined?s.exclusiveMinimum+1:0);
99
- if(s.examples?.length && typeof s.examples[0]==='string' && !s.examples[0].includes('...'))return s.examples[0];
100
- if(s.format==='uri')return 'https://example.com';
101
- if(s.pattern?.includes('a-z'))return 'example';
102
- return s.minLength?'Text':'';
103
- }
104
- export function listSchemaFields() {
105
- const result=[];
106
- for(const [name,root] of Object.entries(schemas)) {
107
- const walk=(schema,path)=>{
108
- if(!schema || typeof schema!=='object')return;
109
- for(const [key,child]of Object.entries(schema.properties??{})){
110
- result.push({schema:name,path:opfPathToJsonPointer([...path,'properties',key]),name:key,description:child.description??'',reference:child.$ref??null,type:child.type??null,values:child.enum??(own(child,'const')?[child.const]:null)});
111
- walk(child,[...path,'properties',key]);
112
- }
113
- for(const [key,child]of Object.entries(schema.$defs??{}))walk(child,[...path,'$defs',key]);
114
- for(const keyword of ['oneOf','anyOf','allOf']) (schema[keyword]??[]).forEach((child,index)=>walk(child,[...path,keyword,String(index)]));
115
- if(schema.items)walk(schema.items,[...path,'items']);
116
- if(typeof schema.additionalProperties==='object')walk(schema.additionalProperties,[...path,'additionalProperties']);
117
- for(const [key,child]of Object.entries(schema.patternProperties??{}))walk(child,[...path,'patternProperties',key]);
118
- };
119
- walk(root,[]);
120
- }
121
- return result;
122
- }
@@ -0,0 +1,109 @@
1
+ // Screen reader support for the source editor (artifacts/source-editor/SPEC.md, section 13): one polite live region next to the code that says
2
+ // what happened while the author types (how many suggestions, which one is selected, what was inserted, which tab stop a snippet is at), and an
3
+ // accessible name for every suggestion that carries its kind and its place in the list. Plain DOM, no network.
4
+ import { ViewPlugin } from "@codemirror/view";
5
+ import { completionStatus, currentCompletions, pickedCompletion, selectedCompletion } from "@codemirror/autocomplete";
6
+
7
+ const CLEAR_AFTER = 1500;
8
+ const SELECTION_DELAY = 100;
9
+ const MERGE_WITHIN = 400;
10
+ const KIND_WORDS = { property: "property", enum: "value", boolean: "value", catalog: "catalog id", reference: "reference", snippet: "snippet", keyword: "keyword", text: "text" };
11
+ const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
12
+
13
+ /**
14
+ * The live region: `say(message)` replaces its text (an identical message in a row is announced again), and the text goes after 1.5 seconds.
15
+ * `onSay` also receives every message (hosts mirror them into their own region; tests read them).
16
+ */
17
+ export function createAnnouncer(parent, onSay) {
18
+ const document = parent.ownerDocument;
19
+ const region = document.createElement("div");
20
+ region.className = "opf-source-live";
21
+ region.setAttribute("role", "status");
22
+ region.setAttribute("aria-live", "polite");
23
+ region.setAttribute("aria-atomic", "true");
24
+ parent.append(region);
25
+ let timer = null, toggle = false;
26
+ return {
27
+ element: region,
28
+ say(message) {
29
+ if (!message) return;
30
+ clearTimeout(timer);
31
+ toggle = !toggle;
32
+ region.textContent = toggle ? message : `${message}​`;
33
+ onSay?.(message);
34
+ timer = setTimeout(() => { region.textContent = ""; }, CLEAR_AFTER);
35
+ },
36
+ destroy() { clearTimeout(timer); region.remove(); },
37
+ };
38
+ }
39
+
40
+ /** The first sentence of a documentation text, for the selection announcement. */
41
+ export function firstSentence(text) {
42
+ if (typeof text !== "string") return "";
43
+ const flat = text.replace(/\s+/g, " ").trim();
44
+ return /^.*?[.!?](?=\s|$)/.exec(flat)?.[0] ?? flat;
45
+ }
46
+
47
+ /** The accessible name of one suggestion: `label, kind[, required][, detail]. n of total`. */
48
+ export function optionName(completion, index, total) {
49
+ const kind = KIND_WORDS[completion.type] ?? completion.type ?? "";
50
+ const parts = [completion.label];
51
+ if (kind) parts.push(kind);
52
+ if (completion.opfRequired) parts.push("required");
53
+ if (completion.detail && completion.detail !== kind) parts.push(completion.detail);
54
+ return `${parts.join(", ")}. ${index + 1} of ${total}`;
55
+ }
56
+
57
+ /**
58
+ * The editor extension: announces the list opening (the count), the selection changing (label, kind and the first sentence of its
59
+ * documentation; after a short pause so arrowing through the list is not read item by item) and a suggestion being inserted; and names
60
+ * every rendered option for assistive technology. `describe(completion)` gives the documentation text of an item.
61
+ */
62
+ export function a11yExtension(announcer, describe = () => "") {
63
+ return ViewPlugin.fromClass(class {
64
+ constructor(view) {
65
+ this.view = view;
66
+ this.open = false;
67
+ this.selected = null;
68
+ this.timer = null;
69
+ this.labelTimer = null;
70
+ }
71
+ update(update) {
72
+ const { state } = update;
73
+ for (const tr of update.transactions) {
74
+ const picked = tr.annotation(pickedCompletion);
75
+ if (picked) announcer.say(`Inserted ${picked.label}`);
76
+ }
77
+ const open = completionStatus(state) === "active" && currentCompletions(state).length > 0;
78
+ const selected = open ? selectedCompletion(state) : null;
79
+ if (open && !this.open) {
80
+ announcer.say(plural(currentCompletions(state).length, "suggestion"));
81
+ } else if (open && selected && selected !== this.selected) {
82
+ clearTimeout(this.timer);
83
+ this.timer = setTimeout(() => {
84
+ const text = firstSentence(describe(selected));
85
+ const kind = KIND_WORDS[selected.type] ?? selected.type ?? "";
86
+ announcer.say(`${selected.label}${kind ? `, ${kind}` : ""}${selected.opfRequired ? ", required" : ""}.${text ? ` ${text}` : ""}`);
87
+ }, SELECTION_DELAY);
88
+ }
89
+ if (!open) clearTimeout(this.timer);
90
+ this.open = open;
91
+ this.selected = selected;
92
+ if (open) {
93
+ clearTimeout(this.labelTimer);
94
+ this.labelTimer = setTimeout(() => this.label(), 0);
95
+ }
96
+ }
97
+ label() {
98
+ const { view } = this, { state } = view;
99
+ const completions = currentCompletions(state);
100
+ if (!completions.length) return;
101
+ for (const node of view.dom.querySelectorAll(".cm-tooltip-autocomplete [role=option]")) {
102
+ const index = Number(node.id.slice(node.id.lastIndexOf("-") + 1));
103
+ const completion = completions[index];
104
+ if (completion) node.setAttribute("aria-label", optionName(completion, index, completions.length));
105
+ }
106
+ }
107
+ destroy() { clearTimeout(this.timer); clearTimeout(this.labelTimer); }
108
+ });
109
+ }