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,51 @@
|
|
|
1
|
+
/* ---------- phone: the home screen ---------- */
|
|
2
|
+
/* The Slides app's home on a phone: a search bar with the menu, folder and account buttons; the sort label and the
|
|
3
|
+
grid/list toggle under it; two columns of decks; a round + at the bottom right. It rearranges home.html's own
|
|
4
|
+
controls (their handlers stay), and phone.css styles them. */
|
|
5
|
+
if (PHONE) (() => {
|
|
6
|
+
const el = (tag, cls, html = '') => Object.assign(document.createElement(tag), { className: cls, innerHTML: html });
|
|
7
|
+
const iconBtn = (icon, title) => { const b = el('button', 'ph-icon-btn', `<span class="icon">${icon}</span>`); b.title = title; return b; };
|
|
8
|
+
const app = $('#hm-app-more'), q = $('#hm-q');
|
|
9
|
+
// the search bar: ≡ | Search | folder | account
|
|
10
|
+
const bar = el('div', 'ph-search'), menu = iconBtn('menu', 'Menu'), folder = iconBtn('folder', 'Folders'), me = el('button', 'ph-avatar');
|
|
11
|
+
const cloud = window.GLIDESHOW_CLOUD, user = typeof cloud === 'object' ? cloud?.name || cloud?.user : '';
|
|
12
|
+
me.title = user || 'Glideshow'; me.innerHTML = user ? '' : '<img src="/editor/glideshow.svg" alt="">'; if (user) me.textContent = user[0].toUpperCase();
|
|
13
|
+
q.placeholder = 'Search in Glideshow';
|
|
14
|
+
bar.append(menu, q, folder, me);
|
|
15
|
+
$('.hm-search').replaceWith(bar);
|
|
16
|
+
menu.onclick = me.onclick = e => { // the app menu, under the button tapped (home.html anchors it to its own ⋮, hidden here)
|
|
17
|
+
e.stopPropagation(); app.click();
|
|
18
|
+
const m = /** @type {HTMLElement} */ (document.querySelector('.menu')), r = /** @type {Element} */ (e.currentTarget).getBoundingClientRect();
|
|
19
|
+
if (m) { m.classList.add('ph-menu'); m.style.left = Math.max(8, Math.min(r.left, innerWidth - m.offsetWidth - 8)) + 'px'; m.style.top = r.bottom + 8 + 'px'; }
|
|
20
|
+
};
|
|
21
|
+
// the folder button: the library's folders (and a new one), to go into
|
|
22
|
+
folder.onclick = async e => {
|
|
23
|
+
e.stopPropagation();
|
|
24
|
+
const fs = await fetch('/api/folders').then(r => r.json()).catch(() => []);
|
|
25
|
+
const go = f => () => { location.hash = f ? '/' + f.split('/').map(encodeURIComponent).join('/') : ''; };
|
|
26
|
+
const items = [['home', 'All decks', go('')], ...(Array.isArray(fs) ? fs : []).map(f => ['folder', f.replace(/\//g, ' › '), go(f)]), '-', ['create_new_folder', 'New folder', () => $('#hm-newfolder').click()]];
|
|
27
|
+
document.querySelector('.menu')?.remove();
|
|
28
|
+
const m = el('div', 'menu ph-menu');
|
|
29
|
+
for (const it of items) {
|
|
30
|
+
if (it === '-') { m.append(el('div', 'menu-sep')); continue; }
|
|
31
|
+
const [icon, label, run] = it, b = el('button', 'menu-item', `<span class="menu-icon icon">${icon}</span><span class="menu-label"></span>`);
|
|
32
|
+
b.querySelector('.menu-label').textContent = label; b.onclick = ev => { ev.stopPropagation(); m.remove(); run(); }; m.append(b);
|
|
33
|
+
}
|
|
34
|
+
document.body.append(m);
|
|
35
|
+
const r = folder.getBoundingClientRect();
|
|
36
|
+
m.style.left = Math.max(8, Math.min(r.left, innerWidth - m.offsetWidth - 8)) + 'px'; m.style.top = r.bottom + 4 + 'px';
|
|
37
|
+
setTimeout(() => addEventListener('pointerdown', ev => { if (!m.contains(/** @type {Node} */ (ev.target))) m.remove(); }, { once: true }));
|
|
38
|
+
};
|
|
39
|
+
// under the search: "Last opened by me ↓" (or "Title"), which flips the sort, and the grid/list toggle
|
|
40
|
+
const sort = $('#hm-sort'), view = $('#hm-view'), row = el('div', 'ph-sortrow'), label = el('button', 'ph-sort');
|
|
41
|
+
const showSort = () => { label.innerHTML = `<span></span><span class="icon">arrow_downward</span>`; label.firstChild.textContent = sort.classList.contains('on') ? 'Title' : 'Last opened by me'; };
|
|
42
|
+
new MutationObserver(showSort).observe(sort, { attributes: true, attributeFilter: ['class'] }); showSort();
|
|
43
|
+
label.onclick = () => sort.click();
|
|
44
|
+
const tools = el('span', 'ph-sorttools'); tools.append(phoneThemeButton(), view);
|
|
45
|
+
row.append(label, tools);
|
|
46
|
+
$('.hm-head').after(row);
|
|
47
|
+
// + : a new presentation, as the round button at the bottom right
|
|
48
|
+
const fab = $('#hm-new'); fab.className = 'ph-fab'; fab.title = 'New presentation'; fab.innerHTML = '<span class="icon">add</span>';
|
|
49
|
+
fab.addEventListener('click', () => { try { sessionStorage.setItem('ph-open-edit', '1'); } catch {} }, true); // a new deck opens for editing, as the app
|
|
50
|
+
document.body.append(fab);
|
|
51
|
+
})();
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/* ---------- phone mode ---------- */
|
|
2
|
+
/* Loaded first in <head>: on a phone (a coarse pointer and a short screen side of 600 CSS px or less, in portrait or
|
|
3
|
+
landscape; not an iPad) the page gets Google Slides' iPhone app instead of the desktop chrome. `phone` on <html>
|
|
4
|
+
switches editor/phone.css on, PHONE tells editor/js/phone-*.js. ?phone=1 / ?phone=0 force it for this load. */
|
|
5
|
+
const PHONE = (() => {
|
|
6
|
+
const q = new URLSearchParams(location.search).get('phone');
|
|
7
|
+
return q === '1' || (q !== '0' && matchMedia('(pointer: coarse)').matches && Math.min(screen.width, screen.height) <= 600);
|
|
8
|
+
})();
|
|
9
|
+
if (PHONE) {
|
|
10
|
+
document.documentElement.classList.add('phone');
|
|
11
|
+
if ('phoneViewport' in document.documentElement.dataset && !document.querySelector('meta[name=viewport]')) {
|
|
12
|
+
const m = document.createElement('meta'); m.name = 'viewport';
|
|
13
|
+
m.content = 'width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, interactive-widget=resizes-content';
|
|
14
|
+
document.head.append(m);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/* A home-screen app on iOS (WebKit bug 297779 and others, iOS 17–26): after the keyboard, WebKit can keep laying the page
|
|
18
|
+
out for a viewport about a status bar shorter than the screen, until the app is quit: the page rides up under the
|
|
19
|
+
status bar with a band at the bottom. So there the page keeps the tallest height the app has had in this orientation
|
|
20
|
+
(never more than the screen), not a viewport unit (html.ph-app, --ph-app-h, phone.css): iOS 27 gives 100lvh more
|
|
21
|
+
than the screen, and 100% or dvh can be left short. While the keyboard is up (html.ph-kb) the page is the visible
|
|
22
|
+
area instead, its height and where it is (iOS slides the visible area down the page to show the caret: --ph-vv-top),
|
|
23
|
+
so the text toolbar sits on the keyboard. And when the height stays short once nothing is being typed,
|
|
24
|
+
hiding the page and showing it again makes WebKit measure afresh (the scroll positions inside kept); at most once a
|
|
25
|
+
second, so it can't loop. */
|
|
26
|
+
if (PHONE && (matchMedia('(display-mode: standalone)').matches || navigator['standalone'])) (() => {
|
|
27
|
+
const root = document.documentElement;
|
|
28
|
+
/** @type {Record<number, number>} */ const tall = {}; // the largest height seen, per width (each orientation)
|
|
29
|
+
const typing = () => { const a = document.activeElement; return !!a && (/** @type {HTMLElement} */ (a).isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName)); };
|
|
30
|
+
const size = () => {
|
|
31
|
+
const w = innerWidth, screenH = w > innerHeight ? Math.min(screen.width, screen.height) : Math.max(screen.width, screen.height);
|
|
32
|
+
tall[w] = Math.min(screenH, Math.max(tall[w] || 0, innerHeight));
|
|
33
|
+
const vv = visualViewport, seen = Math.min(innerHeight, vv?.height || innerHeight), kb = typing() && tall[w] - seen > 120; // a keyboard is at least this tall
|
|
34
|
+
root.classList.toggle('ph-kb', kb);
|
|
35
|
+
root.style.setProperty('--ph-app-h', (kb ? Math.round(seen) : tall[w]) + 'px');
|
|
36
|
+
root.style.setProperty('--ph-vv-top', (kb ? Math.round(vv?.offsetTop || 0) : 0) + 'px');
|
|
37
|
+
return w;
|
|
38
|
+
};
|
|
39
|
+
root.classList.add('ph-app'); size();
|
|
40
|
+
let last = 0;
|
|
41
|
+
const heal = () => {
|
|
42
|
+
const w = size();
|
|
43
|
+
if (typing() || tall[w] - innerHeight <= 4 || Date.now() - last < 1000 || !document.body) return;
|
|
44
|
+
last = Date.now();
|
|
45
|
+
const keep = [...document.querySelectorAll('*')].filter(e => e.scrollTop || e.scrollLeft).map(e => ({ e, t: e.scrollTop, l: e.scrollLeft }));
|
|
46
|
+
const b = document.body; b.style.display = 'none'; void b.offsetHeight; b.style.display = '';
|
|
47
|
+
keep.forEach(({ e, t, l }) => { e.scrollTop = t; e.scrollLeft = l; });
|
|
48
|
+
};
|
|
49
|
+
const soon = () => setTimeout(heal, 150);
|
|
50
|
+
addEventListener('focusout', soon); addEventListener('focusin', () => setTimeout(size, 150)); addEventListener('pageshow', soon); addEventListener('resize', heal);
|
|
51
|
+
visualViewport?.addEventListener('resize', heal); visualViewport?.addEventListener('scroll', () => requestAnimationFrame(size));
|
|
52
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden) soon(); });
|
|
53
|
+
})();
|
|
54
|
+
/* The light / dark switch on the phone (Thermal only; the app's own look is always dark): a sun or moon button that
|
|
55
|
+
flips the same choice as the desktop's (setDark: js/notes.js in the editor, js/theme-toggle.js on Home). */
|
|
56
|
+
function phoneThemeButton() {
|
|
57
|
+
const b = document.createElement('button'); b.className = 'ph-icon-btn ph-theme'; b.type = 'button';
|
|
58
|
+
const show = () => { const dark = document.body.classList.contains('dark'); b.innerHTML = `<span class="icon">${dark ? 'light_mode' : 'dark_mode'}</span>`; b.title = dark ? 'Light mode' : 'Dark mode'; };
|
|
59
|
+
b.onclick = () => window['setDark']?.(!document.body.classList.contains('dark'));
|
|
60
|
+
new MutationObserver(show).observe(document.body, { attributes: true, attributeFilter: ['class'] }); show();
|
|
61
|
+
return b;
|
|
62
|
+
}
|
|
63
|
+
/* Phone layouts, all three on by default: thumb: the editing actions in a pill above the
|
|
64
|
+
filmstrip, the title on top. float: the format bar floats over the selection. shelf: Home as one column, the latest
|
|
65
|
+
deck large, the rest as rows. ?layout=thumb,float,shelf picks some (remembered); ?layout= turns them all off. */
|
|
66
|
+
if (PHONE) {
|
|
67
|
+
const q = new URLSearchParams(location.search), all = 'shelf thumb float';
|
|
68
|
+
let lay = all;
|
|
69
|
+
try { if (q.has('layout')) localStorage.setItem('glideshow-phone-layout', q.get('layout') || 'none'); lay = localStorage.getItem('glideshow-phone-layout') ?? all; }
|
|
70
|
+
catch { if (q.has('layout')) lay = q.get('layout') || 'none'; }
|
|
71
|
+
if (lay && lay !== 'none') document.documentElement.dataset.layout = lay.split(/[\s,+]+/).filter(Boolean).join(' ');
|
|
72
|
+
}
|
|
73
|
+
const phoneLayout = l => (document.documentElement.dataset.layout || '').split(' ').includes(l);
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/* ---------- phone: bottom sheets, and the Insert sheet ---------- */
|
|
2
|
+
/* A sheet rises over the bottom 442 pt of the screen while the editor slides up to keep the slide in view (the top bar
|
|
3
|
+
goes off the top), with no scrim and no grab handle. Its pages push in from the right, iOS style, under a header with
|
|
4
|
+
the page's title (and ‹ back on pushed pages). A tap on the canvas closes it. The Insert sheet (+ in the top bar):
|
|
5
|
+
Comment, Text, Image ›, Shape ›, Line ›, Table ›, Link. */
|
|
6
|
+
/** @type {{ open: (page: any) => void, push: (page: any) => void, close: () => void, isOpen: () => boolean }} */
|
|
7
|
+
let phoneSheet = { open: () => {}, push: () => {}, close: () => {}, isOpen: () => false };
|
|
8
|
+
/** The Insert sheet. */
|
|
9
|
+
let phoneInsert = () => {};
|
|
10
|
+
/** A colour panel: 'color' (text), 'hilite', 'fill' or 'stroke' (border). @type {(what: string) => void} */
|
|
11
|
+
let phoneColors = () => {};
|
|
12
|
+
if (PHONE) (() => {
|
|
13
|
+
const el = (tag, cls, html = '') => Object.assign(document.createElement(tag), { className: cls, innerHTML: html });
|
|
14
|
+
const root = document.documentElement;
|
|
15
|
+
const sheet = el('div', 'ph-sheet'); sheet.id = 'ph-sheet';
|
|
16
|
+
document.body.append(sheet);
|
|
17
|
+
const moving = () => ['#ph-top', 'main', '#ph-film', '#ph-tools', '#ph-view'].map(s => /** @type {HTMLElement} */ ($(s))).filter(Boolean);
|
|
18
|
+
let stack = [];
|
|
19
|
+
|
|
20
|
+
/* a page: { title, build(body) }; rows are [icon, label, run, more?] */
|
|
21
|
+
function pageEl(page, back) {
|
|
22
|
+
const p = el('div', 'ph-page'), head = el('div', 'ph-page-head'), body = el('div', 'ph-page-body');
|
|
23
|
+
if (back) { const b = el('button', 'ph-icon-btn ph-back', '<span class="icon">arrow_back_ios_new</span>'); b.title = 'Back'; b.onclick = pop; head.append(b); }
|
|
24
|
+
head.append(Object.assign(el('div', 'ph-page-title'), { textContent: page.title }));
|
|
25
|
+
page.build(body);
|
|
26
|
+
if (page.bare) p.append(body); else p.append(head, body); // bare: rows from the top, no header (13-present-menu.png)
|
|
27
|
+
return p;
|
|
28
|
+
}
|
|
29
|
+
function open(page) {
|
|
30
|
+
stack = [page]; sheet.replaceChildren(pageEl(page, false));
|
|
31
|
+
root.classList.add('ph-sheet-open');
|
|
32
|
+
// the editor slides up so the slide sits in the space above the sheet
|
|
33
|
+
const f = (root.classList.contains('ph-view') ? $('#ph-view .ph-vslide.cur') || frame : frame).getBoundingClientRect(), top = innerHeight - sheet.offsetHeight, safe = parseFloat(getComputedStyle(root).getPropertyValue('--ph-safe-top')) || 0;
|
|
34
|
+
const shift = Math.max(0, (f.top + f.bottom) / 2 - (safe + top) / 2);
|
|
35
|
+
moving().forEach(e => { e.style.transform = `translateY(${-shift}px)`; });
|
|
36
|
+
}
|
|
37
|
+
function push(page) {
|
|
38
|
+
const p = pageEl(page, true), was = /** @type {HTMLElement} */ (sheet.lastElementChild);
|
|
39
|
+
stack.push(page); p.classList.add('in'); sheet.append(p);
|
|
40
|
+
requestAnimationFrame(() => { p.classList.remove('in'); was?.classList.add('out'); });
|
|
41
|
+
setTimeout(() => was?.remove(), 260);
|
|
42
|
+
}
|
|
43
|
+
function pop() {
|
|
44
|
+
if (stack.length < 2) return close();
|
|
45
|
+
stack.pop(); const page = stack[stack.length - 1], cur = sheet.lastElementChild, p = pageEl(page, stack.length > 1);
|
|
46
|
+
p.classList.add('out'); sheet.prepend(p);
|
|
47
|
+
requestAnimationFrame(() => { p.classList.remove('out'); cur?.classList.add('in'); });
|
|
48
|
+
setTimeout(() => cur?.remove(), 260);
|
|
49
|
+
}
|
|
50
|
+
function close() {
|
|
51
|
+
if (!stack.length) return;
|
|
52
|
+
stack = []; root.classList.remove('ph-sheet-open');
|
|
53
|
+
moving().forEach(e => { e.style.transform = ''; });
|
|
54
|
+
setTimeout(() => { if (!stack.length) sheet.replaceChildren(); }, 260);
|
|
55
|
+
}
|
|
56
|
+
phoneSheet = { open, push, close, isOpen: () => stack.length > 0 };
|
|
57
|
+
stage.addEventListener('pointerdown', e => { if (stack.length) { e.stopImmediatePropagation(); e.preventDefault(); close(); } }, true); // a tap on the canvas closes it
|
|
58
|
+
|
|
59
|
+
const rows = (body, list) => list.forEach(([icon, label, run, more, off]) => {
|
|
60
|
+
const r = el('button', 'ph-row', `<span class="icon">${icon}</span><span class="ph-row-label"></span>${more ? '<span class="icon ph-more">chevron_right</span>' : ''}`);
|
|
61
|
+
r.querySelector('.ph-row-label').textContent = label; r.disabled = !!off; r.onclick = run; body.append(r);
|
|
62
|
+
});
|
|
63
|
+
const done = run => () => { close(); run(); };
|
|
64
|
+
|
|
65
|
+
/* ---- Insert ---- */
|
|
66
|
+
const S = 0.4; // a new shape: a square 0.4 of the slide's width, centred (r04)
|
|
67
|
+
const addSquare = (v, extra) => { const s = W * S; const b = addShape(v, { x: (W - s) / 2, y: (H - s) / 2, w: s, h: s }, extra); sel = new Set([b]); drawOverlay(); updateToolbar(); };
|
|
68
|
+
const shapePage = {
|
|
69
|
+
title: 'Shape',
|
|
70
|
+
build(body) {
|
|
71
|
+
const tabs = el('div', 'ph-tabs'), grid = el('div', 'ph-shape-grid');
|
|
72
|
+
const show = i => {
|
|
73
|
+
tabs.querySelectorAll('button').forEach((t, j) => t.classList.toggle('on', i === j));
|
|
74
|
+
grid.replaceChildren(...SHAPE_GROUPS[i][2].filter(it => it !== '-').map(([v, label, extra]) => {
|
|
75
|
+
const c = el('button', 'ph-shape-cell', shapePic(v, extra)); c.title = label; c.onclick = done(() => addSquare(v, extra)); return c;
|
|
76
|
+
}));
|
|
77
|
+
};
|
|
78
|
+
SHAPE_GROUPS.forEach(([label], i) => { const t = el('button', ''); t.textContent = label; t.onclick = () => show(i); tabs.append(t); });
|
|
79
|
+
body.append(tabs, grid); show(0);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
const LINES = [['line', 'Line', 'pen_size_2'], ['arrowline', 'Arrow', 'arrow_right_alt'], ['elbow', 'Elbow connector', 'turn_right'], ['curve', 'Curved connector', 'conversion_path']];
|
|
83
|
+
const linePage = {
|
|
84
|
+
title: 'Line',
|
|
85
|
+
build(body) {
|
|
86
|
+
rows(body, LINES.map(([v, label, icon]) => [icon, label, done(() => { const b = addShape(v); sel = new Set([b]); drawOverlay(); updateToolbar(); })]));
|
|
87
|
+
rows(body, [['gesture', 'Curve', done(() => armShape('smooth'))], ['polyline', 'Polyline', done(() => armShape('polyline'))], ['draw', 'Scribble', done(() => armShape('scribble'))]]);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
const imagePage = { title: 'Image', build(body) { rows(body, [['photo_library', 'Photos', done(() => $('#ed-file').click())], ['movie', 'Video', done(() => $('#ed-video-file').click())]]); } };
|
|
91
|
+
const tablePage = { title: 'Table', build(body) { const g = tableGrid(); g.classList.add('ph-table-pick'); g.addEventListener('click', () => close()); body.append(g); } };
|
|
92
|
+
/* ---- the colour panels (r03, r04): a hue strip; under it the greys (5 × 2) or the chosen hue's shades (4 × 2);
|
|
93
|
+
the theme's colours; the custom ones. The colour in use has a check in its corner. The panel stays open. ---- */
|
|
94
|
+
const ROWS = Array.from({ length: 8 }, (_, r) => PALETTE.slice(r * 10, r * 10 + 10).map(c => c.toLowerCase())); // the Docs palette: greys, hues, light 3, 2, 1, dark 1, 2, 3
|
|
95
|
+
const HUES = ROWS[1], shades = i => [ROWS[7][i], ROWS[6][i], ROWS[5][i], ROWS[1][i], ROWS[4][i], ROWS[3][i], ROWS[2][i], '#ffffff'];
|
|
96
|
+
const TITLES = { color: 'Text color', hilite: 'Highlight color', fill: 'Fill color', stroke: 'Border color' };
|
|
97
|
+
const hueOf = c => { for (let i = 0; i < HUES.length; i++) if (shades(i).slice(0, 7).includes(c)) return i; return -1; };
|
|
98
|
+
phoneColors = what => {
|
|
99
|
+
const input = $('#ed-' + what);
|
|
100
|
+
const current = () => {
|
|
101
|
+
const b = [...sel][0];
|
|
102
|
+
if (what === 'fill' && b && /^(transparent|none)$/.test(b.style.getPropertyValue('--fill').trim())) return 'none';
|
|
103
|
+
if (what === 'stroke' && b && parseFloat(b.style.getPropertyValue('--sw')) === 0) return 'none';
|
|
104
|
+
if (what === 'hilite') return '';
|
|
105
|
+
return String(input.value).toLowerCase();
|
|
106
|
+
};
|
|
107
|
+
let hue = -2; // -2: follow the colour in use; -1: the greys; else a hue
|
|
108
|
+
const pick = c => { if (c === 'none') { if (what === 'hilite') hilite('transparent'); else if (what === 'fill') { setVar('--fill', 'transparent'); commit(); } else noBorder(); } else setColor(input, c); };
|
|
109
|
+
const swatch = (c, cur) => {
|
|
110
|
+
const b = el('button', 'ph-swatch-cell' + (c === cur ? ' on' : '')); b.style.background = c; b.title = c;
|
|
111
|
+
b.onclick = () => { pick(c); paint(); }; return b;
|
|
112
|
+
};
|
|
113
|
+
let body = null;
|
|
114
|
+
function paint() {
|
|
115
|
+
const cur = current(), h = hue === -2 ? hueOf(cur) : hue;
|
|
116
|
+
body.replaceChildren();
|
|
117
|
+
if (what !== 'color') {
|
|
118
|
+
const none = el('button', 'ph-row ph-none' + (cur === 'none' ? ' on' : ''), '<span class="icon">format_color_reset</span><span class="ph-row-label">No color</span><span class="icon ph-check">check</span>');
|
|
119
|
+
none.onclick = () => { pick('none'); paint(); }; body.append(none);
|
|
120
|
+
}
|
|
121
|
+
const strip = el('div', 'ph-hues');
|
|
122
|
+
[-1, ...HUES.map((_, i) => i)].forEach(i => {
|
|
123
|
+
const seg = el('button', 'ph-hue' + (i === h ? ' on' : '')); seg.style.background = i < 0 ? '#000' : HUES[i];
|
|
124
|
+
seg.title = i < 0 ? 'Greys' : HUES[i]; seg.onclick = () => { hue = i; paint(); }; strip.append(seg);
|
|
125
|
+
});
|
|
126
|
+
const grid = el('div', 'ph-swatches ' + (h < 0 ? 'five' : 'four'));
|
|
127
|
+
(h < 0 ? ROWS[0] : shades(h)).forEach(c => grid.append(swatch(c, cur)));
|
|
128
|
+
const theme = el('div', 'ph-swatches five'); THEME_COLORS.forEach(c => theme.append(swatch(c.toLowerCase(), cur)));
|
|
129
|
+
const custom = el('div', 'ph-swatches custom'), add = el('button', 'ph-swatch-cell ph-add', '<span class="icon">add</span>');
|
|
130
|
+
add.title = 'Custom colour'; add.onclick = () => { input.addEventListener('change', () => { rememberColor(input.value); paint(); }, { once: true }); input.click(); };
|
|
131
|
+
custom.append(add, ...recentColors().map(c => swatch(c.toLowerCase(), cur)));
|
|
132
|
+
body.append(strip, grid, Object.assign(el('div', 'ph-label'), { textContent: 'Theme' }), theme, Object.assign(el('div', 'ph-label'), { textContent: 'Custom' }), custom);
|
|
133
|
+
}
|
|
134
|
+
if (editing) content(editing).blur(); // the keyboard makes way for the panel; the text selection is kept (lastRange)
|
|
135
|
+
open({ title: TITLES[what], build(b) { body = b; paint(); } });
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
phoneInsert = () => open({
|
|
139
|
+
title: 'Insert',
|
|
140
|
+
build(body) {
|
|
141
|
+
const typing = !!editing && !getSelection().isCollapsed;
|
|
142
|
+
rows(body, [
|
|
143
|
+
['add_comment', 'Comment', done(() => addComment())],
|
|
144
|
+
['insert_text', 'Text', done(() => { stopEdit(); startEdit(addText()); })],
|
|
145
|
+
['image', 'Image', () => push(imagePage), true],
|
|
146
|
+
['interests', 'Shape', () => push(shapePage), true],
|
|
147
|
+
['diagonal_line', 'Line', () => push(linePage), true],
|
|
148
|
+
['table', 'Table', () => push(tablePage), true],
|
|
149
|
+
['link', 'Link', done(() => $('#ed-link').click()), false, !typing],
|
|
150
|
+
]);
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
})();
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/* ---------- phone: touching the slide ---------- */
|
|
2
|
+
/* As the Slides app: a tap selects (square blue handles, a knob above for rotating) and, a moment later, shows the
|
|
3
|
+
iOS edit menu (Cut | Copy | Paste | Delete) under the box; a tap on the selected box edits its text; a tap on
|
|
4
|
+
nothing deselects. Dragging a box moves a grey outline of it, with red guides where it lines up, and the box jumps
|
|
5
|
+
there on release; dragging a handle does the same for its size. Runs before pointer.js's handler on the stage
|
|
6
|
+
and stops it; rotation, yellow adjust handles and text being edited keep the desktop's handling. */
|
|
7
|
+
/** The edit menu's actions on the selection (also the landscape side rail's ⋯, phone-edit.js). @returns {[string, () => any][]} */
|
|
8
|
+
const phoneClipActions = () => [['Cut', () => document.execCommand('cut')], ['Copy', () => document.execCommand('copy')],
|
|
9
|
+
['Paste', async () => { // iOS asks the clipboard, then the editor's own paste takes it
|
|
10
|
+
const text = await navigator.clipboard.readText().catch(() => ''); if (!text) return;
|
|
11
|
+
const dt = new DataTransfer(); dt.setData('text/plain', text); dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt }));
|
|
12
|
+
}],
|
|
13
|
+
['Delete', () => deleteSelection()]];
|
|
14
|
+
if (PHONE) (() => {
|
|
15
|
+
const TAP = 8; // screen px a finger may wander and still tap
|
|
16
|
+
const root = document.documentElement;
|
|
17
|
+
const ghostAt = g => { const gh = div('ph-ghost'); place(gh, g); gh.style.setProperty('--k', 1 / scale + 'px'); guides.append(gh); return gh; };
|
|
18
|
+
let menuTimer = 0, menu = null;
|
|
19
|
+
function closeMenu_() { clearTimeout(menuTimer); menu?.remove(); menu = null; }
|
|
20
|
+
|
|
21
|
+
/* ---- two fingers: a pinch zooms about them (1× to 4× the fitted size) and moving them pans (r06, 17-zoom.png) ---- */
|
|
22
|
+
const fingers = new Map(); // pointerId → where it is
|
|
23
|
+
let pinch = null, pinches = 0; // pinches: bumped when one starts, so a one-finger gesture under way gives up
|
|
24
|
+
const fitScale = () => { const r = stage.getBoundingClientRect(); return Math.max(0.05, Math.min((r.width - 30) / W, (r.height - 30) / H)); };
|
|
25
|
+
const zoomed = () => scale / fitScale();
|
|
26
|
+
function zoomTo(z, mx, my, c) { // z × the fitted size, with canvas point c kept under the screen point (mx, my)
|
|
27
|
+
zoom = z <= 1.001 ? null : z * fitScale() * W / 960;
|
|
28
|
+
fit();
|
|
29
|
+
const f = frame.getBoundingClientRect();
|
|
30
|
+
stage.scrollLeft += f.left + c.x * scale - mx; stage.scrollTop += f.top + c.y * scale - my;
|
|
31
|
+
}
|
|
32
|
+
const two = () => { const [a, b] = [...fingers.values()]; return { d: Math.hypot(a.x - b.x, a.y - b.y), x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; };
|
|
33
|
+
stage.addEventListener('pointerdown', e => {
|
|
34
|
+
if (e.isPrimary) fingers.clear(); // a new gesture: forget any finger whose lift we missed
|
|
35
|
+
fingers.set(e.pointerId, { x: e.clientX, y: e.clientY, type: e.pointerType });
|
|
36
|
+
if (fingers.size !== 2 || !root.classList.contains('ph-edit')) return;
|
|
37
|
+
e.stopImmediatePropagation(); e.preventDefault();
|
|
38
|
+
pinches++; closeMenu_();
|
|
39
|
+
const t = two(); pinch = { d0: t.d, z0: zoomed(), c: toCanvas({ clientX: t.x, clientY: t.y }) };
|
|
40
|
+
}, true);
|
|
41
|
+
addEventListener('pointermove', e => {
|
|
42
|
+
const f = fingers.get(e.pointerId); if (!f || f.type !== e.pointerType) return; // (a mouse's id can match a finger's)
|
|
43
|
+
f.x = e.clientX; f.y = e.clientY;
|
|
44
|
+
if (!pinch || fingers.size < 2) return;
|
|
45
|
+
const t = two(); zoomTo(Math.max(1, Math.min(4, pinch.z0 * t.d / pinch.d0)), t.x, t.y, pinch.c);
|
|
46
|
+
}, true);
|
|
47
|
+
const lift = e => {
|
|
48
|
+
fingers.delete(e.pointerId);
|
|
49
|
+
if (pinch && fingers.size < 2) { pinch = null; if (zoomed() < 1.05) { zoom = null; fit(); } }
|
|
50
|
+
};
|
|
51
|
+
addEventListener('pointerup', lift, true); addEventListener('pointercancel', lift, true);
|
|
52
|
+
|
|
53
|
+
stage.addEventListener('pointerdown', e => {
|
|
54
|
+
if (!root.classList.contains('ph-edit') || e.button !== 0 || drawing) return; // drawing a curve or scribble: draw.js
|
|
55
|
+
const t = /** @type {Element} */ (e.target), handle = t.closest('.handle'), b = topBox(t);
|
|
56
|
+
closeMenu_();
|
|
57
|
+
if (handle && !handle.dataset.dir) return; // the rotation knob, a yellow diamond: pointer.js
|
|
58
|
+
if (!handle && editing && b === editing) return; // the caret and text selection: the browser and pointer.js
|
|
59
|
+
const one = sel.size === 1 ? [...sel][0] : null;
|
|
60
|
+
if (handle && (!one || isLine(one) || rot(one) || 'crop' in handle.dataset || flipXY(one).join() !== '1,1')) return; // turned, flipped, lines, groups of boxes: pointer.js
|
|
61
|
+
e.stopImmediatePropagation();
|
|
62
|
+
if (handle) return ghostResize(e, one, handle.dataset.dir);
|
|
63
|
+
tapOrMove(e, b);
|
|
64
|
+
}, true);
|
|
65
|
+
|
|
66
|
+
/* ---- a tap, or a drag that moves the box's outline ---- */
|
|
67
|
+
let lastTap = { b: null, t: 0 };
|
|
68
|
+
function tapOrMove(e, b) {
|
|
69
|
+
const p0 = toCanvas(e), x0 = e.clientX, y0 = e.clientY, was = !!b && sel.size === 1 && sel.has(b), gen = pinches;
|
|
70
|
+
let moved = false, ghost = null, items = [], g0 = null, others = [], d = { x: 0, y: 0 }, last = { x: x0, y: y0 };
|
|
71
|
+
drag(e, ev => {
|
|
72
|
+
if (pinches !== gen) { if (ghost) { ghost.remove(); ghost = null; showGuides(); busy = false; } return; } // a second finger: it's a pinch now
|
|
73
|
+
if (!moved && Math.hypot(ev.clientX - x0, ev.clientY - y0) < TAP) return;
|
|
74
|
+
if (!b) { // one finger on nothing pans a zoomed-in slide
|
|
75
|
+
moved = true; stage.scrollLeft -= ev.clientX - last.x; stage.scrollTop -= ev.clientY - last.y; last = { x: ev.clientX, y: ev.clientY };
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (!moved) {
|
|
79
|
+
moved = true; busy = true;
|
|
80
|
+
if (editing && editing !== b) stopEdit();
|
|
81
|
+
if (!sel.has(b)) { sel = new Set([b]); drawOverlay(); updateToolbar(); }
|
|
82
|
+
items = [...sel]; g0 = bbox(items.map(aabb)); others = boxes().filter(o => !sel.has(o)).map(aabb);
|
|
83
|
+
ghost = ghostAt(g0);
|
|
84
|
+
}
|
|
85
|
+
const p = toCanvas(ev), at = { ...g0, x: g0.x + p.x - p0.x, y: g0.y + p.y - p0.y }, s = snap(at, others);
|
|
86
|
+
d = { x: at.x + s.dx - g0.x, y: at.y + s.dy - g0.y };
|
|
87
|
+
place(ghost, { ...g0, x: g0.x + d.x, y: g0.y + d.y });
|
|
88
|
+
showGuides(s.gx, s.gy, { ...g0, x: g0.x + d.x, y: g0.y + d.y }, s.ox, s.oy, others);
|
|
89
|
+
}, ev => {
|
|
90
|
+
if (pinches !== gen || (moved && !b)) return;
|
|
91
|
+
if (moved) { // the box jumps to where its outline was let go
|
|
92
|
+
busy = false; ghost.remove(); showGuides();
|
|
93
|
+
for (const it of items) { const g = geo(it); setGeo(it, { ...g, x: g.x + d.x, y: g.y + d.y }); if (isLine(it)) { delete it.dataset.from; delete it.dataset.to; } }
|
|
94
|
+
followConnectors(); drawOverlay(); commit();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!b) { stopEdit(); if (sel.size) { sel.clear(); drawOverlay(); updateToolbar(); } return; } // a tap on nothing
|
|
98
|
+
const twice = lastTap.b === b && Date.now() - lastTap.t < 350; lastTap = { b, t: Date.now() };
|
|
99
|
+
if ((was || twice) && !isMedia(b) && !isLine(b) && content(b) && !isGroup(b)) { closeMenu_(); startEdit(b, { x: ev.clientX, y: ev.clientY }); drawOverlay(); return; } // a second tap, or a double tap, types
|
|
100
|
+
if (editing) stopEdit();
|
|
101
|
+
sel = new Set([b]); drawOverlay(); updateToolbar();
|
|
102
|
+
menuTimer = window.setTimeout(() => showMenu(b), 450);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* ---- a handle: the outline takes the new size, the box follows on release; the edge snaps to guides ---- */
|
|
107
|
+
function ghostResize(e, b, dir) {
|
|
108
|
+
const g0 = geo(b), p0 = toCanvas(e), others = boxes().filter(o => o !== b).map(aabb), { xs, ys } = snapLines(others), min = 20;
|
|
109
|
+
const keep = dir.length === 2 && (isMedia(b) || b.dataset.lock === 'aspect'), ratio = g0.w / (g0.h || 1);
|
|
110
|
+
const ghost = ghostAt(g0);
|
|
111
|
+
let g = g0;
|
|
112
|
+
busy = true; ui.classList.add('ph-sizing'); ui.querySelector(`.handle[data-dir="${dir}"]`)?.classList.add('on');
|
|
113
|
+
drag(e, ev => {
|
|
114
|
+
const p = toCanvas(ev), th = SNAP_PX / scale, dx = p.x - p0.x, dy = p.y - p0.y;
|
|
115
|
+
let x0 = g0.x, x1 = g0.x + g0.w, y0 = g0.y, y1 = g0.y + g0.h, gx = null, gy = null;
|
|
116
|
+
const near = (lines, v) => { const c = lines.reduce((m, l) => Math.abs(l - v) < Math.abs(m - v) ? l : m, Infinity); return Math.abs(c - v) < th ? c : null; };
|
|
117
|
+
if (dir.includes('e')) { x1 += dx; const c = near(xs, x1); if (c != null && !keep) x1 = gx = c; }
|
|
118
|
+
if (dir.includes('w')) { x0 += dx; const c = near(xs, x0); if (c != null && !keep) x0 = gx = c; }
|
|
119
|
+
if (dir.includes('s')) { y1 += dy; const c = near(ys, y1); if (c != null && !keep) y1 = gy = c; }
|
|
120
|
+
if (dir.includes('n')) { y0 += dy; const c = near(ys, y0); if (c != null && !keep) y0 = gy = c; }
|
|
121
|
+
let w = Math.max(min, x1 - x0), h = Math.max(min, y1 - y0);
|
|
122
|
+
if (keep) { if (w / g0.w > h / g0.h) h = w / ratio; else w = h * ratio; }
|
|
123
|
+
g = { ...g0, w, h, x: dir.includes('w') ? g0.x + g0.w - w : g0.x, y: dir.includes('n') ? g0.y + g0.h - h : g0.y };
|
|
124
|
+
place(ghost, g); showGuides(gx, gy);
|
|
125
|
+
}, () => {
|
|
126
|
+
busy = false; ghost.remove(); showGuides(); ui.classList.remove('ph-sizing');
|
|
127
|
+
if (g !== g0) { setGeo(b, g); if (!b.dataset.shape && !isMedia(b) && /[ns]/.test(dir)) b.dataset.minh = String(g.h); if (b.dataset.shape) renderShape(b); fitText(b); followConnectors(); commit(); }
|
|
128
|
+
drawOverlay();
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* ---- the iOS edit menu under the selected box (above it when there's no room), as 04-select-text-box.png ---- */
|
|
133
|
+
function showMenu(b) {
|
|
134
|
+
if (!sel.has(b) || editing) return;
|
|
135
|
+
const r = (ui.querySelector('.sel-box') || b).getBoundingClientRect(), s = stage.getBoundingClientRect();
|
|
136
|
+
menu = div('ph-editmenu');
|
|
137
|
+
const item = (label, run) => { const x = document.createElement('button'); x.textContent = label; x.onclick = ev => { ev.stopPropagation(); closeMenu_(); run(); }; return x; };
|
|
138
|
+
menu.append(...phoneClipActions().map(([label, run]) => item(label, run)));
|
|
139
|
+
document.body.append(menu);
|
|
140
|
+
const below = r.bottom + 31 + menu.offsetHeight < s.bottom;
|
|
141
|
+
menu.style.left = Math.max(s.left + 8, Math.min(r.left + r.width / 2 - menu.offsetWidth / 2, s.right - menu.offsetWidth - 8)) + 'px'; // over the slide's area: clear of the side rails
|
|
142
|
+
menu.style.top = (below ? r.bottom + 31 : Math.max(s.top + 8, r.top - 31 - menu.offsetHeight)) + 'px';
|
|
143
|
+
}
|
|
144
|
+
addEventListener('pointerdown', e => { if (menu && !menu.contains(/** @type {Node} */ (e.target))) closeMenu_(); }, true);
|
|
145
|
+
})();
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/* ---------- phone: states and view mode ---------- */
|
|
2
|
+
/* On a phone a deck opens in view mode, as the Slides app: a top bar (back; present, search, share, comments, ⋯) over
|
|
3
|
+
the slides as a vertical list, the current one outlined. Tapping a slide offers "Add comment | Edit slide"; Edit
|
|
4
|
+
slide goes to the editing state (html.ph-edit), whose chrome phone-edit.js builds. Scrolling down hides the bar,
|
|
5
|
+
scrolling up brings it back, and a "Slide N of M" chip shows while the list moves. */
|
|
6
|
+
/** @type {(state: 'view' | 'edit') => void} */
|
|
7
|
+
let phoneState = () => {};
|
|
8
|
+
/** Present from the current slide, phone style (✕ and the title on top). */
|
|
9
|
+
let phonePresent = async () => {};
|
|
10
|
+
if (PHONE) (() => {
|
|
11
|
+
const el = (tag, cls, html = '') => Object.assign(document.createElement(tag), { className: cls, innerHTML: html });
|
|
12
|
+
const iconBtn = (icon, title, run) => { const b = el('button', 'ph-icon-btn', `<span class="icon">${icon}</span>`); b.title = title; b.onclick = run; return b; };
|
|
13
|
+
const root = document.documentElement;
|
|
14
|
+
const top = el('header', 'ph-top'), list = el('div', 'ph-view'), chip = el('div', 'ph-chip');
|
|
15
|
+
list.id = 'ph-view'; top.id = 'ph-top';
|
|
16
|
+
document.body.prepend(top); document.body.append(list, chip);
|
|
17
|
+
let state = 'view';
|
|
18
|
+
|
|
19
|
+
// the top bar of each state; phone-edit.js fills `edit`
|
|
20
|
+
const bars = { view: el('div', 'ph-bar ph-bar-view'), edit: el('div', 'ph-bar ph-bar-edit') };
|
|
21
|
+
bars.view.append(iconBtn('arrow_back_ios_new', 'Back to your decks', () => { location.href = '/'; }), el('span', 'ph-gap'),
|
|
22
|
+
phoneThemeButton(), iconBtn('play_arrow', 'Present', () => phonePresent()), iconBtn('search', 'Find', () => { phoneState('edit'); openFind(); }),
|
|
23
|
+
iconBtn('person_add', 'Share', () => showShare()), iconBtn('comment', 'Comments', () => { phoneState('edit'); setCommentsPanel(true); }),
|
|
24
|
+
iconBtn('more_horiz', 'More', e => viewMenu(/** @type {Element} */ (e.currentTarget))));
|
|
25
|
+
top.append(bars.view, bars.edit);
|
|
26
|
+
/* the page itself never scrolls (each screen scrolls inside): iOS can leave it scrolled up after the keyboard, above
|
|
27
|
+
all in a home-screen app, which hides the top bar under the status bar and leaves a gap at the bottom; so whenever
|
|
28
|
+
nothing is being typed it goes back to the top */
|
|
29
|
+
const typing = () => { const a = document.activeElement; return !!a && (/** @type {HTMLElement} */ (a).isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName)); };
|
|
30
|
+
const pin = () => { if (typing()) return; if (scrollX || scrollY) scrollTo(0, 0); if (document.body.scrollTop) document.body.scrollTop = 0; };
|
|
31
|
+
addEventListener('scroll', pin, { passive: true }); visualViewport?.addEventListener('resize', pin);
|
|
32
|
+
addEventListener('focusout', () => setTimeout(pin, 120));
|
|
33
|
+
phoneState = s => {
|
|
34
|
+
state = s; stopEdit(); closePop();
|
|
35
|
+
root.classList.toggle('ph-view', s === 'view'); root.classList.toggle('ph-edit', s === 'edit');
|
|
36
|
+
if (s === 'view') { sel.clear(); if (slides.length) drawOverlay(); top.classList.remove('away'); fill(true); } else fit();
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/* ---- the list: one picture per slide, drawn when it comes near the screen (a dark placeholder until then) ---- */
|
|
40
|
+
const drawn = new WeakMap(); // item → the slide HTML it shows
|
|
41
|
+
const near = new IntersectionObserver(es => es.forEach(e => { if (e.isIntersecting) draw(/** @type {HTMLElement} */ (e.target)); }), { root: list, rootMargin: '100% 0px' });
|
|
42
|
+
function draw(item) {
|
|
43
|
+
const s = slides[+item.dataset.i]; if (!s || drawn.get(item) === s.outerHTML) return;
|
|
44
|
+
drawn.set(item, s.outerHTML);
|
|
45
|
+
const k = item.clientWidth / W, inner = div('ph-vslide-canvas slides'), c = s.cloneNode(true);
|
|
46
|
+
inner.style.cssText = `width:${W}px;height:${H}px;transform:scale(${k})`;
|
|
47
|
+
paintBg(inner, s);
|
|
48
|
+
c.querySelectorAll('[id]').forEach(e => e.removeAttribute('id'));
|
|
49
|
+
c.querySelectorAll('aside.notes, script, .box.note').forEach(e => e.remove()); // speaker notes and notes for AI stay out of view mode
|
|
50
|
+
c.querySelectorAll('.box[data-shape]').forEach(renderShape); c.querySelectorAll('.box[data-shadow]').forEach(shadeBox); c.querySelectorAll('.box[data-crop]').forEach(cropBox);
|
|
51
|
+
inner.append(c); item.replaceChildren(inner); item.classList.add('drawn');
|
|
52
|
+
highlightCode(c); renderMath(c);
|
|
53
|
+
}
|
|
54
|
+
let filled = false;
|
|
55
|
+
function fill(scrollToCur = false) {
|
|
56
|
+
if (state !== 'view' || !slides.length) return;
|
|
57
|
+
if (!filled) { filled = scrollToCur = true; }
|
|
58
|
+
while (list.children.length > slides.length) list.lastChild.remove();
|
|
59
|
+
while (list.children.length < slides.length) { const it = el('div', 'ph-vslide reveal'); it.dataset.i = String(list.children.length); list.append(it); near.observe(it); }
|
|
60
|
+
[...list.children].forEach((it, i) => { /** @type {HTMLElement} */ (it).style.aspectRatio = `${W} / ${H}`; it.classList.toggle('cur', i === cur); if (it.classList.contains('drawn')) draw(/** @type {HTMLElement} */ (it)); });
|
|
61
|
+
if (scrollToCur) { quietUntil = Date.now() + 200; list.children[cur]?.scrollIntoView({ block: 'nearest' }); }
|
|
62
|
+
}
|
|
63
|
+
const renderThumbs0 = renderThumbs; // the list follows the deck as the filmstrip does (loads, reloads from disk)
|
|
64
|
+
window['renderThumbs'] = () => { renderThumbs0(); fill(); };
|
|
65
|
+
addEventListener('resize', () => { [...list.children].forEach(it => drawn.delete(it)); fill(); });
|
|
66
|
+
|
|
67
|
+
/* ---- scrolling: the bar hides going down and comes back going up; the chip says where you are ---- */
|
|
68
|
+
let lastY = 0, chipTimer = 0, quietUntil = 0; // quiet: a scroll of our own (to the current slide) leaves the bar be
|
|
69
|
+
list.addEventListener('scroll', () => {
|
|
70
|
+
const y = list.scrollTop, d = y - lastY; lastY = y;
|
|
71
|
+
if (Date.now() < quietUntil) return;
|
|
72
|
+
if (y <= 0 || d < -4) top.classList.remove('away'); else if (d > 4 && y > 60) top.classList.add('away');
|
|
73
|
+
const mid = list.getBoundingClientRect().top + list.clientHeight / 3;
|
|
74
|
+
const i = [...list.children].findIndex(it => it.getBoundingClientRect().bottom > mid);
|
|
75
|
+
chip.textContent = `Slide ${Math.max(0, i) + 1} of ${slides.length}`;
|
|
76
|
+
chip.classList.add('on'); clearTimeout(chipTimer); chipTimer = window.setTimeout(() => chip.classList.remove('on'), 1000);
|
|
77
|
+
closePop();
|
|
78
|
+
}, { passive: true });
|
|
79
|
+
|
|
80
|
+
/* ---- tap a slide: "Add comment | Edit slide", as the iOS edit menu, over the tap ---- */
|
|
81
|
+
let pop = null;
|
|
82
|
+
function closePop() { pop?.remove(); pop = null; }
|
|
83
|
+
list.addEventListener('click', e => {
|
|
84
|
+
const it = /** @type {HTMLElement} */ (e.target).closest('.ph-vslide'); if (!it || pop) { closePop(); return; }
|
|
85
|
+
const i = +it.dataset.i;
|
|
86
|
+
pop = el('div', 'ph-editmenu');
|
|
87
|
+
const item = (label, run) => { const b = el('button', '', ''); b.textContent = label; b.onclick = ev => { ev.stopPropagation(); closePop(); run(); }; return b; };
|
|
88
|
+
pop.append(item('Add comment', () => { go(i); phoneState('edit'); addComment(); }), item('Edit slide', () => { go(i); phoneState('edit'); }));
|
|
89
|
+
document.body.append(pop);
|
|
90
|
+
const x = Math.max(8, Math.min(e.clientX - pop.offsetWidth / 2, innerWidth - pop.offsetWidth - 8));
|
|
91
|
+
pop.style.left = x + 'px'; pop.style.top = Math.max(top.getBoundingClientRect().bottom + 8, e.clientY - pop.offsetHeight - 24) + 'px';
|
|
92
|
+
if (i !== cur) go(i);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/* ---- ⋯ : the deck's menu (Google's isn't captured yet; these are Glideshow's) ---- */
|
|
96
|
+
function viewMenu(btn) {
|
|
97
|
+
closeMenu?.();
|
|
98
|
+
const m = el('div', 'menu ph-menu'), items = [['edit', 'Edit', () => phoneState('edit')], ['slideshow', 'Present', () => phonePresent()],
|
|
99
|
+
['person_add', 'Share', () => showShare()], ['desktop_windows', 'Desktop version', () => { location.href = '/edit?phone=0'; }]];
|
|
100
|
+
for (const [icon, label, run] of items) {
|
|
101
|
+
const b = el('button', 'menu-item', `<span class="menu-icon icon">${icon}</span><span class="menu-label"></span>`);
|
|
102
|
+
b.querySelector('.menu-label').textContent = /** @type {string} */ (label); b.onclick = ev => { ev.stopPropagation(); m.remove(); /** @type {Function} */ (run)(); }; m.append(b);
|
|
103
|
+
}
|
|
104
|
+
document.body.append(m);
|
|
105
|
+
const r = btn.getBoundingClientRect(); m.style.left = innerWidth - m.offsetWidth - 8 + 'px'; m.style.top = r.bottom + 4 + 'px';
|
|
106
|
+
setTimeout(() => addEventListener('pointerdown', ev => { if (!m.contains(/** @type {Node} */ (ev.target))) m.remove(); }, { once: true }));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ---- ▷ : present from the current slide, with ✕ and the title on top (13-present.png; the full flow is P5) ---- */
|
|
110
|
+
/* ▷ (13-present-menu.png, r07): a sheet with no title, "Present on this device" (Google's casting and meeting rows
|
|
111
|
+
aren't Glideshow's). Presenting (13-present.png): the slide on black, centred on the whole screen; a bar with ✕
|
|
112
|
+
and the title, which slides away once the slide shows and comes back on a tap. A tap advances (instant, with the
|
|
113
|
+
slide's builds); a swipe goes forward or back. ✕ returns to the editor on the slide last shown. */
|
|
114
|
+
phonePresent = async () => phoneSheet.open({ title: '', bare: true, build(body) {
|
|
115
|
+
const r = el('button', 'ph-row', '<span class="icon">smartphone</span><span class="ph-row-label">Present on this device</span>');
|
|
116
|
+
r.onclick = () => { phoneSheet.close(); startShow(); }; body.append(r);
|
|
117
|
+
} });
|
|
118
|
+
async function startShow() {
|
|
119
|
+
await present(cur);
|
|
120
|
+
const show = $('#ed-show'), frameEl = show.querySelector('iframe'), deck = () => /** @type {any} */ (frameEl.contentWindow)?.Reveal;
|
|
121
|
+
const bar = el('div', 'ph-show-bar'), t = el('span', 'ph-show-title'), count = el('span', 'ph-show-count'), touch = el('div', 'ph-show-touch');
|
|
122
|
+
t.textContent = $('#ed-title').value || 'Untitled presentation';
|
|
123
|
+
const stop = () => { const i = deck()?.getIndices?.(); endPresent(i?.h, i?.v); };
|
|
124
|
+
bar.append(iconBtn('close', 'Stop presenting', stop), t, count);
|
|
125
|
+
// the bar shows on a tap and goes again on its own; Thermal shows "n / N" in it (the app shows the title)
|
|
126
|
+
/** @type {any} */ let hide = 0; const peek = () => { bar.classList.remove('away'); clearTimeout(hide); hide = setTimeout(() => bar.classList.add('away'), 2200); };
|
|
127
|
+
const counted = () => { const d = deck(); if (d?.getTotalSlides) count.textContent = `${d.getSlidePastCount() + 1} / ${d.getTotalSlides()}`; };
|
|
128
|
+
show.append(touch, bar);
|
|
129
|
+
frameEl.addEventListener('load', () => {
|
|
130
|
+
const st = frameEl.contentDocument?.createElement('style'); // the app's look: black around the slide, no controls of the deck's own
|
|
131
|
+
if (st) { st.textContent = '.sl-bar, .reveal .backgrounds { display: none !important; } html, body, .reveal-viewport, .reveal { background: #000 !important; }'; frameEl.contentDocument.head.append(st); }
|
|
132
|
+
deck()?.configure?.({ scrollActivationWidth: 1 }); deck()?.layout?.();
|
|
133
|
+
deck()?.getSlides?.().forEach(sl => { // each slide wears its own background, so only the letterbox is black
|
|
134
|
+
const bg = deck().getSlideBackground(sl), cs = bg && frameEl.contentWindow.getComputedStyle(bg);
|
|
135
|
+
if (cs) Object.assign(sl.style, { backgroundColor: cs.backgroundColor, backgroundImage: cs.backgroundImage, backgroundSize: cs.backgroundSize, backgroundPosition: cs.backgroundPosition, backgroundRepeat: cs.backgroundRepeat });
|
|
136
|
+
}); // reveal.js 5 scrolls the slides on a screen under 435 px wide (and null doesn't turn that off): the app shows one, letterboxed
|
|
137
|
+
deck()?.on?.('slidechanged', counted); counted();
|
|
138
|
+
setTimeout(() => bar.classList.add('away'), 600); // r07: the bar goes once the slide is up
|
|
139
|
+
}, { once: true });
|
|
140
|
+
let x0 = 0, y0 = 0;
|
|
141
|
+
touch.onpointerdown = e => { x0 = e.clientX; y0 = e.clientY; };
|
|
142
|
+
touch.onpointerup = e => {
|
|
143
|
+
const dx = e.clientX - x0, dy = e.clientY - y0;
|
|
144
|
+
if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) { if (dx < 0) deck()?.next(); else deck()?.prev(); return; } // a swipe
|
|
145
|
+
if (Math.hypot(dx, dy) < 10) { peek(); deck()?.next(); } // a tap: the bar comes back for a moment, and on to the next
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
let fresh = false; try { fresh = sessionStorage.getItem('ph-open-edit') === '1'; sessionStorage.removeItem('ph-open-edit'); } catch {}
|
|
149
|
+
phoneState(fresh ? 'edit' : 'view'); // a deck just made with + opens for editing; others open to view, as the app
|
|
150
|
+
})();
|