mobile: touch + pen compatibility pass across rApps

Global shell.css: @media (pointer:coarse) baseline — inputs ≥16px
(no iOS zoom), buttons/role-buttons ≥44×44, touch-action: manipulation,
transparent tap highlight. New utility classes: .rapp-hscroll and
.rapp-drawer for bottom-sheet patterns.

folk-drawfast + folk-makereal: branch on pointerType, honor real pen
pressure (constant 0.5 for mouse/touch), single-pointer capture, palm
rejection (touch ignored while pen is down), pointercancel cleanup.

rNotes: mobile drill-down layout below 768px — three panels collapse
to one full-width view per selection stage (vaults → files → preview)
with back buttons. 16px fonts, 44px touch targets.

rSheets: sticky row/col headers, min-width max-content table, visible
scroll thumb, 72px min cell width on coarse pointers.

rMaps: bottom-sheet handle touchstart/touchend → unified PointerEvents
so pen users get drag-to-expand. Pointer capture + horizontal-swipe
reject.

rPhotos lightbox: pinch-zoom (2-pointer), pan when zoomed, horizontal
swipe between photos, double-tap to toggle zoom, prev/next buttons on
desktop (swipe-only on mobile).

Bump cache versions: folk-notes-app v=10→11, folk-map-viewer v=7→8,
folk-photo-gallery v=3→4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-16 17:13:35 -04:00
parent eee89f9f32
commit df7d0b021f
10 changed files with 387 additions and 16 deletions

View File

@ -584,6 +584,8 @@ export class FolkDrawfast extends FolkShape {
#brushSize = 4; #brushSize = 4;
#tool = "pen"; // pen | eraser #tool = "pen"; // pen | eraser
#isDrawing = false; #isDrawing = false;
#activePointerId: number | null = null;
#penIsActive = false;
#isGenerating = false; #isGenerating = false;
#autoGenerate = false; #autoGenerate = false;
#autoDebounceTimer: ReturnType<typeof setTimeout> | null = null; #autoDebounceTimer: ReturnType<typeof setTimeout> | null = null;
@ -739,15 +741,27 @@ export class FolkDrawfast extends FolkShape {
}); });
providerSelect.addEventListener("pointerdown", (e) => e.stopPropagation()); providerSelect.addEventListener("pointerdown", (e) => e.stopPropagation());
// Drawing events // Drawing events — pen takes priority over touch (palm rejection).
// If a pen pointer is active, touch pointers are ignored until the pen lifts.
this.#canvas.addEventListener("pointerdown", (e) => { this.#canvas.addEventListener("pointerdown", (e) => {
// Palm rejection: when pen is down, ignore touch pointers entirely
if (this.#penIsActive && e.pointerType === "touch") {
e.preventDefault();
return;
}
// Only one drawing pointer at a time (pinch gestures should bubble)
if (this.#isDrawing) return;
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
this.#isDrawing = true; this.#isDrawing = true;
this.#activePointerId = e.pointerId;
if (e.pointerType === "pen") this.#penIsActive = true;
this.#canvas!.setPointerCapture(e.pointerId); this.#canvas!.setPointerCapture(e.pointerId);
const pos = this.#getCanvasPos(e); const pos = this.#getCanvasPos(e);
const pressure = e.pointerType === "pen" ? (e.pressure || 0.5) : 0.5;
this.#currentStroke = { this.#currentStroke = {
points: [{ ...pos, pressure: e.pressure || 0.5 }], points: [{ ...pos, pressure }],
color: this.#tool === "eraser" ? "#ffffff" : this.#color, color: this.#tool === "eraser" ? "#ffffff" : this.#color,
size: this.#tool === "eraser" ? this.#brushSize * 3 : this.#brushSize, size: this.#tool === "eraser" ? this.#brushSize * 3 : this.#brushSize,
tool: this.#tool, tool: this.#tool,
@ -756,15 +770,21 @@ export class FolkDrawfast extends FolkShape {
this.#canvas.addEventListener("pointermove", (e) => { this.#canvas.addEventListener("pointermove", (e) => {
if (!this.#isDrawing || !this.#currentStroke) return; if (!this.#isDrawing || !this.#currentStroke) return;
if (e.pointerId !== this.#activePointerId) return;
e.stopPropagation(); e.stopPropagation();
const pos = this.#getCanvasPos(e); const pos = this.#getCanvasPos(e);
this.#currentStroke.points.push({ ...pos, pressure: e.pressure || 0.5 }); // Pen: honor real pressure. Mouse/touch: use constant 0.5.
const pressure = e.pointerType === "pen" ? (e.pressure || 0.5) : 0.5;
this.#currentStroke.points.push({ ...pos, pressure });
this.#drawStroke(this.#currentStroke); this.#drawStroke(this.#currentStroke);
}); });
const endDraw = (e: PointerEvent) => { const endDraw = (e: PointerEvent) => {
if (!this.#isDrawing) return; if (!this.#isDrawing) return;
if (e.pointerId !== this.#activePointerId) return;
this.#isDrawing = false; this.#isDrawing = false;
this.#activePointerId = null;
if (e.pointerType === "pen") this.#penIsActive = false;
if (this.#currentStroke && this.#currentStroke.points.length > 0) { if (this.#currentStroke && this.#currentStroke.points.length > 0) {
// Try gesture recognition before adding stroke // Try gesture recognition before adding stroke
let gestureResult: RecognizeResult | null = null; let gestureResult: RecognizeResult | null = null;
@ -799,6 +819,7 @@ export class FolkDrawfast extends FolkShape {
this.#canvas.addEventListener("pointerup", endDraw); this.#canvas.addEventListener("pointerup", endDraw);
this.#canvas.addEventListener("pointerleave", endDraw); this.#canvas.addEventListener("pointerleave", endDraw);
this.#canvas.addEventListener("pointercancel", endDraw);
// Generate button // Generate button
this.#generateBtn.addEventListener("click", (e) => { this.#generateBtn.addEventListener("click", (e) => {

View File

@ -344,6 +344,8 @@ export class FolkMakereal extends FolkShape {
#brushSize = 4; #brushSize = 4;
#tool = "pen"; #tool = "pen";
#isDrawing = false; #isDrawing = false;
#activePointerId: number | null = null;
#penIsActive = false;
#isGenerating = false; #isGenerating = false;
#framework = "html"; #framework = "html";
#lastHtml: string | null = null; #lastHtml: string | null = null;
@ -464,15 +466,23 @@ export class FolkMakereal extends FolkShape {
}); });
frameworkSelect.addEventListener("pointerdown", (e) => e.stopPropagation()); frameworkSelect.addEventListener("pointerdown", (e) => e.stopPropagation());
// Drawing events // Drawing events — pen takes priority over touch (palm rejection).
this.#canvas.addEventListener("pointerdown", (e) => { this.#canvas.addEventListener("pointerdown", (e) => {
if (this.#penIsActive && e.pointerType === "touch") {
e.preventDefault();
return;
}
if (this.#isDrawing) return;
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
this.#isDrawing = true; this.#isDrawing = true;
this.#activePointerId = e.pointerId;
if (e.pointerType === "pen") this.#penIsActive = true;
this.#canvas!.setPointerCapture(e.pointerId); this.#canvas!.setPointerCapture(e.pointerId);
const pos = this.#getCanvasPos(e); const pos = this.#getCanvasPos(e);
const pressure = e.pointerType === "pen" ? (e.pressure || 0.5) : 0.5;
this.#currentStroke = { this.#currentStroke = {
points: [{ ...pos, pressure: e.pressure || 0.5 }], points: [{ ...pos, pressure }],
color: this.#tool === "eraser" ? "#ffffff" : this.#color, color: this.#tool === "eraser" ? "#ffffff" : this.#color,
size: this.#tool === "eraser" ? this.#brushSize * 3 : this.#brushSize, size: this.#tool === "eraser" ? this.#brushSize * 3 : this.#brushSize,
tool: this.#tool, tool: this.#tool,
@ -481,15 +491,20 @@ export class FolkMakereal extends FolkShape {
this.#canvas.addEventListener("pointermove", (e) => { this.#canvas.addEventListener("pointermove", (e) => {
if (!this.#isDrawing || !this.#currentStroke) return; if (!this.#isDrawing || !this.#currentStroke) return;
if (e.pointerId !== this.#activePointerId) return;
e.stopPropagation(); e.stopPropagation();
const pos = this.#getCanvasPos(e); const pos = this.#getCanvasPos(e);
this.#currentStroke.points.push({ ...pos, pressure: e.pressure || 0.5 }); const pressure = e.pointerType === "pen" ? (e.pressure || 0.5) : 0.5;
this.#currentStroke.points.push({ ...pos, pressure });
this.#drawStroke(this.#currentStroke); this.#drawStroke(this.#currentStroke);
}); });
const endDraw = () => { const endDraw = (e: PointerEvent) => {
if (!this.#isDrawing) return; if (!this.#isDrawing) return;
if (e.pointerId !== this.#activePointerId) return;
this.#isDrawing = false; this.#isDrawing = false;
this.#activePointerId = null;
if (e.pointerType === "pen") this.#penIsActive = false;
if (this.#currentStroke && this.#currentStroke.points.length > 0) { if (this.#currentStroke && this.#currentStroke.points.length > 0) {
this.#strokes.push(this.#currentStroke); this.#strokes.push(this.#currentStroke);
} }
@ -498,6 +513,7 @@ export class FolkMakereal extends FolkShape {
this.#canvas.addEventListener("pointerup", endDraw); this.#canvas.addEventListener("pointerup", endDraw);
this.#canvas.addEventListener("pointerleave", endDraw); this.#canvas.addEventListener("pointerleave", endDraw);
this.#canvas.addEventListener("pointercancel", endDraw);
// Generate button // Generate button
this.#generateBtn.addEventListener("click", (e) => { this.#generateBtn.addEventListener("click", (e) => {

View File

@ -676,10 +676,28 @@ class FolkMapViewer extends HTMLElement {
const sheetHandle = this.shadow.getElementById("sheet-handle"); const sheetHandle = this.shadow.getElementById("sheet-handle");
if (sheet && sheetHandle) { if (sheet && sheetHandle) {
sheetHandle.addEventListener("click", () => sheet.classList.toggle("expanded")); sheetHandle.addEventListener("click", () => sheet.classList.toggle("expanded"));
// Pointer events: unified mouse/touch/pen, so pen users get drag-to-expand too
let startY = 0; let startY = 0;
let startX = 0;
let activePointer: number | null = null;
let sheetWasExpanded = false; let sheetWasExpanded = false;
sheetHandle.addEventListener("touchstart", (e: Event) => { const te = e as TouchEvent; startY = te.touches[0].clientY; sheetWasExpanded = sheet.classList.contains("expanded"); }, { passive: true }); sheetHandle.addEventListener("pointerdown", (e: PointerEvent) => {
sheetHandle.addEventListener("touchend", (e: Event) => { const te = e as TouchEvent; const dy = te.changedTouches[0].clientY - startY; if (sheetWasExpanded && dy > 40) sheet.classList.remove("expanded"); else if (!sheetWasExpanded && dy < -40) sheet.classList.add("expanded"); }, { passive: true }); activePointer = e.pointerId;
startY = e.clientY;
startX = e.clientX;
sheetWasExpanded = sheet.classList.contains("expanded");
sheetHandle.setPointerCapture?.(e.pointerId);
});
sheetHandle.addEventListener("pointerup", (e: PointerEvent) => {
if (activePointer !== e.pointerId) return;
activePointer = null;
const dy = e.clientY - startY;
const dx = Math.abs(e.clientX - startX);
if (dx > 50) return; // Horizontal swipe — ignore
if (sheetWasExpanded && dy > 40) sheet.classList.remove("expanded");
else if (!sheetWasExpanded && dy < -40) sheet.classList.add("expanded");
});
sheetHandle.addEventListener("pointercancel", () => { activePointer = null; });
} }
// Restricted controls — show toast // Restricted controls — show toast

View File

@ -425,7 +425,7 @@ routes.get("/", (c) => {
body: `<folk-map-viewer space="${space}"></folk-map-viewer>`, body: `<folk-map-viewer space="${space}"></folk-map-viewer>`,
scripts: `<link rel="preload" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.1.2/dist/maplibre-gl.js" as="script"> scripts: `<link rel="preload" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.1.2/dist/maplibre-gl.js" as="script">
<link rel="preload" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.1.2/dist/maplibre-gl.css" as="style"> <link rel="preload" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.1.2/dist/maplibre-gl.css" as="style">
<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=7"></script>`, <script type="module" src="/modules/rmaps/folk-map-viewer.js?v=8"></script>`,
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css?v=3">`, styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css?v=3">`,
})); }));
}); });
@ -444,7 +444,7 @@ routes.get("/:room", (c) => {
body: `<folk-map-viewer space="${space}" room="${room}"></folk-map-viewer>`, body: `<folk-map-viewer space="${space}" room="${room}"></folk-map-viewer>`,
scripts: `<link rel="preload" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.1.2/dist/maplibre-gl.js" as="script"> scripts: `<link rel="preload" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.1.2/dist/maplibre-gl.js" as="script">
<link rel="preload" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.1.2/dist/maplibre-gl.css" as="style"> <link rel="preload" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.1.2/dist/maplibre-gl.css" as="style">
<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=7"></script>`, <script type="module" src="/modules/rmaps/folk-map-viewer.js?v=8"></script>`,
})); }));
}); });

View File

@ -182,6 +182,7 @@ export class FolkNotesApp extends HTMLElement {
private _loading = false; private _loading = false;
private _uploadOpen = false; private _uploadOpen = false;
private _uploadStatus = ''; private _uploadStatus = '';
private _mobileView: 'vaults' | 'files' | 'preview' = 'vaults';
private _shadow: ShadowRoot; private _shadow: ShadowRoot;
constructor() { constructor() {
@ -233,6 +234,7 @@ export class FolkNotesApp extends HTMLElement {
this._selectedNotePath = null; this._selectedNotePath = null;
this._noteContent = ''; this._noteContent = '';
this._notes = []; this._notes = [];
this._mobileView = 'files';
this._render(); this._render();
try { try {
const base = this._getApiBase(); const base = this._getApiBase();
@ -249,6 +251,7 @@ export class FolkNotesApp extends HTMLElement {
if (!this._selectedVaultId) return; if (!this._selectedVaultId) return;
this._selectedNotePath = path; this._selectedNotePath = path;
this._noteContent = ''; this._noteContent = '';
this._mobileView = 'preview';
this._render(); this._render();
try { try {
const base = this._getApiBase(); const base = this._getApiBase();
@ -312,6 +315,7 @@ export class FolkNotesApp extends HTMLElement {
/* Layout */ /* Layout */
.layout { display: flex; flex: 1; overflow: hidden; } .layout { display: flex; flex: 1; overflow: hidden; }
.mobile-back { display: none; background: none; border: none; color: #14b8a6; cursor: pointer; font-size: 14px; padding: 4px 8px; margin-right: 6px; }
/* Left sidebar */ /* Left sidebar */
.sidebar { width: 250px; flex-shrink: 0; background: #111; border-right: 1px solid #222; display: flex; flex-direction: column; overflow: hidden; } .sidebar { width: 250px; flex-shrink: 0; background: #111; border-right: 1px solid #222; display: flex; flex-direction: column; overflow: hidden; }
@ -394,6 +398,34 @@ export class FolkNotesApp extends HTMLElement {
/* Loading spinner */ /* Loading spinner */
.spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid #333; border-top-color: #14b8a6; border-radius: 50%; animation: spin 0.7s linear infinite; } .spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid #333; border-top-color: #14b8a6; border-radius: 50%; animation: spin 0.7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
/* ── Mobile: drill-down layout (vaults → files → preview) ── */
@media (max-width: 767px) {
:host { font-size: 15px; }
.toolbar { padding: 10px 12px; gap: 8px; }
.toolbar-title { font-size: 13px; }
.btn { padding: 8px 12px; font-size: 14px; min-height: 40px; }
.search-input { font-size: 16px; min-height: 40px; }
.layout { position: relative; }
.sidebar, .file-tree, .preview {
position: absolute; inset: 0; width: 100%; height: 100%;
border-right: none;
}
.layout[data-view="vaults"] .sidebar { z-index: 3; }
.layout[data-view="files"] .file-tree { z-index: 3; }
.layout[data-view="preview"] .preview { z-index: 3; }
.layout[data-view="vaults"] .file-tree,
.layout[data-view="vaults"] .preview,
.layout[data-view="files"] .sidebar,
.layout[data-view="files"] .preview,
.layout[data-view="preview"] .sidebar,
.layout[data-view="preview"] .file-tree { display: none; }
.mobile-back { display: inline-flex; align-items: center; min-height: 40px; min-width: 40px; }
.vault-item, .note-item, .folder-header { padding: 12px 14px; min-height: 44px; }
.preview-body { padding: 18px 16px; }
.dialog { padding: 20px; width: 100%; max-width: 92vw; border-radius: 12px; }
.field input, .field select { font-size: 16px; padding: 10px; min-height: 40px; }
}
`; `;
} }
@ -404,7 +436,7 @@ export class FolkNotesApp extends HTMLElement {
<span class="toolbar-title">${ICON_SEARCH} ${escHtml(this._space || 'rNotes')}</span> <span class="toolbar-title">${ICON_SEARCH} ${escHtml(this._space || 'rNotes')}</span>
<button class="btn primary" id="btn-upload">${ICON_UPLOAD} Upload Vault</button> <button class="btn primary" id="btn-upload">${ICON_UPLOAD} Upload Vault</button>
</div> </div>
<div class="layout"> <div class="layout" data-view="${this._mobileView}">
${this._renderSidebar()} ${this._renderSidebar()}
${this._renderFileTree()} ${this._renderFileTree()}
${this._renderPreview(vault)} ${this._renderPreview(vault)}
@ -487,6 +519,7 @@ ${this._uploadOpen ? this._renderUploadDialog() : ''}
return ` return `
<div class="file-tree"> <div class="file-tree">
<div class="tree-search"> <div class="tree-search">
<button class="mobile-back" id="mb-back-vaults" aria-label="Back to vaults"> Vaults</button>
<input class="search-input" type="search" id="tree-search" placeholder="Search notes…" value="${escHtml(this._searchQuery)}"> <input class="search-input" type="search" id="tree-search" placeholder="Search notes…" value="${escHtml(this._searchQuery)}">
</div> </div>
<div class="tree-body"> <div class="tree-body">
@ -513,6 +546,7 @@ ${this._uploadOpen ? this._renderUploadDialog() : ''}
return ` return `
<div class="preview"> <div class="preview">
<div class="preview-header"> <div class="preview-header">
<button class="mobile-back" id="mb-back-files" aria-label="Back to file list"> Files</button>
<div class="preview-path">${escHtml(this._selectedNotePath)} ${modTime ? `· ${modTime}` : ''}</div> <div class="preview-path">${escHtml(this._selectedNotePath)} ${modTime ? `· ${modTime}` : ''}</div>
${tags ? `<div class="preview-tags">${tags}</div>` : ''} ${tags ? `<div class="preview-tags">${tags}</div>` : ''}
</div> </div>
@ -610,6 +644,14 @@ ${this._uploadOpen ? this._renderUploadDialog() : ''}
this._render(); this._render();
}); });
// Mobile drill-down back buttons
$('mb-back-vaults')?.addEventListener('click', () => {
this._mobileView = 'vaults'; this._render();
});
$('mb-back-files')?.addEventListener('click', () => {
this._mobileView = 'files'; this._render();
});
// Wikilinks in preview // Wikilinks in preview
this._shadow.querySelectorAll<HTMLAnchorElement>('a.wikilink').forEach(a => { this._shadow.querySelectorAll<HTMLAnchorElement>('a.wikilink').forEach(a => {
a.addEventListener('click', (e) => { a.addEventListener('click', (e) => {

View File

@ -160,7 +160,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-notes-app space="${space}"></folk-notes-app>`, body: `<folk-notes-app space="${space}"></folk-notes-app>`,
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js?v=10"></script>`, scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js?v=11"></script>`,
}), }),
); );
}); });

View File

@ -318,6 +318,23 @@ class FolkPhotoGallery extends HTMLElement {
this.render(); this.render();
} }
private currentAssetList(): Asset[] {
return this.selectedAlbum ? this.albumAssets : this.assets;
}
private navigateLightbox(dir: 1 | -1) {
if (!this.lightboxAsset) return;
const list = this.currentAssetList();
const idx = list.findIndex((a) => a.id === this.lightboxAsset!.id);
if (idx < 0) return;
const next = list[idx + dir];
if (next) {
this.lightboxAsset = next;
this._history.push("lightbox", { assetId: next.id });
this.render();
}
}
private goBack() { private goBack() {
const prev = this._history.back(); const prev = this._history.back();
if (!prev) return; if (!prev) return;
@ -527,6 +544,36 @@ class FolkPhotoGallery extends HTMLElement {
max-height: 80vh; max-height: 80vh;
object-fit: contain; object-fit: contain;
border-radius: 8px; border-radius: 8px;
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-user-drag: none;
transform-origin: center center;
transition: transform 0.2s ease;
will-change: transform;
}
.lightbox img.dragging { transition: none; }
.lightbox-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255,255,255,0.1);
border: none;
color: #fff;
font-size: 28px;
width: 48px;
height: 48px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.lightbox-nav:hover { background: rgba(255,255,255,0.2); }
.lightbox-nav.prev { left: 16px; }
.lightbox-nav.next { right: 16px; }
@media (max-width: 640px) {
.lightbox-nav { display: none; } /* mobile uses swipe */
} }
.lightbox-close { .lightbox-close {
position: absolute; position: absolute;
@ -780,6 +827,11 @@ class FolkPhotoGallery extends HTMLElement {
const demoMeta = this.isDemo() ? this.getDemoAssetMeta(asset.id) : null; const demoMeta = this.isDemo() ? this.getDemoAssetMeta(asset.id) : null;
const displayName = asset.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "); const displayName = asset.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " ");
const list = this.currentAssetList();
const idx = list.findIndex((a) => a.id === asset.id);
const hasPrev = idx > 0;
const hasNext = idx >= 0 && idx < list.length - 1;
return ` return `
<div class="lightbox" data-lightbox> <div class="lightbox" data-lightbox>
<button class="lightbox-close" data-close-lightbox></button> <button class="lightbox-close" data-close-lightbox></button>
@ -788,9 +840,11 @@ class FolkPhotoGallery extends HTMLElement {
<button class="lightbox-delete" data-delete-asset="${asset.id}">Delete</button> <button class="lightbox-delete" data-delete-asset="${asset.id}">Delete</button>
</div> </div>
` : ''} ` : ''}
${hasPrev ? `<button class="lightbox-nav prev" data-lightbox-prev aria-label="Previous photo"></button>` : ''}
${hasNext ? `<button class="lightbox-nav next" data-lightbox-next aria-label="Next photo"></button>` : ''}
${demoMeta ${demoMeta
? `<div class="demo-lightbox-img" style="background:${demoMeta.color}">${this.esc(displayName)}</div>` ? `<div class="demo-lightbox-img" style="background:${demoMeta.color}">${this.esc(displayName)}</div>`
: `<img src="${this.originalUrl(asset.id)}" alt="${this.esc(asset.originalFileName)}">`} : `<img src="${this.originalUrl(asset.id)}" alt="${this.esc(asset.originalFileName)}" data-lightbox-img>`}
<div class="lightbox-info"> <div class="lightbox-info">
${asset.originalFileName}${demoMeta ? ` &middot; ${demoMeta.width}x${demoMeta.height}` : ""} ${asset.originalFileName}${demoMeta ? ` &middot; ${demoMeta.width}x${demoMeta.height}` : ""}
${location ? ` &middot; ${this.esc(location)}` : ""} ${location ? ` &middot; ${this.esc(location)}` : ""}
@ -801,6 +855,105 @@ class FolkPhotoGallery extends HTMLElement {
`; `;
} }
private attachLightboxGestures() {
const img = this.shadow.querySelector<HTMLImageElement>("[data-lightbox-img]");
if (!img) return;
// Gesture state
const pointers = new Map<number, { x: number; y: number; startX: number; startY: number }>();
let scale = 1;
let translateX = 0;
let translateY = 0;
let pinchStartDist = 0;
let pinchStartScale = 1;
let singleStartX = 0;
let singleStartY = 0;
let translateStartX = 0;
let translateStartY = 0;
const SWIPE_THRESHOLD = 60;
const SWIPE_MAX_VERTICAL = 80;
const apply = () => {
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
};
const reset = () => {
scale = 1; translateX = 0; translateY = 0; apply();
};
img.addEventListener("pointerdown", (e) => {
img.setPointerCapture(e.pointerId);
img.classList.add("dragging");
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY, startX: e.clientX, startY: e.clientY });
if (pointers.size === 2) {
const [a, b] = [...pointers.values()];
pinchStartDist = Math.hypot(a.x - b.x, a.y - b.y);
pinchStartScale = scale;
} else if (pointers.size === 1) {
singleStartX = e.clientX;
singleStartY = e.clientY;
translateStartX = translateX;
translateStartY = translateY;
}
});
img.addEventListener("pointermove", (e) => {
const p = pointers.get(e.pointerId);
if (!p) return;
p.x = e.clientX;
p.y = e.clientY;
if (pointers.size === 2) {
const [a, b] = [...pointers.values()];
const dist = Math.hypot(a.x - b.x, a.y - b.y);
if (pinchStartDist > 0) {
scale = Math.max(1, Math.min(6, pinchStartScale * (dist / pinchStartDist)));
apply();
}
} else if (pointers.size === 1 && scale > 1) {
// Pan while zoomed in
translateX = translateStartX + (e.clientX - singleStartX);
translateY = translateStartY + (e.clientY - singleStartY);
apply();
}
});
const up = (e: PointerEvent) => {
const p = pointers.get(e.pointerId);
if (!p) return;
const dx = e.clientX - p.startX;
const dy = e.clientY - p.startY;
pointers.delete(e.pointerId);
if (pointers.size === 0) {
img.classList.remove("dragging");
// Swipe gesture — only when not zoomed, single pointer, horizontal dominant
if (scale === 1 && Math.abs(dx) > SWIPE_THRESHOLD && Math.abs(dy) < SWIPE_MAX_VERTICAL) {
this.navigateLightbox(dx < 0 ? 1 : -1);
} else if (scale < 1.05) {
reset();
}
}
};
img.addEventListener("pointerup", up);
img.addEventListener("pointercancel", up);
// Double-tap / double-click to toggle zoom
let lastTap = 0;
img.addEventListener("click", (e) => {
const now = Date.now();
if (now - lastTap < 300) {
if (scale === 1) {
scale = 2.5;
const rect = img.getBoundingClientRect();
translateX = (rect.width / 2 - (e.clientX - rect.left)) * (scale - 1);
translateY = (rect.height / 2 - (e.clientY - rect.top)) * (scale - 1);
} else {
reset();
}
apply();
}
lastTap = now;
});
}
private attachListeners() { private attachListeners() {
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour()); this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
this.shadow.querySelector("#btn-upload")?.addEventListener("click", () => this.handleUpload()); this.shadow.querySelector("#btn-upload")?.addEventListener("click", () => this.handleUpload());
@ -853,6 +1006,19 @@ class FolkPhotoGallery extends HTMLElement {
this.shadow.querySelector("[data-lightbox]")?.addEventListener("click", (e) => { this.shadow.querySelector("[data-lightbox]")?.addEventListener("click", (e) => {
if ((e.target as HTMLElement).matches("[data-lightbox]")) this.goBack(); if ((e.target as HTMLElement).matches("[data-lightbox]")) this.goBack();
}); });
// Lightbox prev/next buttons
this.shadow.querySelector("[data-lightbox-prev]")?.addEventListener("click", (e) => {
e.stopPropagation();
this.navigateLightbox(-1);
});
this.shadow.querySelector("[data-lightbox-next]")?.addEventListener("click", (e) => {
e.stopPropagation();
this.navigateLightbox(1);
});
// Gestures on the lightbox image (pinch-zoom + swipe)
if (this.view === "lightbox" && this.lightboxAsset) {
this.attachLightboxGestures();
}
} }
private esc(s: string): string { private esc(s: string): string {

View File

@ -475,7 +475,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-photo-gallery space="${spaceSlug}"></folk-photo-gallery>`, body: `<folk-photo-gallery space="${spaceSlug}"></folk-photo-gallery>`,
scripts: `<script type="module" src="/modules/rphotos/folk-photo-gallery.js?v=3"></script>`, scripts: `<script type="module" src="/modules/rphotos/folk-photo-gallery.js?v=4"></script>`,
styles: `<link rel="stylesheet" href="/modules/rphotos/photos.css">`, styles: `<link rel="stylesheet" href="/modules/rphotos/photos.css">`,
})); }));
}); });

View File

@ -131,7 +131,23 @@ routes.get("/app", (c) => {
<title>rSheets ${space}</title> <title>rSheets ${space}</title>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #sheet { width: 100%; height: 100%; background: #0f172a; color: #e2e8f0; } html, body { width: 100%; height: 100%; background: #0f172a; color: #e2e8f0;
overscroll-behavior: none; -webkit-tap-highlight-color: transparent; }
#sheet { width: 100%; height: 100%; overflow: auto; -webkit-overflow-scrolling: touch; }
#sheet::-webkit-scrollbar { width: 10px; height: 10px; }
#sheet::-webkit-scrollbar-thumb { background: #334155; border-radius: 5px; }
#sheet::-webkit-scrollbar-track { background: #0f172a; }
/* Sticky row/col headers so they stay visible while scrolling */
#sheet table { border-collapse: separate; border-spacing: 0; min-width: max-content; }
#sheet thead th { position: sticky; top: 0; z-index: 2; }
#sheet tbody td:first-child,
#sheet tbody tr > td:first-of-type { position: sticky; left: 0; z-index: 1; }
#sheet thead th:first-child { z-index: 3; left: 0; }
/* Touch-friendly cell sizing on mobile — bigger hit targets, 16px font avoids iOS zoom */
@media (pointer: coarse) {
#sheet table { font-size: 16px !important; }
#sheet th, #sheet td { min-width: 72px; min-height: 36px; padding: 8px !important; }
}
#loading { display: flex; align-items: center; justify-content: center; height: 100%; font-family: system-ui; color: #94a3b8; } #loading { display: flex; align-items: center; justify-content: center; height: 100%; font-family: system-ui; color: #94a3b8; }
#loading.hidden { display: none; } #loading.hidden { display: none; }
</style> </style>

View File

@ -691,3 +691,95 @@ body.rspace-headers-minimized .rapp-minimize-btn:hover {
.rapp-nav > * + * { margin-left: 8px; } .rapp-nav > * + * { margin-left: 8px; }
.rapp-nav__actions > * + * { margin-left: 8px; } .rapp-nav__actions > * + * { margin-left: 8px; }
} }
/* Mobile / touch baseline (applies to all modules)
iOS Safari auto-zooms on focus for <input>/<select>/<textarea> if
computed font-size < 16px. Force 16px on pointer:coarse devices.
Also enforce a 44×44 minimum hit area per Apple HIG on touch screens. */
@media (pointer: coarse) {
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]),
select,
textarea {
font-size: max(16px, 1rem);
}
button,
[role="button"],
a.rapp-nav__btn,
a.rapp-nav__back,
.btn,
.rapp-nav__btn,
.rapp-nav__back,
summary {
min-height: 44px;
min-width: 44px;
}
/* Inline/icon-only buttons inside dense toolbars may opt out with
class="btn--dense" still targetable but won't bloat toolbars. */
.btn--dense,
[role="button"].btn--dense {
min-height: 36px;
min-width: 36px;
}
/* Kill 300ms tap-delay and blue highlight across the board */
a, button, [role="button"], input[type="submit"], input[type="button"],
.rapp-nav__btn, .rapp-nav__back, summary, label {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
}
/* ── Utility classes for modules ── */
/* Horizontal scroller with visible thumb on touch (for rsheets, data tables) */
.rapp-hscroll {
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.rapp-hscroll::-webkit-scrollbar {
height: 8px;
}
.rapp-hscroll::-webkit-scrollbar-thumb {
background: var(--rs-border-strong, rgba(128,128,128,0.5));
border-radius: 4px;
}
/* Bottom-sheet drawer helper (for rmaps, rnotes panel collapse) */
.rapp-drawer {
position: fixed;
left: 0; right: 0;
background: var(--rs-bg-page);
border-top: 1px solid var(--rs-border);
box-shadow: 0 -4px 16px rgba(0,0,0,0.18);
transition: transform 0.25s ease;
z-index: 1000;
padding-bottom: env(safe-area-inset-bottom);
}
.rapp-drawer--bottom {
bottom: 0;
max-height: 70dvh;
overflow-y: auto;
border-radius: 16px 16px 0 0;
}
.rapp-drawer--collapsed {
transform: translateY(calc(100% - 40px));
}
.rapp-drawer__handle {
display: flex;
justify-content: center;
padding: 8px 0 4px;
cursor: grab;
touch-action: none;
}
.rapp-drawer__handle::before {
content: "";
width: 40px;
height: 4px;
border-radius: 2px;
background: var(--rs-border-strong, rgba(128,128,128,0.6));
}