@openpresentation/opf-editor 0.18.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/canvas.d.ts +3 -1
- package/dist/canvas.js +13 -1
- package/dist/data-grid.js +3 -2
- package/dist/export.d.ts +7 -1
- package/dist/export.js +19 -3
- package/dist/find-panel.js +3 -2
- package/dist/review-panel.js +8 -5
- package/dist/slide-manager.js +13 -8
- package/package.json +14 -14
package/README.md
CHANGED
|
@@ -158,6 +158,19 @@ Plain-text carets are resolved from the rendered SVG glyphs: each line carries i
|
|
|
158
158
|
|
|
159
159
|
The canvas retains canonical SVG glyphs while a transparent native input supplies the caret. Advanced shaping, freeform object positioning, all chart/media treatments, and cross-engine pixel identity remain work in progress. The Source dialog in the playground now provides a live JSON preview; changes are validated before committing.
|
|
160
160
|
|
|
161
|
+
### Input methods (IME): not certified
|
|
162
|
+
|
|
163
|
+
IME input is **not certified**. Real Japanese (Kana to Kanji), Chinese (pinyin) and Korean (Hangul) input methods have not been tested in this editor, on any browser or operating system; no run with a real input method is recorded (opf-editor#137). Only synthetic `CompositionEvent`s are tested (`test/rich-input-browser.mjs` dispatches `compositionstart`, an `insertCompositionText` input and `compositionend` on the rich-text input and checks that the canvas does not commit midway and that one Undo restores the original). Synthetic events do not reproduce a candidate window, a re-render during a composition, or the order of `keydown`, `compositionend` and `input` that real browsers and input methods produce, so treat inline editing with an input method as unverified until a pass is recorded.
|
|
164
|
+
|
|
165
|
+
What exists in the code (written for input methods, not verified with one):
|
|
166
|
+
|
|
167
|
+
- **Canvas** (`src/canvas.js`): the inline text input marks the field as composing between `compositionstart` and `compositionend` (no draft and no commit are made meanwhile, and the input is repositioned), ignores `keydown` while `isComposing` (so Enter and Escape that act on a composition do not commit or cancel the edit), and queues a draft when the composition ends.
|
|
168
|
+
- **Rich-text input** (`src/rich-text-input.js`): tracks composition, keeps the pre-composition value so one history step covers the composed text, and ignores `isComposing` key events.
|
|
169
|
+
- **Data grid** (`src/data-grid.js`): an Enter or Tab that confirms a composition is not treated as a command (`isComposing`).
|
|
170
|
+
- **Outline view** (`src/outline-view.js`) and the **design controls** (`src/design-controls.js`): Enter during a composition does not commit (`isComposing`).
|
|
171
|
+
|
|
172
|
+
Not tested at all, even with synthetic events: the data grid, the outline view and the design controls. Failures with a real input method are reported as defects with the input method named.
|
|
173
|
+
|
|
161
174
|
## List numbering (RR-33)
|
|
162
175
|
|
|
163
176
|
`@openpresentation/opf-editor/numbering` is the headless model and `/numbering-panel` the DOM control for the `numbering` field of `items` and `bullets` payloads (a style name, a `{ style, start, suffix }` object, or an array per list level). `setNumbering(editor, path, value)` numbers the list a path points at or into (`undefined` turns numbering off and drops the entry `start` values), `setEntryStart(editor, itemPath, start)` restarts the count at an entry, `numberingValue(levels)` writes the shortest form, `numberingState(presentation, path)` reads the settings and the markers the list draws, and `findNumberableLists(presentation, slideIndex)` lists a slide's lists. Each write is one validated, undoable session edit. `createNumberingPanel(container, { editor, getTarget, getSlideIndex, onStatus })` mounts the control: number this list, style, start and suffix, different numbering per level, the markers it will draw, and a restart at the selected entry. The playground opens it from **List numbering**. Needs a core release that ships `numbering`; see core's [numbered lists](https://github.com/OpenPresentation/opf/blob/main/docs/numbered-lists.md).
|
|
@@ -631,6 +644,17 @@ const { files, findings } = await convert(editor.presentation, {
|
|
|
631
644
|
```
|
|
632
645
|
|
|
633
646
|
- **Same drawing as the preview.** The slides are drawn by `toSvg` with the same fonts handle as the preview (its `textMeasurement`), so a PNG or SVG is the preview, and the PDF is converted from those SVGs rather than laid out again.
|
|
647
|
+
- **Subset faces and outlined text (opf-editor#141).** A browser fonts handle embeds whole faces in an SVG (hundreds of KB to MBs per slide) unless it was created with `subsetWasm`, and shapes outlined text with fontkit unless it has `shapeWasm`. Serve harfbuzzjs's two WASM files like the font files (the copies of the `harfbuzzjs` package the renderer depends on: `harfbuzzjs/dist/harfbuzz-subset.wasm`, 651 KB, and `harfbuzzjs/dist/harfbuzz.wasm`, 434 KB, both MIT) and create the handle you pass as `fonts` with them:
|
|
648
|
+
|
|
649
|
+
```js
|
|
650
|
+
const fonts = await loadFonts({
|
|
651
|
+
faces, scriptBaseUrl, lazyFontsBaseUrl,
|
|
652
|
+
subsetWasm: "/vendor/harfbuzz-subset.wasm", // URL, bytes or a WebAssembly.Module; SVG downloads embed each face cut to the slide's glyphs
|
|
653
|
+
shapeWasm: "/vendor/harfbuzz.wasm", // outlined text (renderOptions.text: "paths") is shaped by HarfBuzz, as browsers shape text
|
|
654
|
+
});
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
`convert` reads `fonts.subsets` and `fonts.outlines` from that handle, so nothing else changes: the same call writes SVG files with the faces cut to what each slide draws (the licence rule above still decides which faces may be embedded, and the renderer cuts only faces whose licence allows a modified version; the others stay whole) and, with `renderOptions: { text: "paths" }`, SVG and PNG files whose text is glyph outlines (no font needed). A PDF is unaffected: it embeds the registry's faces and cuts its own subsets, and it always draws selectable text. `renderOptions.subsetFonts: false` embeds whole faces. The canvas draws with `text: "system"`, which embeds nothing, so it never reads either. The playground does this: `npm run build:playground` copies both files next to the page.
|
|
634
658
|
- **Fonts.** The fonts handle loads the faces the deck needs before anything is drawn (a failure rejects with `fonts-unavailable`). Only faces the handle's registry holds are embedded (bundled or hash-pinned, never a system font), only where a slide draws them, as `@font-face` data in each SVG and as subsets in the PDF. A face whose own license text is not OFL, Apache, MIT or UFL is left out and reported (`fonts/export-font-license`).
|
|
635
659
|
- **PDF** is the renderer's vector PDF (selectable text, vector shapes, embedded subsets, tagged structure), `raster: true` is the image-only form. **PNG** is drawn on a canvas from the same SVG (within anti-aliasing of the renderer's resvg PNG) and is limited to 40 megapixels. **SVG** files are standalone (XML header, fonts embedded, no external references).
|
|
636
660
|
- **Files.** A PDF is one file (`pages`, and the one-based `slides` it shows). PNG and SVG give one file per slide, in slide order, each with its one-based `slide`, its `id` and its `width` and `height` in pixels, as core's `convert` does; with `zip: true` they are one archive (`createZip`, no dependency; `entries` names the files in it), even for one slide. `zip` with a PDF, or `raster` with PNG or SVG, rejects with `invalid-option`.
|
package/dist/canvas.d.ts
CHANGED
|
@@ -6,8 +6,10 @@ import type { Catalog } from "@openpresentation/opf";
|
|
|
6
6
|
/**
|
|
7
7
|
* The fonts the canvas, the export and `whenFontsReady` take: the renderer's handle from `loadFonts()` (`@openpresentation/opf-render/fonts-browser`).
|
|
8
8
|
* Only `textMeasurement` is required to measure; `pending` and `ensure` make the canvas load script and vendored faces before it draws.
|
|
9
|
+
* `subsets` and `outlines` are there when the handle was created with `loadFonts({ subsetWasm, shapeWasm })` (opf-editor#141): `convert` then
|
|
10
|
+
* writes SVG downloads with each face cut to the slide's glyphs and can draw `renderOptions.text: "paths"` with HarfBuzz-shaped outlines.
|
|
9
11
|
*/
|
|
10
|
-
export type EditorFonts = Pick<BrowserFontsHandle, "textMeasurement"> & Partial<Pick<BrowserFontsHandle, "embeddedFonts" | "pending" | "ensure" | "registry">>;
|
|
12
|
+
export type EditorFonts = Pick<BrowserFontsHandle, "textMeasurement"> & Partial<Pick<BrowserFontsHandle, "embeddedFonts" | "pending" | "ensure" | "registry" | "subsets" | "outlines">>;
|
|
11
13
|
export interface FontsReadyHandlers {
|
|
12
14
|
/** Return false once the document was superseded; the late result is then dropped. */
|
|
13
15
|
isCurrent?: () => boolean;
|
package/dist/canvas.js
CHANGED
|
@@ -247,6 +247,15 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
247
247
|
if (fonts?.pending(presentation ?? editor.presentation, resolveOptions()).length) show(presentation);
|
|
248
248
|
else render(presentation);
|
|
249
249
|
}
|
|
250
|
+
// The text an editing target draws: its outermost <text> elements, whitespace collapsed (the renderer's <title> and <desc> are not drawn).
|
|
251
|
+
function targetText(node) {
|
|
252
|
+
return [...node.querySelectorAll("text")]
|
|
253
|
+
.filter((text) => !text.parentNode.closest("text"))
|
|
254
|
+
.map((text) => text.textContent)
|
|
255
|
+
.join(" ")
|
|
256
|
+
.replace(/\s+/g, " ")
|
|
257
|
+
.trim();
|
|
258
|
+
}
|
|
250
259
|
function render(presentation = editor.presentation) {
|
|
251
260
|
if (disposed) return;
|
|
252
261
|
requireFonts(presentation);
|
|
@@ -300,9 +309,12 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
300
309
|
}
|
|
301
310
|
node.setAttribute("role", "button");
|
|
302
311
|
node.setAttribute("tabindex", "0");
|
|
312
|
+
// WCAG 2.5.3 (label in name): the name carries every word the target draws, in drawing order, after "Edit <field>:", so the visible
|
|
313
|
+
// text is always part of the name. Content with no drawn text (a picture) falls back to the source text or "content properties".
|
|
314
|
+
const drawn = targetText(node);
|
|
303
315
|
node.setAttribute(
|
|
304
316
|
"aria-label",
|
|
305
|
-
`Edit ${path.split(".").at(-1)}: ${["string", "number"].includes(typeof value) ? String(value)
|
|
317
|
+
`Edit ${path.split(".").at(-1)}: ${drawn || (["string", "number"].includes(typeof value) ? String(value) : "content properties")}`,
|
|
306
318
|
);
|
|
307
319
|
if (node.matches("g")) {
|
|
308
320
|
const bounds = allocatedSelectionBox(node, item);
|
package/dist/data-grid.js
CHANGED
|
@@ -446,8 +446,9 @@ export function createDataGrid(container, options = {}) {
|
|
|
446
446
|
updateFormatRow();
|
|
447
447
|
const nameRows = count > 1 ? `${count} rows` : "row";
|
|
448
448
|
buttons.get("delete-rows").textContent = count > 1 ? `Delete ${count} rows` : "Delete rows";
|
|
449
|
-
|
|
450
|
-
buttons.get("insert-row-
|
|
449
|
+
// WCAG 2.5.3 (label in name): the button says what it does in its text, so there is no aria-label to differ from it.
|
|
450
|
+
buttons.get("insert-row-above").textContent = `Insert ${nameRows} above`;
|
|
451
|
+
buttons.get("insert-row-below").textContent = `Insert ${nameRows} below`;
|
|
451
452
|
buttons.get("delete-columns").textContent = columns > 1 ? `Delete ${columns} columns` : "Delete columns";
|
|
452
453
|
}
|
|
453
454
|
|
package/dist/export.d.ts
CHANGED
|
@@ -53,7 +53,13 @@ export interface ConvertOptions {
|
|
|
53
53
|
renderOptions?: ToSvgOptions;
|
|
54
54
|
/** Host catalogs (merged with `renderOptions.catalogs`). The deck is embedded first (core `embed`), like a saved file. */
|
|
55
55
|
catalogs?: readonly Catalog[];
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* The renderer's fonts handle (`loadFonts()`): it measures the text, loads the faces the deck needs before anything is drawn, and its registry's faces are what gets embedded.
|
|
58
|
+
* Create a browser handle with `loadFonts({ subsetWasm, shapeWasm })` (the URLs, bytes or compiled modules of harfbuzzjs's `harfbuzz-subset.wasm` and `harfbuzz.wasm`, which the host
|
|
59
|
+
* serves) and `convert` uses them: an SVG embeds each face cut to the glyphs its slide draws (a few KB to tens of KB instead of whole faces of hundreds of KB to MBs), and
|
|
60
|
+
* `renderOptions.text: "paths"` draws outlines shaped by HarfBuzz instead of fontkit. A Node handle (`/fonts-node`) always has both. Only SVG files are subset (a PNG is a picture, and a PDF cuts its own
|
|
61
|
+
* subsets); a PNG takes the outlines, and a PDF is always drawn as selectable text (`text: "paths"` is ignored for it).
|
|
62
|
+
*/
|
|
57
63
|
fonts?: EditorFonts;
|
|
58
64
|
/** Faces to embed instead of the registry's. */
|
|
59
65
|
embeddedFonts?: Array<Record<string, unknown>>;
|
package/dist/export.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// one-based `slide`). The slides are drawn by the same renderer the preview uses (`toSvg`, with the fonts handle's text measurement),
|
|
4
4
|
// so a file is the preview, not a second layout. Fonts are the renderer's fonts handle's: the faces the document needs load through
|
|
5
5
|
// the handle first, and only faces its registry holds (bundled or hash-pinned, permissively licensed) are embedded, as @font-face
|
|
6
|
-
// data in each SVG and as subsets in the PDF. No system font is read and nothing is fetched at export time.
|
|
6
|
+
// data in each SVG and as subsets in the PDF. No system font is read and nothing is fetched at export time. A browser handle made with
|
|
7
|
+
// `loadFonts({ subsetWasm, shapeWasm })` (opf-editor#141) also cuts each SVG's faces to the slide's glyphs and draws
|
|
8
|
+
// `renderOptions.text: "paths"` with HarfBuzz-shaped outlines.
|
|
7
9
|
//
|
|
8
10
|
// const { files, findings } = await convert(editor.presentation, { format: "pdf", fonts, renderOptions, signal, onProgress });
|
|
9
11
|
// // files: one PDF; one PNG or SVG per slide; or, with `zip: true`, one archive of them.
|
|
@@ -241,11 +243,25 @@ export async function convert(input, options = {}) {
|
|
|
241
243
|
});
|
|
242
244
|
progress("render", 0, numbers.length, "Drawing slides…");
|
|
243
245
|
await yieldToHost();
|
|
246
|
+
// RR-65, RR-64: the handle's subset engine (a browser handle created with `subsetWasm`) cuts each face a standalone SVG embeds to the
|
|
247
|
+
// slide's glyphs, and its outline engine (`shapeWasm` shapes them with HarfBuzz) draws `renderOptions.text: "paths"`. Both are carried
|
|
248
|
+
// from the handle by hand because the face list above is the editor's licensed one, not the handle's. A PDF reads whole faces and
|
|
249
|
+
// the SVG's own @font-face data (its converter cuts its own subsets), and selectable text is its point, so it gets neither: a
|
|
250
|
+
// `text: "paths"` render option draws a PDF's text as text. A PNG is a picture of the SVG: it takes the outlines, not the subsets.
|
|
251
|
+
const toPdf = format.extension === "pdf";
|
|
252
|
+
const handle = options.fonts;
|
|
253
|
+
const drawFonts = {
|
|
254
|
+
textMeasurement: handle?.textMeasurement,
|
|
255
|
+
embeddedFonts,
|
|
256
|
+
...(format.extension === "svg" && handle?.subsets ? { subsets: handle.subsets } : {}),
|
|
257
|
+
...(!toPdf && handle?.outlines ? { outlines: handle.outlines } : {}),
|
|
258
|
+
};
|
|
244
259
|
const svgs = toSvg(deck, numbers, {
|
|
245
260
|
...renderOptions,
|
|
246
|
-
|
|
261
|
+
...(toPdf && renderOptions.text === "paths" ? { text: "fonts" } : {}),
|
|
262
|
+
fonts: drawFonts,
|
|
247
263
|
// The PDF reports problems by element path; the SVG and PNG are the plain drawing.
|
|
248
|
-
trace:
|
|
264
|
+
trace: toPdf,
|
|
249
265
|
onDiagnostic: (diagnostic) => {
|
|
250
266
|
record("render", diagnostic);
|
|
251
267
|
renderOptions.onDiagnostic?.(diagnostic);
|
package/dist/find-panel.js
CHANGED
|
@@ -147,8 +147,9 @@ export function createFindPanel(container, options) {
|
|
|
147
147
|
matches.slice(0, MAX_LISTED).forEach((match, index) => {
|
|
148
148
|
const mark = h(doc, "mark", { text: match.context.match });
|
|
149
149
|
const text = h(doc, "span", { class: "opf-find-text" }, doc.createTextNode(match.context.before), mark, doc.createTextNode(match.context.after));
|
|
150
|
-
const button = h(doc, "button", { type: "button", class: "opf-find-result", "aria-current": index === current ? "true" : "false", "data-index": String(index)
|
|
151
|
-
|
|
150
|
+
const button = h(doc, "button", { type: "button", class: "opf-find-result", "aria-current": index === current ? "true" : "false", "data-index": String(index) },
|
|
151
|
+
// WCAG 2.5.3 (label in name): no aria-label. The name is the visible text, the place and then the matching line, kept apart by a space.
|
|
152
|
+
h(doc, "span", { class: "opf-find-where", text: whereLabel(match) }), " ", text);
|
|
152
153
|
list.append(h(doc, "li", {}, button));
|
|
153
154
|
});
|
|
154
155
|
if (matches.length > MAX_LISTED) list.append(h(doc, "li", { class: "opf-find-more", text: `Showing the first ${MAX_LISTED} of ${matches.length}${result.truncated ? "+" : ""} matches.` }));
|
package/dist/review-panel.js
CHANGED
|
@@ -159,7 +159,8 @@ export function createReviewPanel(container, options) {
|
|
|
159
159
|
const list = h(doc, "div", { class: "opf-review-list", role: "group", "aria-label": "Review findings" });
|
|
160
160
|
const empty = h(doc, "p", { class: "opf-review-empty" });
|
|
161
161
|
const ignoredBox = h(doc, "details", { class: "opf-review-ignored", hidden: true });
|
|
162
|
-
|
|
162
|
+
const gotoHint = h(doc, "span", { id: `${id}-goto-hint`, hidden: true, text: "Go to it." });
|
|
163
|
+
root.append(head, ...(hookStatus ? [hookStatus] : []), filters, list, empty, ignoredBox, gotoHint);
|
|
163
164
|
container.append(root);
|
|
164
165
|
|
|
165
166
|
const say = (message, error = false) => {
|
|
@@ -310,11 +311,13 @@ export function createReviewPanel(container, options) {
|
|
|
310
311
|
type: "button",
|
|
311
312
|
class: "opf-review-goto",
|
|
312
313
|
"data-finding-id": finding.id,
|
|
313
|
-
|
|
314
|
+
// WCAG 2.5.3 (label in name): no aria-label. The name is the visible text (severity, message, where and rule), kept apart by spaces;
|
|
315
|
+
// what the button does is its description.
|
|
316
|
+
"aria-describedby": `${id}-goto-hint`,
|
|
314
317
|
onclick: () => go(finding),
|
|
315
318
|
},
|
|
316
|
-
h(doc, "span", { class: "opf-review-sev", text: SEVERITY_LABELS[finding.severity] }),
|
|
317
|
-
h(doc, "span", { class: "opf-review-msg", text: finding.message }),
|
|
319
|
+
h(doc, "span", { class: "opf-review-sev", text: SEVERITY_LABELS[finding.severity] }), " ",
|
|
320
|
+
h(doc, "span", { class: "opf-review-msg", text: finding.message }), " ",
|
|
318
321
|
h(doc, "span", { class: "opf-review-meta", text: `${where} · ${finding.ruleId}${finding.source && finding.source !== CORE_SOURCE ? ` · ${finding.source}` : ""}` }));
|
|
319
322
|
const li = h(doc, "li", { class: "opf-review-item", "data-finding-id": finding.id, "data-rule": finding.ruleId, "data-source": finding.source ?? CORE_SOURCE, "data-severity": finding.severity, "aria-current": String(finding.id === currentId) }, go_,
|
|
320
323
|
finding.help ? h(doc, "p", { class: "opf-review-help", text: finding.help }) : undefined);
|
|
@@ -359,7 +362,7 @@ export function createReviewPanel(container, options) {
|
|
|
359
362
|
ignoredBox.replaceChildren(
|
|
360
363
|
h(doc, "summary", { text: `Hidden checks (${ignored.size})` }),
|
|
361
364
|
h(doc, "ul", { class: "opf-review-ignored-list", style: "list-style:none;padding:0" }, ...[...ignored].map((ruleId) => h(doc, "li", {}, h(doc, "span", { text: ruleId }),
|
|
362
|
-
h(doc, "button", { type: "button", class: "opf-review-fix quiet", "data-restore": ruleId, text: "Show again", "aria-label": `Show ${ruleId} findings
|
|
365
|
+
h(doc, "button", { type: "button", class: "opf-review-fix quiet", "data-restore": ruleId, text: "Show again", "aria-label": `Show again, ${ruleId} findings`, onclick: () => setIgnored(ruleId, false) })))),
|
|
363
366
|
);
|
|
364
367
|
}
|
|
365
368
|
|
package/dist/slide-manager.js
CHANGED
|
@@ -139,10 +139,12 @@ export function createSlideManager(container, options) {
|
|
|
139
139
|
const selected = selection.has(index);
|
|
140
140
|
const title = slideTitle(slide) || "Untitled slide";
|
|
141
141
|
const hidden = slide.hidden === true;
|
|
142
|
+
// WCAG 2.5.3 (label in name): the card has no aria-label. Its name is its own content (the number, the title and the "Hidden" badge;
|
|
143
|
+
// the thumbnail is aria-hidden), so the name always contains the visible text. The position and the selection are the description.
|
|
144
|
+
const position = `Slide ${index + 1} of ${slideCount}${selected && selection.size > 1 ? ", selected" : ""}`;
|
|
142
145
|
const button = el("button", {
|
|
143
|
-
type: "button", class: "slide-card", draggable: "true", "data-index": index, "aria-current": String(index === current()),
|
|
144
|
-
"aria-
|
|
145
|
-
"aria-describedby": `${id}-hint`, "aria-keyshortcuts": "Alt+ArrowUp Alt+ArrowDown Delete", tabindex: index === focusIndex ? "0" : "-1",
|
|
146
|
+
type: "button", class: "slide-card", draggable: "true", "data-index": index, "data-menu-label": `Slide ${index + 1} of ${slideCount}: ${title}`, "aria-current": String(index === current()),
|
|
147
|
+
"aria-describedby": `${id}-${index}-position ${id}-hint`, "aria-keyshortcuts": "Alt+ArrowUp Alt+ArrowDown Delete", tabindex: index === focusIndex ? "0" : "-1",
|
|
146
148
|
});
|
|
147
149
|
button.dataset.selected = String(selected);
|
|
148
150
|
button.dataset.hidden = String(hidden);
|
|
@@ -156,8 +158,9 @@ export function createSlideManager(container, options) {
|
|
|
156
158
|
thumbnail.querySelectorAll("[tabindex]").forEach((node) => node.removeAttribute("tabindex"));
|
|
157
159
|
const label = el("span", { class: "thumbnail-title", text: slideTitle(slide) || "Untitled slide" });
|
|
158
160
|
const content = el("span", { class: "thumbnail-content", draggable: "true" }, thumbnail, label);
|
|
159
|
-
if (hidden) content.append(el("span", { class: "slide-badge", text: "Hidden" }));
|
|
160
|
-
|
|
161
|
+
if (hidden) content.append(" ", el("span", { class: "slide-badge", text: "Hidden" }));
|
|
162
|
+
// The explicit spaces keep the number, title and badge apart in the computed name; hidden elements still describe the button.
|
|
163
|
+
button.append(number, " ", content, el("span", { id: `${id}-${index}-position`, hidden: true, text: position }));
|
|
161
164
|
button.addEventListener("click", (event) => choose(index, event));
|
|
162
165
|
button.addEventListener("keydown", (event) => keydown(event, index));
|
|
163
166
|
button.addEventListener("focus", () => { focusIndex = index; });
|
|
@@ -172,9 +175,11 @@ export function createSlideManager(container, options) {
|
|
|
172
175
|
const name = sectionLabel(section.name);
|
|
173
176
|
const header = el("div", { class: "slide-section", "data-section": section.index, "data-unnamed": String(section.unnamed) });
|
|
174
177
|
const toggle = el("button", {
|
|
175
|
-
type: "button", class: "section-toggle", "aria-expanded": String(!isCollapsed), "aria-
|
|
178
|
+
type: "button", class: "section-toggle", "aria-expanded": String(!isCollapsed), "aria-describedby": `${id}-section-${section.index}-count`,
|
|
176
179
|
onclick: () => { if (isCollapsed) collapsed.delete(key); else collapsed.add(key); render(); },
|
|
177
|
-
}, el("span", { class: "section-caret", "aria-hidden": "true", text: isCollapsed ? "▸" : "▾" }), el("span", { class: "section-name", text: name }), el("span", { class: "section-count", "aria-hidden": "true", text: String(section.count) })
|
|
180
|
+
}, el("span", { class: "section-caret", "aria-hidden": "true", text: isCollapsed ? "▸" : "▾" }), " ", el("span", { class: "section-name", text: name }), " ", el("span", { class: "section-count", "aria-hidden": "true", text: String(section.count) }),
|
|
181
|
+
// WCAG 2.5.3: no aria-label. The name is the visible section name; the slide count is the description.
|
|
182
|
+
el("span", { id: `${id}-section-${section.index}-count`, hidden: true, text: plural(section.count, "slide") }));
|
|
178
183
|
const more = el("button", { type: "button", class: "section-more", "aria-label": `Section actions: ${name}`, "aria-haspopup": "menu", title: "Section actions" });
|
|
179
184
|
more.append(icon("more"));
|
|
180
185
|
more.addEventListener("click", () => { const box = more.getBoundingClientRect(); openMenu(box.left, box.bottom, more, sectionMenu(section)); });
|
|
@@ -424,7 +429,7 @@ export function createSlideManager(container, options) {
|
|
|
424
429
|
}
|
|
425
430
|
function openMenu(x, y, opener, items = slideMenu()) {
|
|
426
431
|
closeMenu(false);
|
|
427
|
-
const root = el("div", { class: "opf-menu", role: "menu", "aria-label": opener.getAttribute?.("aria-label") ?? "Slide actions", tabindex: "-1" });
|
|
432
|
+
const root = el("div", { class: "opf-menu", role: "menu", "aria-label": opener.getAttribute?.("aria-label") ?? opener.dataset?.menuLabel ?? "Slide actions", tabindex: "-1" });
|
|
428
433
|
const buttons = [];
|
|
429
434
|
for (const item of items) {
|
|
430
435
|
if (item.separator) { root.append(el("div", { class: "opf-menu-separator", role: "separator" })); continue; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-editor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -257,12 +257,12 @@
|
|
|
257
257
|
"@codemirror/search": "6.7.2",
|
|
258
258
|
"@codemirror/state": "6.7.6",
|
|
259
259
|
"@codemirror/view": "6.43.13",
|
|
260
|
-
"@lezer/highlight": "1.2.
|
|
261
|
-
"@openpresentation/opf": "^0.18.
|
|
260
|
+
"@lezer/highlight": "1.2.5",
|
|
261
|
+
"@openpresentation/opf": "^0.18.3",
|
|
262
262
|
"jsonc-parser": "3.3.1"
|
|
263
263
|
},
|
|
264
264
|
"peerDependencies": {
|
|
265
|
-
"@openpresentation/opf-render": "^0.18.
|
|
265
|
+
"@openpresentation/opf-render": "^0.18.1"
|
|
266
266
|
},
|
|
267
267
|
"peerDependenciesMeta": {
|
|
268
268
|
"@openpresentation/opf-render": {
|
|
@@ -274,8 +274,8 @@
|
|
|
274
274
|
"@expo-google-fonts/caladea": "0.4.2",
|
|
275
275
|
"@expo-google-fonts/cousine": "0.4.3",
|
|
276
276
|
"@expo-google-fonts/gelasio": "0.4.1",
|
|
277
|
-
"@expo-google-fonts/noto-color-emoji": "0.4.
|
|
278
|
-
"@expo-google-fonts/noto-emoji": "0.4.
|
|
277
|
+
"@expo-google-fonts/noto-color-emoji": "0.4.7",
|
|
278
|
+
"@expo-google-fonts/noto-emoji": "0.4.8",
|
|
279
279
|
"@expo-google-fonts/noto-naskh-arabic": "0.4.5",
|
|
280
280
|
"@expo-google-fonts/noto-nastaliq-urdu": "0.4.2",
|
|
281
281
|
"@expo-google-fonts/noto-sans": "0.4.2",
|
|
@@ -288,23 +288,23 @@
|
|
|
288
288
|
"@expo-google-fonts/noto-sans-gujarati": "0.4.2",
|
|
289
289
|
"@expo-google-fonts/noto-sans-gurmukhi": "0.4.1",
|
|
290
290
|
"@expo-google-fonts/noto-sans-hebrew": "0.4.1",
|
|
291
|
-
"@expo-google-fonts/noto-sans-jp": "0.4.
|
|
291
|
+
"@expo-google-fonts/noto-sans-jp": "0.4.4",
|
|
292
292
|
"@expo-google-fonts/noto-sans-kannada": "0.4.3",
|
|
293
293
|
"@expo-google-fonts/noto-sans-khmer": "0.4.3",
|
|
294
|
-
"@expo-google-fonts/noto-sans-kr": "0.4.
|
|
294
|
+
"@expo-google-fonts/noto-sans-kr": "0.4.4",
|
|
295
295
|
"@expo-google-fonts/noto-sans-lao": "0.4.2",
|
|
296
296
|
"@expo-google-fonts/noto-sans-malayalam": "0.4.2",
|
|
297
297
|
"@expo-google-fonts/noto-sans-math": "0.4.2",
|
|
298
298
|
"@expo-google-fonts/noto-sans-mongolian": "0.4.1",
|
|
299
299
|
"@expo-google-fonts/noto-sans-myanmar": "0.4.2",
|
|
300
300
|
"@expo-google-fonts/noto-sans-oriya": "0.4.2",
|
|
301
|
-
"@expo-google-fonts/noto-sans-sc": "0.4.
|
|
301
|
+
"@expo-google-fonts/noto-sans-sc": "0.4.4",
|
|
302
302
|
"@expo-google-fonts/noto-sans-sinhala": "0.4.2",
|
|
303
303
|
"@expo-google-fonts/noto-sans-symbols": "0.4.1",
|
|
304
304
|
"@expo-google-fonts/noto-sans-symbols-2": "0.4.1",
|
|
305
305
|
"@expo-google-fonts/noto-sans-syriac": "0.4.2",
|
|
306
306
|
"@expo-google-fonts/noto-sans-tamil": "0.4.3",
|
|
307
|
-
"@expo-google-fonts/noto-sans-tc": "0.4.
|
|
307
|
+
"@expo-google-fonts/noto-sans-tc": "0.4.4",
|
|
308
308
|
"@expo-google-fonts/noto-sans-telugu": "0.4.2",
|
|
309
309
|
"@expo-google-fonts/noto-sans-thaana": "0.4.2",
|
|
310
310
|
"@expo-google-fonts/noto-sans-thai": "0.4.2",
|
|
@@ -314,14 +314,14 @@
|
|
|
314
314
|
"@expo-google-fonts/roboto-mono": "0.4.2",
|
|
315
315
|
"@expo-google-fonts/stix-two-math": "0.4.0",
|
|
316
316
|
"@expo-google-fonts/tinos": "0.4.2",
|
|
317
|
-
"@openpresentation/opf-pptx": "^0.18.
|
|
318
|
-
"@openpresentation/opf-render": "^0.18.
|
|
317
|
+
"@openpresentation/opf-pptx": "^0.18.1",
|
|
318
|
+
"@openpresentation/opf-render": "^0.18.1",
|
|
319
319
|
"@resvg/resvg-js": "2.6.2",
|
|
320
|
-
"axe-core": "4.
|
|
320
|
+
"axe-core": "4.14.0",
|
|
321
321
|
"esbuild": "0.28.2",
|
|
322
322
|
"jszip": "3.10.2",
|
|
323
323
|
"pdf-lib": "1.17.1",
|
|
324
|
-
"pdfjs-dist": "6.
|
|
324
|
+
"pdfjs-dist": "6.4.299",
|
|
325
325
|
"playwright": "1.63.0"
|
|
326
326
|
}
|
|
327
327
|
}
|