experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(grid): add freeform layout mode with drag/resize editing

Add 'freeform' as a 4th viewMode to peek-grid. Items are absolutely
positioned with saved bounds or auto-placed in a grid pattern. Edit mode
enables pointer-based drag (move) and resize (all 8 edges/corners) with
snap-to-grid. Toolbar gets a freeform view button and edit toggle.

+379 -6
+61 -2
app/components/peek-grid-toolbar.js
··· 6 6 * 7 7 * @element peek-grid-toolbar 8 8 * 9 - * @prop {string} viewMode - 'columns' | 'list' | 'masonry', default 'columns' 9 + * @prop {string} viewMode - 'columns' | 'list' | 'masonry' | 'freeform', default 'columns' 10 + * @prop {boolean} freeformEditing - Whether freeform edit mode is active 10 11 * @prop {string} sortBy - Current sort key 11 12 * @prop {string} sortDirection - 'asc' | 'desc' 12 13 * @prop {Array} sortOptions - [{value, label}] 13 14 * 14 15 * @fires view-mode-change - When view mode changes. Detail: { mode } 15 16 * @fires sort-change - When sort changes. Detail: { sortBy, sortDirection } 17 + * @fires freeform-editing-change - When freeform edit toggle changes. Detail: { editing } 16 18 */ 17 19 18 20 import { html, css } from 'lit'; ··· 23 25 viewMode: { type: String, attribute: 'view-mode', reflect: true }, 24 26 sortBy: { type: String, attribute: 'sort-by' }, 25 27 sortDirection: { type: String, attribute: 'sort-direction' }, 26 - sortOptions: { type: Array } 28 + sortOptions: { type: Array }, 29 + freeformEditing: { type: Boolean, attribute: 'freeform-editing', reflect: true } 27 30 }; 28 31 29 32 static styles = [ ··· 143 146 color: #fff; 144 147 border-color: var(--theme-accent, #007aff); 145 148 } 149 + 150 + .edit-toggle { 151 + display: inline-flex; 152 + align-items: center; 153 + justify-content: center; 154 + width: var(--peek-btn-height-sm, 28px); 155 + height: var(--peek-btn-height-sm, 28px); 156 + padding: 0; 157 + margin-left: 4px; 158 + border: 1px solid var(--theme-border, #e0e0e0); 159 + border-radius: var(--peek-radius-sm); 160 + background: var(--theme-bg-secondary, #fff); 161 + color: var(--theme-text-muted, #999); 162 + cursor: pointer; 163 + transition: background var(--peek-transition-fast), color var(--peek-transition-fast); 164 + } 165 + 166 + .edit-toggle:hover { 167 + background: var(--theme-bg-tertiary, #f5f5f5); 168 + color: var(--theme-text, #333); 169 + } 170 + 171 + .edit-toggle.active { 172 + background: var(--theme-accent, #007aff); 173 + color: #fff; 174 + border-color: var(--theme-accent, #007aff); 175 + } 146 176 ` 147 177 ]; 148 178 ··· 152 182 this.sortBy = ''; 153 183 this.sortDirection = 'asc'; 154 184 this.sortOptions = []; 185 + this.freeformEditing = false; 155 186 } 156 187 157 188 _handleSortChange(e) { ··· 167 198 _handleViewMode(mode) { 168 199 this.viewMode = mode; 169 200 this.emit('view-mode-change', { mode }); 201 + } 202 + 203 + _handleEditToggle() { 204 + this.freeformEditing = !this.freeformEditing; 205 + this.emit('freeform-editing-change', { editing: this.freeformEditing }); 170 206 } 171 207 172 208 render() { ··· 234 270 <rect x="8" y="7" width="5" height="6" rx="1" stroke="currentColor" stroke-width="1.5"/> 235 271 </svg> 236 272 </button> 273 + <button 274 + class="view-btn ${this.viewMode === 'freeform' ? 'active' : ''}" 275 + title="Freeform view" 276 + @click=${() => this._handleViewMode('freeform')} 277 + > 278 + <svg width="14" height="14" viewBox="0 0 14 14" fill="none"> 279 + <rect x="1" y="1" width="4" height="3" rx="0.5" stroke="currentColor" stroke-width="1.5"/> 280 + <rect x="7" y="2" width="6" height="4" rx="0.5" stroke="currentColor" stroke-width="1.5"/> 281 + <rect x="2" y="6" width="5" height="4" rx="0.5" stroke="currentColor" stroke-width="1.5"/> 282 + <rect x="9" y="8" width="4" height="5" rx="0.5" stroke="currentColor" stroke-width="1.5"/> 283 + </svg> 284 + </button> 285 + ${this.viewMode === 'freeform' ? html` 286 + <button 287 + class="edit-toggle ${this.freeformEditing ? 'active' : ''}" 288 + title="${this.freeformEditing ? 'Exit edit mode' : 'Edit layout'}" 289 + @click=${this._handleEditToggle} 290 + > 291 + <svg width="14" height="14" viewBox="0 0 14 14" fill="none"> 292 + <path d="M10.5 1.5L12.5 3.5L4.5 11.5L1.5 12.5L2.5 9.5L10.5 1.5Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 293 + </svg> 294 + </button> 295 + ` : ''} 237 296 </div> 238 297 </div> 239 298 `;
+318 -4
app/components/peek-grid.js
··· 12 12 * @prop {string} align - Item alignment: 'start' | 'center' | 'end' | 'stretch' 13 13 * @prop {boolean} dense - Enable dense packing algorithm 14 14 * @prop {boolean} overlay - Optimize spacing for overlay contexts 15 - * @prop {string} viewMode - Layout mode: 'columns' | 'list' | 'masonry' 15 + * @prop {string} viewMode - Layout mode: 'columns' | 'list' | 'masonry' | 'freeform' 16 + * @prop {Object} freeformLayout - Map of item id → {x, y, w, h} bounds 17 + * @prop {boolean} freeformEditing - Toggle edit mode for freeform layout 18 + * @prop {number} freeformSnap - Snap grid in px (default 20, 0 = off) 16 19 * 17 20 * @slot - Default slot for grid items 18 21 * ··· 41 44 align: { type: String, reflect: true }, 42 45 dense: { type: Boolean }, 43 46 overlay: { type: Boolean, reflect: true }, 44 - viewMode: { type: String, attribute: 'view-mode', reflect: true } 47 + viewMode: { type: String, attribute: 'view-mode', reflect: true }, 48 + freeformLayout: { type: Object, attribute: false }, 49 + freeformEditing: { type: Boolean, attribute: 'freeform-editing', reflect: true }, 50 + freeformSnap: { type: Number, attribute: 'freeform-snap' } 45 51 }; 46 52 47 53 static styles = [ ··· 93 99 break-inside: avoid; 94 100 margin-bottom: var(--_grid-gap); 95 101 } 102 + 103 + /* Freeform mode — container relative, items absolute */ 104 + :host([view-mode="freeform"]) .grid { 105 + display: block; 106 + position: relative; 107 + min-height: 400px; 108 + } 109 + 110 + :host([view-mode="freeform"]) ::slotted(*) { 111 + position: absolute; 112 + box-sizing: border-box; 113 + } 114 + 115 + /* Edit mode — visual affordance */ 116 + :host([view-mode="freeform"][freeform-editing]) ::slotted(*) { 117 + cursor: move; 118 + outline: 1px dashed var(--peek-border, #ccc); 119 + outline-offset: 2px; 120 + user-select: none; 121 + } 122 + 123 + :host([view-mode="freeform"][freeform-editing]) ::slotted(*:hover) { 124 + outline-color: var(--peek-accent, #007aff); 125 + } 96 126 ` 97 127 ]; 98 128 ··· 105 135 this.dense = false; 106 136 this.overlay = false; 107 137 this.viewMode = 'columns'; 138 + this.freeformLayout = {}; 139 + this.freeformEditing = false; 140 + this.freeformSnap = 20; 108 141 this._resizeObserver = null; 142 + this._dragState = null; 143 + this._boundPointerMove = this._onPointerMove.bind(this); 144 + this._boundPointerUp = this._onPointerUp.bind(this); 109 145 } 110 146 111 147 connectedCallback() { 112 148 super.connectedCallback(); 113 149 this._resizeObserver = new ResizeObserver(() => { 114 - if (this.viewMode === 'masonry') { 150 + if (this.viewMode === 'masonry' || this.viewMode === 'freeform') { 115 151 this.requestUpdate(); 116 152 } 117 153 }); ··· 124 160 this._resizeObserver.disconnect(); 125 161 this._resizeObserver = null; 126 162 } 163 + this._cleanupFreeformListeners(); 164 + } 165 + 166 + updated(changedProps) { 167 + super.updated?.(changedProps); 168 + 169 + const wasFreeform = changedProps.has('viewMode') && changedProps.get('viewMode') === 'freeform'; 170 + const isFreeform = this.viewMode === 'freeform'; 171 + 172 + // Clean up when leaving freeform 173 + if (wasFreeform && !isFreeform) { 174 + this._clearFreeformStyles(); 175 + this._cleanupFreeformListeners(); 176 + return; 177 + } 178 + 179 + if (isFreeform) { 180 + this._applyFreeformPositions(); 181 + if (this.freeformEditing) { 182 + this._attachEditListeners(); 183 + } else { 184 + this._cleanupFreeformListeners(); 185 + } 186 + } 187 + } 188 + 189 + // --- Freeform: positioning --- 190 + 191 + _getSlottedChildren() { 192 + const slot = this.shadowRoot?.querySelector('slot'); 193 + return slot ? slot.assignedElements() : []; 194 + } 195 + 196 + _getChildId(el) { 197 + return el.id || el.dataset?.id || null; 198 + } 199 + 200 + _snap(value) { 201 + if (!this.freeformSnap) return value; 202 + return Math.round(value / this.freeformSnap) * this.freeformSnap; 203 + } 204 + 205 + _applyFreeformPositions() { 206 + const children = this._getSlottedChildren(); 207 + const containerWidth = this.clientWidth || 600; 208 + const gap = this.gap; 209 + const itemW = Math.min(300, Math.max(200, Math.floor(containerWidth / 3) - gap)); 210 + const itemH = 200; 211 + const cols = Math.max(1, Math.floor(containerWidth / (itemW + gap))); 212 + let autoIndex = 0; 213 + let maxBottom = 400; 214 + 215 + for (const child of children) { 216 + const id = this._getChildId(child); 217 + let bounds = id && this.freeformLayout?.[id]; 218 + 219 + if (!bounds) { 220 + // Auto-place in grid pattern 221 + const col = autoIndex % cols; 222 + const row = Math.floor(autoIndex / cols); 223 + bounds = { 224 + x: col * (itemW + gap), 225 + y: row * (itemH + gap), 226 + w: itemW, 227 + h: itemH 228 + }; 229 + autoIndex++; 230 + } 231 + 232 + child.style.position = 'absolute'; 233 + child.style.left = `${bounds.x}px`; 234 + child.style.top = `${bounds.y}px`; 235 + child.style.width = `${bounds.w}px`; 236 + child.style.height = `${bounds.h}px`; 237 + child.style.boxSizing = 'border-box'; 238 + 239 + maxBottom = Math.max(maxBottom, bounds.y + bounds.h + gap); 240 + } 241 + 242 + // Set container min-height 243 + const grid = this.shadowRoot?.querySelector('.grid'); 244 + if (grid) grid.style.minHeight = `${maxBottom}px`; 245 + } 246 + 247 + _clearFreeformStyles() { 248 + const children = this._getSlottedChildren(); 249 + for (const child of children) { 250 + child.style.position = ''; 251 + child.style.left = ''; 252 + child.style.top = ''; 253 + child.style.width = ''; 254 + child.style.height = ''; 255 + child.style.boxSizing = ''; 256 + child.style.cursor = ''; 257 + } 258 + const grid = this.shadowRoot?.querySelector('.grid'); 259 + if (grid) grid.style.minHeight = ''; 260 + } 261 + 262 + // --- Freeform: edit mode listeners --- 263 + 264 + _attachEditListeners() { 265 + const children = this._getSlottedChildren(); 266 + for (const child of children) { 267 + if (!child._peekFreeformDown) { 268 + child._peekFreeformDown = (e) => this._onChildPointerDown(child, e); 269 + child._peekFreeformMove = (e) => this._onChildHoverMove(child, e); 270 + child.addEventListener('pointerdown', child._peekFreeformDown); 271 + child.addEventListener('pointermove', child._peekFreeformMove); 272 + } 273 + } 274 + } 275 + 276 + _cleanupFreeformListeners() { 277 + const children = this._getSlottedChildren(); 278 + for (const child of children) { 279 + if (child._peekFreeformDown) { 280 + child.removeEventListener('pointerdown', child._peekFreeformDown); 281 + child.removeEventListener('pointermove', child._peekFreeformMove); 282 + delete child._peekFreeformDown; 283 + delete child._peekFreeformMove; 284 + child.style.cursor = ''; 285 + } 286 + } 287 + } 288 + 289 + // --- Freeform: edge detection --- 290 + 291 + _detectEdge(el, e) { 292 + const rect = el.getBoundingClientRect(); 293 + const t = 8; // threshold px 294 + const x = e.clientX - rect.left; 295 + const y = e.clientY - rect.top; 296 + const w = rect.width; 297 + const h = rect.height; 298 + 299 + const nearLeft = x < t; 300 + const nearRight = x > w - t; 301 + const nearTop = y < t; 302 + const nearBottom = y > h - t; 303 + 304 + if (nearTop && nearLeft) return 'nw'; 305 + if (nearTop && nearRight) return 'ne'; 306 + if (nearBottom && nearLeft) return 'sw'; 307 + if (nearBottom && nearRight) return 'se'; 308 + if (nearLeft) return 'w'; 309 + if (nearRight) return 'e'; 310 + if (nearTop) return 'n'; 311 + if (nearBottom) return 's'; 312 + return 'move'; 313 + } 314 + 315 + _cursorForEdge(edge) { 316 + const map = { 317 + n: 'ns-resize', s: 'ns-resize', 318 + e: 'ew-resize', w: 'ew-resize', 319 + ne: 'nesw-resize', sw: 'nesw-resize', 320 + nw: 'nwse-resize', se: 'nwse-resize', 321 + move: 'move' 322 + }; 323 + return map[edge] || 'move'; 324 + } 325 + 326 + _onChildHoverMove(child, e) { 327 + if (!this.freeformEditing || this._dragState) return; 328 + const edge = this._detectEdge(child, e); 329 + child.style.cursor = this._cursorForEdge(edge); 330 + } 331 + 332 + // --- Freeform: drag/resize --- 333 + 334 + _onChildPointerDown(child, e) { 335 + if (!this.freeformEditing) return; 336 + e.preventDefault(); 337 + e.stopPropagation(); 338 + 339 + const edge = this._detectEdge(child, e); 340 + const rect = child.getBoundingClientRect(); 341 + 342 + this._dragState = { 343 + child, 344 + edge, 345 + startX: e.clientX, 346 + startY: e.clientY, 347 + origLeft: parseFloat(child.style.left) || 0, 348 + origTop: parseFloat(child.style.top) || 0, 349 + origWidth: rect.width, 350 + origHeight: rect.height 351 + }; 352 + 353 + child.setPointerCapture(e.pointerId); 354 + child.addEventListener('pointermove', this._boundPointerMove); 355 + child.addEventListener('pointerup', this._boundPointerUp); 356 + } 357 + 358 + _onPointerMove(e) { 359 + const s = this._dragState; 360 + if (!s) return; 361 + e.preventDefault(); 362 + 363 + const dx = e.clientX - s.startX; 364 + const dy = e.clientY - s.startY; 365 + const minW = 100; 366 + const minH = 60; 367 + 368 + if (s.edge === 'move') { 369 + s.child.style.left = `${this._snap(s.origLeft + dx)}px`; 370 + s.child.style.top = `${this._snap(s.origTop + dy)}px`; 371 + return; 372 + } 373 + 374 + // Resize 375 + let newLeft = s.origLeft; 376 + let newTop = s.origTop; 377 + let newW = s.origWidth; 378 + let newH = s.origHeight; 379 + 380 + if (s.edge.includes('e')) newW = Math.max(minW, s.origWidth + dx); 381 + if (s.edge.includes('w')) { 382 + newW = Math.max(minW, s.origWidth - dx); 383 + newLeft = s.origLeft + (s.origWidth - newW); 384 + } 385 + if (s.edge.includes('s')) newH = Math.max(minH, s.origHeight + dy); 386 + if (s.edge.includes('n')) { 387 + newH = Math.max(minH, s.origHeight - dy); 388 + newTop = s.origTop + (s.origHeight - newH); 389 + } 390 + 391 + s.child.style.left = `${this._snap(newLeft)}px`; 392 + s.child.style.top = `${this._snap(newTop)}px`; 393 + s.child.style.width = `${this._snap(newW)}px`; 394 + s.child.style.height = `${this._snap(newH)}px`; 395 + } 396 + 397 + _onPointerUp(e) { 398 + const s = this._dragState; 399 + if (!s) return; 400 + 401 + s.child.releasePointerCapture(e.pointerId); 402 + s.child.removeEventListener('pointermove', this._boundPointerMove); 403 + s.child.removeEventListener('pointerup', this._boundPointerUp); 404 + 405 + const id = this._getChildId(s.child); 406 + const bounds = { 407 + x: parseFloat(s.child.style.left) || 0, 408 + y: parseFloat(s.child.style.top) || 0, 409 + w: parseFloat(s.child.style.width) || s.origWidth, 410 + h: parseFloat(s.child.style.height) || s.origHeight 411 + }; 412 + 413 + if (id) { 414 + this.freeformLayout = { ...this.freeformLayout, [id]: bounds }; 415 + } 416 + 417 + this.emit('freeform-layout-change', { layout: this.freeformLayout, itemId: id, bounds }); 418 + this._dragState = null; 419 + 420 + // Update container min-height 421 + this._updateContainerHeight(); 422 + } 423 + 424 + _updateContainerHeight() { 425 + const children = this._getSlottedChildren(); 426 + let maxBottom = 400; 427 + for (const child of children) { 428 + const top = parseFloat(child.style.top) || 0; 429 + const height = parseFloat(child.style.height) || 0; 430 + maxBottom = Math.max(maxBottom, top + height + this.gap); 431 + } 432 + const grid = this.shadowRoot?.querySelector('.grid'); 433 + if (grid) grid.style.minHeight = `${maxBottom}px`; 434 + } 435 + 436 + _onSlotChange() { 437 + if (this.viewMode === 'freeform') { 438 + this._applyFreeformPositions(); 439 + if (this.freeformEditing) this._attachEditListeners(); 440 + } 127 441 } 128 442 129 443 _getMasonryColumns() { ··· 165 479 --_masonry-columns: ${masonryColumns}; 166 480 " 167 481 > 168 - <slot></slot> 482 + <slot @slotchange=${this._onSlotChange}></slot> 169 483 </div> 170 484 `; 171 485 }