glideshow 0.1.2__py3-none-any.whl
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.
- glideshow/__init__.py +15 -0
- glideshow/__main__.py +3 -0
- glideshow/agent.py +681 -0
- glideshow/build.py +54 -0
- glideshow/cli.py +320 -0
- glideshow/comments.py +35 -0
- glideshow/decks.py +230 -0
- glideshow/docs/agents.md +223 -0
- glideshow/docs/deck-format.md +125 -0
- glideshow/editor/.assetsignore +5 -0
- glideshow/editor/apple-touch-icon.png +0 -0
- glideshow/editor/data/font-metrics.json +1 -0
- glideshow/editor/data/fonts.json +1 -0
- glideshow/editor/data/icons.json +1 -0
- glideshow/editor/data/symbols.js +42 -0
- glideshow/editor/data/themes.js +15 -0
- glideshow/editor/deck/base.css +92 -0
- glideshow/editor/deck/present.js +222 -0
- glideshow/editor/deck/shapes.js +377 -0
- glideshow/editor/deck/template.html +37 -0
- glideshow/editor/editor.css +657 -0
- glideshow/editor/glideshow-1024.png +0 -0
- glideshow/editor/glideshow.svg +10 -0
- glideshow/editor/globals.d.ts +21 -0
- glideshow/editor/home.html +358 -0
- glideshow/editor/index.html +386 -0
- glideshow/editor/js/arrange.js +122 -0
- glideshow/editor/js/boot.js +66 -0
- glideshow/editor/js/boxes.js +25 -0
- glideshow/editor/js/comments.js +85 -0
- glideshow/editor/js/context-menus.js +93 -0
- glideshow/editor/js/dialogs.js +168 -0
- glideshow/editor/js/dock.js +22 -0
- glideshow/editor/js/draw.js +107 -0
- glideshow/editor/js/file.js +318 -0
- glideshow/editor/js/find.js +72 -0
- glideshow/editor/js/format-panel.js +101 -0
- glideshow/editor/js/geometry.js +153 -0
- glideshow/editor/js/guides.js +37 -0
- glideshow/editor/js/insert.js +73 -0
- glideshow/editor/js/keyboard.js +256 -0
- glideshow/editor/js/live.js +48 -0
- glideshow/editor/js/menubar.js +290 -0
- glideshow/editor/js/menus.js +93 -0
- glideshow/editor/js/motion.js +148 -0
- glideshow/editor/js/notes.js +63 -0
- glideshow/editor/js/objects.js +202 -0
- glideshow/editor/js/people.js +73 -0
- glideshow/editor/js/phone-comments.js +43 -0
- glideshow/editor/js/phone-edit.js +253 -0
- glideshow/editor/js/phone-home.js +51 -0
- glideshow/editor/js/phone-mode.js +73 -0
- glideshow/editor/js/phone-sheets.js +153 -0
- glideshow/editor/js/phone-touch.js +145 -0
- glideshow/editor/js/phone-view.js +150 -0
- glideshow/editor/js/pointer.js +407 -0
- glideshow/editor/js/render.js +353 -0
- glideshow/editor/js/shortcuts.js +67 -0
- glideshow/editor/js/slides.js +120 -0
- glideshow/editor/js/spacing.js +23 -0
- glideshow/editor/js/state.js +58 -0
- glideshow/editor/js/storage.js +250 -0
- glideshow/editor/js/text.js +366 -0
- glideshow/editor/js/theme-toggle.js +41 -0
- glideshow/editor/js/theme.js +173 -0
- glideshow/editor/js/toolbar-groups.js +109 -0
- glideshow/editor/js/toolbar.js +372 -0
- glideshow/editor/lib/browser-db.js +47 -0
- glideshow/editor/lib/library.js +612 -0
- glideshow/editor/lib/merge.js +174 -0
- glideshow/editor/lib/move-local.js +51 -0
- glideshow/editor/lib/onefile.js +108 -0
- glideshow/editor/lib/store-browser.js +75 -0
- glideshow/editor/lib/store-folder.js +109 -0
- glideshow/editor/local/sw.js +66 -0
- glideshow/editor/local/tsconfig.json +5 -0
- glideshow/editor/looks/glider.svg +9 -0
- glideshow/editor/looks/thermal-phone.css +144 -0
- glideshow/editor/looks/thermal.css +268 -0
- glideshow/editor/manifest.json +23 -0
- glideshow/editor/phone.css +345 -0
- glideshow/editor/share/share.css +124 -0
- glideshow/editor/share/share.js +256 -0
- glideshow/editor/share/speaker.html +88 -0
- glideshow/editor/tsconfig.json +6 -0
- glideshow/figure.py +33 -0
- glideshow/fonts.py +63 -0
- glideshow/gslides.py +1065 -0
- glideshow/history.py +210 -0
- glideshow/merge.py +210 -0
- glideshow/osutil.py +44 -0
- glideshow/pptx_export.py +207 -0
- glideshow/pptx_import.py +115 -0
- glideshow/publish.py +106 -0
- glideshow/render.py +288 -0
- glideshow/server.py +549 -0
- glideshow/service.py +65 -0
- glideshow/skills/glideshow/SKILL.md +85 -0
- glideshow-0.1.2.dist-info/METADATA +69 -0
- glideshow-0.1.2.dist-info/RECORD +103 -0
- glideshow-0.1.2.dist-info/WHEEL +4 -0
- glideshow-0.1.2.dist-info/entry_points.txt +2 -0
- glideshow-0.1.2.dist-info/licenses/LICENSE +662 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/* The Thermal look's shorter toolbar: each control lives in one place and the toolbar shows only what fits the selection.
|
|
2
|
+
Inserting stays in the insert bar at the bottom, so the toolbar's copies of it go; Print and Paint format move to the menus;
|
|
3
|
+
related controls fold behind one ▾ button each (text style, paragraph, border). The controls themselves are the toolbar's
|
|
4
|
+
own buttons moved into small trays, so their ids, handlers, menus and shortcuts are unchanged. Only with ?look=thermal. */
|
|
5
|
+
const GROUPED = document.documentElement.dataset.look === 'thermal';
|
|
6
|
+
let openTray = null;
|
|
7
|
+
function closeTray() { openTray?.tray.classList.remove('open'); openTray?.btn.classList.remove('open'); openTray = null; }
|
|
8
|
+
/** @param {string} id @param {string} icon @param {string} tip @param {string[]} ids @param {{ bar?: boolean }} [opts] */
|
|
9
|
+
function makeTray(id, icon, tip, ids, { bar = false } = {}) {
|
|
10
|
+
const btn = document.createElement('button');
|
|
11
|
+
btn.id = id; btn.className = 'tray-btn'; btn.dataset.tip = tip;
|
|
12
|
+
btn.innerHTML = bar ? `<span class="sw"><span class="icon">${icon}</span><i class="bar"></i></span><span class="icon caret">arrow_drop_down</span>`
|
|
13
|
+
: `<span class="icon glyph">${icon}</span><span class="icon caret">arrow_drop_down</span>`;
|
|
14
|
+
const tray = div('tray'); tray.id = id + '-tray';
|
|
15
|
+
ids.forEach(i => { const el = $(i); if (el) { const input = el.nextElementSibling?.classList.contains('color-input') ? el.nextElementSibling : null; tray.append(el); if (input) tray.append(input); } });
|
|
16
|
+
btn.onclick = () => {
|
|
17
|
+
if (openTray?.btn === btn) return closeTray();
|
|
18
|
+
closeTray(); closeMenu();
|
|
19
|
+
const r = btn.getBoundingClientRect();
|
|
20
|
+
tray.classList.add('open'); btn.classList.add('open'); openTray = { btn, tray };
|
|
21
|
+
tray.style.left = Math.max(8, Math.min(r.left, innerWidth - tray.offsetWidth - 8)) + 'px'; tray.style.top = r.bottom + 6 + 'px';
|
|
22
|
+
};
|
|
23
|
+
return { btn, tray };
|
|
24
|
+
}
|
|
25
|
+
if (GROUPED) {
|
|
26
|
+
document.documentElement.classList.add('tb-grouped');
|
|
27
|
+
const g = i => $(`#ed-toolbar > .grp[data-i="${i}"]`);
|
|
28
|
+
// text style: underline, text color, highlight, clear formatting; paragraph: align, spacing, lists, indent; border: color, weight, dash
|
|
29
|
+
const style = makeTray('ed-textstyle', 'format_color_text', 'Text style', ['#ed-underline', '.swatch[data-for=color]', '.swatch[data-for=hilite]', '#ed-clear-fmt'], { bar: true });
|
|
30
|
+
const para = makeTray('ed-para', 'format_align_left', 'Paragraph', ['#ed-align', '#ed-lh', '#ed-ul', '#ed-ol', '#ed-indent-less', '#ed-indent-more']);
|
|
31
|
+
const border = makeTray('ed-border', 'border_color', 'Border', ['.swatch[data-for=stroke]', '#ed-sw', '#ed-dash'], { bar: true });
|
|
32
|
+
const ends = makeTray('ed-ends', 'arrow_right_alt', 'Arrowheads', ['#ed-lstart', '#ed-lend']);
|
|
33
|
+
ends.btn.classList.add('line-only');
|
|
34
|
+
$('#ed-italic').after(style.btn, para.btn);
|
|
35
|
+
$('.swatch[data-for=fill]').nextElementSibling.after(border.btn, ends.btn);
|
|
36
|
+
// the trays sit in the toolbar (so its button styles apply), shown as small cards under their button
|
|
37
|
+
[style, para, border, ends].forEach(t => $('#ed-toolbar').append(t.tray));
|
|
38
|
+
// Format options as an icon; Replace image shorter; Animate is Insert → Animation
|
|
39
|
+
const fo = $('#ed-format-options'); fo.classList.remove('text'); fo.innerHTML = '<span class="icon">tune</span>';
|
|
40
|
+
$('#ed-replace-image').textContent = 'Replace';
|
|
41
|
+
g(5).prepend($('#ed-crop'));
|
|
42
|
+
FORMAT_MENU.push('-', /** @type {any} */ ({ label: 'Paint format', icon: 'format_paint', run: () => $('#ed-paint-format').click() }));
|
|
43
|
+
addEventListener('pointerdown', e => {
|
|
44
|
+
if (openTray && ![openTray.tray, openTray.btn, openMenuEl, ...subMenus].some(el => el?.contains(e.target))) closeTray();
|
|
45
|
+
}, true);
|
|
46
|
+
addEventListener('keydown', e => { if (e.key === 'Escape' && openTray && !openMenuEl) closeTray(); }, true);
|
|
47
|
+
addEventListener('resize', closeTray);
|
|
48
|
+
// the ▾ buttons show what's inside: the text and border colors, the alignment
|
|
49
|
+
const sync = () => {
|
|
50
|
+
style.btn.querySelector('.bar').style.background = $('#ed-color').value;
|
|
51
|
+
border.btn.querySelector('.bar').style.background = $('#ed-stroke').value;
|
|
52
|
+
para.btn.querySelector('.glyph').textContent = $('#ed-align .icon').textContent;
|
|
53
|
+
};
|
|
54
|
+
const paintBars0 = paintBars; window['paintBars'] = () => { paintBars0(); sync(); };
|
|
55
|
+
let lastSel = '';
|
|
56
|
+
const updateToolbar0 = updateToolbar; window['updateToolbar'] = () => {
|
|
57
|
+
updateToolbar0();
|
|
58
|
+
const key = [...sel].map(x => x.id).join(); if (key !== lastSel) closeTray(); lastSel = key; // a tray stays open while its selection does
|
|
59
|
+
// a plain text box (no shape) shows fill and border only once it has one; shapes always do
|
|
60
|
+
const b = [...sel][0], plain = !!b && [...sel].every(x => !x.dataset.shape && !x.classList.contains('image') && !isStroke(x) && !isGroup(x)
|
|
61
|
+
&& !/\S/.test((x.style.getPropertyValue('--fill') + x.style.getPropertyValue('--stroke')).replace(/transparent|none/g, '')));
|
|
62
|
+
document.body.classList.toggle('sel-plaintext', plain);
|
|
63
|
+
sync();
|
|
64
|
+
};
|
|
65
|
+
sync();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* View → Floating toolbar (this look only, remembered in this browser): the toolbar becomes a small bar over the selection,
|
|
69
|
+
above it, or below it when there's no room; with nothing selected there's none. The menu bar folds into Menus (click to
|
|
70
|
+
browse File, Edit, …, type to search, ⌥/), and Menus, undo, redo and zoom move to the top row. ⌥/ and every shortcut stay. */
|
|
71
|
+
const FLOAT_KEY = 'glideshow-toolbar';
|
|
72
|
+
const floatOn = () => document.documentElement.classList.contains('tb-float');
|
|
73
|
+
function placeFloat() {
|
|
74
|
+
const bar = $('#ed-toolbar');
|
|
75
|
+
if (!floatOn()) { bar.style.left = bar.style.top = ''; bar.classList.remove('shown'); return; }
|
|
76
|
+
const rs = [...document.querySelectorAll('.sel-box:not(.scope)')].map(e => e.getBoundingClientRect()).filter(r => r.width || r.height);
|
|
77
|
+
if (!sel.size || !rs.length || document.body.classList.contains('tb-moving')) { bar.classList.remove('shown'); closeTray(); return; }
|
|
78
|
+
const r = { left: Math.min(...rs.map(x => x.left)), right: Math.max(...rs.map(x => x.right)), top: Math.min(...rs.map(x => x.top)), bottom: Math.max(...rs.map(x => x.bottom)) };
|
|
79
|
+
const st = $('#ed-stage').getBoundingClientRect(), knob = sel.size === 1 && !editing && !isLine([...sel][0]) ? 26 : 0, gap = 10; // clear of the rotation knob
|
|
80
|
+
bar.classList.add('shown');
|
|
81
|
+
const ruler = $('#ed-ruler-h').getBoundingClientRect(), top = Math.max(st.top, ruler.height ? ruler.bottom : 0); // the bar never covers the ruler
|
|
82
|
+
const w = bar.offsetWidth, h = bar.offsetHeight, above = r.top - knob - gap - h >= top + 4;
|
|
83
|
+
bar.classList.toggle('below', !above);
|
|
84
|
+
bar.style.left = Math.max(st.left + 8, Math.min((r.left + r.right) / 2 - w / 2, st.right - w - 8)) + 'px';
|
|
85
|
+
bar.style.top = Math.max(top + 4, Math.min(above ? r.top - knob - gap - h : r.bottom + gap, st.bottom - h - 8)) + 'px';
|
|
86
|
+
}
|
|
87
|
+
function setFloat(on) {
|
|
88
|
+
document.documentElement.classList.toggle('tb-float', on);
|
|
89
|
+
try { on ? localStorage.setItem(FLOAT_KEY, 'float') : localStorage.removeItem(FLOAT_KEY); } catch {}
|
|
90
|
+
closeTray(); closeMenu();
|
|
91
|
+
if (on) $('#ed-deckrow').append($('#ed-menus'), $('#ed-undo'), $('#ed-redo'), $('#ed-zoom'));
|
|
92
|
+
else { $('#ed-toolbar').prepend($('#ed-menus')); $('#ed-print').before($('#ed-undo'), $('#ed-redo')); $('#ed-paint-format').after($('#ed-zoom')); }
|
|
93
|
+
layoutToolbar(); placeFloat();
|
|
94
|
+
}
|
|
95
|
+
if (GROUPED) {
|
|
96
|
+
const row = div('deckrow'); row.id = 'ed-deckrow'; $('#ed-comments-btn').before(row);
|
|
97
|
+
MENUS.View.push('-', { label: 'Floating toolbar', icon: 'web_asset', on: floatOn, run: () => setFloat(!floatOn()) });
|
|
98
|
+
const drawOverlay0 = drawOverlay; window['drawOverlay'] = () => { drawOverlay0(); placeFloat(); };
|
|
99
|
+
const updateToolbar1 = updateToolbar; window['updateToolbar'] = () => { updateToolbar1(); placeFloat(); };
|
|
100
|
+
$('#ed-stage').addEventListener('scroll', placeFloat, { passive: true }); addEventListener('resize', placeFloat);
|
|
101
|
+
// while a box is dragged or resized the bar steps aside, and comes back where the box lands
|
|
102
|
+
$('#ed-stage').addEventListener('pointerdown', e => { if (floatOn() && e.button === 0 && e.target.closest('.box, .sel-wrap, .handle')) document.body.classList.add('tb-moving'); }, true);
|
|
103
|
+
addEventListener('pointerup', () => { if (document.body.classList.contains('tb-moving')) { document.body.classList.remove('tb-moving'); placeFloat(); } }, true);
|
|
104
|
+
// Menus, empty: the menu bar's menus as a list
|
|
105
|
+
const browse = () => { if (floatOn() && !menuInput.value.trim()) openMenu($('#ed-menus'), Object.keys(MENUS).map(k => ({ label: k, items: MENUS[k] }))); };
|
|
106
|
+
menuInput.addEventListener('focus', browse); menuInput.addEventListener('input', browse); menuInput.addEventListener('click', () => { if (!openMenuEl) browse(); });
|
|
107
|
+
let on = false; try { on = localStorage.getItem(FLOAT_KEY) === 'float'; } catch {}
|
|
108
|
+
if (on) setFloat(true);
|
|
109
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/* ---------- toolbar ---------- */
|
|
2
|
+
|
|
3
|
+
// Buttons must not take focus away from text being edited.
|
|
4
|
+
$('#ed-bar').addEventListener('mousedown', e => { if (e.target.closest('button')) e.preventDefault(); });
|
|
5
|
+
|
|
6
|
+
$('#ed-undo').onclick = undo;
|
|
7
|
+
$('#ed-redo').onclick = redo;
|
|
8
|
+
$('#ed-add-text').onclick = () => { stopEdit(); startEdit(addText()); };
|
|
9
|
+
/* Icon buttons that open a menu, as in Slides' toolbar. */
|
|
10
|
+
const menuButton = (btn, items) => { btn.onclick = () => openAnchor === btn ? closeMenu() : openMenu(btn, items()); };
|
|
11
|
+
menuButton($('#ed-add-shape'), () => shapeItems());
|
|
12
|
+
menuButton($('#ed-add-line'), () => lineItems());
|
|
13
|
+
$('#ed-video-file').onchange = async e => { stopEdit(); for (const f of e.target.files) await addVideo(f); e.target.value = ''; };
|
|
14
|
+
$('#ed-file').onchange = async e => { stopEdit(); for (const f of e.target.files) await addImage(f); e.target.value = ''; };
|
|
15
|
+
|
|
16
|
+
function setVar(name, v) {
|
|
17
|
+
for (const b of sel) {
|
|
18
|
+
if (v == null) b.style.removeProperty(name); else b.style.setProperty(name, v);
|
|
19
|
+
if (b.dataset.shape) renderShape(b);
|
|
20
|
+
}
|
|
21
|
+
drawOverlay();
|
|
22
|
+
}
|
|
23
|
+
/* The bar under a color icon shows the current color. */
|
|
24
|
+
function paintBars() { document.querySelectorAll('.swatch[data-for] .bar').forEach(bar => { bar.style.background = $('#ed-' + bar.parentElement.dataset.for).value; }); }
|
|
25
|
+
document.querySelectorAll('.color-input').forEach(i => i.addEventListener('input', paintBars));
|
|
26
|
+
|
|
27
|
+
/* Google's color popover: Transparent (fill, border), the 8×10 standard palette, and Custom (+ opens
|
|
28
|
+
the system picker; picked colors are remembered in this browser). Applying a color goes through
|
|
29
|
+
the hidden <input type=color>, so it shares the input/change handlers above. */
|
|
30
|
+
const PALETTE = `000000 434343 666666 999999 b7b7b7 cccccc d9d9d9 efefef f3f3f3 ffffff
|
|
31
|
+
980000 ff0000 ff9900 ffff00 00ff00 00ffff 4a86e8 0000ff 9900ff ff00ff
|
|
32
|
+
e6b8af f4cccc fce5cd fff2cc d9ead3 d0e0e3 c9daf8 cfe2f3 d9d2e9 ead1dc
|
|
33
|
+
dd7e6b ea9999 f9cb9c ffe599 b6d7a8 a2c4c9 a4c2f4 9fc5e8 b4a7d6 d5a6bd
|
|
34
|
+
cc4125 e06666 f6b26b ffd966 93c47d 76a5af 6d9eeb 6fa8dc 8e7cc3 c27ba0
|
|
35
|
+
a61c00 cc0000 e69138 f1c232 6aa84f 45818e 3c78d8 3d85c6 674ea7 a64d79
|
|
36
|
+
85200c 990000 b45f06 bf9000 38761d 134f5c 1155cc 0b5394 351c75 741b47
|
|
37
|
+
5b0f00 660000 783f04 7f6000 274e13 0c343d 1c4587 073763 20124d 4c1130`.split(/\s+/).map(c => '#' + c);
|
|
38
|
+
/* The default theme's colors (Slides' "Simple Light"), shown above the standard palette. */
|
|
39
|
+
const THEME_COLORS = ['#000000', '#ffffff', '#595959', '#eeeeee', '#4285f4', '#212121', '#78909c', '#ffab40', '#0097a7', '#eeff41'];
|
|
40
|
+
const recentColors = () => { try { return JSON.parse(localStorage.getItem('slides-custom-colors')) || []; } catch { return []; } };
|
|
41
|
+
function rememberColor(c) {
|
|
42
|
+
const list = [c, ...recentColors().filter(x => x !== c)].slice(0, 10);
|
|
43
|
+
try { localStorage.setItem('slides-custom-colors', JSON.stringify(list)); } catch {}
|
|
44
|
+
}
|
|
45
|
+
function setColor(input, c) { input.value = c; input.dispatchEvent(new Event('input')); input.dispatchEvent(new Event('change')); }
|
|
46
|
+
function openPalette(btn) {
|
|
47
|
+
closeMenu();
|
|
48
|
+
const input = $('#ed-' + btn.dataset.for), cur = input.value.toLowerCase(), m = div('menu palette');
|
|
49
|
+
const cell = c => {
|
|
50
|
+
const e = document.createElement('button');
|
|
51
|
+
e.className = 'cell' + (c === cur ? ' on' : ''); e.style.background = c; e.dataset.color = c; e.dataset.tip = c;
|
|
52
|
+
e.onclick = () => { closeMenu(); setColor(input, c); };
|
|
53
|
+
return e;
|
|
54
|
+
};
|
|
55
|
+
if (btn.dataset.for !== 'color' && btn.dataset.for !== 'bg') {
|
|
56
|
+
const t = document.createElement('button'), none = btn.dataset.for === 'hilite';
|
|
57
|
+
t.className = 'menu-item transparent'; t.innerHTML = `<span class="icon">format_color_reset</span><span class="menu-label">${none ? 'None' : 'Transparent'}</span>`;
|
|
58
|
+
t.onclick = () => { closeMenu(); if (none) hilite('transparent'); else if (btn.dataset.for === 'fill') { setVar('--fill', 'transparent'); commit(); } else noBorder(); };
|
|
59
|
+
m.append(t, div('menu-sep'));
|
|
60
|
+
}
|
|
61
|
+
const theme = div('palette-head'); theme.textContent = 'SIMPLE LIGHT'; m.append(theme);
|
|
62
|
+
const themeRow = div('cells'); THEME_COLORS.forEach(c => themeRow.append(cell(c))); m.append(themeRow);
|
|
63
|
+
const head = div('palette-head'); head.textContent = 'CUSTOM'; m.append(head);
|
|
64
|
+
const custom = div('cells');
|
|
65
|
+
const plus = document.createElement('button');
|
|
66
|
+
plus.className = 'cell plus'; plus.innerHTML = '<span class="icon">add</span>'; plus.dataset.tip = 'Custom';
|
|
67
|
+
plus.onclick = () => {
|
|
68
|
+
closeMenu();
|
|
69
|
+
input.addEventListener('change', () => rememberColor(input.value), { once: true });
|
|
70
|
+
input.click();
|
|
71
|
+
};
|
|
72
|
+
const drop = document.createElement('button'); // eyedropper (Chromium's EyeDropper API)
|
|
73
|
+
drop.className = 'cell plus'; drop.innerHTML = '<span class="icon">colorize</span>'; drop.dataset.tip = 'Pick a color from the screen';
|
|
74
|
+
drop.hidden = !window.EyeDropper;
|
|
75
|
+
drop.onclick = async () => { closeMenu(); try { const { sRGBHex } = await new EyeDropper().open(); rememberColor(sRGBHex); setColor(input, sRGBHex); } catch {} };
|
|
76
|
+
custom.append(plus, drop, ...recentColors().map(cell)); m.append(custom, div('menu-sep'));
|
|
77
|
+
const grid = div('cells'); PALETTE.forEach(c => grid.append(cell(c))); m.append(grid);
|
|
78
|
+
if (btn.dataset.for === 'fill' && sel.size) { // fill transparency, as Slides' custom colour: 0 % opaque … 100 % clear
|
|
79
|
+
const fill = [...sel][0].style.getPropertyValue('--fill'), row = div('palette-alpha');
|
|
80
|
+
row.innerHTML = '<span>Transparency</span><input type="range" min="0" max="100" step="1"><span class="pct"></span>';
|
|
81
|
+
const range = row.querySelector('input'), pct = row.querySelector('.pct');
|
|
82
|
+
range.value = String(Math.round((1 - alphaOf(fill)) * 100)); pct.textContent = range.value + '%';
|
|
83
|
+
range.oninput = () => { pct.textContent = range.value + '%'; setVar('--fill', withAlpha(toHex([...sel][0].style.getPropertyValue('--fill'), '#4a86e8'), 1 - +range.value / 100)); };
|
|
84
|
+
range.onchange = commit;
|
|
85
|
+
m.append(div('menu-sep'), row);
|
|
86
|
+
}
|
|
87
|
+
document.body.append(m);
|
|
88
|
+
const r = btn.getBoundingClientRect();
|
|
89
|
+
m.style.left = Math.min(r.left, innerWidth - m.offsetWidth - 8) + 'px'; m.style.top = r.bottom + 2 + 'px';
|
|
90
|
+
btn.classList.add('open'); openMenuEl = m; openAnchor = btn;
|
|
91
|
+
}
|
|
92
|
+
document.querySelectorAll('.swatch[data-for]').forEach(btn => { btn.onclick = () => openAnchor === btn ? closeMenu() : openPalette(btn); });
|
|
93
|
+
$('#ed-hilite').onchange = e => hilite(e.target.value);
|
|
94
|
+
$('#ed-fill').oninput = e => setVar('--fill', e.target.value);
|
|
95
|
+
$('#ed-fill').onchange = commit;
|
|
96
|
+
$('#ed-stroke').oninput = e => {
|
|
97
|
+
setVar('--stroke', e.target.value);
|
|
98
|
+
sel.forEach(b => { if (!parseFloat(b.style.getPropertyValue('--sw'))) { b.style.setProperty('--sw', '2px'); if (b.dataset.shape) renderShape(b); } });
|
|
99
|
+
};
|
|
100
|
+
$('#ed-stroke').onchange = commit;
|
|
101
|
+
function noBorder() { sel.forEach(b => { if (!isLine(b)) b.style.setProperty('--sw', '0px'); if (b.dataset.shape) renderShape(b); }); drawOverlay(); commit(); }
|
|
102
|
+
const curSw = () => parseFloat([...sel][0]?.style.getPropertyValue('--sw')) || 0;
|
|
103
|
+
/* Border dash and line ends, as Slides: pictures of each style, the current one ticked. */
|
|
104
|
+
const DASHES = [['', 'Solid'], ['dot', 'Dot'], ['dash', 'Dash'], ['dashdot', 'Dash-dot'], ['longdash', 'Long dash'], ['longdashdot', 'Long dash-dot']];
|
|
105
|
+
const ENDS = [['none', 'None'], ['arrow', 'Arrow'], ['fill-arrow', 'Filled arrow'], ['circle', 'Circle'], ['square', 'Square'], ['diamond', 'Diamond'],
|
|
106
|
+
['open-arrow', 'Open arrow'], ['open-circle', 'Open circle'], ['open-square', 'Open square'], ['open-diamond', 'Open diamond']];
|
|
107
|
+
const DASH_PREVIEW = { '': '', dot: '2 4', dash: '8 6', dashdot: '8 4 2 4', longdash: '14 6', longdashdot: '14 4 2 4' };
|
|
108
|
+
const endPreview = (v, side) => { // a short line with the end drawn the way shapes.js draws it
|
|
109
|
+
const b = div('box'); b.dataset.shape = 'line'; b.style.cssText = 'width:44px;height:0;--sw:2px;--stroke:#1f1f1f';
|
|
110
|
+
b.dataset[side] = v; renderShape(b); const svg = b.querySelector('svg');
|
|
111
|
+
svg.setAttribute('viewBox', '-10 -10 64 20'); svg.setAttribute('width', '48'); svg.setAttribute('height', '16'); svg.style.cssText = 'overflow:visible';
|
|
112
|
+
return svg.outerHTML.replace('class="shape"', 'class="end-pic"');
|
|
113
|
+
};
|
|
114
|
+
const setData = (k, v) => { for (const b of sel) { if (v) b.dataset[k] = v; else delete b.dataset[k]; if (b.dataset.shape) renderShape(b); } commit(); };
|
|
115
|
+
menuButton($('#ed-dash'), () => DASHES.map(([v, label]) => ({ label, on: () => ([...sel][0]?.dataset.dash || '') === v, run: () => setData('dash', v),
|
|
116
|
+
svg: `<svg width="64" height="8"><line x1="2" y1="4" x2="62" y2="4" stroke="#1f1f1f" stroke-width="2" stroke-dasharray="${DASH_PREVIEW[v]}"/></svg>` })));
|
|
117
|
+
const lineEnd = side => () => ENDS.map(([v, label]) => ({ label, svg: endPreview(v, side), run: () => setData(side, v === 'none' && side === 'start' ? '' : v),
|
|
118
|
+
on: () => { const b = [...sel][0]; return (b?.dataset[side] || (side === 'end' && b?.dataset.shape === 'arrowline' ? 'fill-arrow' : 'none')) === v; } }));
|
|
119
|
+
menuButton($('#ed-lstart'), lineEnd('start'));
|
|
120
|
+
menuButton($('#ed-lend'), lineEnd('end'));
|
|
121
|
+
menuButton($('#ed-sw'), () => [1, 2, 3, 4, 8, 12, 16, 24].map(v => ({ label: v + 'px', narrow: true, on: () => curSw() === v, run: () => { setVar('--sw', v + 'px'); commit(); } }))); // Slides' weights
|
|
122
|
+
|
|
123
|
+
const FONTS = [['', 'Default'], ['Arial, Helvetica, sans-serif', 'Arial'], ["'Helvetica Neue', Helvetica, Arial, sans-serif", 'Helvetica'],
|
|
124
|
+
['Verdana, Geneva, sans-serif', 'Verdana'], ["'Trebuchet MS', sans-serif", 'Trebuchet MS'], ['Georgia, serif', 'Georgia'],
|
|
125
|
+
["'Times New Roman', Times, serif", 'Times New Roman'], ["Palatino, 'Palatino Linotype', serif", 'Palatino'],
|
|
126
|
+
["'Courier New', Courier, monospace", 'Courier New'], ["Menlo, 'SF Mono', Consolas, monospace", 'Menlo']];
|
|
127
|
+
const recentFonts = () => { try { return JSON.parse(localStorage.getItem('slides-recent-fonts')) || []; } catch { return []; } };
|
|
128
|
+
const setFont = v => { // on a whole box it is the box's font: the runs' own fonts (an imported deck has them) give way
|
|
129
|
+
styleText('fontName', v || 'inherit', c => { c.style.fontFamily = v; c.querySelectorAll('[style]').forEach(e => e.style.removeProperty('font-family')); });
|
|
130
|
+
try { localStorage.setItem('slides-recent-fonts', JSON.stringify([v, ...recentFonts().filter(x => x !== v)].slice(0, 5))); } catch {}
|
|
131
|
+
};
|
|
132
|
+
const curFont = () => {
|
|
133
|
+
const b = [...sel][0], own = b ? content(b).style.fontFamily : '', runs = b && shownStyle(b, 'fontFamily', true);
|
|
134
|
+
if (runs === '') return ['', '']; // mixed fonts: blank, as Slides
|
|
135
|
+
const css = runs || own, fam = famOf(css);
|
|
136
|
+
return FONTS.find(([v]) => v && fam && v.replace(/['"]/g, '').startsWith(fam)) || (fam ? [css, fam] : FONTS[0]);
|
|
137
|
+
};
|
|
138
|
+
/* What the size and font boxes show, as Slides: the text at the caret (or the selection's start) while editing, otherwise
|
|
139
|
+
the box's text when it is all one size or font, and nothing when it is mixed. `own` asks only for styles the text sets
|
|
140
|
+
itself (inline), so a box that just inherits the theme's font still shows Default. */
|
|
141
|
+
function textAt(b) {
|
|
142
|
+
const s = getSelection();
|
|
143
|
+
if (editing !== b || !s.rangeCount || !content(b).contains(s.anchorNode)) return null;
|
|
144
|
+
const n = s.getRangeAt(0).startContainer; return n.nodeType === 1 ? n : n.parentElement;
|
|
145
|
+
}
|
|
146
|
+
function textLeaves(b) {
|
|
147
|
+
const c = content(b), w = document.createTreeWalker(c, NodeFilter.SHOW_TEXT), out = new Set();
|
|
148
|
+
while (w.nextNode()) if (w.currentNode.textContent.trim()) out.add(w.currentNode.parentElement);
|
|
149
|
+
return out.size ? [...out] : [c];
|
|
150
|
+
}
|
|
151
|
+
function shownStyle(b, prop, own = false) {
|
|
152
|
+
const set = e => { for (let x = e; x; x = x === content(b) ? null : x.parentElement) if (x.style[prop]) return true; return false; };
|
|
153
|
+
const at = textAt(b), leaves = at ? [at] : textLeaves(b);
|
|
154
|
+
if (own && !leaves.some(set)) return null; // nothing set on the text itself: the box's (or theme's) own value stands
|
|
155
|
+
const vals = new Set(leaves.map(e => getComputedStyle(e)[prop]));
|
|
156
|
+
return vals.size === 1 ? [...vals][0] : '';
|
|
157
|
+
}
|
|
158
|
+
function showTextStyle(b) {
|
|
159
|
+
const fs = shownStyle(b, 'fontSize');
|
|
160
|
+
$('#ed-size').value = fs ? String(Math.round(parseFloat(fs) / PT * 2) / 2) : ''; // in points, as Slides
|
|
161
|
+
$('#ed-font .font-name').textContent = curFont()[1];
|
|
162
|
+
}
|
|
163
|
+
document.addEventListener('selectionchange', () => { if (editing && sel.has(editing)) showTextStyle(editing); });
|
|
164
|
+
/* The font menu, as Slides: every Google Fonts family (fetched by the server, snapshot offline) in one searchable,
|
|
165
|
+
virtualised list with Used in this deck / Recent / All sections and a category filter. Each row is drawn in its own
|
|
166
|
+
face from a name-only subset as it scrolls into view. Picking a Google family adds it to the deck's
|
|
167
|
+
<link id="deck-fonts">, so the viewer loads it too. */
|
|
168
|
+
let gfonts = null; // name → [name, category, styles, popularity, weight?]
|
|
169
|
+
const GENERIC = { sans: 'sans-serif', serif: 'serif', mono: 'monospace', display: 'sans-serif', handwriting: 'cursive' };
|
|
170
|
+
const fontsReady = () => gfonts ? Promise.resolve(gfonts) : fetch('/api/fonts').then(r => r.json()).catch(() => fetch('/editor/data/fonts.json').then(r => r.json()))
|
|
171
|
+
.then(rows => (gfonts = new Map(rows.map(r => [r[0], r]))));
|
|
172
|
+
const famOf = css => (css || '').split(',')[0].replace(/['"]/g, '').trim();
|
|
173
|
+
const fontCSS = name => { const g = gfonts?.get(name); return g ? `'${name}', ${GENERIC[g[1]]}` : FONTS.find(f => f[1] === name)?.[0] || ''; };
|
|
174
|
+
function gfontSpec(name) { // css2's family=… for the faces the family has
|
|
175
|
+
const [, , bits, , w] = gfonts.get(name), fam = name.replace(/ /g, '+');
|
|
176
|
+
if (!(bits & 1)) return `family=${fam}:wght@${w}`;
|
|
177
|
+
const faces = [[0, 400, 1], [0, 700, 2], [1, 400, 4], [1, 700, 8]].filter(f => bits & f[2]);
|
|
178
|
+
return faces.length === 1 ? `family=${fam}` : `family=${fam}:ital,wght@${faces.map(f => f[0] + ',' + f[1]).join(';')}`;
|
|
179
|
+
}
|
|
180
|
+
const deckFontLink = doc => doc.querySelector('link#deck-fonts');
|
|
181
|
+
const deckGFonts = () => [...(deckFontLink(deckDoc || document)?.getAttribute('href') || '').matchAll(/family=([^:&]+)/g)].map(m => decodeURIComponent(m[1].replace(/\+/g, ' ')));
|
|
182
|
+
function syncEditorFonts() { // the editor page loads the deck's fonts the same way the deck does
|
|
183
|
+
const href = deckFontLink(deckDoc)?.getAttribute('href') || '';
|
|
184
|
+
let l = $('#ed-deck-fonts'); if (!l) { l = Object.assign(document.createElement('link'), { id: 'ed-deck-fonts', rel: 'stylesheet' }); document.head.append(l); }
|
|
185
|
+
if (l.getAttribute('href') !== href) href ? l.setAttribute('href', href) : l.removeAttribute('href');
|
|
186
|
+
}
|
|
187
|
+
function useGFont(name) {
|
|
188
|
+
if (!gfonts?.has(name) || deckGFonts().includes(name)) return;
|
|
189
|
+
const names = [...deckGFonts(), name];
|
|
190
|
+
let l = deckFontLink(deckDoc);
|
|
191
|
+
if (!l) { l = deckDoc.createElement('link'); l.id = 'deck-fonts'; l.rel = 'stylesheet'; (deckDoc.querySelector('#deck-base') || deckDoc.head.lastChild).before(l); }
|
|
192
|
+
l.setAttribute('href', 'https://fonts.googleapis.com/css2?' + names.filter(n => gfonts.has(n)).map(gfontSpec).join('&') + '&display=swap');
|
|
193
|
+
syncEditorFonts(); scheduleSave();
|
|
194
|
+
}
|
|
195
|
+
const previewed = new Set();
|
|
196
|
+
function previewFont(name) { // just the glyphs of its own name: a few KB
|
|
197
|
+
if (!gfonts?.has(name) || previewed.has(name)) return;
|
|
198
|
+
previewed.add(name);
|
|
199
|
+
document.head.append(Object.assign(document.createElement('link'), { rel: 'stylesheet', href: `https://fonts.googleapis.com/css2?family=${name.replace(/ /g, '+')}&text=${encodeURIComponent(name)}&display=swap` }));
|
|
200
|
+
}
|
|
201
|
+
const usedFonts = () => [...new Set(slides.flatMap(s => [...s.querySelectorAll('.content, .content [style*="font-family"], font[face]')].map(e => famOf(e.style?.fontFamily || e.getAttribute('face')))))].filter(Boolean);
|
|
202
|
+
const FONT_CATS = [['', 'All'], ['sans', 'Sans serif'], ['serif', 'Serif'], ['mono', 'Monospace'], ['display', 'Display'], ['handwriting', 'Handwriting'], ['yours', 'Your fonts']];
|
|
203
|
+
/* Your fonts: files installed on this computer (listed by the server) and fonts already in the deck. Using one copies the
|
|
204
|
+
file into assets/fonts/ and declares it with an @font-face in #deck-theme, so the deck carries it. */
|
|
205
|
+
let localFonts = null;
|
|
206
|
+
const deckFaces = () => [...(deckDoc?.querySelector('#deck-theme')?.textContent || '').matchAll(/@font-face\s*{[^}]*font-family:\s*['"]([^'"]+)['"]/g)].map(m => m[1]);
|
|
207
|
+
function addFontFace(family, src) {
|
|
208
|
+
if (deckFaces().includes(family)) return;
|
|
209
|
+
const el = deckDoc.querySelector('#deck-theme') || deckDoc.head.appendChild(Object.assign(deckDoc.createElement('style'), { id: 'deck-theme' }));
|
|
210
|
+
el.textContent = el.textContent.replace(/\s*$/, '') + `\n@font-face { font-family: '${family}'; src: url('${src}'); font-display: swap; }\n`;
|
|
211
|
+
$('#ed-deck-theme-live').textContent = el.textContent; scheduleSave();
|
|
212
|
+
}
|
|
213
|
+
async function useFontFile(q) { // q: path=… (installed) or name=… with the bytes as body
|
|
214
|
+
const j = await (await fetch('/api/font?' + q.query, { method: 'POST', body: q.body || '' })).json();
|
|
215
|
+
if (j.error) { setStatus(j.error, 'error'); return null; }
|
|
216
|
+
addFontFace(j.family, j.src); return j.family;
|
|
217
|
+
}
|
|
218
|
+
const previewedLocal = new Set();
|
|
219
|
+
function previewLocal(f) {
|
|
220
|
+
if (previewedLocal.has(f.path)) return; previewedLocal.add(f.path);
|
|
221
|
+
new FontFace(f.name, `url(/api/localfont?path=${encodeURIComponent(f.path)})`).load().then(ff => document.fonts.add(ff)).catch(() => {});
|
|
222
|
+
}
|
|
223
|
+
let fontCat = '';
|
|
224
|
+
async function openFontMenu(btn) {
|
|
225
|
+
closeMenu(); fontCat = ''; // each opening starts on All
|
|
226
|
+
const m = div('menu font-menu'); m.addEventListener('mousedown', e => { if (e.target.tagName !== 'INPUT') e.preventDefault(); });
|
|
227
|
+
const q = Object.assign(document.createElement('input'), { type: 'search', placeholder: 'Search fonts', className: 'font-search' });
|
|
228
|
+
const cats = div('font-cats'), list = div('font-list'), pad = div('font-pad');
|
|
229
|
+
list.append(pad); m.append(q, cats, list);
|
|
230
|
+
placeMenu(m, btn.getBoundingClientRect().left, btn.getBoundingClientRect().bottom + 2);
|
|
231
|
+
btn.classList.add('open'); openMenuEl = m; openAnchor = btn;
|
|
232
|
+
await fontsReady();
|
|
233
|
+
if (openMenuEl !== m) return;
|
|
234
|
+
const ROW = 32, cur = famOf(curFont()[0]) || curFont()[1];
|
|
235
|
+
const sys = FONTS.filter(f => f[0]).map(f => [f[1], 'system']);
|
|
236
|
+
const all = [...sys, ...gfonts.values()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
237
|
+
let rows = [];
|
|
238
|
+
const build = () => {
|
|
239
|
+
const t = q.value.trim().toLowerCase(), keep = r => (!fontCat || r[1] === fontCat) && (!t || r[0].toLowerCase().includes(t));
|
|
240
|
+
const byName = n => all.find(r => r[0] === n), sec = (head, names) => { const rs = names.map(byName).filter(Boolean).filter(keep); return rs.length ? [{ head }, ...rs] : []; };
|
|
241
|
+
if (fontCat === 'yours') {
|
|
242
|
+
const ins = deckFaces().map(n => ({ face: n })), mine = (localFonts || []).map(f => ({ local: f }));
|
|
243
|
+
const has = r => !t || (r.face || r.local.name).toLowerCase().includes(t);
|
|
244
|
+
rows = [...(ins.length ? [{ head: 'IN THIS DECK' }, ...ins.filter(has)] : []), { head: 'ON THIS COMPUTER' }, ...mine.filter(has)];
|
|
245
|
+
} else rows = t || fontCat ? all.filter(keep) : [{ name: 'Default' }, ...sec('USED IN THIS DECK', usedFonts()), ...sec('RECENT', recentFonts().map(famOf)), { head: 'ALL FONTS' }, ...all];
|
|
246
|
+
pad.style.height = rows.length * ROW + 'px'; list.scrollTop = 0; paint();
|
|
247
|
+
};
|
|
248
|
+
const paint = () => {
|
|
249
|
+
const from = Math.max(0, Math.floor(list.scrollTop / ROW) - 4), to = Math.min(rows.length, from + Math.ceil(list.clientHeight / ROW) + 8);
|
|
250
|
+
pad.replaceChildren(...rows.slice(from, to).map((r, k) => {
|
|
251
|
+
const top = `top:${(from + k) * ROW}px`;
|
|
252
|
+
if (r.head) { const h = div('menu-head'); h.textContent = r.head; h.style.cssText = top; return h; }
|
|
253
|
+
const name = r.name || r.face || r.local?.name || r[0], row = document.createElement('button'); row.className = 'menu-item';
|
|
254
|
+
row.innerHTML = '<span class="menu-icon icon"></span><span class="menu-label"></span>'; row.style.cssText = top;
|
|
255
|
+
row.children[0].textContent = name === cur ? 'check' : ''; row.children[1].textContent = name;
|
|
256
|
+
if (r.local) previewLocal(r.local);
|
|
257
|
+
if (r.face || r.local) row.children[1].style.fontFamily = `'${name}'`;
|
|
258
|
+
else if (!r.name) { previewFont(name); row.children[1].style.fontFamily = fontCSS(name); }
|
|
259
|
+
row.onclick = async () => {
|
|
260
|
+
closeMenu();
|
|
261
|
+
if (r.name) return setFont('');
|
|
262
|
+
if (r.face) return setFont(`'${name}', sans-serif`);
|
|
263
|
+
if (r.local) { const fam = await useFontFile({ query: 'path=' + encodeURIComponent(r.local.path) }); if (fam) setFont(`'${fam}', sans-serif`); return; }
|
|
264
|
+
if (gfonts.has(name)) useGFont(name); setFont(fontCSS(name));
|
|
265
|
+
};
|
|
266
|
+
return row;
|
|
267
|
+
}));
|
|
268
|
+
};
|
|
269
|
+
for (const [v, label] of FONT_CATS) {
|
|
270
|
+
const c = document.createElement('button'); c.className = 'font-cat' + (v === fontCat ? ' on' : ''); c.textContent = label;
|
|
271
|
+
c.onclick = async () => {
|
|
272
|
+
fontCat = v; cats.querySelectorAll('.font-cat').forEach(x => x.classList.toggle('on', x === c));
|
|
273
|
+
if (v === 'yours' && !localFonts) localFonts = await fetch('/api/localfonts').then(r => r.json()).catch(() => []);
|
|
274
|
+
build();
|
|
275
|
+
};
|
|
276
|
+
cats.append(c);
|
|
277
|
+
}
|
|
278
|
+
q.oninput = build; list.onscroll = paint;
|
|
279
|
+
build(); q.focus();
|
|
280
|
+
}
|
|
281
|
+
$('#ed-font').onclick = () => openAnchor === $('#ed-font') ? closeMenu() : openFontMenu($('#ed-font'));
|
|
282
|
+
$('#ed-color').oninput = e => styleText('foreColor', e.target.value, c => { c.style.color = e.target.value; });
|
|
283
|
+
function setSize(v) { // the selected characters while editing; otherwise whole boxes, the runs' own sizes giving way
|
|
284
|
+
const s = editing && editSelection();
|
|
285
|
+
if (s && s.rangeCount && !s.isCollapsed) { spanStyle('font-size', v + 'px'); showTextStyle(editing); return; }
|
|
286
|
+
sel.forEach(b => { const c = content(b); c.style.fontSize = v + 'px'; c.querySelectorAll('[style]').forEach(e => e.style.removeProperty('font-size')); fitHeight(b); });
|
|
287
|
+
commit();
|
|
288
|
+
}
|
|
289
|
+
/* Slides' points on a 10-inch slide map to our 1920px canvas at 2.667px per point (18pt = 48px). */
|
|
290
|
+
const PT = 192 / 72; // 1 pt in canvas px: the 1920px slide is 10 in, as in Slides (OOXML: 12700 EMU)
|
|
291
|
+
$('#ed-size').onclick = () => { if (openAnchor !== $('#ed-size')) openMenu($('#ed-size'), PT_SIZES.map(v => ({ label: String(v), on: () => +$('#ed-size').value === v, run: () => setSize(Math.round(v * PT * 100) / 100) }))); };
|
|
292
|
+
$('#ed-size').onchange = e => { const v = parseFloat(e.target.value); if (v > 0) setSize(Math.round(v * PT * 100) / 100); };
|
|
293
|
+
/* Google Slides' size steps (8 9 10 11 12 14 18 24 30 36 48 60 72 96 pt), scaled ×2.5 for the 1920 canvas. */
|
|
294
|
+
const PT_SIZES = [6, 7, 8, 9, 10, 11, 12, 14, 18, 24, 30, 36, 48, 60, 72, 96]; // Slides' size list, in points
|
|
295
|
+
const SIZES = PT_SIZES.map(v => Math.round(v * PT * 100) / 100);
|
|
296
|
+
function stepSize(dir) { // as Slides: each size in the text moves one step, so mixed sizes keep their difference
|
|
297
|
+
const b = [...sel][0]; if (!b) return;
|
|
298
|
+
const step = cur => dir > 0 ? SIZES.find(v => v > cur + 0.01) : [...SIZES].reverse().find(v => v < cur - 0.01);
|
|
299
|
+
const s = editing && editSelection();
|
|
300
|
+
if (s && s.rangeCount && !s.isCollapsed) { const next = step(parseFloat(shownStyle(b, 'fontSize') || getComputedStyle(content(b)).fontSize)); if (next) setSize(next); return; }
|
|
301
|
+
for (const x of sel) {
|
|
302
|
+
const c = content(x), sized = [c, ...[...c.querySelectorAll('[style]')].filter(e => e.style.fontSize)].map(e => [e, parseFloat(getComputedStyle(e).fontSize)]);
|
|
303
|
+
for (const [e, cur] of sized) { const next = step(cur); if (next) e.style.fontSize = next + 'px'; } // measured first, then changed
|
|
304
|
+
fitHeight(x);
|
|
305
|
+
}
|
|
306
|
+
commit();
|
|
307
|
+
}
|
|
308
|
+
const curLh = () => [...sel][0] ? content([...sel][0]).style.lineHeight || '1.3' : '';
|
|
309
|
+
menuButton($('#ed-lh'), () => lhItems());
|
|
310
|
+
const strike = () => styleText('strikeThrough', null, c => { c.style.textDecoration = c.style.textDecoration === 'line-through' ? '' : 'line-through'; });
|
|
311
|
+
const superscript = () => styleText('superscript', null, c => { c.style.verticalAlign = c.style.verticalAlign === 'super' ? '' : 'super'; });
|
|
312
|
+
const subscript = () => styleText('subscript', null, c => { c.style.verticalAlign = c.style.verticalAlign === 'sub' ? '' : 'sub'; });
|
|
313
|
+
$('#ed-link').onclick = linkText;
|
|
314
|
+
$('#ed-clear-fmt').onclick = clearFormat;
|
|
315
|
+
canvas.addEventListener('click', e => { if (e.target.closest('a')) e.preventDefault(); }); // links open only in the viewer
|
|
316
|
+
$('#ed-indent-less').onclick = () => indentText(-1);
|
|
317
|
+
$('#ed-indent-more').onclick = () => indentText(1);
|
|
318
|
+
$('#ed-ul').onclick = () => listText('insertUnorderedList');
|
|
319
|
+
$('#ed-ol').onclick = () => listText('insertOrderedList');
|
|
320
|
+
$('#ed-size-up').onclick = () => stepSize(1);
|
|
321
|
+
$('#ed-size-down').onclick = () => stepSize(-1);
|
|
322
|
+
$('#ed-bold').onclick = () => styleText('bold', null, c => { c.style.fontWeight = c.style.fontWeight === 'bold' ? '' : 'bold'; });
|
|
323
|
+
$('#ed-italic').onclick = () => styleText('italic', null, c => { c.style.fontStyle = c.style.fontStyle === 'italic' ? '' : 'italic'; });
|
|
324
|
+
$('#ed-underline').onclick = () => styleText('underline', null, c => { c.style.textDecoration = c.style.textDecoration === 'underline' ? '' : 'underline'; });
|
|
325
|
+
/* Align, as Slides' toolbar popover: four horizontal, three vertical. Shortcuts ⇧⌘L/E/R/J. */
|
|
326
|
+
const ALIGNS = [['left', 'format_align_left', 'Left align', '⇧⌘L'], ['center', 'format_align_center', 'Center align', '⇧⌘E'],
|
|
327
|
+
['right', 'format_align_right', 'Right align', '⇧⌘R'], ['justify', 'format_align_justify', 'Justify', '⇧⌘J']];
|
|
328
|
+
const VALIGNS = [['top', 'vertical_align_top', 'Top'], ['middle', 'vertical_align_center', 'Middle'], ['bottom', 'vertical_align_bottom', 'Bottom']];
|
|
329
|
+
const curAlign = () => { const b = [...sel][0]; const a = b ? getComputedStyle(content(b)).textAlign : 'left'; return a === 'start' ? 'left' : a === 'end' ? 'right' : a; };
|
|
330
|
+
const curValign = () => [...sel][0]?.dataset.valign || 'middle';
|
|
331
|
+
function setAlign(a) { sel.forEach(b => { content(b).style.textAlign = a; }); commit(); }
|
|
332
|
+
function setValign(v) { sel.forEach(b => { if (v === 'middle') delete b.dataset.valign; else b.dataset.valign = v; }); commit(); }
|
|
333
|
+
function openAlign(btn) {
|
|
334
|
+
closeMenu();
|
|
335
|
+
const m = div('menu align-pop');
|
|
336
|
+
const row = (list, on, set) => {
|
|
337
|
+
const r = div('align-row');
|
|
338
|
+
for (const [v, icon, label, key] of list) {
|
|
339
|
+
const e = document.createElement('button');
|
|
340
|
+
e.className = 'align-btn' + (on() === v ? ' on' : ''); e.dataset.v = v; e.dataset.tip = key ? `${label} (${key})` : label;
|
|
341
|
+
e.innerHTML = `<span class="icon">${icon}</span>`;
|
|
342
|
+
e.onclick = () => { closeMenu(); set(v); };
|
|
343
|
+
r.append(e);
|
|
344
|
+
}
|
|
345
|
+
return r;
|
|
346
|
+
};
|
|
347
|
+
m.addEventListener('mousedown', e => e.preventDefault());
|
|
348
|
+
m.append(row(ALIGNS, curAlign, setAlign), div('menu-sep'), row(VALIGNS, curValign, setValign));
|
|
349
|
+
const r = btn.getBoundingClientRect(); placeMenu(m, r.left, r.bottom + 2);
|
|
350
|
+
btn.classList.add('open'); openMenuEl = m; openAnchor = btn;
|
|
351
|
+
}
|
|
352
|
+
$('#ed-align').onclick = () => openAnchor === $('#ed-align') ? closeMenu() : openAlign($('#ed-align'));
|
|
353
|
+
|
|
354
|
+
/* Text columns (1–3) on the whole box, via CSS columns. */
|
|
355
|
+
const curCols = () => +([...sel][0] && content([...sel][0]).style.columnCount) || 1;
|
|
356
|
+
function setColumns(n) {
|
|
357
|
+
sel.forEach(b => { if (isMedia(b) || isLine(b)) return; const c = content(b).style; c.columnCount = n > 1 ? n : ''; c.columnGap = n > 1 ? '48px' : ''; fitHeight(b); });
|
|
358
|
+
commit();
|
|
359
|
+
}
|
|
360
|
+
const RESIZE_KEYS = { KeyB: [20, 0], KeyI: [0, 20], KeyK: [20, 20], KeyW: [-20, 0], KeyJ: [-20, -20] }; // Slides' Mac keys (⌃⌘…)
|
|
361
|
+
function stepForward() { // each selected box swaps with the next unselected box above it
|
|
362
|
+
for (const b of boxes().filter(b => sel.has(b)).reverse()) { let n = b.nextElementSibling; while (n && !n.classList.contains('box')) n = n.nextElementSibling; if (n && !sel.has(n)) n.after(b); }
|
|
363
|
+
commit();
|
|
364
|
+
}
|
|
365
|
+
function stepBackward() {
|
|
366
|
+
for (const b of boxes().filter(b => sel.has(b))) { let n = b.previousElementSibling; while (n && !n.classList.contains('box')) n = n.previousElementSibling; if (n && !sel.has(n)) n.before(b); }
|
|
367
|
+
commit();
|
|
368
|
+
}
|
|
369
|
+
function centerOnPage(axis) {
|
|
370
|
+
const g = bbox([...sel].map(aabb)), dx = axis === 'h' ? W / 2 - (g.x + g.w / 2) : 0, dy = axis === 'v' ? H / 2 - (g.y + g.h / 2) : 0;
|
|
371
|
+
sel.forEach(b => { const o = geo(b); setGeo(b, { ...o, x: o.x + dx, y: o.y + dy }); }); drawOverlay(); commit();
|
|
372
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/* IndexedDB for a library kept in this browser (local/sw.js): one database, "glideshow", with
|
|
2
|
+
kv the library's own state, as a Durable Object keeps it (lib/library.js `ctx.storage`)
|
|
3
|
+
blobs file contents, by id
|
|
4
|
+
commits the library's versions (store-browser.js), and "head", the newest */
|
|
5
|
+
|
|
6
|
+
/** @type {Promise<IDBDatabase> | null} */
|
|
7
|
+
let opening = null;
|
|
8
|
+
const db = () => opening ??= new Promise((resolve, reject) => {
|
|
9
|
+
const r = indexedDB.open('glideshow', 1);
|
|
10
|
+
r.onupgradeneeded = () => { for (const s of ['kv', 'blobs', 'commits']) if (!r.result.objectStoreNames.contains(s)) r.result.createObjectStore(s); };
|
|
11
|
+
r.onsuccess = () => resolve(r.result);
|
|
12
|
+
r.onerror = () => { opening = null; reject(r.error); };
|
|
13
|
+
});
|
|
14
|
+
/** @template T @param {IDBRequest<T>} r @returns {Promise<T>} */
|
|
15
|
+
const done = r => new Promise((resolve, reject) => { r.onsuccess = () => resolve(r.result); r.onerror = () => reject(r.error); });
|
|
16
|
+
/** @param {string} store @param {IDBTransactionMode} mode @param {(s: IDBObjectStore) => any} fn */
|
|
17
|
+
async function tx(store, mode, fn) {
|
|
18
|
+
const t = (await db()).transaction(store, mode), out = fn(t.objectStore(store));
|
|
19
|
+
await new Promise((resolve, reject) => { t.oncomplete = resolve; t.onerror = () => reject(t.error); t.onabort = () => reject(t.error); });
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @param {string} store @param {string} key */
|
|
24
|
+
export const get = (store, key) => tx(store, 'readonly', s => done(s.get(key))).then(p => p);
|
|
25
|
+
/** @param {string} store @param {string[]} keys */
|
|
26
|
+
export const getMany = (store, keys) => tx(store, 'readonly', s => Promise.all(keys.map(k => done(s.get(k))))).then(p => p);
|
|
27
|
+
/** @param {string} store @param {[string, any][]} entries */
|
|
28
|
+
export const put = (store, entries) => tx(store, 'readwrite', s => { for (const [k, v] of entries) s.put(v, k); });
|
|
29
|
+
/** @param {string} store */
|
|
30
|
+
export const keys = store => tx(store, 'readonly', s => done(s.getAllKeys())).then(p => /** @type {string[]} */ (p));
|
|
31
|
+
/** @param {string} store @param {string[]} keys */
|
|
32
|
+
export const del = (store, keys) => tx(store, 'readwrite', s => { for (const k of keys) s.delete(k); });
|
|
33
|
+
|
|
34
|
+
/* The part of a Durable Object's storage API that lib/library.js uses. */
|
|
35
|
+
export const storage = {
|
|
36
|
+
/** @param {string | string[]} key */
|
|
37
|
+
async get(key) {
|
|
38
|
+
if (!Array.isArray(key)) return (await get('kv', key)) ?? undefined;
|
|
39
|
+
const vals = await getMany('kv', key), out = new Map();
|
|
40
|
+
key.forEach((k, i) => { if (vals[i] !== undefined) out.set(k, vals[i]); });
|
|
41
|
+
return out;
|
|
42
|
+
},
|
|
43
|
+
/** @param {string | Record<string, any>} key @param {any} [value] */
|
|
44
|
+
put(key, value) { return put('kv', typeof key === 'string' ? [[key, value]] : Object.entries(key)); },
|
|
45
|
+
/** @param {string | string[]} key */
|
|
46
|
+
delete(key) { return del('kv', Array.isArray(key) ? key : [key]); },
|
|
47
|
+
};
|