fika-editor 3.2.9 → 3.2.10

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,252 @@
1
+ function bounds(el) {
2
+ if (el.type === 'line') {
3
+ const points = [el.start, el.end, el.broken, el.broken2, el.curve, ...(el.cubic ?? [])].filter((p) => !!p);
4
+ return { left: el.left + Math.min(...points.map(p => p[0])), right: el.left + Math.max(...points.map(p => p[0])), top: el.top + Math.min(...points.map(p => p[1])), bottom: el.top + Math.max(...points.map(p => p[1])) };
5
+ }
6
+ const angle = (el.rotate ?? 0) * Math.PI / 180;
7
+ const w = Math.abs(el.width * Math.cos(angle)) + Math.abs(el.height * Math.sin(angle));
8
+ const h = Math.abs(el.width * Math.sin(angle)) + Math.abs(el.height * Math.cos(angle));
9
+ return { left: el.left + (el.width - w) / 2, right: el.left + (el.width + w) / 2, top: el.top + (el.height - h) / 2, bottom: el.top + (el.height + h) / 2 };
10
+ }
11
+ function union(a, b) {
12
+ return { left: Math.min(a.left, b.left), right: Math.max(a.right, b.right), top: Math.min(a.top, b.top), bottom: Math.max(a.bottom, b.bottom) };
13
+ }
14
+ function isBackdrop(el, canvas) {
15
+ return el.type === 'shape' && !el.text && !el.rotate && !el.fixedRatio && el.left <= 1 && el.top <= 1 && el.width >= canvas.width - 2 && el.height >= canvas.height - 2;
16
+ }
17
+ function isDecor(el, canvas) {
18
+ return el.type === 'shape' && !el.text && !el.groupId && (el.name === 'Decor' || el.left < -1 || el.top < -1 || el.left + el.width > canvas.width + 1 || el.top + el.height > canvas.height + 1);
19
+ }
20
+ function edgeBand(el, canvas) {
21
+ if (el.type !== 'shape' || el.text || el.groupId || el.rotate || el.fixedRatio)
22
+ return;
23
+ if (Math.abs(el.top) <= 1 && Math.abs(el.height - canvas.height) <= 2 && el.width < canvas.width * 0.3
24
+ && (Math.abs(el.left) <= 1 || Math.abs(el.left + el.width - canvas.width) <= 1))
25
+ return 'vertical';
26
+ if (Math.abs(el.left) <= 1 && Math.abs(el.width - canvas.width) <= 2 && el.height < canvas.height * 0.2
27
+ && (Math.abs(el.top) <= 1 || Math.abs(el.top + el.height - canvas.height) <= 1))
28
+ return 'horizontal';
29
+ }
30
+ /** Piecewise affine map: occupied tracks retain their scale; only empty gutters expand.
31
+ * Group bounds occupy a single track, so labels never detach from their diagram.
32
+ */
33
+ function axisTracks(intervals, source) {
34
+ const merged = [];
35
+ for (const [start, end] of intervals.sort((a, b) => a[0] - b[0])) {
36
+ const lo = Math.max(0, start), hi = Math.min(source, end);
37
+ if (hi <= lo)
38
+ continue;
39
+ const last = merged.at(-1);
40
+ if (last && lo <= last[1] + 1)
41
+ last[1] = Math.max(last[1], hi);
42
+ else
43
+ merged.push([lo, hi]);
44
+ }
45
+ const gaps = [];
46
+ let cursor = 0;
47
+ for (const [lo, hi] of merged) {
48
+ if (lo > cursor)
49
+ gaps.push([cursor, lo]);
50
+ cursor = hi;
51
+ }
52
+ if (cursor < source)
53
+ gaps.push([cursor, source]);
54
+ const free = gaps.reduce((sum, [lo, hi]) => sum + hi - lo, 0);
55
+ return { gaps, free };
56
+ }
57
+ function axisMap(intervals, source, target, scale) {
58
+ const { gaps, free } = axisTracks(intervals, source);
59
+ const extra = target - source * scale;
60
+ return (position) => position * scale + (free > 0
61
+ ? extra * gaps.reduce((sum, [lo, hi]) => sum + Math.max(0, Math.min(position, hi) - lo), 0) / free
62
+ : extra / 2);
63
+ }
64
+ /** Recognize a full-width heading above independent body columns. Each column
65
+ * gets its own vertical tracks; a long item in the left column cannot prevent
66
+ * the right column from distributing its rows through the available height.
67
+ */
68
+ function bodyColumns(boxes, from) {
69
+ if (boxes.length < 3)
70
+ return;
71
+ const top = Math.min(...boxes.map(b => b.top));
72
+ const headers = boxes.filter(b => Math.abs(b.top - top) < 1 && b.right - b.left >= from.width * 0.55 && b.bottom <= from.height * 0.26);
73
+ if (!headers.length)
74
+ return;
75
+ const headerBottom = Math.max(...headers.map(b => b.bottom));
76
+ const body = boxes.filter(b => !headers.includes(b));
77
+ if (body.some(b => b.top < headerBottom))
78
+ return;
79
+ const bodyTop = Math.min(...body.map(b => b.top));
80
+ const ranges = [];
81
+ for (const box of [...body].sort((a, b) => a.left - b.left)) {
82
+ const last = ranges.at(-1);
83
+ if (last && box.left <= last.right + from.width * 0.025) {
84
+ last.right = Math.max(last.right, box.right);
85
+ last.boxes.push(box);
86
+ }
87
+ else
88
+ ranges.push({ left: box.left, right: box.right, boxes: [box] });
89
+ }
90
+ if (ranges.length > 4)
91
+ return;
92
+ const margin = Math.min(from.height * 0.06, Math.max(top, from.height * 0.03));
93
+ const columns = ranges.map(range => {
94
+ const height = Math.max(...range.boxes.map(b => b.bottom)) - bodyTop;
95
+ const intervals = range.boxes.map(b => [b.top - bodyTop, b.bottom - bodyTop]);
96
+ const { free } = axisTracks(intervals, height);
97
+ return { ...range, height, intervals, minHeight: height - free + Math.min(free, height * 0.08) };
98
+ });
99
+ return { bodyTop, margin, columns, minHeight: bodyTop + margin + Math.max(...columns.map(c => c.minHeight)) };
100
+ }
101
+ function scalePixels(html, scale) {
102
+ // CSS lengths only: do not rewrite lesson text containing quantities in px.
103
+ return html.replace(/style=(['"])(.*?)\1/gi, (_, quote, css) => `style=${quote}${css.replace(/(-?\d+(?:\.\d+)?)px\b/g, (_m, n) => `${Number((Number(n) * scale).toFixed(4))}px`)}${quote}`);
104
+ }
105
+ function scaleNumber(record, key, scale) {
106
+ const obj = record;
107
+ if (typeof obj[key] === 'number')
108
+ obj[key] = obj[key] * scale;
109
+ }
110
+ function scaleTypography(el, scale) {
111
+ if (scale === 1)
112
+ return;
113
+ const text = el.type === 'text' ? el : el.type === 'shape' ? el.text : undefined;
114
+ if (text) {
115
+ text.content = scalePixels(text.content, scale);
116
+ text.inset = (text.inset ?? [10, 10, 10, 10]).map(v => v * scale);
117
+ text.paragraphSpace = (text.paragraphSpace ?? 5);
118
+ for (const key of ['wordSpace', 'paragraphSpace', 'placeholderFontSize', 'placeholderLayoutHeight'])
119
+ scaleNumber(text, key, scale);
120
+ if (el.type === 'text' && el.structuredText) {
121
+ // HTML is the source of truth; discard the stale import cache.
122
+ delete el.structuredText;
123
+ }
124
+ }
125
+ if (el.type === 'table') {
126
+ el.cellMinHeight *= scale;
127
+ for (const row of el.data)
128
+ for (const cell of row) {
129
+ cell.text = scalePixels(cell.text, scale);
130
+ if (cell.style?.fontsize)
131
+ cell.style.fontsize = cell.style.fontsize.replace(/^(\d+(?:\.\d+)?)(px|pt)$/i, (_, value, unit) => `${Number(value) * scale}${unit}`);
132
+ }
133
+ }
134
+ if (el.type === 'chart')
135
+ el.options = { ...el.options, fontSize: (el.options?.fontSize ?? 12) * scale };
136
+ if (el.type === 'code')
137
+ el.fontSize *= scale;
138
+ if (el.type === 'latex')
139
+ el.strokeWidth *= scale;
140
+ if ('outline' in el && el.outline)
141
+ scaleNumber(el.outline, 'width', scale);
142
+ if ('shadow' in el && el.shadow)
143
+ for (const key of ['h', 'v', 'blur'])
144
+ scaleNumber(el.shadow, key, scale);
145
+ }
146
+ /** Resize a deck without distorting media or changing reading order.
147
+ * Uniformly scale content and typography, redistribute available space through
148
+ * empty tracks, and keep decorative edge shapes attached to their canvas edge.
149
+ * Overlaps, rotations, z-order and grouped compositions remain intact.
150
+ */
151
+ export function resizeDeckSlides(slides, from, to) {
152
+ for (const n of [from.width, from.height, to.width, to.height]) {
153
+ if (!Number.isFinite(n) || n <= 0)
154
+ throw new Error('Canvas dimensions must be finite and positive.');
155
+ }
156
+ const result = structuredClone(slides);
157
+ if (from.width === to.width && from.height === to.height)
158
+ return result;
159
+ for (const slide of result) {
160
+ const groups = new Map();
161
+ for (const el of slide.elements) {
162
+ if ((el.type === 'line' && !el.groupId) || isBackdrop(el, from) || isDecor(el, from) || edgeBand(el, from))
163
+ continue;
164
+ const key = el.groupId ?? el.id, box = bounds(el);
165
+ groups.set(key, groups.has(key) ? union(groups.get(key), box) : box);
166
+ }
167
+ const boxes = [...groups.values()];
168
+ const vertical = boxes.map(b => [b.top, b.bottom]);
169
+ const { free } = axisTracks(vertical, from.height);
170
+ // Width sets the preferred type size. Consume spare vertical gutters before
171
+ // shrinking text; reversing a ratio change should not progressively shrink it.
172
+ const minHeight = from.height - free + Math.min(free, from.height * 0.08);
173
+ const body = bodyColumns(boxes, from);
174
+ const scale = Math.min(to.width / from.width, to.height / Math.max(1, body?.minHeight ?? minHeight));
175
+ const x = axisMap(boxes.map(b => [b.left, b.right]), from.width, to.width, scale);
176
+ const y = axisMap(boxes.map(b => [b.top, b.bottom]), from.height, to.height, scale);
177
+ const columnMaps = body?.columns.map(column => ({ ...column,
178
+ map: axisMap(column.intervals, column.height, Math.max(1, to.height - (body.bodyTop + body.margin) * scale), scale),
179
+ }));
180
+ const mapY = (px, py) => {
181
+ if (!body || !columnMaps)
182
+ return y(py);
183
+ if (py < body.bodyTop - 1)
184
+ return py * scale;
185
+ const column = columnMaps.find(c => px >= c.left - 1 && px <= c.right + 1);
186
+ return column ? body.bodyTop * scale + column.map(py - body.bodyTop) : y(py);
187
+ };
188
+ for (const el of slide.elements) {
189
+ if (el.type === 'shape' && isBackdrop(el, from)) {
190
+ el.left = 0;
191
+ el.top = 0;
192
+ el.width = to.width;
193
+ el.height = to.height;
194
+ continue;
195
+ }
196
+ const band = edgeBand(el, from);
197
+ if (el.type === 'shape' && band) {
198
+ if (band === 'vertical') {
199
+ el.width *= scale;
200
+ el.height = to.height;
201
+ el.top = 0;
202
+ el.left = el.left < from.width / 2 ? 0 : to.width - el.width;
203
+ }
204
+ else {
205
+ el.height *= scale;
206
+ el.width = to.width;
207
+ el.left = 0;
208
+ el.top = el.top < from.height / 2 ? 0 : to.height - el.height;
209
+ }
210
+ scaleTypography(el, scale);
211
+ continue;
212
+ }
213
+ const left = el.left, top = el.top;
214
+ if (el.type === 'line') {
215
+ // Remap connector geometry with the same tracks as its attached objects.
216
+ const group = el.groupId ? groups.get(el.groupId) : undefined;
217
+ const lineX = group ? (v) => x(group.left) + (v - group.left) * scale : x;
218
+ const lineY = group ? (_px, v) => mapY(group.left, group.top) + (v - group.top) * scale : mapY;
219
+ el.left = lineX(left);
220
+ el.top = lineY(left, top);
221
+ el.width *= scale;
222
+ const point = (p) => [lineX(left + p[0]) - el.left, lineY(left + p[0], top + p[1]) - el.top];
223
+ el.start = point(el.start);
224
+ el.end = point(el.end);
225
+ if (el.broken)
226
+ el.broken = point(el.broken);
227
+ if (el.broken2)
228
+ el.broken2 = point(el.broken2);
229
+ if (el.curve)
230
+ el.curve = point(el.curve);
231
+ if (el.cubic)
232
+ el.cubic = [point(el.cubic[0]), point(el.cubic[1])];
233
+ }
234
+ else if (isDecor(el, from)) {
235
+ const cx = left + el.width / 2, cy = top + el.height / 2;
236
+ el.left = cx > from.width / 2 ? to.width - (from.width - left) * scale : left * scale;
237
+ el.top = cy > from.height / 2 ? to.height - (from.height - top) * scale : top * scale;
238
+ el.width *= scale;
239
+ el.height *= scale;
240
+ }
241
+ else {
242
+ const box = groups.get(el.groupId ?? el.id);
243
+ el.left = x(box.left) + (left - box.left) * scale;
244
+ el.top = mapY(box.left, box.top) + (top - box.top) * scale;
245
+ el.width *= scale;
246
+ el.height *= scale;
247
+ }
248
+ scaleTypography(el, scale);
249
+ }
250
+ }
251
+ return result;
252
+ }
@@ -7,6 +7,8 @@ export interface PresetTheme {
7
7
  background: string;
8
8
  fontColor: string;
9
9
  featureFontColor?: string;
10
+ /** Retain pale panels and accent roles when restyling host-authored decks. */
11
+ preserveColorRoles?: boolean;
10
12
  fontname: string;
11
13
  colors: string[];
12
14
  /** Series palette when `colors` includes near-background swatches (Ink #171717). */
@@ -12,3 +12,4 @@ export type { Locales as FikaLocales } from '../i18n/locale';
12
12
  export type { ChartData, ChartOptions, ChartType, Note, NoteReply, PPTAnimation, PPTAudioElement, PPTChartElement, PPTElement, PPTElementLink, PPTImageElement, PPTLatexElement, PPTLineElement, PPTShapeElement, PPTTableElement, PPTTextElement, PPTVideoElement, ShapeText, Slide, SlideBackground, SlideTemplate, SlideTheme, TableCell, TableCellStyle, TextAlign, TurningMode, } from '../types/slides';
13
13
  export type { FikaDesignTheme } from '../configs/designThemes';
14
14
  export { getFikaDefaultDesignThemes } from '../configs/designThemes';
15
+ export { resizeDeckSlides, type SlideCanvas } from '../utils/resizeDeck';
@@ -0,0 +1 @@
1
+ export default function useResizeDeck(): (width: number, height: number) => void;
@@ -1,6 +1,6 @@
1
1
  import type { Slide, SlideTheme } from '../types/slides';
2
2
  import { type PresetTheme } from '../configs/theme';
3
- export declare const setSlideTheme: (slide: Slide, theme: PresetTheme, index?: number) => void;
3
+ export declare const setSlideTheme: (slide: Slide, theme: PresetTheme, index?: number, sourceAccents?: readonly string[]) => void;
4
4
  /** Shared by the design picker and agent command, with no store/history effects. */
5
5
  export declare function applyDesignTheme(slides: readonly Slide[], currentTheme: SlideTheme, preset: PresetTheme): {
6
6
  slides: Slide[];
@@ -0,0 +1,10 @@
1
+ import type { PresetTheme } from '../configs/theme';
2
+ import type { PPTElement, Slide } from '../types/slides';
3
+ /** Keep neutral surfaces, pale panels and saturated accents in distinct roles. */
4
+ export declare function designColorRoles(theme: PresetTheme, sourceAccents: readonly string[]): {
5
+ fill: (color: string) => string;
6
+ ink: (surface: string, original?: string) => string;
7
+ };
8
+ /** Find the painted panel below a text object; preserve white lettering on badges. */
9
+ export declare function textSurface(slide: Slide, el: PPTElement, fallback: string): string;
10
+ export declare function retintInlineText(html: string, surface: string, ink: (surface: string, original?: string) => string): string;
@@ -0,0 +1,11 @@
1
+ import type { Slide } from '../types/slides';
2
+ export interface SlideCanvas {
3
+ width: number;
4
+ height: number;
5
+ }
6
+ /** Resize a deck without distorting media or changing reading order.
7
+ * Uniformly scale content and typography, redistribute available space through
8
+ * empty tracks, and keep decorative edge shapes attached to their canvas edge.
9
+ * Overlaps, rotations, z-order and grouped compositions remain intact.
10
+ */
11
+ export declare function resizeDeckSlides(slides: readonly Slide[], from: SlideCanvas, to: SlideCanvas): Slide[];
package/docs/EMBED.md CHANGED
@@ -519,3 +519,26 @@ Application uses the picker implementation and creates one undo step for the
519
519
  whole deck. Unknown IDs fail without changing the presentation. Hosts should
520
520
  include the small catalog in agent context so applying a named theme needs only
521
521
  one call, without a preceding discovery request.
522
+
523
+ ### Content-aware canvas resizing
524
+
525
+ `controller.deck.setViewport({ ratio: 0.75 })` fits the entire deck to 4:3 in
526
+ one undo step, using the same operation as the size menu and custom size dialog.
527
+ It identifies full-width headings and independent body columns, distributes
528
+ rows through their available height, scales type with its boxes, preserves media
529
+ proportions and groups, remaps connectors, and anchors vector edge decorations.
530
+ It consumes spare vertical gutters before shrinking type when returning to a
531
+ shorter canvas. It preserves the authored composition rather than inventing a
532
+ new reading order or cropping photographs. Imported objects already outside the
533
+ canvas may still need manual correction.
534
+
535
+ Hosts authoring slides into a differently sized deck can use the pure,
536
+ DOM-free `resizeDeckSlides(slides, from, to)` export from `fika-editor/layout`;
537
+ `from` and `to` are `{ width, height }`. Raw document loading remains available
538
+ through `deck.set` / `deck.patch` with `slides` and `viewport` supplied together.
539
+
540
+ Host design themes can opt into `preserveColorRoles: true` to keep pale panels,
541
+ neutral surfaces and saturated accents separate during theme application.
542
+ Text is retinted against the actual panel beneath it, including badge and table
543
+ cell fills. Named `Decor` shapes become soft accents on the selected background.
544
+ Reapplying a selected theme is supported, including for older generated decks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fika-editor",
3
- "version": "3.2.9",
3
+ "version": "3.2.10",
4
4
  "description": "Fika presentation editor embed bundle with a typed agentic bridge.",
5
5
  "type": "module",
6
6
  "main": "dist/embed/fika-embed.js",
@@ -12,7 +12,8 @@
12
12
  "docs/EMBED.md",
13
13
  "docs/AGENTIC_BRIDGE.md",
14
14
  "docs/RELEASE.md",
15
- "dist/text"
15
+ "dist/text",
16
+ "dist/layout"
16
17
  ],
17
18
  "exports": {
18
19
  ".": {
@@ -35,6 +36,11 @@
35
36
  "types": "./dist/text/index.d.ts",
36
37
  "import": "./dist/text/index.js",
37
38
  "default": "./dist/text/index.js"
39
+ },
40
+ "./layout": {
41
+ "types": "./dist/types/utils/resizeDeck.d.ts",
42
+ "import": "./dist/layout/utils/resizeDeck.js",
43
+ "default": "./dist/layout/utils/resizeDeck.js"
38
44
  }
39
45
  },
40
46
  "engines": {
@@ -55,7 +61,7 @@
55
61
  "dx:start": "1dxway start",
56
62
  "doctor": "react-doctor --no-telemetry",
57
63
  "build": "run-p type-check \"build-only {@}\" --",
58
- "build:embed": "node scripts/run-i18n.mjs --no-watch && npm run build:text && npm run build:types && rsbuild build --config rsbuild.config.embed.ts && node scripts/rewrite-embed-base-uri.mjs && node scripts/merge-embed-css.mjs && node scripts/scope-embed-css.mjs && node scripts/extract-embed-fonts.mjs && node scripts/copy-embed-public.mjs && node scripts/generate-agentic-manifest.mjs",
64
+ "build:embed": "node scripts/run-i18n.mjs --no-watch && npm run build:text && npm run build:layout && npm run build:types && rsbuild build --config rsbuild.config.embed.ts && node scripts/rewrite-embed-base-uri.mjs && node scripts/merge-embed-css.mjs && node scripts/scope-embed-css.mjs && node scripts/extract-embed-fonts.mjs && node scripts/copy-embed-public.mjs && node scripts/generate-agentic-manifest.mjs",
59
65
  "agentic:manifest": "node scripts/generate-agentic-manifest.mjs",
60
66
  "build:types": "node -e \"require('node:fs').rmSync('dist/types',{recursive:true,force:true})\" && tsc -p tsconfig.types.json && node scripts/rewrite-embed-types.mjs",
61
67
  "prepack": "node scripts/verify-embed-pack.mjs",
@@ -69,7 +75,8 @@
69
75
  "test": "node scripts/test.mjs",
70
76
  "i18n": "node scripts/run-i18n.mjs",
71
77
  "i18n:build": "node scripts/run-i18n.mjs --no-watch",
72
- "build:text": "tsc -p tsconfig.text.json"
78
+ "build:text": "tsc -p tsconfig.text.json",
79
+ "build:layout": "tsc -p tsconfig.layout.json"
73
80
  },
74
81
  "author": "Matěj \"lofcz\" Štágl",
75
82
  "license": "MIT",