@openpresentation/opf-editor 0.22.0 → 0.23.0
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 +3 -1
- package/dist/chart-options-panel.js +79 -3
- package/dist/chart-options.d.ts +20 -1
- package/dist/chart-options.js +74 -6
- package/dist/color-slots.d.ts +12 -0
- package/dist/color-slots.js +31 -0
- package/dist/design-controls.js +74 -2
- package/dist/design-variants.d.ts +90 -0
- package/dist/design-variants.js +180 -0
- package/package.json +13 -5
package/README.md
CHANGED
|
@@ -110,7 +110,7 @@ Chart options (RR-35): `@openpresentation/opf-editor/chart-options` edits a char
|
|
|
110
110
|
|
|
111
111
|
Chart highlight (FA-14): the same module edits `chart.highlight`: `setChartOptions(editor, chartPath, { highlight: { series: ["Revenue"], categories: ["Q3"] } })` replaces each listed part (an empty list removes it, `null` removes the field), refuses a name the chart does not have, and writes only what the chart type can highlight (core `chartOptionSupport(...).highlight`). `readChartOptions(chart, document)` returns the `choices` (plotted series and category labels, resolved through the document for a dataset chart), and the panel lists them as two groups of checkboxes ("Highlight series", "Highlight categories").
|
|
112
112
|
|
|
113
|
-
Chart colors (
|
|
113
|
+
Chart colors (RR-96): the same module edits `chart.colors`, the colors by position, as one undoable patch: `setChartOptions(editor, chartPath, { colors: ["accent3", "#7B241C"] })` replaces the list (an entry is a hex color, a scheme slot such as `accent3`, which keeps following the deck and is written to PowerPoint as a theme color, or a `var:` color variable; `null` or `""` leaves that position to the deck palette, written as the palette color itself when a later position is set; `null` for the whole value removes the field; at most 24). `readChartOptions` reports what a position means (`colors.by`: the plotted series, the slices or rows of a pie, doughnut, treemap or funnel, or the colors a histogram, pareto or waterfall draws with; `colors.labels`) and the list as written (`state.colors`). The chart options panel gains a "Colors" group: one row per position with the deck's accent swatches, a "Default" choice and a hex field, plus "Reset to deck palette". The swatches come from `readSchemeSlots` (`/color-slots`), the deck's resolved scheme at a slide. The opf-render preview draws `chart.colors` once opf-render#256 lands; the document, Undo and the PowerPoint export carry it now.
|
|
114
114
|
|
|
115
115
|
```js
|
|
116
116
|
import { createCanvasEditor } from '@openpresentation/opf-editor/canvas';
|
|
@@ -351,6 +351,8 @@ The playground's Content tab mounts the block-level and slide-structure actions
|
|
|
351
351
|
|
|
352
352
|
`@openpresentation/opf-editor/design-options` edits the design-level settings that used to need All properties. Each call is one validated patch and one undo step, at the deck or on one slide with `slideIndex`; `null` removes a value so it is inherited again. `prepare...` forms return the patch without touching a session.
|
|
353
353
|
|
|
354
|
+
Color variants (RR-96, needs core 0.23): `@openpresentation/opf-editor/design-variants` edits `design.variant`, PowerPoint's Variants for the deck's one color scheme: a mapping over its twelve slots (never a second scheme) that says which slot is the background, and so the text and the alternate surface, plus an optional accent order. `setDesignVariant(editor, "dark1")` writes the slot name (so it keeps following the scheme), at the deck or on one slide with `{ slideIndex }`; `{ accents: [...] }` reorders the six accents and keeps the background; `null` removes the variant (a slide then follows the deck); `{ clearSlideOverrides: true }` also removes slides' own variants that would hide a deck change, and the result's `shadowed` lists the slides whose own variant or theme-slot background still does. `readDesignVariant` reports the scope's own variant and the effective one (core's resolution, so a theme-slot background or a picture's `tone` counts) and `variantSwatches` the four variants (`light1`, `light2`, `dark1`, `dark2`) as colors built from the deck's own slots, like PowerPoint's gallery. The design controls gain a "Color variant" section with those four swatches, "Same as presentation" on a slide, and an accent order control; the editor imports core's `normalizeColorVariant`, so it needs core 0.23 (the peer floor moves with the release). The preview and the PowerPoint export read the variant from core's roles (the renderer and exporter releases that follow core 0.23); the editor only commits the patch.
|
|
355
|
+
|
|
354
356
|
```js
|
|
355
357
|
import { setDesignOption, setOrganizationLogo, setHeaderFooterZone, insertZoneValue, insertZoneLogo, getDesignOption, designWarnings } from "@openpresentation/opf-editor/design-options";
|
|
356
358
|
|
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
// `setChartOptions` (src/chart-options.js), the canvas redraws, and Undo restores the chart. The panel shows only what
|
|
5
5
|
// the selected chart type can show (core's support table), and hides itself when no chart is selected. On a combo chart
|
|
6
6
|
// (FA-15) a "Series" group sets which series are lines and which lines use the secondary value axis, and a secondary axis
|
|
7
|
-
// title field appears while a line uses that axis.
|
|
7
|
+
// title field appears while a line uses that axis. (RR-96) A "Colors" group sets the color of each series (or slice) from the deck's accent
|
|
8
|
+
// swatches or a hex color; a swatch writes the scheme slot, so the chart keeps following the deck.
|
|
8
9
|
import { CHART_LABEL_CONTENT, CHART_LEGEND_POSITIONS, parseChartPath, readChartOptions, setChartOptions } from "./chart-options.js";
|
|
10
|
+
import { readSchemeSlots } from "./color-slots.js";
|
|
11
|
+
import { isColorRef } from "./background-options.js";
|
|
9
12
|
|
|
10
13
|
const LEGEND_LABELS = { default: "Default", none: "None", top: "Top", bottom: "Bottom", left: "Left", right: "Right" };
|
|
11
14
|
const CONTENT_LABELS = { category: "Category", value: "Value", percent: "Percent" };
|
|
@@ -46,6 +49,7 @@ export function createChartOptionsPanel(host, { editor, getSelectedPath, onStatu
|
|
|
46
49
|
<label class="opf-co-row" data-row="position"><span>Label position</span><select id="${id}-position" aria-label="Label position" data-opf-chart-option="dataLabels.position"></select></label>
|
|
47
50
|
<label class="opf-co-row" data-row="separator"><span>Separator</span><input type="text" id="${id}-separator" aria-label="Label separator" data-opf-chart-option="dataLabels.separator" autocomplete="off"></label>
|
|
48
51
|
</div>
|
|
52
|
+
<fieldset class="opf-co-group opf-co-colors" data-group="colors"><legend>Colors</legend><div data-row="colors"></div><button type="button" class="opf-co-reset" data-opf-chart-option="colors.reset" aria-label="Reset chart colors to the deck palette">Reset to deck palette</button></fieldset>
|
|
49
53
|
<div class="opf-co-group" data-group="highlight">
|
|
50
54
|
<fieldset class="opf-co-highlight" data-part="series"><legend>Highlight series</legend><div class="opf-co-choices" data-choices="series"></div></fieldset>
|
|
51
55
|
<fieldset class="opf-co-highlight" data-part="categories"><legend>Highlight categories</legend><div class="opf-co-choices" data-choices="categories"></div></fieldset>
|
|
@@ -54,11 +58,12 @@ export function createChartOptionsPanel(host, { editor, getSelectedPath, onStatu
|
|
|
54
58
|
const $ = (selector) => root.querySelector(selector);
|
|
55
59
|
const fields = {
|
|
56
60
|
alt: $(`#${id}-alt`), decorative: $(`#${id}-decorative`), category: $(`#${id}-category`), value: $(`#${id}-value`), secondary: $(`#${id}-secondary`), legend: $(`#${id}-legend`), labels: $(`#${id}-labels`),
|
|
57
|
-
series: $("[data-row='combo-series']"),
|
|
61
|
+
series: $("[data-row='combo-series']"), colors: $("[data-row='colors']"), colorsReset: $("[data-opf-chart-option='colors.reset']"),
|
|
58
62
|
position: $(`#${id}-position`), separator: $(`#${id}-separator`), contents: [...root.querySelectorAll("[data-opf-chart-option^='dataLabels.content.']")],
|
|
59
63
|
highlight: { series: $("[data-choices='series']"), categories: $("[data-choices='categories']") },
|
|
60
64
|
};
|
|
61
65
|
let chartPath;
|
|
66
|
+
let colorState = { entries: [], count: 0 };
|
|
62
67
|
|
|
63
68
|
function refresh() {
|
|
64
69
|
const path = parseChartPath(getSelectedPath?.() ?? "");
|
|
@@ -70,7 +75,7 @@ export function createChartOptionsPanel(host, { editor, getSelectedPath, onStatu
|
|
|
70
75
|
return;
|
|
71
76
|
}
|
|
72
77
|
chartPath = path;
|
|
73
|
-
const { fields: support, state, choices, combo } = readChartOptions(chart, editor.presentation);
|
|
78
|
+
const { fields: support, state, choices, combo, colors } = readChartOptions(chart, editor.presentation);
|
|
74
79
|
root.hidden = false;
|
|
75
80
|
const setValue = (input, value) => { if (input !== document.activeElement && input.value !== value) input.value = value; };
|
|
76
81
|
setValue(fields.alt, state.alt);
|
|
@@ -145,6 +150,62 @@ export function createChartOptionsPanel(host, { editor, getSelectedPath, onStatu
|
|
|
145
150
|
for (const input of list.querySelectorAll("input")) input.checked = state.highlight[part].includes(input.value);
|
|
146
151
|
}
|
|
147
152
|
$("[data-group='highlight']").hidden = $("[data-part='series']").hidden && $("[data-part='categories']").hidden;
|
|
153
|
+
refreshColors(colors, state.colors, chartPath);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// RR-96: one row per position of `chart.colors`: the deck's accent swatches, a "Default" choice and a hex field. The swatches are the
|
|
157
|
+
// deck's own colors (the resolved scheme of the chart's slide), and choosing one writes its slot name.
|
|
158
|
+
function refreshColors(positions, entries, path) {
|
|
159
|
+
colorState = { entries, count: positions.labels.length };
|
|
160
|
+
const slide = Number(/^slides\.(\d+)/.exec(path)?.[1] ?? 0);
|
|
161
|
+
const slots = readSchemeSlots(editor.presentation, { slideIndex: slide, catalogs: editor.catalogs });
|
|
162
|
+
const signature = JSON.stringify([positions.labels, positions.slots.map((slot) => slots[slot] ?? ""), entries]);
|
|
163
|
+
$("[data-group='colors']").hidden = positions.labels.length === 0;
|
|
164
|
+
fields.colorsReset.hidden = entries.length === 0;
|
|
165
|
+
if (fields.colors.dataset.signature === signature) return;
|
|
166
|
+
fields.colors.dataset.signature = signature;
|
|
167
|
+
fields.colors.replaceChildren(...positions.labels.map((name, index) => {
|
|
168
|
+
const entry = entries[index] ?? "";
|
|
169
|
+
const row = document.createElement("div");
|
|
170
|
+
row.className = "opf-co-row opf-co-color-row";
|
|
171
|
+
row.dataset.position = String(index);
|
|
172
|
+
const label = document.createElement("span");
|
|
173
|
+
label.textContent = name;
|
|
174
|
+
const group = document.createElement("span");
|
|
175
|
+
group.className = "opf-co-swatches";
|
|
176
|
+
group.setAttribute("role", "group");
|
|
177
|
+
group.setAttribute("aria-label", `${name} color`);
|
|
178
|
+
const choose = (value, text, color) => {
|
|
179
|
+
const button = document.createElement("button");
|
|
180
|
+
button.type = "button";
|
|
181
|
+
button.className = "opf-co-swatch";
|
|
182
|
+
button.dataset.opfChartOption = "colors.choose";
|
|
183
|
+
button.dataset.color = value;
|
|
184
|
+
button.setAttribute("aria-label", `${name}: ${text}`);
|
|
185
|
+
button.setAttribute("aria-pressed", String(entry === value));
|
|
186
|
+
if (color) button.style.background = color;
|
|
187
|
+
else button.textContent = "Default";
|
|
188
|
+
return button;
|
|
189
|
+
};
|
|
190
|
+
group.append(choose("", "deck palette", undefined), ...positions.slots.map((slot, k) => choose(slot, `accent ${k + 1}`, slots[slot])));
|
|
191
|
+
const hex = document.createElement("input");
|
|
192
|
+
hex.type = "text";
|
|
193
|
+
hex.className = "opf-co-hex";
|
|
194
|
+
hex.dataset.opfChartOption = "colors.hex";
|
|
195
|
+
hex.setAttribute("aria-label", `${name} hex color`);
|
|
196
|
+
hex.placeholder = "#RRGGBB";
|
|
197
|
+
hex.autocomplete = "off";
|
|
198
|
+
hex.value = entry && !positions.slots.includes(entry) ? entry : "";
|
|
199
|
+
row.append(label, group, hex);
|
|
200
|
+
return row;
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// The `colors` list with position `index` set to `value` ("" leaves it to the deck palette); other positions keep what the chart has.
|
|
205
|
+
function colorsWith(index, value) {
|
|
206
|
+
const next = Array.from({ length: Math.max(colorState.entries.length, index + 1) }, (_, k) => colorState.entries[k] ?? null);
|
|
207
|
+
next[index] = value || null;
|
|
208
|
+
return next;
|
|
148
209
|
}
|
|
149
210
|
|
|
150
211
|
function commit(change, message) {
|
|
@@ -175,6 +236,21 @@ export function createChartOptionsPanel(host, { editor, getSelectedPath, onStatu
|
|
|
175
236
|
[fields.labels, "change", () => commit({ dataLabels: fields.labels.checked }, fields.labels.checked ? "Showing data labels. Undo restores the chart." : "Hid the data labels. Undo restores them.")],
|
|
176
237
|
[fields.position, "change", () => commit({ dataLabels: { position: fields.position.value } }, "Moved the data labels. Undo restores them.")],
|
|
177
238
|
[fields.separator, "change", () => commit({ dataLabels: { separator: fields.separator.value } }, "Changed the label separator. Undo restores it.")],
|
|
239
|
+
[fields.colors, "click", (event) => {
|
|
240
|
+
const button = event.target.closest?.("[data-opf-chart-option='colors.choose']");
|
|
241
|
+
if (!button) return;
|
|
242
|
+
const index = Number(button.closest("[data-position]").dataset.position);
|
|
243
|
+
commit({ colors: colorsWith(index, button.dataset.color) }, button.dataset.color ? "Changed a chart color. Undo restores it." : "Returned a chart color to the deck palette. Undo restores it.");
|
|
244
|
+
}],
|
|
245
|
+
[fields.colors, "change", (event) => {
|
|
246
|
+
const input = event.target.closest?.("[data-opf-chart-option='colors.hex']");
|
|
247
|
+
if (!input) return;
|
|
248
|
+
const index = Number(input.closest("[data-position]").dataset.position);
|
|
249
|
+
const value = input.value.trim();
|
|
250
|
+
if (value && !isColorRef(value)) { onStatus("Use a hex color such as #7B241C."); refresh(); return; }
|
|
251
|
+
commit({ colors: colorsWith(index, value) }, "Changed a chart color. Undo restores it.");
|
|
252
|
+
}],
|
|
253
|
+
[fields.colorsReset, "click", () => commit({ colors: null }, "Returned the chart colors to the deck palette. Undo restores them.")],
|
|
178
254
|
...["series", "categories"].map((part) => [fields.highlight[part], "change", () => {
|
|
179
255
|
const names = checkedNames(part);
|
|
180
256
|
commit({ highlight: { [part]: names } }, names.length ? `Highlighted ${names.join(", ")}. Undo restores the colors.` : `Cleared the ${part} highlight. Undo restores it.`);
|
package/dist/chart-options.d.ts
CHANGED
|
@@ -6,6 +6,10 @@ export type ChartLabelPosition = "center" | "inside-end" | "inside-base" | "outs
|
|
|
6
6
|
|
|
7
7
|
export declare const CHART_LEGEND_POSITIONS: readonly ["default", "none", "top", "bottom", "left", "right"];
|
|
8
8
|
export declare const CHART_LABEL_CONTENT: readonly ["category", "value", "percent"];
|
|
9
|
+
/** The most colors `chart.colors` takes. */
|
|
10
|
+
export declare const CHART_COLORS_MAX: 24;
|
|
11
|
+
/** The scheme slots a chart color is offered from, in palette order. */
|
|
12
|
+
export declare const CHART_COLOR_SLOTS: readonly ["accent1", "accent2", "accent3", "accent4", "accent5", "accent6"];
|
|
9
13
|
|
|
10
14
|
/** What a chart type can show (core's `chartOptionSupport`). */
|
|
11
15
|
export interface ChartOptionFields {
|
|
@@ -26,6 +30,14 @@ export interface ChartOptionFields {
|
|
|
26
30
|
}
|
|
27
31
|
/** The names a highlight can take on a chart, from its resolved data (empty when the data does not resolve). */
|
|
28
32
|
export interface ChartHighlightChoices { series: string[]; categories: string[] }
|
|
33
|
+
/** What the positions of `chart.colors` mean on a chart (RR-96): its plotted series, its slices or rows, or the colors a histogram, pareto or waterfall draws with. */
|
|
34
|
+
export interface ChartColorPositions {
|
|
35
|
+
by: "series" | "categories" | "colors";
|
|
36
|
+
/** One name per position (at most 24), in the order `chart.colors` is matched. */
|
|
37
|
+
labels: string[];
|
|
38
|
+
/** The scheme slots to offer as swatches. */
|
|
39
|
+
slots: readonly string[];
|
|
40
|
+
}
|
|
29
41
|
/** A plotted series of a combo chart: drawn as columns ("bar") or as a line, on the primary or secondary value axis (FA-15). */
|
|
30
42
|
export interface ChartComboSeriesState {
|
|
31
43
|
name: string;
|
|
@@ -43,6 +55,8 @@ export interface ChartOptionState {
|
|
|
43
55
|
dataLabels: { on: boolean; explicit: boolean; content: ChartLabelContent[]; position: ChartLabelPosition | "auto"; separator: string };
|
|
44
56
|
/** The series and categories `chart.highlight` names (empty lists when it is absent). */
|
|
45
57
|
highlight: { series: string[]; categories: string[] };
|
|
58
|
+
/** `chart.colors` as written, a ColorRef per position (empty when absent: the deck palette applies). */
|
|
59
|
+
colors: string[];
|
|
46
60
|
}
|
|
47
61
|
export interface ChartOptionChange {
|
|
48
62
|
/** The text alternative: what the data shows. Trimmed; an empty string or null removes it. */
|
|
@@ -58,6 +72,11 @@ export interface ChartOptionChange {
|
|
|
58
72
|
dataLabels?: boolean | null | { content?: ChartLabelContent[]; position?: ChartLabelPosition | "auto"; separator?: string };
|
|
59
73
|
/** `null` removes the highlight; a list replaces that part (an empty list removes it) and the other part is kept. */
|
|
60
74
|
highlight?: null | { series?: string[]; categories?: string[] };
|
|
75
|
+
/**
|
|
76
|
+
* RR-96: `chart.colors`, the colors by position. `null` removes it; a list replaces it: an entry is a hex color, a scheme color (`accent3`, which keeps following the
|
|
77
|
+
* deck) or `var:<id>`, and `null` or `""` leaves that position to the deck palette (a gap before a chosen color is written as the palette color itself). At most 24.
|
|
78
|
+
*/
|
|
79
|
+
colors?: null | Array<string | null>;
|
|
61
80
|
}
|
|
62
81
|
export interface PreparedChartOptions {
|
|
63
82
|
action: "chart-options";
|
|
@@ -70,6 +89,6 @@ export interface PreparedChartOptions {
|
|
|
70
89
|
/** The chart path a selection path points at, or undefined. */
|
|
71
90
|
export declare function parseChartPath(path: string): string | undefined;
|
|
72
91
|
/** `combo` is present on a combo chart: its plotted series in data order. Pass the `document` for a dataset-backed chart. */
|
|
73
|
-
export declare function readChartOptions(chart: unknown, presentation?: unknown): { target: unknown; fields: ChartOptionFields; choices: ChartHighlightChoices; state: ChartOptionState; combo?: { series: ChartComboSeriesState[] } };
|
|
92
|
+
export declare function readChartOptions(chart: unknown, presentation?: unknown): { target: unknown; fields: ChartOptionFields; choices: ChartHighlightChoices; colors: ChartColorPositions; state: ChartOptionState; combo?: { series: ChartComboSeriesState[] } };
|
|
74
93
|
export declare function prepareChartOptions(presentation: unknown, chartPath: string, change: ChartOptionChange): PreparedChartOptions;
|
|
75
94
|
export declare function setChartOptions(editor: EditorSession, chartPath: string, change: ChartOptionChange, meta?: Record<string, unknown>): EditorChange & { action: "chart-options"; chartPath: string; changed: boolean };
|
package/dist/chart-options.js
CHANGED
|
@@ -7,15 +7,21 @@
|
|
|
7
7
|
// choice, and only a pie or doughnut can show percent. A highlight names plotted series and category labels (the
|
|
8
8
|
// choices come from the chart's resolved data, so a dataset chart offers its dataset's columns and rows); a pie
|
|
9
9
|
// highlights slices (categories), an area only series. Nothing here invents text. A combo chart (FA-15) also offers which series are lines (`line`), which lines use the secondary value
|
|
10
|
-
// axis (`secondaryAxis`) and that axis's title.
|
|
11
|
-
|
|
12
|
-
import {
|
|
10
|
+
// axis (`secondaryAxis`) and that axis's title. (RR-96) `colors` edits `chart.colors`, the series (or slice) colors by position, each a hex
|
|
11
|
+
// color, a scheme slot such as `accent3` (it keeps following the deck) or a `var:` color variable.
|
|
12
|
+
import { resolveChartData, resolveSlideContext } from "@openpresentation/opf";
|
|
13
|
+
import { chartOptionSupport, chartOptionTarget, chartSeriesPalette } from "@openpresentation/opf/composition";
|
|
13
14
|
import { getValueAtPath, opfPathToJsonPointer, splitOpfPath } from "./index.js";
|
|
14
15
|
import { checkedDocument, fail, same } from "./edit-helpers.js";
|
|
15
16
|
import { checkFormat } from "./checks.js";
|
|
17
|
+
import { isColorRef } from "./background-options.js";
|
|
16
18
|
|
|
17
19
|
export const CHART_LEGEND_POSITIONS = Object.freeze(["default", "none", "top", "bottom", "left", "right"]);
|
|
18
20
|
export const CHART_LABEL_CONTENT = Object.freeze(["category", "value", "percent"]);
|
|
21
|
+
/** The most colors `chart.colors` takes (the schema's maxItems). */
|
|
22
|
+
export const CHART_COLORS_MAX = 24;
|
|
23
|
+
/** The scheme slots a chart color is offered from, in palette order: the deck's accents. */
|
|
24
|
+
export const CHART_COLOR_SLOTS = Object.freeze(["accent1", "accent2", "accent3", "accent4", "accent5", "accent6"]);
|
|
19
25
|
|
|
20
26
|
const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
21
27
|
const CONTENT_ORDER = CHART_LABEL_CONTENT;
|
|
@@ -52,6 +58,29 @@ function highlightChoices(chart, document) {
|
|
|
52
58
|
return { series: resolved.columns.slice(first), categories: [...new Set(resolved.rows.map((row) => label(row[0])).filter(Boolean))] };
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
// RR-96: what `chart.colors` positions mean on a chart. A series chart colors its plotted series, a pie, doughnut, treemap or funnel
|
|
62
|
+
// its slices or rows (one per data row, to the schema's 24), and a histogram, pareto or waterfall the colors it draws with.
|
|
63
|
+
const SERIES_KINDS = new Set(["bar", "line", "area", "scatter", "radar", "combo"]);
|
|
64
|
+
const ROW_KINDS = new Set(["pie", "doughnut", "treemap", "funnel"]);
|
|
65
|
+
function colorPositions(chart, target, document, combo) {
|
|
66
|
+
const kind = target?.kind;
|
|
67
|
+
const label = (cell) => (cell === null || cell === undefined ? "" : String(cell));
|
|
68
|
+
let by = "colors";
|
|
69
|
+
let labels = [];
|
|
70
|
+
if (SERIES_KINDS.has(kind)) {
|
|
71
|
+
by = "series";
|
|
72
|
+
labels = combo ? combo.map((entry) => entry.name) : highlightChoices(chart, document).series;
|
|
73
|
+
} else if (ROW_KINDS.has(kind)) {
|
|
74
|
+
by = "categories";
|
|
75
|
+
try {
|
|
76
|
+
const resolved = resolveChartData(chart, document);
|
|
77
|
+
if (resolved?.ok) labels = resolved.rows.map((row) => label(row[0]));
|
|
78
|
+
} catch { /* unresolved data: no positions to name */ }
|
|
79
|
+
}
|
|
80
|
+
if (!labels.length) { by = "colors"; labels = CHART_COLOR_SLOTS.map((_, index) => `Color ${index + 1}`); }
|
|
81
|
+
return { by, labels: labels.slice(0, CHART_COLORS_MAX).map((text, index) => text || `Color ${index + 1}`), slots: CHART_COLOR_SLOTS };
|
|
82
|
+
}
|
|
83
|
+
|
|
55
84
|
const names = (value) => (Array.isArray(value) ? [...new Set(value.filter((entry) => typeof entry === "string"))] : []);
|
|
56
85
|
|
|
57
86
|
/**
|
|
@@ -85,7 +114,10 @@ export function readChartOptions(chart, presentation) {
|
|
|
85
114
|
// FA-15: a combo chart's plotted series, each drawn as columns ("bar") or as a line, on the primary or secondary value axis.
|
|
86
115
|
...(combo ? { combo: { series: combo } } : {}),
|
|
87
116
|
choices: highlightChoices(chart, presentation),
|
|
117
|
+
colors: colorPositions(chart, target, presentation, combo),
|
|
88
118
|
state: {
|
|
119
|
+
// RR-96: `chart.colors` as written (a ColorRef per position); [] when absent, so the deck palette applies.
|
|
120
|
+
colors: Array.isArray(chart?.colors) ? chart.colors.filter((entry) => typeof entry === "string") : [],
|
|
89
121
|
// FA-09: the text alternative. `decorative` is the empty alt, a reviewed choice, not a missing one.
|
|
90
122
|
alt: typeof chart?.alt === "string" ? chart.alt : "",
|
|
91
123
|
decorative: chart?.alt === "",
|
|
@@ -142,7 +174,7 @@ function comboFields(chart, change, document) {
|
|
|
142
174
|
}
|
|
143
175
|
|
|
144
176
|
// The desired value of each field (undefined removes it), from the chart and a patch.
|
|
145
|
-
function desired(chart, change, support, choices, current, document) {
|
|
177
|
+
function desired(chart, change, support, choices, current, document, parts) {
|
|
146
178
|
const out = {};
|
|
147
179
|
const combo = support.axisTitles.secondary === true;
|
|
148
180
|
if (change.line !== undefined || change.secondaryAxis !== undefined) {
|
|
@@ -201,12 +233,46 @@ function desired(chart, change, support, choices, current, document) {
|
|
|
201
233
|
out.highlight = Object.keys(next).length ? next : undefined;
|
|
202
234
|
}
|
|
203
235
|
}
|
|
236
|
+
if (change.colors !== undefined) out.colors = chartColors(change.colors, document, parts);
|
|
204
237
|
return out;
|
|
205
238
|
}
|
|
206
239
|
|
|
240
|
+
// RR-96: the `chart.colors` list to write (undefined removes the field). A position left to the deck palette (null or "") is only
|
|
241
|
+
// expressible as the palette color itself, so a gap before a chosen color is filled with the accent slot (the palette's own color for
|
|
242
|
+
// the first six; later ones are its tints, written as that hex), and trailing gaps are dropped, which keeps the palette.
|
|
243
|
+
function chartColors(wanted, document, parts) {
|
|
244
|
+
if (wanted === null) return undefined;
|
|
245
|
+
if (!Array.isArray(wanted)) throw fail("invalid-chart-option", "colors is a list of colors by position, or null to remove it.", { colors: wanted });
|
|
246
|
+
if (wanted.length > CHART_COLORS_MAX) throw fail("invalid-chart-option", `A chart takes at most ${CHART_COLORS_MAX} colors.`, { count: wanted.length });
|
|
247
|
+
const entries = wanted.map((entry, index) => {
|
|
248
|
+
if (entry === null || entry === undefined || entry === "") return undefined;
|
|
249
|
+
if (!isColorRef(entry)) throw fail("invalid-chart-option", `Color ${index + 1} must be a hex color such as #7B241C, a scheme color such as accent3, or var:<id>.`, { index, color: entry });
|
|
250
|
+
return /^#/.test(entry) ? entry.toUpperCase() : entry;
|
|
251
|
+
});
|
|
252
|
+
while (entries.length && entries[entries.length - 1] === undefined) entries.pop();
|
|
253
|
+
if (!entries.length) return undefined;
|
|
254
|
+
let palette;
|
|
255
|
+
return entries.map((entry, index) => {
|
|
256
|
+
if (entry !== undefined) return entry;
|
|
257
|
+
if (index < CHART_COLOR_SLOTS.length) return CHART_COLOR_SLOTS[index];
|
|
258
|
+
palette ??= paletteOf(document, parts);
|
|
259
|
+
return palette[index];
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// The deck palette of the slide a chart is on (`chartSeriesPalette` of its resolved color scheme), for positions past the six accents.
|
|
264
|
+
function paletteOf(document, parts) {
|
|
265
|
+
let scheme = {};
|
|
266
|
+
try {
|
|
267
|
+
const slide = parts[0] === "slides" ? Number(parts[1]) : 0;
|
|
268
|
+
scheme = resolveSlideContext(document, Number.isInteger(slide) ? slide : 0, {}).resolved?.colorScheme ?? {};
|
|
269
|
+
} catch { /* the engine default palette */ }
|
|
270
|
+
return chartSeriesPalette(scheme);
|
|
271
|
+
}
|
|
272
|
+
|
|
207
273
|
/**
|
|
208
274
|
* Prepare one validated patch for a change to the chart's options. `change` is
|
|
209
|
-
* `{ alt?, decorative?, axisTitles?: { category?, value?, secondary? }, legend?, dataLabels?, highlight?, line?, secondaryAxis? }`: `alt` is the text alternative (trimmed; empty or `null`
|
|
275
|
+
* `{ alt?, decorative?, axisTitles?: { category?, value?, secondary? }, legend?, dataLabels?, highlight?, colors?, line?, secondaryAxis? }`: `alt` is the text alternative (trimmed; empty or `null`
|
|
210
276
|
* removes it), `decorative: true` writes the empty alt (and wins over `alt`), `decorative: false` removes an empty alt; `axisTitles` entries are strings (empty removes a title),
|
|
211
277
|
* `legend` is `"default"` (remove the field), `"none"`, `"top"`, `"bottom"`, `"left"` or `"right"`, and `dataLabels` is
|
|
212
278
|
* `true`, `false` (or `null`, which removes the field), or `{ content?, position?, separator? }` merged over the current labels, and
|
|
@@ -214,13 +280,15 @@ function desired(chart, change, support, choices, current, document) {
|
|
|
214
280
|
* chart does not have is refused) and the other part is kept. On a combo chart (FA-15), `line` lists the series drawn as lines and
|
|
215
281
|
* `secondaryAxis` the lines on the secondary value axis, by name; at least one series stays columns and at least one is a line, `line`
|
|
216
282
|
* is written only when it differs from the default (the last series), and the secondary axis title goes when no series uses that axis.
|
|
283
|
+
* `colors` (RR-96) is `null` (remove the field) or a list of colors by position (a hex color, a scheme color such as `accent3` that keeps following the deck, or
|
|
284
|
+
* `var:<id>`; `null` or `""` leaves that position to the deck palette), at most 24; it replaces the whole list, and trailing positions left to the palette are dropped.
|
|
217
285
|
* Fields the chart type cannot show are never written. The document is not modified.
|
|
218
286
|
*/
|
|
219
287
|
export function prepareChartOptions(presentation, chartPath, change) {
|
|
220
288
|
const before = checkFormat(presentation);
|
|
221
289
|
const { parts, chart } = chartAt(presentation, chartPath);
|
|
222
290
|
const read = readChartOptions(chart, presentation);
|
|
223
|
-
const wanted = desired(chart, change, read.fields, read.choices, read.state, presentation);
|
|
291
|
+
const wanted = desired(chart, change, read.fields, read.choices, read.state, presentation, parts);
|
|
224
292
|
const patches = [];
|
|
225
293
|
for (const [key, value] of Object.entries(wanted)) {
|
|
226
294
|
const path = opfPathToJsonPointer([...parts, key]);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Catalog } from "@openpresentation/opf";
|
|
2
|
+
|
|
3
|
+
/** The four background slots a variant maps to the background: `light1`, `light2`, `dark1`, `dark2`. */
|
|
4
|
+
export declare const VARIANT_BACKGROUND_SLOTS: readonly ["light1", "light2", "dark1", "dark2"];
|
|
5
|
+
/** The six accent slots, in the scheme's own order. */
|
|
6
|
+
export declare const ACCENT_SLOTS: readonly ["accent1", "accent2", "accent3", "accent4", "accent5", "accent6"];
|
|
7
|
+
export type VariantBackgroundSlot = (typeof VARIANT_BACKGROUND_SLOTS)[number];
|
|
8
|
+
export type AccentSlot = (typeof ACCENT_SLOTS)[number];
|
|
9
|
+
/** Slot name to `#RRGGBB`; a slot the resolved scheme leaves out is absent. */
|
|
10
|
+
export type SchemeSlots = Partial<Record<VariantBackgroundSlot | AccentSlot, string>>;
|
|
11
|
+
/** The deck's resolved color scheme slots at `slideIndex` (default 0), for swatches. Never throws: an unresolvable document gives `{}`. */
|
|
12
|
+
export declare function readSchemeSlots(presentation: unknown, options?: { slideIndex?: number; catalogs?: readonly Catalog[] }): SchemeSlots;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// RR-96: the color scheme slots a picker offers as swatches, read from the scheme the document resolves for a slide. A slot is
|
|
2
|
+
// shown as its own color, so a swatch is what the deck draws, and a choice writes the slot name (`accent3`, `dark1`), never the hex,
|
|
3
|
+
// so it keeps following the deck if its scheme changes. Read only: no edit goes through here.
|
|
4
|
+
import { resolveSlideContext } from "@openpresentation/opf";
|
|
5
|
+
|
|
6
|
+
/** The four background slots a variant maps to the background, in the order PowerPoint's Variants gallery shows them. */
|
|
7
|
+
export const VARIANT_BACKGROUND_SLOTS = Object.freeze(["light1", "light2", "dark1", "dark2"]);
|
|
8
|
+
/** The six accent slots, in the scheme's own order. */
|
|
9
|
+
export const ACCENT_SLOTS = Object.freeze(["accent1", "accent2", "accent3", "accent4", "accent5", "accent6"]);
|
|
10
|
+
|
|
11
|
+
const HEX = /^#[0-9a-f]{6}$/i;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The hex color of each of `light1`, `light2`, `dark1`, `dark2` and `accent1` to `accent6` in the deck's resolved color scheme at
|
|
15
|
+
* `slideIndex` (default: the first slide; a slide has the deck's scheme unless it sets its own). A slot the scheme leaves out is
|
|
16
|
+
* absent. Never throws: an unresolvable document gives `{}`.
|
|
17
|
+
*/
|
|
18
|
+
export function readSchemeSlots(presentation, { slideIndex = 0, catalogs } = {}) {
|
|
19
|
+
let scheme;
|
|
20
|
+
try {
|
|
21
|
+
const slides = Array.isArray(presentation?.slides) ? presentation.slides.length : 0;
|
|
22
|
+
scheme = resolveSlideContext(presentation, Math.min(Math.max(slideIndex, 0), Math.max(slides - 1, 0)), catalogs ? { catalogs } : {}).resolved?.colorScheme;
|
|
23
|
+
} catch {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
const slots = {};
|
|
27
|
+
for (const name of [...VARIANT_BACKGROUND_SLOTS, ...ACCENT_SLOTS]) {
|
|
28
|
+
if (typeof scheme?.[name] === "string" && HEX.test(scheme[name])) slots[name] = scheme[name];
|
|
29
|
+
}
|
|
30
|
+
return slots;
|
|
31
|
+
}
|
package/dist/design-controls.js
CHANGED
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
setHeaderFooterZone,
|
|
42
42
|
setOrganizationLogo,
|
|
43
43
|
} from "./design-options.js";
|
|
44
|
+
import { ACCENT_SLOTS } from "./color-slots.js";
|
|
45
|
+
import { readDesignVariant, setDesignVariant, variantSwatches } from "./design-variants.js";
|
|
44
46
|
import { createContentControls } from "./content-controls.js";
|
|
45
47
|
import { IMAGE_EDGES, IMAGE_FITS, IMAGE_SHAPES, readImageTreatments, setImageTreatment } from "./image-options.js";
|
|
46
48
|
import { applyCatalogUpdate, catalogRecordAt, checkCatalogUpdates, mergeCatalogs, moveToCustom, referenceFindings } from "./catalogs.js";
|
|
@@ -48,10 +50,11 @@ import { describeTableCell, mergeTableCells, parseTableCellPath, readTableAlt, r
|
|
|
48
50
|
import { OPFEditorError } from "./index.js";
|
|
49
51
|
|
|
50
52
|
/** The sections a panel can show. `selection` and `table` follow the current selection; the rest follow the deck or the current slide. */
|
|
51
|
-
export const DESIGN_CONTROL_SECTIONS = Object.freeze(["look", "background", "header-footer", "brand", "layout-options", "info", "selection", "image", "table", "slide-content", "catalog"]);
|
|
53
|
+
export const DESIGN_CONTROL_SECTIONS = Object.freeze(["look", "variant", "background", "header-footer", "brand", "layout-options", "info", "selection", "image", "table", "slide-content", "catalog"]);
|
|
52
54
|
|
|
53
55
|
const SECTION_TITLES = {
|
|
54
56
|
look: "Look and language",
|
|
57
|
+
variant: "Color variant",
|
|
55
58
|
background: "Background",
|
|
56
59
|
image: "Selected image",
|
|
57
60
|
catalog: "Catalog records",
|
|
@@ -431,7 +434,7 @@ export function createDesignControls(container, options = {}) {
|
|
|
431
434
|
const scopeIndex = () => (state.scope === "slide" ? getSlide() : undefined);
|
|
432
435
|
const scoped = (extra = {}) => (state.scope === "slide" ? { slideIndex: getSlide(), ...extra } : extra);
|
|
433
436
|
const sourceNote = (scope, inherited) => (state.scope === "slide" ? (scope === "slide" ? "set on this slide" : inherited ? "from the presentation" : "default") : "");
|
|
434
|
-
const scopeNeeded = sections.some((name) => ["look", "background", "header-footer", "brand", "layout-options"].includes(name));
|
|
437
|
+
const scopeNeeded = sections.some((name) => ["look", "variant", "background", "header-footer", "brand", "layout-options"].includes(name));
|
|
435
438
|
let scopeField;
|
|
436
439
|
if (scopeNeeded) {
|
|
437
440
|
scopeField = selectField("scope", "Applies to", {
|
|
@@ -502,6 +505,75 @@ export function createDesignControls(container, options = {}) {
|
|
|
502
505
|
});
|
|
503
506
|
}
|
|
504
507
|
|
|
508
|
+
// --- color variant (RR-96) ----------------------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
// PowerPoint's Variants gallery: four swatches built from the deck's own scheme slots (light1, light2, dark1, dark2) and, below
|
|
511
|
+
// them, the order of the accents. A choice writes the slot name (`design.variant`), so it keeps following the color scheme, and the
|
|
512
|
+
// preview comes from core's roles. Needs core 0.23 (`design.variant`).
|
|
513
|
+
if (built.variant) {
|
|
514
|
+
const { body } = built.variant;
|
|
515
|
+
const view = () => ({ ...scoped(), catalogs: catalogOptions().catalogs });
|
|
516
|
+
const SLOT_LABELS = { light1: "Light 1", light2: "Light 2", dark1: "Dark 1", dark2: "Dark 2" };
|
|
517
|
+
const gallery = h("div", { class: "opf-dc-variants", role: "radiogroup", "aria-label": "Color variant" });
|
|
518
|
+
const sameAsDeck = button("Same as presentation", () => run(() => setDesignVariant(editor, null, view()), "This slide follows the presentation's color variant."), { quiet: true });
|
|
519
|
+
const accentRows = ACCENT_SLOTS.map((_, index) => {
|
|
520
|
+
const field = selectField(`variant-accent-${index + 1}`, `Accent ${index + 1}`, {
|
|
521
|
+
onChange: (value) => {
|
|
522
|
+
const order = readDesignVariant(editor.presentation, view()).accents;
|
|
523
|
+
const from = order.indexOf(value);
|
|
524
|
+
const next = [...order];
|
|
525
|
+
// A permutation: the slot that was drawn as this accent takes the place of the one chosen.
|
|
526
|
+
[next[index], next[from]] = [order[from], order[index]];
|
|
527
|
+
run(() => setDesignVariant(editor, { accents: next }, view()), "Accent order changed.");
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
field.setOptions(ACCENT_SLOTS.map((slot, k) => ({ value: slot, label: `Accent ${k + 1} color (${slot})` })));
|
|
531
|
+
return field;
|
|
532
|
+
});
|
|
533
|
+
const resetAccents = button("Reset accent order", () => run(() => {
|
|
534
|
+
const current = readDesignVariant(editor.presentation, view());
|
|
535
|
+
return setDesignVariant(editor, { background: current.background, accents: ACCENT_SLOTS }, view());
|
|
536
|
+
}, "Accents are back in the color scheme's own order."), { quiet: true });
|
|
537
|
+
const accentGroup = h("fieldset", { class: "opf-dc-fieldset", "data-variant-accents": "" }, h("legend", {}, "Accent order"), ...accentRows.map((row) => row.wrap), resetAccents);
|
|
538
|
+
body.append(gallery, sameAsDeck, accentGroup);
|
|
539
|
+
const pick = (slot) => {
|
|
540
|
+
const current = readDesignVariant(editor.presentation, view());
|
|
541
|
+
// Keep a reordered accent order when only the background changes.
|
|
542
|
+
const accents = current.own?.accents;
|
|
543
|
+
return run(() => setDesignVariant(editor, accents ? { background: slot, accents } : slot, view()), (change) => {
|
|
544
|
+
const hidden = change.shadowed?.length ? ` ${change.shadowed.length === 1 ? "One slide keeps its own" : `${change.shadowed.length} slides keep their own`} background or variant.` : "";
|
|
545
|
+
return `Color variant set to ${SLOT_LABELS[slot]}.${hidden}`;
|
|
546
|
+
});
|
|
547
|
+
};
|
|
548
|
+
syncs.push(() => {
|
|
549
|
+
const current = readDesignVariant(editor.presentation, view());
|
|
550
|
+
const swatches = variantSwatches(editor.presentation, view());
|
|
551
|
+
const signature = JSON.stringify([swatches, state.scope]);
|
|
552
|
+
if (gallery.dataset.signature !== signature) {
|
|
553
|
+
gallery.dataset.signature = signature;
|
|
554
|
+
gallery.replaceChildren(...swatches.map((entry) => {
|
|
555
|
+
const chip = (color, className) => h("span", { class: className, style: color ? `background:${color}` : undefined, "aria-hidden": "true" });
|
|
556
|
+
return h("button", {
|
|
557
|
+
type: "button",
|
|
558
|
+
class: "opf-dc-variant",
|
|
559
|
+
role: "radio",
|
|
560
|
+
"aria-checked": String(entry.selected),
|
|
561
|
+
"data-variant": entry.slot,
|
|
562
|
+
"aria-label": `${SLOT_LABELS[entry.slot]}: ${entry.slot} background${entry.dark ? " (dark)" : ""}`,
|
|
563
|
+
title: `${SLOT_LABELS[entry.slot]}: ${entry.slot} background`,
|
|
564
|
+
style: entry.background ? `background:${entry.background};color:${entry.text ?? "inherit"}` : undefined,
|
|
565
|
+
onclick: () => pick(entry.slot),
|
|
566
|
+
}, h("span", { class: "opf-dc-variant-text", "aria-hidden": "true" }, "Aa"), h("span", { class: "opf-dc-variant-chips", "aria-hidden": "true" }, chip(entry.surface, "opf-dc-variant-chip"), ...entry.accents.map((color) => chip(color, "opf-dc-variant-chip"))));
|
|
567
|
+
}));
|
|
568
|
+
}
|
|
569
|
+
const note = current.scope === "default" ? "default" : state.scope === "slide" ? (current.inherited ? "from the presentation" : "set on this slide") : "";
|
|
570
|
+
gallery.setAttribute("aria-description", note);
|
|
571
|
+
sameAsDeck.hidden = !(state.scope === "slide" && current.own !== undefined);
|
|
572
|
+
accentRows.forEach((row, index) => row.set(current.accents[index]));
|
|
573
|
+
resetAccents.hidden = current.own?.accents === undefined;
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
505
577
|
// --- background ---------------------------------------------------------------------------------
|
|
506
578
|
|
|
507
579
|
if (built.background) {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Catalog } from "@openpresentation/opf";
|
|
2
|
+
import type { EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
|
|
3
|
+
import type { AccentSlot, VariantBackgroundSlot } from "./color-slots.js";
|
|
4
|
+
|
|
5
|
+
export { ACCENT_SLOTS, VARIANT_BACKGROUND_SLOTS } from "./color-slots.js";
|
|
6
|
+
export type { AccentSlot, VariantBackgroundSlot } from "./color-slots.js";
|
|
7
|
+
|
|
8
|
+
/** A variant of the deck's one color scheme: the slot that is the background and, optionally, the order of the accents (core's `ColorVariant`). */
|
|
9
|
+
export interface DesignVariant {
|
|
10
|
+
background: VariantBackgroundSlot;
|
|
11
|
+
/** A permutation of `accent1` to `accent6`: entry i is the slot drawn as accent i + 1 (primary, secondary, accent and the chart series colors follow it). */
|
|
12
|
+
accents?: AccentSlot[];
|
|
13
|
+
}
|
|
14
|
+
/** What `design.variant` takes: the background slot (the shorthand) or the object. */
|
|
15
|
+
export type DesignVariantInput = VariantBackgroundSlot | DesignVariant;
|
|
16
|
+
/** What the editor writes: the slot as a string, or the object only for a reordered accent order. */
|
|
17
|
+
export type StoredDesignVariant = VariantBackgroundSlot | { background: VariantBackgroundSlot; accents: AccentSlot[] };
|
|
18
|
+
|
|
19
|
+
/** For each background slot, the slots the variant maps to bg1, tx1, bg2 and tx2 (PowerPoint's color map), and whether it is dark. */
|
|
20
|
+
export declare const VARIANT_MAPS: Readonly<Record<VariantBackgroundSlot, { readonly bg1: string; readonly tx1: string; readonly bg2: string; readonly tx2: string; readonly dark: boolean }>>;
|
|
21
|
+
|
|
22
|
+
/** Validate a variant and return what to store (see {@link StoredDesignVariant}). Throws `invalid-variant` with a sentence a person can act on. */
|
|
23
|
+
export declare function normalizeVariant(value: DesignVariantInput): StoredDesignVariant;
|
|
24
|
+
|
|
25
|
+
export interface DesignVariantOptions {
|
|
26
|
+
/** One slide instead of the deck. */
|
|
27
|
+
slideIndex?: number;
|
|
28
|
+
/** Deck scope: also remove the slides' own variants that would hide the change. */
|
|
29
|
+
clearSlideOverrides?: boolean;
|
|
30
|
+
/** Catalogs the document's references resolve against (a session form uses the session's). */
|
|
31
|
+
catalogs?: readonly Catalog[];
|
|
32
|
+
/** Session change metadata (session form only). */
|
|
33
|
+
meta?: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
export interface DesignVariantState {
|
|
36
|
+
/** What this scope's own `design.variant` says, normalized to the object; undefined when it sets none. */
|
|
37
|
+
own?: DesignVariant;
|
|
38
|
+
/** The slide's effective variant background, by core's resolution (a variant, a theme-slot background or a picture's tone, the deck's, the theme's, else light1). */
|
|
39
|
+
background: VariantBackgroundSlot;
|
|
40
|
+
/** The effective accent order (the six slots; the scheme's own order unless a variant reorders it). */
|
|
41
|
+
accents: AccentSlot[];
|
|
42
|
+
/** Where `own` comes from: "slide" (the slide sets it), "deck", or "default" (nothing sets one). */
|
|
43
|
+
scope: "slide" | "deck" | "default";
|
|
44
|
+
/** True when a slide takes its variant from the deck (it sets none itself). */
|
|
45
|
+
inherited: boolean;
|
|
46
|
+
}
|
|
47
|
+
export declare function readDesignVariant(presentation: unknown, options?: Pick<DesignVariantOptions, "slideIndex" | "catalogs">): DesignVariantState;
|
|
48
|
+
|
|
49
|
+
/** One of the four variants as a swatch built from the deck's own scheme slots. */
|
|
50
|
+
export interface VariantSwatch {
|
|
51
|
+
slot: VariantBackgroundSlot;
|
|
52
|
+
dark: boolean;
|
|
53
|
+
/** Hex colors of the slots the variant maps to its background, text and alternate surface; undefined when the scheme leaves a slot out. */
|
|
54
|
+
background?: string;
|
|
55
|
+
text?: string;
|
|
56
|
+
surface?: string;
|
|
57
|
+
/** The first three accents in the effective accent order. */
|
|
58
|
+
accents: Array<string | undefined>;
|
|
59
|
+
/** Whether it is the effective variant of the slide. */
|
|
60
|
+
selected: boolean;
|
|
61
|
+
}
|
|
62
|
+
/** The four variants (`light1`, `light2`, `dark1`, `dark2`) as swatches from the deck's scheme at a scope, like PowerPoint's Variants gallery. */
|
|
63
|
+
export declare function variantSwatches(presentation: unknown, options?: Pick<DesignVariantOptions, "slideIndex" | "catalogs">): VariantSwatch[];
|
|
64
|
+
|
|
65
|
+
export interface PreparedDesignVariant {
|
|
66
|
+
option: "variant";
|
|
67
|
+
scope: "deck" | "slide";
|
|
68
|
+
slideIndex?: number;
|
|
69
|
+
presentation: unknown;
|
|
70
|
+
patches: JsonPatchOperation[];
|
|
71
|
+
changed: boolean;
|
|
72
|
+
/** Slides whose own variant, or theme-slot or toned background, still hides a deck-level change. */
|
|
73
|
+
shadowed: number[];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The patch that sets the variant (a slot, `{ background, accents? }`, or `null` to remove it; `{ accents }` alone keeps the background)
|
|
77
|
+
* at the deck, or on one slide with `slideIndex`. The document is not modified.
|
|
78
|
+
*/
|
|
79
|
+
export declare function prepareDesignVariant(presentation: unknown, value: DesignVariantInput | { accents: AccentSlot[] } | null, options?: Omit<DesignVariantOptions, "meta">): PreparedDesignVariant;
|
|
80
|
+
export interface DesignVariantChange extends Omit<EditorChange, "presentation" | "patches"> {
|
|
81
|
+
presentation: unknown;
|
|
82
|
+
patches: JsonPatchOperation[];
|
|
83
|
+
option: "variant";
|
|
84
|
+
scope: "deck" | "slide";
|
|
85
|
+
slideIndex?: number;
|
|
86
|
+
changed: boolean;
|
|
87
|
+
shadowed: number[];
|
|
88
|
+
}
|
|
89
|
+
/** Set the variant as one undoable transaction. */
|
|
90
|
+
export declare function setDesignVariant(editor: EditorSession, value: DesignVariantInput | { accents: AccentSlot[] } | null, options?: DesignVariantOptions): DesignVariantChange;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Color variants (RR-96): `design.variant`, at the deck or on one slide. A variant is a mapping over the deck's one twelve-slot color
|
|
2
|
+
// scheme, as PowerPoint's color map is, never a second scheme: which slot is the background (and so the text and the alternate
|
|
3
|
+
// surface) and, optionally, the order of the accents. `light1`, `light2`, `dark1` and `dark2` are the four backgrounds PowerPoint's
|
|
4
|
+
// Variants gallery offers. A choice writes the slot name, so it keeps following the deck's color scheme. Every function has a
|
|
5
|
+
// `prepare…` form that returns the patch without touching a session and a session form that commits it as one undoable edit, like
|
|
6
|
+
// every other design option; the preview and the PowerPoint export read the variant from core, nothing is drawn here.
|
|
7
|
+
import { resolveSlideContext } from "@openpresentation/opf";
|
|
8
|
+
import { normalizeColorVariant } from "@openpresentation/opf/composition";
|
|
9
|
+
import { getValueAtPath, opfPathToJsonPointer } from "./index.js";
|
|
10
|
+
import { checkedDocument, designPatches, fail } from "./edit-helpers.js";
|
|
11
|
+
import { checkFormat } from "./checks.js";
|
|
12
|
+
import { ACCENT_SLOTS, VARIANT_BACKGROUND_SLOTS, readSchemeSlots } from "./color-slots.js";
|
|
13
|
+
|
|
14
|
+
export { ACCENT_SLOTS, VARIANT_BACKGROUND_SLOTS };
|
|
15
|
+
const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
16
|
+
const sameOrder = (accents) => accents.every((slot, index) => slot === ACCENT_SLOTS[index]);
|
|
17
|
+
|
|
18
|
+
/** For each background slot: the slots the variant maps to the text, the alternate surface and the secondary text (the schema's ColorVariant table). */
|
|
19
|
+
export const VARIANT_MAPS = Object.freeze({
|
|
20
|
+
light1: Object.freeze({ bg1: "light1", tx1: "dark1", bg2: "light2", tx2: "dark2", dark: false }),
|
|
21
|
+
light2: Object.freeze({ bg1: "light2", tx1: "dark1", bg2: "light2", tx2: "dark2", dark: false }),
|
|
22
|
+
dark1: Object.freeze({ bg1: "dark1", tx1: "light1", bg2: "dark2", tx2: "light2", dark: true }),
|
|
23
|
+
dark2: Object.freeze({ bg1: "dark2", tx1: "light1", bg2: "dark2", tx2: "light2", dark: true }),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validate a variant and return what to store: the background slot as a string, or `{ background, accents }` only when the accents are
|
|
28
|
+
* reordered. Accepts a slot, `{ background, accents? }`; a full accent list in the scheme's own order is dropped (it says nothing).
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeVariant(value) {
|
|
31
|
+
const spec = typeof value === "string" ? { background: value } : value;
|
|
32
|
+
if (!isObject(spec)) throw fail("invalid-variant", `A variant is a background slot (${VARIANT_BACKGROUND_SLOTS.join(", ")}) or { background, accents }.`, { value });
|
|
33
|
+
const extra = Object.keys(spec).filter((key) => key !== "background" && key !== "accents");
|
|
34
|
+
if (extra.length) throw fail("invalid-variant", `A variant has a background and optionally accents, not ${extra.join(", ")}.`, { fields: extra });
|
|
35
|
+
if (!VARIANT_BACKGROUND_SLOTS.includes(spec.background)) throw fail("invalid-variant", `The variant background is one of ${VARIANT_BACKGROUND_SLOTS.join(", ")}.`, { background: spec.background });
|
|
36
|
+
if (spec.accents === undefined || spec.accents === null) return spec.background;
|
|
37
|
+
const accents = spec.accents;
|
|
38
|
+
if (!Array.isArray(accents) || accents.length !== ACCENT_SLOTS.length || new Set(accents).size !== ACCENT_SLOTS.length || accents.some((slot) => !ACCENT_SLOTS.includes(slot)))
|
|
39
|
+
throw fail("invalid-variant", "The accent order uses each of accent1 to accent6 once.", { accents });
|
|
40
|
+
return sameOrder(accents) ? spec.background : { background: spec.background, accents: [...accents] };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Core's own normalization of a stored variant (the string shorthand or the object) to the object it resolves with.
|
|
44
|
+
const asObject = (stored) => normalizeColorVariant(stored);
|
|
45
|
+
|
|
46
|
+
function scopeOf(presentation, options) {
|
|
47
|
+
const index = options.slideIndex;
|
|
48
|
+
if (index === undefined) return { scope: "deck", base: [] };
|
|
49
|
+
if (!Number.isInteger(index) || !presentation.slides?.[index]) throw fail("slide-index-out-of-range", "Choose an existing slide for this option.", { slideIndex: index });
|
|
50
|
+
return { scope: "slide", base: ["slides", String(index)], slideIndex: index };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ownVariant = (presentation, base) => {
|
|
54
|
+
const design = getValueAtPath(presentation, base.length ? [...base, "design"] : ["design"]);
|
|
55
|
+
return isObject(design) && design.variant !== undefined ? design.variant : undefined;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The variant at a scope, for a panel. `own` is what that scope's `design.variant` says (`{ background, accents? }`, undefined when
|
|
60
|
+
* it sets none), `background` and `accents` the slide's effective variant (core's resolution: a slide's variant, a theme-slot
|
|
61
|
+
* background, a picture or gradient `tone`, the deck's variant, the theme's, then the default; `accents` always has the six slots in
|
|
62
|
+
* order), `scope` where `own` comes from ("slide", "deck" or "default") and `inherited` whether a slide takes it from the deck.
|
|
63
|
+
*/
|
|
64
|
+
export function readDesignVariant(presentation, options = {}) {
|
|
65
|
+
const { base } = scopeOf(presentation, options);
|
|
66
|
+
const own = ownVariant(presentation, base);
|
|
67
|
+
const deck = ownVariant(presentation, []);
|
|
68
|
+
let effective;
|
|
69
|
+
try {
|
|
70
|
+
const slide = options.slideIndex ?? 0;
|
|
71
|
+
effective = resolveSlideContext(presentation, Math.min(Math.max(slide, 0), Math.max((presentation.slides?.length ?? 1) - 1, 0)), options.catalogs ? { catalogs: options.catalogs } : {}).resolved?.roles?.variant;
|
|
72
|
+
} catch { /* older core or an unresolvable document: fall back to what the document says */ }
|
|
73
|
+
const fallback = asObject(own ?? deck) ?? { background: "light1" };
|
|
74
|
+
const shown = isObject(effective) && typeof effective.background === "string" ? effective : fallback;
|
|
75
|
+
const slideScope = options.slideIndex !== undefined;
|
|
76
|
+
return {
|
|
77
|
+
own: own === undefined ? undefined : asObject(own),
|
|
78
|
+
background: shown.background,
|
|
79
|
+
accents: Array.isArray(shown.accents) ? [...shown.accents] : [...ACCENT_SLOTS],
|
|
80
|
+
scope: own !== undefined ? (slideScope ? "slide" : "deck") : slideScope && deck !== undefined ? "deck" : "default",
|
|
81
|
+
inherited: slideScope && own === undefined,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The four variants as swatches built from the deck's own scheme slots at a scope: for each of `light1`, `light2`, `dark1` and `dark2`
|
|
87
|
+
* the colors of its background, text, alternate surface and first three accents (in the effective accent order), and whether it is
|
|
88
|
+
* the effective one. A color the scheme leaves out is undefined.
|
|
89
|
+
*/
|
|
90
|
+
export function variantSwatches(presentation, options = {}) {
|
|
91
|
+
const slots = readSchemeSlots(presentation, { slideIndex: options.slideIndex ?? 0, catalogs: options.catalogs });
|
|
92
|
+
const current = readDesignVariant(presentation, options);
|
|
93
|
+
return VARIANT_BACKGROUND_SLOTS.map((slot) => {
|
|
94
|
+
const map = VARIANT_MAPS[slot];
|
|
95
|
+
return {
|
|
96
|
+
slot,
|
|
97
|
+
dark: map.dark,
|
|
98
|
+
background: slots[map.bg1],
|
|
99
|
+
text: slots[map.tx1],
|
|
100
|
+
surface: slots[map.bg2],
|
|
101
|
+
accents: current.accents.slice(0, 3).map((accent) => slots[accent]),
|
|
102
|
+
selected: current.background === slot,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Slides whose own design hides a deck-level variant: their own `variant`, or a theme-slot background that decides the slide's variant (or a tone). */
|
|
108
|
+
function shadowedSlides(presentation) {
|
|
109
|
+
const hidden = [];
|
|
110
|
+
(presentation.slides ?? []).forEach((slide, index) => {
|
|
111
|
+
const design = slide?.design;
|
|
112
|
+
if (!isObject(design)) return;
|
|
113
|
+
const background = design.background;
|
|
114
|
+
const decides = design.variant !== undefined || background === "light1" || background === "light2" || background === "dark1" || background === "dark2" || (isObject(background) && (background.type === "theme" || background.tone !== undefined));
|
|
115
|
+
if (decides) hidden.push(index);
|
|
116
|
+
});
|
|
117
|
+
return hidden;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Compute the patch that sets the variant, at the deck or (with `slideIndex`) on one slide. `value` is a background slot, `{ background,
|
|
122
|
+
* accents? }`, or `null` to remove the variant at that scope (a slide then follows the deck). `{ accents }` alone keeps the scope's
|
|
123
|
+
* (or else the effective) background. Deck scope: `clearSlideOverrides` also removes the slides' own variants that would hide the
|
|
124
|
+
* change; `shadowed` lists the slides whose own variant or background would still hide it.
|
|
125
|
+
*/
|
|
126
|
+
export function prepareDesignVariant(presentation, value, options = {}) {
|
|
127
|
+
if (!isObject(presentation)) throw fail("invalid-input", "Design variants need an OPF document object.");
|
|
128
|
+
if (value === undefined) throw fail("invalid-variant", "Pass a variant, or null to remove it.");
|
|
129
|
+
const before = checkFormat(presentation);
|
|
130
|
+
const { scope, base, slideIndex } = scopeOf(presentation, options);
|
|
131
|
+
let stored = null;
|
|
132
|
+
if (value !== null) {
|
|
133
|
+
let spec = value;
|
|
134
|
+
if (isObject(value) && value.background === undefined) {
|
|
135
|
+
const current = readDesignVariant(presentation, { slideIndex, catalogs: options.catalogs });
|
|
136
|
+
spec = { background: current.background, ...value };
|
|
137
|
+
}
|
|
138
|
+
stored = normalizeVariant(spec);
|
|
139
|
+
}
|
|
140
|
+
const patches = designPatches(presentation, base, { variant: stored });
|
|
141
|
+
let shadowed = [];
|
|
142
|
+
if (scope === "deck") {
|
|
143
|
+
shadowed = shadowedSlides(presentation);
|
|
144
|
+
if (options.clearSlideOverrides) {
|
|
145
|
+
for (const index of shadowed) {
|
|
146
|
+
if (presentation.slides[index].design?.variant !== undefined) patches.push({ op: "remove", path: opfPathToJsonPointer(["slides", String(index), "design", "variant"]) });
|
|
147
|
+
}
|
|
148
|
+
shadowed = shadowedSlides(applyRemovals(presentation, patches));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const next = checkedDocument(presentation, patches, before);
|
|
152
|
+
return { option: "variant", scope, ...(slideIndex !== undefined ? { slideIndex } : {}), presentation: next, patches, changed: patches.length > 0, shadowed };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function applyRemovals(presentation, patches) {
|
|
156
|
+
const copy = structuredClone(presentation);
|
|
157
|
+
for (const patch of patches) {
|
|
158
|
+
if (patch.op !== "remove") continue;
|
|
159
|
+
const match = /^\/slides\/(\d+)\/design\/variant$/.exec(patch.path);
|
|
160
|
+
if (match) delete copy.slides[Number(match[1])].design.variant;
|
|
161
|
+
}
|
|
162
|
+
return copy;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function checkEditor(editor) {
|
|
166
|
+
if (!editor || typeof editor.applyPatch !== "function" || typeof editor.subscribe !== "function")
|
|
167
|
+
throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Set the variant as one undoable transaction. See {@link prepareDesignVariant}. */
|
|
171
|
+
export function setDesignVariant(editor, value, options = {}) {
|
|
172
|
+
checkEditor(editor);
|
|
173
|
+
const { meta = {}, ...rest } = options;
|
|
174
|
+
const prepared = prepareDesignVariant(editor.presentation, value, { catalogs: editor.catalogs, ...rest });
|
|
175
|
+
const { presentation, patches, ...summary } = prepared;
|
|
176
|
+
void presentation;
|
|
177
|
+
if (!prepared.changed) return { ...summary, presentation: editor.presentation, patches: [], inversePatches: [], validation: editor.validation };
|
|
178
|
+
const change = editor.applyPatch(patches, { ...meta, source: meta.source ?? "design-option", option: "variant", scope: prepared.scope });
|
|
179
|
+
return { ...change, ...summary };
|
|
180
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -114,6 +114,14 @@
|
|
|
114
114
|
"types": "./dist/assets.d.ts",
|
|
115
115
|
"default": "./dist/assets.js"
|
|
116
116
|
},
|
|
117
|
+
"./design-variants": {
|
|
118
|
+
"types": "./dist/design-variants.d.ts",
|
|
119
|
+
"default": "./dist/design-variants.js"
|
|
120
|
+
},
|
|
121
|
+
"./color-slots": {
|
|
122
|
+
"types": "./dist/color-slots.d.ts",
|
|
123
|
+
"default": "./dist/color-slots.js"
|
|
124
|
+
},
|
|
117
125
|
"./backgrounds": {
|
|
118
126
|
"types": "./dist/background-options.d.ts",
|
|
119
127
|
"default": "./dist/background-options.js"
|
|
@@ -266,11 +274,11 @@
|
|
|
266
274
|
"@codemirror/state": "6.7.6",
|
|
267
275
|
"@codemirror/view": "6.43.14",
|
|
268
276
|
"@lezer/highlight": "1.2.5",
|
|
269
|
-
"@openpresentation/opf": "^0.
|
|
277
|
+
"@openpresentation/opf": "^0.23.0",
|
|
270
278
|
"jsonc-parser": "3.3.1"
|
|
271
279
|
},
|
|
272
280
|
"peerDependencies": {
|
|
273
|
-
"@openpresentation/opf-render": "^0.
|
|
281
|
+
"@openpresentation/opf-render": "^0.23.0"
|
|
274
282
|
},
|
|
275
283
|
"peerDependenciesMeta": {
|
|
276
284
|
"@openpresentation/opf-render": {
|
|
@@ -323,8 +331,8 @@
|
|
|
323
331
|
"@expo-google-fonts/roboto-mono": "0.4.2",
|
|
324
332
|
"@expo-google-fonts/stix-two-math": "0.4.0",
|
|
325
333
|
"@expo-google-fonts/tinos": "0.4.2",
|
|
326
|
-
"@openpresentation/opf-pptx": "^0.
|
|
327
|
-
"@openpresentation/opf-render": "^0.
|
|
334
|
+
"@openpresentation/opf-pptx": "^0.23.0",
|
|
335
|
+
"@openpresentation/opf-render": "^0.23.0",
|
|
328
336
|
"@resvg/resvg-js": "2.6.2",
|
|
329
337
|
"axe-core": "4.14.0",
|
|
330
338
|
"esbuild": "0.28.2",
|