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:
parent
eee89f9f32
commit
df7d0b021f
|
|
@ -584,6 +584,8 @@ export class FolkDrawfast extends FolkShape {
|
|||
#brushSize = 4;
|
||||
#tool = "pen"; // pen | eraser
|
||||
#isDrawing = false;
|
||||
#activePointerId: number | null = null;
|
||||
#penIsActive = false;
|
||||
#isGenerating = false;
|
||||
#autoGenerate = false;
|
||||
#autoDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
|
@ -739,15 +741,27 @@ export class FolkDrawfast extends FolkShape {
|
|||
});
|
||||
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) => {
|
||||
// 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.preventDefault();
|
||||
this.#isDrawing = true;
|
||||
this.#activePointerId = e.pointerId;
|
||||
if (e.pointerType === "pen") this.#penIsActive = true;
|
||||
this.#canvas!.setPointerCapture(e.pointerId);
|
||||
const pos = this.#getCanvasPos(e);
|
||||
const pressure = e.pointerType === "pen" ? (e.pressure || 0.5) : 0.5;
|
||||
this.#currentStroke = {
|
||||
points: [{ ...pos, pressure: e.pressure || 0.5 }],
|
||||
points: [{ ...pos, pressure }],
|
||||
color: this.#tool === "eraser" ? "#ffffff" : this.#color,
|
||||
size: this.#tool === "eraser" ? this.#brushSize * 3 : this.#brushSize,
|
||||
tool: this.#tool,
|
||||
|
|
@ -756,15 +770,21 @@ export class FolkDrawfast extends FolkShape {
|
|||
|
||||
this.#canvas.addEventListener("pointermove", (e) => {
|
||||
if (!this.#isDrawing || !this.#currentStroke) return;
|
||||
if (e.pointerId !== this.#activePointerId) return;
|
||||
e.stopPropagation();
|
||||
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);
|
||||
});
|
||||
|
||||
const endDraw = (e: PointerEvent) => {
|
||||
if (!this.#isDrawing) return;
|
||||
if (e.pointerId !== this.#activePointerId) return;
|
||||
this.#isDrawing = false;
|
||||
this.#activePointerId = null;
|
||||
if (e.pointerType === "pen") this.#penIsActive = false;
|
||||
if (this.#currentStroke && this.#currentStroke.points.length > 0) {
|
||||
// Try gesture recognition before adding stroke
|
||||
let gestureResult: RecognizeResult | null = null;
|
||||
|
|
@ -799,6 +819,7 @@ export class FolkDrawfast extends FolkShape {
|
|||
|
||||
this.#canvas.addEventListener("pointerup", endDraw);
|
||||
this.#canvas.addEventListener("pointerleave", endDraw);
|
||||
this.#canvas.addEventListener("pointercancel", endDraw);
|
||||
|
||||
// Generate button
|
||||
this.#generateBtn.addEventListener("click", (e) => {
|
||||
|
|
|
|||
|
|
@ -344,6 +344,8 @@ export class FolkMakereal extends FolkShape {
|
|||
#brushSize = 4;
|
||||
#tool = "pen";
|
||||
#isDrawing = false;
|
||||
#activePointerId: number | null = null;
|
||||
#penIsActive = false;
|
||||
#isGenerating = false;
|
||||
#framework = "html";
|
||||
#lastHtml: string | null = null;
|
||||
|
|
@ -464,15 +466,23 @@ export class FolkMakereal extends FolkShape {
|
|||
});
|
||||
frameworkSelect.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||||
|
||||
// Drawing events
|
||||
// Drawing events — pen takes priority over touch (palm rejection).
|
||||
this.#canvas.addEventListener("pointerdown", (e) => {
|
||||
if (this.#penIsActive && e.pointerType === "touch") {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (this.#isDrawing) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.#isDrawing = true;
|
||||
this.#activePointerId = e.pointerId;
|
||||
if (e.pointerType === "pen") this.#penIsActive = true;
|
||||
this.#canvas!.setPointerCapture(e.pointerId);
|
||||
const pos = this.#getCanvasPos(e);
|
||||
const pressure = e.pointerType === "pen" ? (e.pressure || 0.5) : 0.5;
|
||||
this.#currentStroke = {
|
||||
points: [{ ...pos, pressure: e.pressure || 0.5 }],
|
||||
points: [{ ...pos, pressure }],
|
||||
color: this.#tool === "eraser" ? "#ffffff" : this.#color,
|
||||
size: this.#tool === "eraser" ? this.#brushSize * 3 : this.#brushSize,
|
||||
tool: this.#tool,
|
||||
|
|
@ -481,15 +491,20 @@ export class FolkMakereal extends FolkShape {
|
|||
|
||||
this.#canvas.addEventListener("pointermove", (e) => {
|
||||
if (!this.#isDrawing || !this.#currentStroke) return;
|
||||
if (e.pointerId !== this.#activePointerId) return;
|
||||
e.stopPropagation();
|
||||
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);
|
||||
});
|
||||
|
||||
const endDraw = () => {
|
||||
const endDraw = (e: PointerEvent) => {
|
||||
if (!this.#isDrawing) return;
|
||||
if (e.pointerId !== this.#activePointerId) return;
|
||||
this.#isDrawing = false;
|
||||
this.#activePointerId = null;
|
||||
if (e.pointerType === "pen") this.#penIsActive = false;
|
||||
if (this.#currentStroke && this.#currentStroke.points.length > 0) {
|
||||
this.#strokes.push(this.#currentStroke);
|
||||
}
|
||||
|
|
@ -498,6 +513,7 @@ export class FolkMakereal extends FolkShape {
|
|||
|
||||
this.#canvas.addEventListener("pointerup", endDraw);
|
||||
this.#canvas.addEventListener("pointerleave", endDraw);
|
||||
this.#canvas.addEventListener("pointercancel", endDraw);
|
||||
|
||||
// Generate button
|
||||
this.#generateBtn.addEventListener("click", (e) => {
|
||||
|
|
|
|||
|
|
@ -676,10 +676,28 @@ class FolkMapViewer extends HTMLElement {
|
|||
const sheetHandle = this.shadow.getElementById("sheet-handle");
|
||||
if (sheet && sheetHandle) {
|
||||
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 startX = 0;
|
||||
let activePointer: number | null = null;
|
||||
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("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 });
|
||||
sheetHandle.addEventListener("pointerdown", (e: PointerEvent) => {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -425,7 +425,7 @@ routes.get("/", (c) => {
|
|||
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">
|
||||
<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">`,
|
||||
}));
|
||||
});
|
||||
|
|
@ -444,7 +444,7 @@ routes.get("/:room", (c) => {
|
|||
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">
|
||||
<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>`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ export class FolkNotesApp extends HTMLElement {
|
|||
private _loading = false;
|
||||
private _uploadOpen = false;
|
||||
private _uploadStatus = '';
|
||||
private _mobileView: 'vaults' | 'files' | 'preview' = 'vaults';
|
||||
private _shadow: ShadowRoot;
|
||||
|
||||
constructor() {
|
||||
|
|
@ -233,6 +234,7 @@ export class FolkNotesApp extends HTMLElement {
|
|||
this._selectedNotePath = null;
|
||||
this._noteContent = '';
|
||||
this._notes = [];
|
||||
this._mobileView = 'files';
|
||||
this._render();
|
||||
try {
|
||||
const base = this._getApiBase();
|
||||
|
|
@ -249,6 +251,7 @@ export class FolkNotesApp extends HTMLElement {
|
|||
if (!this._selectedVaultId) return;
|
||||
this._selectedNotePath = path;
|
||||
this._noteContent = '';
|
||||
this._mobileView = 'preview';
|
||||
this._render();
|
||||
try {
|
||||
const base = this._getApiBase();
|
||||
|
|
@ -312,6 +315,7 @@ export class FolkNotesApp extends HTMLElement {
|
|||
|
||||
/* Layout */
|
||||
.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 */
|
||||
.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 */
|
||||
.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); } }
|
||||
|
||||
/* ── 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>
|
||||
<button class="btn primary" id="btn-upload">${ICON_UPLOAD} Upload Vault</button>
|
||||
</div>
|
||||
<div class="layout">
|
||||
<div class="layout" data-view="${this._mobileView}">
|
||||
${this._renderSidebar()}
|
||||
${this._renderFileTree()}
|
||||
${this._renderPreview(vault)}
|
||||
|
|
@ -487,6 +519,7 @@ ${this._uploadOpen ? this._renderUploadDialog() : ''}
|
|||
return `
|
||||
<div class="file-tree">
|
||||
<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)}">
|
||||
</div>
|
||||
<div class="tree-body">
|
||||
|
|
@ -513,6 +546,7 @@ ${this._uploadOpen ? this._renderUploadDialog() : ''}
|
|||
return `
|
||||
<div class="preview">
|
||||
<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>
|
||||
${tags ? `<div class="preview-tags">${tags}</div>` : ''}
|
||||
</div>
|
||||
|
|
@ -610,6 +644,14 @@ ${this._uploadOpen ? this._renderUploadDialog() : ''}
|
|||
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
|
||||
this._shadow.querySelectorAll<HTMLAnchorElement>('a.wikilink').forEach(a => {
|
||||
a.addEventListener('click', (e) => {
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ routes.get("/", (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
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>`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -318,6 +318,23 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
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() {
|
||||
const prev = this._history.back();
|
||||
if (!prev) return;
|
||||
|
|
@ -527,6 +544,36 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
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 {
|
||||
position: absolute;
|
||||
|
|
@ -780,6 +827,11 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
const demoMeta = this.isDemo() ? this.getDemoAssetMeta(asset.id) : null;
|
||||
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 `
|
||||
<div class="lightbox" data-lightbox>
|
||||
<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>
|
||||
</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
|
||||
? `<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">
|
||||
${asset.originalFileName}${demoMeta ? ` · ${demoMeta.width}x${demoMeta.height}` : ""}
|
||||
${location ? ` · ${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() {
|
||||
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
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) => {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -475,7 +475,7 @@ routes.get("/", (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
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">`,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -131,7 +131,23 @@ routes.get("/app", (c) => {
|
|||
<title>rSheets — ${space}</title>
|
||||
<style>
|
||||
* { 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.hidden { display: none; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -691,3 +691,95 @@ body.rspace-headers-minimized .rapp-minimize-btn:hover {
|
|||
.rapp-nav > * + * { 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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue