@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.
- package/README.md +64 -1
- package/dist/json-editor.d.ts +2 -0
- package/dist/json-editor.js +34 -5
- package/dist/json-options.d.ts +21 -0
- package/dist/json-options.js +127 -27
- package/dist/presenter.d.ts +102 -0
- package/dist/presenter.js +429 -0
- package/dist/read-deck-file.d.ts +59 -0
- package/dist/read-deck-file.js +142 -0
- package/dist/source-findings.js +27 -0
- package/dist/source-pane.d.ts +2 -0
- package/dist/source-pane.js +28 -23
- package/dist/styles.css +202 -1
- package/package.json +12 -2
package/README.md
CHANGED
|
@@ -104,6 +104,7 @@ const pane = createSourcePane(container, {
|
|
|
104
104
|
onDownload: file => saveAs(file.filename, file.text), // default: a Blob link named <name>.opf.<ext>
|
|
105
105
|
density: 'comfortable', // 'touch' makes the controls 40px
|
|
106
106
|
readOnly: false,
|
|
107
|
+
completions: true, // live property and value suggestions while typing JSON (default true)
|
|
107
108
|
});
|
|
108
109
|
pane.setFormat('yaml'); pane.focusPath('/slides/1/title'); pane.getText(); pane.destroy();
|
|
109
110
|
```
|
|
@@ -113,7 +114,8 @@ Methods: `setFindings(list | null)`, `setFormat(format)`, `focusPath(pointer)` (
|
|
|
113
114
|
- **JSON is canonical** and keeps the author's bytes: a YAML or Markdown edit rewrites only the changed JSON tokens (`exact-source`), and Undo restores the exact spelling.
|
|
114
115
|
- **YAML and Markdown** are core's browser-safe converters, `@openpresentation/opf/yaml` and `/markdown`. Switching format writes the session's document in the new format. An edit parses back to the document and reaches the session as one `replace` patch, after a short delay (`applyDelay`, default 250 ms) so one burst of typing is one undo step. A syntax error, or an OPF rule the session refuses, is listed with its line and never reaches the document; Undo, Redo and outside edits replace a draft that did not parse.
|
|
115
116
|
- **Nothing is lost silently.** Markdown writes what its dialect has no syntax for (a `design`, region keys, nested groups) as YAML in `opf-slide` and `opf-block` fences; each such part is a warning finding with its JSON Pointer and reason. Comments in YAML cannot survive a rewrite from the document: Format and outside edits drop them and say how many. A document that is not valid OPF cannot be written as YAML or Markdown; the pane stays in JSON and says so.
|
|
116
|
-
- **
|
|
117
|
+
- **Completions.** In JSON the pane offers property names and values as you type (`completions: false` keeps the field menu only): the keys the schema allows at the cursor's object, and the enum, boolean and catalog values of the field, from the same data as the field menu (`getJsonCompletions` in `/json-options`, which reads drafts that do not parse yet). Enter accepts, Escape closes, and Ctrl+Space or Alt+ArrowDown still open the field menu. YAML and Markdown keep the menu only. `mountJsonCodeEditor` takes the same `completions` option, off by default there.
|
|
118
|
+
- **Accessibility.** The editor's `aria-invalid` is `true` while the findings include an error and `false` otherwise. The format button (`aria-label="Source format"`) opens a `role="menu"` of `menuitemradio` items: Arrow keys, Home, End, Enter, Escape (focus returns to the button) and Tab. The file name is a button that becomes a text input; Enter or Escape commits. Controls show a 2px `--opf-ui-ring` focus outline.
|
|
117
119
|
- **Styles.** `src/styles/source-pane.css` uses only `--opf-ui-*` tokens under the `opf-source-` prefix. It ships in the editor's `styles.css` (`@openpresentation/opf-editor/styles.css`), which also defines the tokens.
|
|
118
120
|
- **Dependency.** `@codemirror/lang-yaml` (MIT) highlights YAML; Markdown uses a small stream tokenizer built on `@codemirror/language`, so no Markdown, HTML, CSS or JavaScript grammar is added.
|
|
119
121
|
|
|
@@ -829,6 +831,67 @@ const dialog = createAgentHandoffDialog(document.body, { document: deck, target:
|
|
|
829
831
|
|
|
830
832
|
Icons are inlined path data from [Lucide](https://lucide.dev) (ISC license), never loaded from a CDN.
|
|
831
833
|
|
|
834
|
+
## Presenter and Present button (RR-98)
|
|
835
|
+
|
|
836
|
+
`@openpresentation/opf-editor/presenter` shows the deck full screen, one slide at a time. It is plain DOM like the other modules, with no network, no fonts or CSS fetched at runtime, and no second renderer: slides are drawn by `toSvg` with the same fonts handle, catalogs and render options the canvas and the thumbnails use. Its CSS is `src/styles/presenter.css` (classes `opf-presenter-*` and `opf-present-button`; `--opf-ui-*` tokens only, part of `styles.css`). The module needs `@openpresentation/opf-render`, an optional peer, like the thumbnail strip.
|
|
837
|
+
|
|
838
|
+
```js
|
|
839
|
+
import { createPresenter, createPresentButton, bindPresentShortcut } from "@openpresentation/opf-editor/presenter";
|
|
840
|
+
|
|
841
|
+
const presenter = createPresenter(document.body, {
|
|
842
|
+
editor, // or presentation: deck (an object, or a function returning it)
|
|
843
|
+
fonts, // the renderer's fonts handle, the same one the canvas and the exports use
|
|
844
|
+
renderOptions: { catalogs }, // merged after the session's catalogs
|
|
845
|
+
slideIndex: current, // the slide start() opens on (default 0)
|
|
846
|
+
onSlideChange: (index) => {}, // the shown slide changed (deck index)
|
|
847
|
+
onExit: (index) => { current = index; redraw(); }, // by any route: the host returns to that slide
|
|
848
|
+
density: "comfortable", // "touch": the exit button is 40px
|
|
849
|
+
});
|
|
850
|
+
createPresentButton(document.querySelector("#present"), { onPresent: () => presenter.start(current) });
|
|
851
|
+
bindPresentShortcut(document, () => presenter.start(current)); // F5
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
`createPresenter(container, options)` returns `{ element, start(slideIndex?), stop(), next(), previous(), goTo(index), getSlide(), isRunning(), destroy() }`. The presenter stays hidden until `start`. Call `start` from a user gesture (a click or a key press): the browser grants full screen only then. `start` resolves `"fullscreen"` or `"overlay"`.
|
|
855
|
+
|
|
856
|
+
- **Display.** The presenter's own element goes full screen through the Fullscreen API. When the browser refuses (no gesture, an iframe without `allow="fullscreen"`, iPhone Safari), the same element is a fixed overlay over the window, so everything below works the same. Slides fill the viewport and letterbox on black. The next and previous slides are drawn ahead, so advancing draws nothing. Slides draw with their `{{variables}}` resolved, as the exports do.
|
|
857
|
+
- **Navigation.** Right, Space, PageDown and Enter go forward; Left, PageUp and Backspace go back; Home and End jump; Esc exits. A click advances, and a click on the left third goes back. On touch, a swipe left goes forward and a swipe right goes back; a tap uses the same zones. Keys with Ctrl, Alt or Meta are left to the browser, and the keys the presenter handles do not reach the host page.
|
|
858
|
+
- **Hidden slides** are skipped, as the exports skip them (`slidesToConvert`; `includeHidden: true` presents them). `start(i)` and `goTo(i)` on a hidden slide show the next slide that is not hidden (`onSlideChange` says which). A deck with only hidden slides shows a message and `onExit(-1)`.
|
|
859
|
+
- **Counter.** A subtle "03 / 12" (mono, the navigator's format; hidden slides are not counted) and the exit button show on pointer move and hide after 2 seconds, with the cursor.
|
|
860
|
+
- **Exit.** Esc, the exit button, leaving full screen (the browser's Esc), `stop()` and `destroy()` all call `onExit(index)` with the deck index of the slide shown, then focus returns to the element that was focused at `start`.
|
|
861
|
+
- **Accessibility.** The element is `role="dialog"` with `aria-modal="true"` and an `aria-label` ("Presentation"; `labels.dialog`). Focus moves into it on start and stays in it (Tab cycles the dialog and the exit button). An `aria-live="polite"` region announces "Slide 3 of 12" (`labels.position`).
|
|
862
|
+
- **Fonts.** Like the canvas, the presenter waits for the faces the deck needs (`fonts.pending` and `fonts.ensure`) and shows "Loading fonts…" meanwhile.
|
|
863
|
+
|
|
864
|
+
`createPresentButton(container, { onPresent, disabled, density })` mounts an icon-only button (the Lucide Presentation icon, inlined) named "Present" (`aria-label`), titled "Present (F5)" and styled like the Export menu's trigger. `disabled` is `true` or a reason string (the title); the button stays focusable (`aria-disabled`) and does nothing. It returns `{ element, setDisabled(reason), setDensity(density), destroy() }`. `bindPresentShortcut(target, onPresent)` binds F5 (and prevents the browser's reload); it ignores F5 while typing in inputs, selects and editors (CodeMirror, contenteditable), in menus and dialogs, and with a modifier key, and returns a function that removes the listener. `examples/playground.js` wires all three next to the Export menu.
|
|
865
|
+
|
|
866
|
+
Tests: `npm run test:presenter-browser` and `npm run test:present-button-browser`.
|
|
867
|
+
|
|
868
|
+
## Opening a deck file (RR-98)
|
|
869
|
+
|
|
870
|
+
`@openpresentation/opf-editor/read-deck-file` reads a deck file in any OPF source format, with no DOM:
|
|
871
|
+
|
|
872
|
+
```js
|
|
873
|
+
import { readDeckFile, DECK_FILE_ACCEPT, DeckFileError } from "@openpresentation/opf-editor/read-deck-file";
|
|
874
|
+
|
|
875
|
+
input.accept = DECK_FILE_ACCEPT; // ".json,.opf,.yaml,.yml,.md"
|
|
876
|
+
input.onchange = async () => {
|
|
877
|
+
try {
|
|
878
|
+
const { document, format, name, findings } = await readDeckFile(input.files[0], { catalogs });
|
|
879
|
+
const editor = createEditorSession(document, { catalogs }); // the session validates it; findings say what to fix first
|
|
880
|
+
} catch (error) {
|
|
881
|
+
if (error instanceof DeckFileError) show(error.code, error.message, error.findings);
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
`readDeckFile(file | { name, text }, { catalogs? })` takes a `File` (or anything with `name` and `text()`), or `{ name, text }` with the text as a string, and returns `{ document, format: "json" | "yaml" | "md", name, findings }`.
|
|
887
|
+
|
|
888
|
+
- **Format.** The extension picks it: `.yaml` and `.yml` are YAML, `.md` is Markdown, and `.json`, `.opf` or anything else is JSON. If JSON fails to parse, the text is tried as YAML, and as Markdown when it starts with front matter (`---`); `format` is the one that read it. YAML and Markdown go through core's browser-safe `fromYaml` and `fromMarkdown` (`@openpresentation/opf/yaml` and `/markdown`, loaded on first use). JSON that wraps a deck (`opfSnippet`, `opf`, `presentation`) is unwrapped as `parseOpfTransfer` does.
|
|
889
|
+
- **Name.** The file name without a directory and without `.opf.json`, `.opf.yaml`, `.opf.md` or its plain extension (`Q3 plan.opf.yaml` gives `Q3 plan`).
|
|
890
|
+
- **Findings.** The conversion's findings for YAML and Markdown, in core's `Finding` shape: `path` (a JSON Pointer such as `/slides/1/bogus`), `message`, `severity` and `location` (line and column in the file). A deck that reads but has OPF errors is returned with them, so a host can show them; JSON gives none (the editor session validates it).
|
|
891
|
+
- **Errors.** Text that cannot be read at all throws `DeckFileError` with a `code` and a clear message: `unreadable`, `file-too-large` (over 20 MB), `empty-file`, `unsupported-file` (a .pptx or other binary file), `invalid-json`, `invalid-yaml`, `invalid-markdown` (with the syntax `findings` and the line) and `not-a-deck` (JSON without a `slides` list, such as one slide or a block, which a host can send to its paste path as the playground does).
|
|
892
|
+
|
|
893
|
+
The source pane has no file-opening path of its own, so it keeps no second copy of this. The playground's Import dialog uses `readDeckFile` and `DECK_FILE_ACCEPT` for its file tab and for dropped files (`examples/transfer-controls.js`). Icons in the presenter button are inlined [Lucide](https://lucide.dev) paths (ISC license).
|
|
894
|
+
|
|
832
895
|
## Optional React Bindings
|
|
833
896
|
|
|
834
897
|
React bindings are isolated under `@openpresentation/opf-editor/react` and require the host app to pass its React runtime. The core package does not add React to the critical path.
|
package/dist/json-editor.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface JsonCodeEditorOptions {
|
|
|
23
23
|
/** Highlighting and behavior: "json" (default) adds the parse linter, field highlights and the option menu. */
|
|
24
24
|
language?: "json" | "yaml" | "markdown";
|
|
25
25
|
readOnly?: boolean;
|
|
26
|
+
/** Live completion popup in JSON: property names and enum, boolean and catalog values at the cursor's JSON path, from the same data as the field menu (default false here; the source pane turns it on). Ctrl+Space still opens the field menu. */
|
|
27
|
+
completions?: boolean;
|
|
26
28
|
catalogs?: JsonCatalogContext;
|
|
27
29
|
/** Overrides the built-in menu, allowing an existing host UI to keep its appearance. */
|
|
28
30
|
onOptions?(left?: number, top?: number): void;
|
package/dist/json-editor.js
CHANGED
|
@@ -4,7 +4,7 @@ import { defaultKeymap, history, historyKeymap, indentWithTab, isolateHistory, r
|
|
|
4
4
|
import { HighlightStyle, StreamLanguage, bracketMatching, codeFolding, foldKeymap, indentOnInput, indentUnit, syntaxHighlighting } from "@codemirror/language";
|
|
5
5
|
import { json, jsonParseLinter } from "@codemirror/lang-json";
|
|
6
6
|
import { yaml } from "@codemirror/lang-yaml";
|
|
7
|
-
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
|
|
7
|
+
import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap, startCompletion } from "@codemirror/autocomplete";
|
|
8
8
|
import { highlightSelectionMatches, search, searchKeymap } from "@codemirror/search";
|
|
9
9
|
import { linter } from "@codemirror/lint";
|
|
10
10
|
import { tags } from "@lezer/highlight";
|
|
@@ -65,10 +65,25 @@ const markdownLanguage = StreamLanguage.define({
|
|
|
65
65
|
return null;
|
|
66
66
|
}
|
|
67
67
|
});
|
|
68
|
-
function languageExtension(name) {
|
|
68
|
+
function languageExtension(name, completions) {
|
|
69
69
|
if (name === "yaml") return yaml();
|
|
70
70
|
if (name === "markdown") return markdownLanguage;
|
|
71
|
-
return [json(), linter(jsonParseLinter(), { delay: 500 }), fieldDecorations];
|
|
71
|
+
return [json(), linter(jsonParseLinter(), { delay: 500 }), fieldDecorations, completions ?? []];
|
|
72
|
+
}
|
|
73
|
+
// The completion popup keeps CodeMirror's keys (arrows, Enter, Escape) but not Ctrl-Space, which opens the field menu.
|
|
74
|
+
const completionKeys = Prec.highest(keymap.of(completionKeymap.filter((binding) => binding.run !== startCompletion)));
|
|
75
|
+
// Accept a completion by rewriting its whole token (quotes included), so a string key, a bare word and a changed value type all land
|
|
76
|
+
// as valid JSON. A key adds ": " when none follows and then offers the values of that key.
|
|
77
|
+
function completionApply(found, option) {
|
|
78
|
+
return (view, completion, from, to) => {
|
|
79
|
+
const start = found.quoted ? from - 1 : from;
|
|
80
|
+
const end = found.closed ? to + 1 : to;
|
|
81
|
+
let insert = JSON.stringify(option.value);
|
|
82
|
+
const chain = found.kind === "key" && !/^\s*:/.test(view.state.sliceDoc(end, Math.min(view.state.doc.length, end + 200)));
|
|
83
|
+
if (chain) insert += ": ";
|
|
84
|
+
view.dispatch({ changes: { from: start, to: end, insert }, selection: { anchor: start + insert.length }, userEvent: "input.complete", scrollIntoView: true });
|
|
85
|
+
if (chain) setTimeout(() => startCompletion(view), 0);
|
|
86
|
+
};
|
|
72
87
|
}
|
|
73
88
|
function indentation(source) {
|
|
74
89
|
const spaces = source.match(/^[\t ]+(?=\S)/gm) ?? [];
|
|
@@ -168,6 +183,20 @@ function mountJsonCodeEditor(parent, options) {
|
|
|
168
183
|
menu=mountJsonFieldMenu(parent,api,catalogs,left,top,()=>{menu=null;});
|
|
169
184
|
}catch(error){options.onError?.(error);}
|
|
170
185
|
}
|
|
186
|
+
// Live completions (JSON only, `completions: true`): property names and values at the cursor's JSON path, from the same data as the
|
|
187
|
+
// field menu. The data loads with the field menu's chunk on first use.
|
|
188
|
+
async function completionSource(context) {
|
|
189
|
+
if (disposed || languageName !== "json") return null;
|
|
190
|
+
let module;
|
|
191
|
+
try { module = await import('./json-options.js'); } catch (error) { options.onError?.(error); return null; }
|
|
192
|
+
if (disposed || context.aborted) return null;
|
|
193
|
+
const found = module.getJsonCompletions(context.state.doc.toString(), context.pos, catalogs, { explicit: context.explicit });
|
|
194
|
+
if (!found) return null;
|
|
195
|
+
return { from: found.from, to: found.to, filter: true, options: found.options.map((option) => ({
|
|
196
|
+
label: option.label, detail: option.detail, info: option.info, boost: option.boost, type: found.kind === "key" ? "property" : "enum", apply: completionApply(found, option),
|
|
197
|
+
})) };
|
|
198
|
+
}
|
|
199
|
+
const completionExtension = options.completions ? [autocompletion({ override: [completionSource], defaultKeymap: false, icons: false, maxRenderedOptions: 60 }), completionKeys] : null;
|
|
171
200
|
let currentIndent = indentation(options.code);
|
|
172
201
|
const contentAttributes = (invalid = options.invalid, describedBy = options.describedBy) => EditorView.contentAttributes.of({
|
|
173
202
|
"aria-label": options.label,
|
|
@@ -179,7 +208,7 @@ function mountJsonCodeEditor(parent, options) {
|
|
|
179
208
|
autocorrect: "off"
|
|
180
209
|
});
|
|
181
210
|
const view = new EditorView({ parent, state: EditorState.create({ doc: options.code, extensions: [
|
|
182
|
-
language.of(languageExtension(languageName)),
|
|
211
|
+
language.of(languageExtension(languageName, completionExtension)),
|
|
183
212
|
editable.of(editableExtension(options.readOnly)),
|
|
184
213
|
jsonSource.init(()=>options.code),jsonSourceHistory,
|
|
185
214
|
indent.of(indentUnit.of(currentIndent)),
|
|
@@ -300,7 +329,7 @@ function mountJsonCodeEditor(parent, options) {
|
|
|
300
329
|
setLanguage(name){
|
|
301
330
|
if(name===languageName)return;
|
|
302
331
|
languageName=name;closeOptions();
|
|
303
|
-
view.dispatch({ effects: language.reconfigure(languageExtension(name)) });
|
|
332
|
+
view.dispatch({ effects: language.reconfigure(languageExtension(name, completionExtension)) });
|
|
304
333
|
},
|
|
305
334
|
setReadOnly(value){view.dispatch({ effects: editable.reconfigure(editableExtension(value)) });},
|
|
306
335
|
/** The CodeMirror view, for hosts that scroll or measure it. */
|
package/dist/json-options.d.ts
CHANGED
|
@@ -31,3 +31,24 @@ export declare function getJsonFieldContext(source: string, position: number, lo
|
|
|
31
31
|
export declare function replaceFieldOption(context: JsonFieldContext, value: JsonOptionValue): string;
|
|
32
32
|
/** Minimal single replacement for atomic source-editor undo. */
|
|
33
33
|
export declare function fieldOptionEdit(context: JsonFieldContext, value: JsonOptionValue): {from:number;to:number;insert:string};
|
|
34
|
+
export interface JsonCompletionOption {
|
|
35
|
+
/** A key option's property name, or the JSON value to write. */
|
|
36
|
+
value: JsonOptionValue;
|
|
37
|
+
label: string;
|
|
38
|
+
detail?: string;
|
|
39
|
+
info?: string;
|
|
40
|
+
/** CodeMirror ranking, -99 to 99. */
|
|
41
|
+
boost: number;
|
|
42
|
+
}
|
|
43
|
+
export interface JsonCompletions {
|
|
44
|
+
kind: 'key' | 'value';
|
|
45
|
+
/** The text being typed: inside the quotes of a string, or the bare word. */
|
|
46
|
+
from: number;
|
|
47
|
+
to: number;
|
|
48
|
+
quoted: boolean;
|
|
49
|
+
/** Whether a closing quote follows the typed text. */
|
|
50
|
+
closed: boolean;
|
|
51
|
+
options: JsonCompletionOption[];
|
|
52
|
+
}
|
|
53
|
+
/** The property names and values to offer at `position` of a JSON draft (it need not parse); null when there are none. `explicit` also offers choices where nothing is typed yet. */
|
|
54
|
+
export declare function getJsonCompletions(source: string, position: number, loadedCatalogs?: JsonCatalogContext, options?: { explicit?: boolean }): JsonCompletions | null;
|
package/dist/json-options.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { checkFormat, firstErrorMessage } from "./checks.js";
|
|
2
2
|
import { listCatalogRecords } from "./catalogs.js";
|
|
3
|
-
import { schemaAtPath, schemaVariants } from "./schema.js";
|
|
4
|
-
import { findNodeAtOffset, getNodePath, parseTree } from "jsonc-parser";
|
|
3
|
+
import { resolveSchema, schemaAtPath, schemaVariants } from "./schema.js";
|
|
4
|
+
import { findNodeAtOffset, getNodePath, parse as parseJsonc, parseTree } from "jsonc-parser";
|
|
5
5
|
import { LAYOUT_INTENT_GROUPS, layoutRegionSummary } from "./layout-templates.js";
|
|
6
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
7
|
const ROOT_REFERENCES = { narrative: "narratives", audience: "audiences", purpose: "purposes", tone: "tones" };
|
|
@@ -39,29 +39,9 @@ function describeOptions(choices, records, current, catalog) {
|
|
|
39
39
|
const priority = option => option.source === "Document catalog" ? 2 : option.source === "Loaded catalog" ? 1 : 0;
|
|
40
40
|
return choices.sort((a, b) => (catalog === "layouts" ? layoutGroups.indexOf(a.layoutGroup) - layoutGroups.indexOf(b.layoutGroup) : priority(b) - priority(a)) || a.label.localeCompare(b.label, "en", {numeric: true}) || String(a.value).localeCompare(String(b.value), "en"));
|
|
41
41
|
}
|
|
42
|
-
// The
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
let presentation;
|
|
46
|
-
try {
|
|
47
|
-
presentation = JSON.parse(source);
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
const root = parseTree(source);
|
|
53
|
-
if (!root)
|
|
54
|
-
return null;
|
|
55
|
-
let node = findNodeAtOffset(root, position, true);
|
|
56
|
-
if (node?.parent?.type === "property" && node.parent.children?.[0] === node)
|
|
57
|
-
node = node.parent.children[1];
|
|
58
|
-
if (node?.type === "object")
|
|
59
|
-
node = node.children?.find(property => property.children?.[0]?.value === "id")?.children?.[1];
|
|
60
|
-
if (!node || !["string", "number", "boolean"].includes(node.type))
|
|
61
|
-
return null;
|
|
62
|
-
const path = getNodePath(node);
|
|
63
|
-
if (!path.length)
|
|
64
|
-
return null;
|
|
42
|
+
// The choices a field offers, shared by the field menu and live completions: catalog records for a reference field (when the
|
|
43
|
+
// value is a string), schema enum values and booleans otherwise.
|
|
44
|
+
function fieldOptions(presentation, path, loadedCatalogs, { string, boolean }) {
|
|
65
45
|
const schema = schemaAtPath(presentation, path.map(String));
|
|
66
46
|
const variants = schemaVariants(schema);
|
|
67
47
|
const catalog = catalogAtPath(path);
|
|
@@ -70,7 +50,7 @@ export function getJsonFieldContext(source, position, loadedCatalogs = []) {
|
|
|
70
50
|
// A document group whose source no registered catalog matches: its records beyond the embedded ones are not available here.
|
|
71
51
|
const registered = new Set((Array.isArray(loadedCatalogs) ? loadedCatalogs : []).map(entry => entry?.source));
|
|
72
52
|
const unloadedSource = Boolean(catalog) && Object.entries(presentation.catalogs ?? {}).some(([name, group]) => name !== "custom" && group && typeof group === "object" && typeof group.source === "string" && !registered.has(group.source));
|
|
73
|
-
if (catalog &&
|
|
53
|
+
if (catalog && string) {
|
|
74
54
|
const hostCatalogs = Array.isArray(loadedCatalogs) ? loadedCatalogs : [];
|
|
75
55
|
// A draft whose own catalogs are malformed still offers the host's records; a malformed host list offers nothing.
|
|
76
56
|
const attempt = (document) => { try { return listCatalogRecords(document, catalog, { catalogs: hostCatalogs }); } catch { return undefined; } };
|
|
@@ -87,11 +67,37 @@ export function getJsonFieldContext(source, position, loadedCatalogs = []) {
|
|
|
87
67
|
if (["string", "number", "boolean"].includes(typeof value))
|
|
88
68
|
options.set(JSON.stringify(value), { value, label: String(value), source: "Schema" });
|
|
89
69
|
}
|
|
90
|
-
if (
|
|
70
|
+
if (boolean && variants.some(variant => variant.type === "boolean" && variant.const === undefined)) {
|
|
91
71
|
for (const value of [true, false])
|
|
92
72
|
options.set(String(value), { value, label: String(value), source: "Schema" });
|
|
93
73
|
}
|
|
94
74
|
}
|
|
75
|
+
return { options, records, catalog, unloadedSource };
|
|
76
|
+
}
|
|
77
|
+
// The installed schema identifies fields; a document field merely named "layout"
|
|
78
|
+
// inside arbitrary metadata is not mistaken for an OPF layout reference.
|
|
79
|
+
export function getJsonFieldContext(source, position, loadedCatalogs = []) {
|
|
80
|
+
let presentation;
|
|
81
|
+
try {
|
|
82
|
+
presentation = JSON.parse(source);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const root = parseTree(source);
|
|
88
|
+
if (!root)
|
|
89
|
+
return null;
|
|
90
|
+
let node = findNodeAtOffset(root, position, true);
|
|
91
|
+
if (node?.parent?.type === "property" && node.parent.children?.[0] === node)
|
|
92
|
+
node = node.parent.children[1];
|
|
93
|
+
if (node?.type === "object")
|
|
94
|
+
node = node.children?.find(property => property.children?.[0]?.value === "id")?.children?.[1];
|
|
95
|
+
if (!node || !["string", "number", "boolean"].includes(node.type))
|
|
96
|
+
return null;
|
|
97
|
+
const path = getNodePath(node);
|
|
98
|
+
if (!path.length)
|
|
99
|
+
return null;
|
|
100
|
+
const { options, records, catalog, unloadedSource } = fieldOptions(presentation, path, loadedCatalogs, { string: typeof node.value === "string", boolean: node.type === "boolean" });
|
|
95
101
|
if (!options.size)
|
|
96
102
|
return null;
|
|
97
103
|
const choices = [...options.values()];
|
|
@@ -122,3 +128,97 @@ export function fieldOptionEdit(context, value) {
|
|
|
122
128
|
while (to > from && end > from && source[to - 1] === next[end - 1]) { to--; end--; }
|
|
123
129
|
return {from, to, insert: next.slice(from, end)};
|
|
124
130
|
}
|
|
131
|
+
|
|
132
|
+
// ---- Live completions ----------------------------------------------------------------------------------------------------------
|
|
133
|
+
// What the editor offers while you type: the property names the schema allows at the cursor's object, and the same enum, boolean and
|
|
134
|
+
// catalog choices the field menu lists at a value. The draft is usually not valid JSON mid-keystroke, so the token under the cursor
|
|
135
|
+
// is swapped for a placeholder string and the rest is read with the tolerant parser.
|
|
136
|
+
const PLACEHOLDER = "__opf_completion__";
|
|
137
|
+
const WORD = /[A-Za-z0-9_.-]/;
|
|
138
|
+
// 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) {
|
|
140
|
+
const lineStart = Math.max(text.lastIndexOf("\n", position - 1), text.lastIndexOf("\r", position - 1)) + 1;
|
|
141
|
+
let open = -1;
|
|
142
|
+
for (let index = lineStart; index < position; index++) {
|
|
143
|
+
if (open >= 0) { if (text[index] === "\\") index++; else if (text[index] === '"') open = -1; }
|
|
144
|
+
else if (text[index] === '"') open = index;
|
|
145
|
+
}
|
|
146
|
+
if (open >= 0) {
|
|
147
|
+
let close = -1;
|
|
148
|
+
for (let index = position; index < text.length && text[index] !== "\n" && text[index] !== "\r"; index++) {
|
|
149
|
+
if (text[index] === "\\") index++;
|
|
150
|
+
else if (text[index] === '"') { close = index; break; }
|
|
151
|
+
}
|
|
152
|
+
return { quoted: true, closed: close >= 0, start: open, end: close >= 0 ? close + 1 : position, from: open + 1, to: close >= 0 ? close : position };
|
|
153
|
+
}
|
|
154
|
+
let start = position, end = position;
|
|
155
|
+
while (start > lineStart && WORD.test(text[start - 1])) start--;
|
|
156
|
+
while (end < text.length && WORD.test(text[end])) end++;
|
|
157
|
+
if (start === position && !explicit) return null;
|
|
158
|
+
let before = start - 1;
|
|
159
|
+
while (before >= lineStart && /\s/.test(text[before])) before--;
|
|
160
|
+
// A bare word starts a key or a value only after `{`, `,`, `:` or `[` (or at the start of an empty line).
|
|
161
|
+
const previous = before >= lineStart ? text[before] : (text.slice(0, lineStart).trimEnd().at(-1) ?? "");
|
|
162
|
+
if (!["{", ",", ":", "["].includes(previous)) return null;
|
|
163
|
+
return { quoted: false, closed: false, start, end, from: start, to: end };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Schema descriptions run to paragraphs; the popup shows the first sentence, at most 200 characters.
|
|
167
|
+
function brief(text) {
|
|
168
|
+
if (typeof text !== "string" || !text.trim()) return undefined;
|
|
169
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
170
|
+
const sentence = /^.*?[.!?](?=\s|$)/.exec(flat)?.[0] ?? flat;
|
|
171
|
+
return sentence.length <= 200 ? sentence : `${sentence.slice(0, 199).trimEnd()}…`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The completions at `position` of a JSON draft (it need not parse): `{ kind: "key" | "value", from, to, quoted, closed, options }`,
|
|
176
|
+
* or null when nothing is offered. `from` and `to` bound the text being typed (inside the quotes of a string); each option is
|
|
177
|
+
* `{ value, label, detail?, info?, boost }`. A key option's `value` is the property name; a value option's is the JSON value.
|
|
178
|
+
* `explicit` also offers choices where nothing has been typed yet.
|
|
179
|
+
*/
|
|
180
|
+
export function getJsonCompletions(source, position, loadedCatalogs = [], { explicit = false } = {}) {
|
|
181
|
+
const token = tokenAt(source, position, explicit);
|
|
182
|
+
if (!token) return null;
|
|
183
|
+
const draft = source.slice(0, token.start) + JSON.stringify(PLACEHOLDER) + source.slice(token.end);
|
|
184
|
+
const root = parseTree(draft, [], { allowTrailingComma: true });
|
|
185
|
+
if (!root) return null;
|
|
186
|
+
const node = findNodeAtOffset(root, token.start + 1, true);
|
|
187
|
+
if (node?.type !== "string" || node.value !== PLACEHOLDER) return null;
|
|
188
|
+
const presentation = parseJsonc(draft, [], { allowTrailingComma: true });
|
|
189
|
+
if (presentation === null || typeof presentation !== "object" || Array.isArray(presentation)) return null;
|
|
190
|
+
const range = { from: token.from, to: token.to, quoted: token.quoted, closed: token.closed };
|
|
191
|
+
try {
|
|
192
|
+
if (node.parent?.type === "property" && node.parent.children?.[0] === node) {
|
|
193
|
+
const object = node.parent.parent;
|
|
194
|
+
const present = new Set((object?.children ?? []).filter(property => property !== node.parent).map(property => property.children?.[0]?.value));
|
|
195
|
+
const properties = new Map();
|
|
196
|
+
for (const variant of schemaVariants(schemaAtPath(presentation, getNodePath(object).map(String))))
|
|
197
|
+
for (const [key, child] of Object.entries(variant.properties ?? {}))
|
|
198
|
+
if (!present.has(key) && !properties.has(key)) properties.set(key, { child, required: (variant.required ?? []).includes(key) });
|
|
199
|
+
if (!properties.size) return null;
|
|
200
|
+
const options = [...properties].map(([key, { child, required }], index) => {
|
|
201
|
+
const resolved = resolveSchema(child);
|
|
202
|
+
const type = Array.isArray(resolved.type) ? resolved.type.join(" | ") : resolved.type ?? (resolved.enum ? "enum" : resolved.oneOf || resolved.anyOf ? "choice" : "");
|
|
203
|
+
return { value: key, label: key, detail: type || undefined, info: brief(child.description ?? resolved.description), boost: Math.max(-99, (required ? 50 : 0) - index) };
|
|
204
|
+
});
|
|
205
|
+
return { kind: "key", ...range, options };
|
|
206
|
+
}
|
|
207
|
+
const path = getNodePath(node);
|
|
208
|
+
if (!path.length) return null;
|
|
209
|
+
const { options: found, records, catalog } = fieldOptions(presentation, path, loadedCatalogs, { string: true, boolean: true });
|
|
210
|
+
if (!found.size) return null;
|
|
211
|
+
const choices = describeOptions([...found.values()], records, undefined, catalog);
|
|
212
|
+
const options = choices.map((option, index) => ({
|
|
213
|
+
value: option.value,
|
|
214
|
+
label: String(option.value),
|
|
215
|
+
detail: String(option.label) !== String(option.value) ? String(option.label) : option.sourceLabel,
|
|
216
|
+
info: [brief(option.description), option.regions].filter(Boolean).join("\n") || undefined,
|
|
217
|
+
boost: Math.max(-99, 50 - index),
|
|
218
|
+
}));
|
|
219
|
+
return { kind: "value", ...range, options };
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { EditorSession } from "./index.js";
|
|
2
|
+
import type { ToSvgOptions } from "@openpresentation/opf-render";
|
|
3
|
+
import type { EditorFonts } from "./canvas.js";
|
|
4
|
+
|
|
5
|
+
export interface PresenterLabels {
|
|
6
|
+
/** The accessible name of the presenter dialog. Default: "Presentation". */
|
|
7
|
+
dialog?: string;
|
|
8
|
+
/** The accessible name and title of the exit button. Default: "Exit presentation". */
|
|
9
|
+
exit?: string;
|
|
10
|
+
/** Default: "Exit (Esc)". */
|
|
11
|
+
exitTitle?: string;
|
|
12
|
+
/** What the live region announces. Default: "Slide 3 of 12" (both numbers count the slides that are shown, not the hidden ones). */
|
|
13
|
+
position?: (position: number, count: number) => string;
|
|
14
|
+
/** Shown while the faces the deck needs load. Default: "Loading fonts…". */
|
|
15
|
+
loading?: string;
|
|
16
|
+
/** Shown when every slide is hidden. */
|
|
17
|
+
empty?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PresenterOptions {
|
|
21
|
+
/** The editor session. The presenter reads its current document and catalogs. */
|
|
22
|
+
editor?: EditorSession;
|
|
23
|
+
/** Without an editor: an OPF presentation, or a function returning the current one. Keep the object stable while presenting: slides are drawn once per object. */
|
|
24
|
+
presentation?: { slides?: unknown[] } | (() => { slides?: unknown[] });
|
|
25
|
+
/**
|
|
26
|
+
* The renderer's fonts handle (`loadFonts()`), the one the canvas, the thumbnails and the exports use. With its `pending` and `ensure` the
|
|
27
|
+
* presenter loads the faces a deck needs before it draws ("Loading fonts…" meanwhile).
|
|
28
|
+
*/
|
|
29
|
+
fonts?: EditorFonts;
|
|
30
|
+
/** Extra `toSvg` options, such as `catalogs` (merged after the session's). Slides draw with `text: "system"`, `trace: false` and their `{{variables}}` resolved. */
|
|
31
|
+
renderOptions?: ToSvgOptions;
|
|
32
|
+
/** The slide `start()` opens on (0-based deck index; default 0). A hidden slide opens the next one that is not hidden. */
|
|
33
|
+
slideIndex?: number;
|
|
34
|
+
/** The presentation closed, by any route (Esc, the exit button, leaving full screen, `stop()`, `destroy()`). `index` is the deck index of the slide it showed, so the host can return to it. */
|
|
35
|
+
onExit?(index: number): void;
|
|
36
|
+
/** The shown slide changed (deck index): by key, click, swipe or `next`, `previous` and `goTo`. Also once at start when a hidden slide was requested. */
|
|
37
|
+
onSlideChange?(index: number): void;
|
|
38
|
+
/** `"touch"` makes the exit button 40px. Default `"comfortable"` (32px). */
|
|
39
|
+
density?: "comfortable" | "touch";
|
|
40
|
+
/** Show hidden slides too (default false: they are skipped, as the exports skip them). */
|
|
41
|
+
includeHidden?: boolean;
|
|
42
|
+
labels?: PresenterLabels;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Presenter {
|
|
46
|
+
/** The root element, mounted in the container: `role="dialog"`, `aria-modal`, hidden until `start`. `data-mode` is `fullscreen` or `overlay`. */
|
|
47
|
+
readonly element: HTMLElement;
|
|
48
|
+
/**
|
|
49
|
+
* Open the presentation on `slideIndex` (default: the `slideIndex` option) and ask the browser for full screen. Call it from a user gesture
|
|
50
|
+
* (a click, a key press): browsers refuse full screen otherwise, and the presentation then fills the window as a fixed overlay. Resolves
|
|
51
|
+
* with the mode once the browser answered. Calling it while open goes to that slide.
|
|
52
|
+
*/
|
|
53
|
+
start(slideIndex?: number): Promise<"fullscreen" | "overlay">;
|
|
54
|
+
/** Close the presentation, leave full screen, return focus to the element that was focused at `start`, and call `onExit(index)`. */
|
|
55
|
+
stop(): void;
|
|
56
|
+
/** Show the next slide that is not hidden. Returns false at the end. */
|
|
57
|
+
next(): boolean;
|
|
58
|
+
/** Show the previous slide that is not hidden. Returns false at the start. */
|
|
59
|
+
previous(): boolean;
|
|
60
|
+
/** Show a slide by its deck index (a hidden slide shows the next one that is not hidden). Returns false when the shown slide did not change. */
|
|
61
|
+
goTo(index: number): boolean;
|
|
62
|
+
/** The deck index of the shown slide; -1 when stopped or when every slide is hidden. */
|
|
63
|
+
getSlide(): number;
|
|
64
|
+
isRunning(): boolean;
|
|
65
|
+
/** `stop()`, then remove the element. */
|
|
66
|
+
destroy(): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The presenter: full screen (or a fixed overlay), one slide at a time on black, drawn by the renderer, catalogs and fonts handle the canvas
|
|
71
|
+
* uses. Right, Space, PageDown and Enter go forward; Left, PageUp and Backspace go back; Home and End jump; Esc exits. A click goes forward,
|
|
72
|
+
* a click on the left third goes back, and a swipe on touch moves. A "03 / 12" counter shows on pointer move for 2 seconds.
|
|
73
|
+
* CSS: `src/styles/presenter.css`, classes `opf-presenter-*`.
|
|
74
|
+
*/
|
|
75
|
+
export declare function createPresenter(container: HTMLElement, options: PresenterOptions): Presenter;
|
|
76
|
+
|
|
77
|
+
export interface PresentButtonOptions {
|
|
78
|
+
/** The button was activated (not called while disabled). */
|
|
79
|
+
onPresent?(event: MouseEvent): void;
|
|
80
|
+
/** `true` disables with a default reason, a string with your own (it is the title). The button stays focusable (`aria-disabled`) and does nothing. */
|
|
81
|
+
disabled?: boolean | string;
|
|
82
|
+
/** `"touch"` makes the button 40px. Default `"comfortable"` (32px). */
|
|
83
|
+
density?: "comfortable" | "touch";
|
|
84
|
+
/** The accessible name (default "Present"). */
|
|
85
|
+
label?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface PresentButton {
|
|
89
|
+
readonly element: HTMLButtonElement;
|
|
90
|
+
setDisabled(reason?: boolean | string): void;
|
|
91
|
+
setDensity(density: "comfortable" | "touch"): void;
|
|
92
|
+
destroy(): void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The icon-only Present button: a Lucide Presentation icon, `aria-label="Present"`, title "Present (F5)", styled like the Export trigger. */
|
|
96
|
+
export declare function createPresentButton(container: HTMLElement, options?: PresentButtonOptions): PresentButton;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Bind F5 to `onPresent(event)` on `target` (default: the document); the browser's reload is suppressed when it fires. F5 is ignored while
|
|
100
|
+
* typing in inputs, selects and editors, in menus and dialogs, and with a modifier key held. Returns a function that removes the listener.
|
|
101
|
+
*/
|
|
102
|
+
export declare function bindPresentShortcut(target: Document | HTMLElement | Window | undefined | null, onPresent: (event: KeyboardEvent) => void): () => void;
|