commit 632d0fc7a2f766f8ccc2d767b7527990f277128d
parent 5082d6c887b2982b3b61fa29469c59f05869373b
Author: Hunter
Date:   Mon, 20 Oct 2025 00:06:49 -0400

split out html, css, and js

Diffstat:
Mindex.html | 682+------------------------------------------------------------------------------
Ascript.js | 417+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astyle.css | 259+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 678 insertions(+), 680 deletions(-)

diff --git a/index.html b/index.html @@ -5,691 +5,13 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>memori</title> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>✂️</text></svg>"> - <style> - :root { - --color-bg-body: #f5f5f0; - --color-bg-page: #fafaf8; - --color-grid-line: #d0d0d0; - --color-accent: #4a90e2; - --color-white: white; - } - - * { - margin: 0; - padding: 0; - box-sizing: border-box; - } - - body { - background: var(--color-bg-body); - display: flex; - justify-content: center; - align-items: flex-start; - min-height: 100vh; - padding: 20px; - } - - .page { - width: 8.5in; - height: 11in; - background: var(--color-bg-page); - position: relative; - box-shadow: 0 2px 10px rgba(0,0,0,0.1); - transform-origin: top center; - } - - .grid { - position: absolute; - top: calc((11in - 264mm) / 2); - left: calc((8.5in - 200mm) / 2); - display: grid; - grid-template-columns: repeat(50, 4mm); - grid-template-rows: repeat(66, 4mm); - gap: 0; - line-height: 0; - } - - .grid-cell { - width: 4mm; - height: 4mm; - box-sizing: border-box; - border-right: 1px dashed var(--color-grid-line); - border-bottom: 1px dashed var(--color-grid-line); - margin: 0; - padding: 0; - display: block; - } - - .grid-cell:nth-child(-n+50) { - border-top: 1px dashed var(--color-grid-line); - } - - .grid-cell:nth-child(50n+1) { - border-left: 1px dashed var(--color-grid-line); - } - - .image-container { - position: absolute; - cursor: move; - outline: 2px solid transparent; - outline-offset: -2px; - transition: outline-color 0.2s; - background: var(--color-bg-page); - } - - .image-container::after { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - pointer-events: none; - border: 2px solid transparent; - transition: border-color 0.2s; - z-index: 1; - overflow: hidden; - } - - .image-container .image-wrapper { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - overflow: hidden; - } - - .image-container:hover::after { - border-color: var(--color-accent); - } - - .image-container.dragging { - opacity: 0.7; - } - - .image-container.dragging::after { - border-color: var(--color-accent); - } - - .image-container.resizing::after { - border-color: var(--color-accent); - } - - body.resizing *, - body.dragging * { - cursor: inherit !important; - } - - .image-container img { - position: absolute; - top: 50%; - left: 50%; - display: block; - pointer-events: none; - transform-origin: center center; - transition: none; - } - - .resize-handle { - position: absolute; - background: var(--color-accent); - opacity: 0; - transition: opacity 0.2s; - z-index: 2; - } - - .image-container:hover .resize-handle { - opacity: 0.8; - } - - .resize-handle.corner { - width: 10px; - height: 10px; - background: transparent; - } - - .resize-handle.corner::before { - content: ''; - position: absolute; - width: 10px; - height: 10px; - background: var(--color-accent); - border-radius: 50%; - } - - .resize-handle.edge { - background: transparent; - } - - .resize-handle.n { top: -5px; left: 5px; right: 5px; height: 10px; cursor: n-resize; } - .resize-handle.s { bottom: -5px; left: 5px; right: 5px; height: 10px; cursor: s-resize; } - .resize-handle.e { right: -5px; top: 5px; bottom: 5px; width: 10px; cursor: e-resize; } - .resize-handle.w { left: -5px; top: 5px; bottom: 5px; width: 10px; cursor: w-resize; } - - .resize-handle.ne { top: -5px; right: -5px; cursor: ne-resize; } - .resize-handle.ne::before { top: 0; right: 0; } - - .resize-handle.nw { top: -5px; left: -5px; cursor: nw-resize; } - .resize-handle.nw::before { top: 0; left: 0; } - - .resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; } - .resize-handle.se::before { bottom: 0; right: 0; } - - .resize-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; } - .resize-handle.sw::before { bottom: 0; left: 0; } - - @media print { - * { - -webkit-print-color-adjust: exact !important; - print-color-adjust: exact !important; - } - - body { - background: var(--color-white); - padding: 0; - margin: 0; - display: block; - align-items: initial; - justify-content: initial; - } - - .page { - width: 8.5in; - height: 11in; - box-shadow: none; - margin: 0; - padding: 0; - background: var(--color-white); - transform: none !important; - position: relative; - } - - .grid { - position: absolute; - top: calc((11in - 264mm) / 2); - left: calc((8.5in - 200mm) / 2); - display: grid; - grid-template-columns: repeat(50, 4mm); - grid-template-rows: repeat(66, 4mm); - } - - .grid-cell { - border-right: 1px dashed var(--color-grid-line) !important; - border-bottom: 1px dashed var(--color-grid-line) !important; - box-sizing: border-box !important; - width: 4mm !important; - height: 4mm !important; - margin: 0 !important; - padding: 0 !important; - } - - .grid-cell:nth-child(-n+50) { - border-top: 1px dashed var(--color-grid-line) !important; - } - - .grid-cell:nth-child(50n+1) { - border-left: 1px dashed var(--color-grid-line) !important; - } - - .image-container { - outline: none !important; - position: absolute !important; - background: var(--color-white) !important; - } - - .image-container .image-wrapper { - position: absolute !important; - top: 0 !important; - left: 0 !important; - right: 0 !important; - bottom: 0 !important; - overflow: hidden !important; - } - - .image-container img { - position: absolute !important; - top: 50% !important; - left: 50% !important; - display: block !important; - pointer-events: none !important; - /* Preserve the transform from the screen view */ - } - - .resize-handle { - display: none !important; - } - } - - @page { - size: 8.5in 11in; - margin: 0; - } - </style> + <link rel="stylesheet" href="style.css"> </head> <body> <div class="page"> <div class="grid" id="grid"></div> </div> - <script> - const MM_TO_PX = 96 / 25.4; - const CELL_SIZE_MM = 4; - const CELL_SIZE_PX = CELL_SIZE_MM * MM_TO_PX; - const GRID_OFFSET_IN = 0.25; - const GRID_OFFSET_PX = GRID_OFFSET_IN * 96; - const GRID_COLS = 50; - const GRID_ROWS = 66; - - const grid = document.getElementById('grid'); - const page = document.querySelector('.page'); - - // Create grid cells - for (let i = 0; i < GRID_COLS * GRID_ROWS; i++) { - const cell = document.createElement('div'); - cell.className = 'grid-cell'; - grid.appendChild(cell); - } - - let images = []; - let dragState = null; - let resizeState = null; - - // Prevent default drag behavior on entire document - document.addEventListener('dragover', (e) => { - e.preventDefault(); - e.stopPropagation(); - }); - - document.addEventListener('drop', async (e) => { - e.preventDefault(); - e.stopPropagation(); - - const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/')); - if (files.length === 0) return; - - // Get drop position relative to the grid - const gridRect = grid.getBoundingClientRect(); - const gridWidth = GRID_COLS * CELL_SIZE_PX; - const gridHeight = GRID_ROWS * CELL_SIZE_PX; - - // Clamp drop position to grid boundaries - let dropX = e.clientX - gridRect.left; - let dropY = e.clientY - gridRect.top; - dropX = Math.max(0, Math.min(gridWidth, dropX)); - dropY = Math.max(0, Math.min(gridHeight, dropY)); - - // Load first image to get base dimensions - let firstImageDimensions = null; - if (files.length > 0) { - const firstFile = files[0]; - const reader = new FileReader(); - const dataUrl = await new Promise(resolve => { - reader.onload = (e) => resolve(e.target.result); - reader.readAsDataURL(firstFile); - }); - - const img = new Image(); - await new Promise(resolve => { - img.onload = () => { - const aspectRatio = img.width / img.height; - let widthCells, heightCells; - if (aspectRatio >= 1) { - heightCells = 5; - widthCells = Math.round(heightCells * aspectRatio); - } else { - widthCells = 5; - heightCells = Math.round(widthCells / aspectRatio); - } - widthCells = Math.min(widthCells, GRID_COLS); - heightCells = Math.min(heightCells, GRID_ROWS); - firstImageDimensions = { widthCells, heightCells }; - resolve(); - }; - img.src = dataUrl; - }); - } - - // Now process all files - files.forEach((file, idx) => { - const reader = new FileReader(); - reader.onload = (event) => { - const img = new Image(); - img.onload = () => { - const aspectRatio = img.width / img.height; - - // Set smaller dimension to 5 cells, calculate larger dimension - let widthCells, heightCells; - if (aspectRatio >= 1) { - // Width is larger or equal - heightCells = 5; - widthCells = Math.round(heightCells * aspectRatio); - } else { - // Height is larger - widthCells = 5; - heightCells = Math.round(widthCells / aspectRatio); - } - - // Clamp to grid boundaries - widthCells = Math.min(widthCells, GRID_COLS); - heightCells = Math.min(heightCells, GRID_ROWS); - - // Calculate position with first image's center under drop point - let xCell = Math.round(dropX / CELL_SIZE_PX - firstImageDimensions.widthCells / 2) + (idx * 2); - let yCell = Math.round(dropY / CELL_SIZE_PX - firstImageDimensions.heightCells / 2); - - // Ensure image stays within grid bounds - xCell = Math.max(0, Math.min(GRID_COLS - widthCells, xCell)); - yCell = Math.max(0, Math.min(GRID_ROWS - heightCells, yCell)); - - addImage(event.target.result, xCell, yCell, widthCells, heightCells, idx + 1); - }; - img.src = event.target.result; - }; - reader.readAsDataURL(file); - }); - }); - - function addImage(src, xCell, yCell, widthCells, heightCells, zIndex = 0) { - const container = document.createElement('div'); - container.className = 'image-container'; - if (zIndex > 0) { - container.style.zIndex = zIndex; - } - - const wrapper = document.createElement('div'); - wrapper.className = 'image-wrapper'; - const img = document.createElement('img'); - img.src = src; - wrapper.appendChild(img); - container.appendChild(wrapper); - - // Add resize handles - const handles = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw']; - handles.forEach(dir => { - const handle = document.createElement('div'); - handle.className = `resize-handle ${dir.length === 1 ? 'edge' : 'corner'} ${dir}`; - handle.dataset.direction = dir; - container.appendChild(handle); - }); - - const imageData = { - container, - xCell, - yCell, - widthCells, - heightCells, - // Image positioning within container (in pixels, relative to center) - panX: 0, - panY: 0, - userScale: 1, // user zoom level (1-5) - // Store natural image dimensions for calculations - naturalWidth: 0, - naturalHeight: 0, - baseScale: 1 // scale needed to cover container - }; - - // Calculate dimensions and scale once image loads - img.onload = () => { - imageData.naturalWidth = img.naturalWidth; - imageData.naturalHeight = img.naturalHeight; - - // Calculate base scale to cover container (mimics object-fit: cover) - const containerWidth = imageData.widthCells * CELL_SIZE_PX; - const containerHeight = imageData.heightCells * CELL_SIZE_PX; - const scaleX = containerWidth / img.naturalWidth; - const scaleY = containerHeight / img.naturalHeight; - imageData.baseScale = Math.max(scaleX, scaleY); - - updateImagePosition(imageData); - }; - images.push(imageData); - - updateImagePosition(imageData); - grid.appendChild(container); - - setupImageHandlers(imageData); - } - - function calculatePanBounds(imageData) { - const containerWidth = imageData.widthCells * CELL_SIZE_PX; - const containerHeight = imageData.heightCells * CELL_SIZE_PX; - - if (imageData.naturalWidth === 0 || imageData.naturalHeight === 0) { - return { maxPanX: 0, maxPanY: 0 }; - } - - // Calculate actual rendered size with both base scale and user scale - const totalScale = imageData.baseScale * imageData.userScale; - const renderedWidth = imageData.naturalWidth * totalScale; - const renderedHeight = imageData.naturalHeight * totalScale; - - // Calculate maximum pan in each direction - const maxPanX = Math.max(0, (renderedWidth - containerWidth) / 2); - const maxPanY = Math.max(0, (renderedHeight - containerHeight) / 2); - - return { maxPanX, maxPanY }; - } - - function clampPan(imageData) { - const { maxPanX, maxPanY } = calculatePanBounds(imageData); - imageData.panX = Math.max(-maxPanX, Math.min(maxPanX, imageData.panX)); - imageData.panY = Math.max(-maxPanY, Math.min(maxPanY, imageData.panY)); - } - - function updateImagePosition(img) { - img.container.style.left = img.xCell * CELL_SIZE_PX + 'px'; - img.container.style.top = img.yCell * CELL_SIZE_PX + 'px'; - img.container.style.width = img.widthCells * CELL_SIZE_PX + 'px'; - img.container.style.height = img.heightCells * CELL_SIZE_PX + 'px'; - - // Recalculate baseScale if container size changed - if (img.naturalWidth > 0 && img.naturalHeight > 0) { - const containerWidth = img.widthCells * CELL_SIZE_PX; - const containerHeight = img.heightCells * CELL_SIZE_PX; - const scaleX = containerWidth / img.naturalWidth; - const scaleY = containerHeight / img.naturalHeight; - img.baseScale = Math.max(scaleX, scaleY); - - // Reclamp pan after recalculating base scale - clampPan(img); - } - - // Apply image positioning and scale using transform - const imgElement = img.container.querySelector('img'); - if (imgElement) { - const totalScale = img.baseScale * img.userScale; - // Transform: translate from center (-50%, -50%), then pan, then scale - imgElement.style.transform = `translate(-50%, -50%) translate(${img.panX}px, ${img.panY}px) scale(${totalScale})`; - } - } - - function setupImageHandlers(imageData) { - const container = imageData.container; - - // Moving / Deleting - container.addEventListener('mousedown', (e) => { - if (e.target.classList.contains('resize-handle')) return; - - e.preventDefault(); - - // Shift-click to delete - if (e.shiftKey) { - const index = images.indexOf(imageData); - if (index > -1) { - images.splice(index, 1); - } - container.remove(); - return; - } - - // Lock cursor to move during drag operation - document.body.style.cursor = 'move'; - document.body.classList.add('dragging'); - - dragState = { - image: imageData, - startX: e.clientX, - startY: e.clientY, - startXCell: imageData.xCell, - startYCell: imageData.yCell - }; - container.classList.add('dragging'); - }); - - // Resizing - container.querySelectorAll('.resize-handle').forEach(handle => { - handle.addEventListener('mousedown', (e) => { - e.preventDefault(); - e.stopPropagation(); - - // Get the cursor style from the handle and apply it to the body - const cursorStyle = window.getComputedStyle(handle).cursor; - document.body.style.cursor = cursorStyle; - document.body.classList.add('resizing'); - - resizeState = { - image: imageData, - direction: handle.dataset.direction, - startX: e.clientX, - startY: e.clientY, - startXCell: imageData.xCell, - startYCell: imageData.yCell, - startWidthCells: imageData.widthCells, - startHeightCells: imageData.heightCells - }; - container.classList.add('resizing'); - }); - }); - - // Pan and Zoom with wheel events (macOS trackpad gestures) - container.addEventListener('wheel', (e) => { - // Don't interfere with dragging or resizing - if (dragState || resizeState) return; - - e.preventDefault(); - e.stopPropagation(); - - // Detect pinch zoom (ctrlKey is set for pinch gestures on macOS trackpad) - if (e.ctrlKey) { - // Zoom under cursor - const rect = container.getBoundingClientRect(); - const mouseX = e.clientX - rect.left; - const mouseY = e.clientY - rect.top; - - // Calculate mouse position relative to container center - const centerX = rect.width / 2; - const centerY = rect.height / 2; - const offsetX = mouseX - centerX; - const offsetY = mouseY - centerY; - - const oldUserScale = imageData.userScale; - const zoomDelta = -e.deltaY * 0.01; - const newUserScale = Math.max(1, Math.min(5, oldUserScale * (1 + zoomDelta))); - - // Adjust pan to keep point under cursor stationary - const scaleFactor = newUserScale / oldUserScale - 1; - imageData.panX -= offsetX * scaleFactor; - imageData.panY -= offsetY * scaleFactor; - imageData.userScale = newUserScale; - - // Clamp after zooming to prevent whitespace - clampPan(imageData); - } else { - // Pan (two-finger scroll on macOS trackpad) - imageData.panX -= e.deltaX; - imageData.panY -= e.deltaY; - - // Clamp pan to prevent whitespace - clampPan(imageData); - } - - updateImagePosition(imageData); - }, { passive: false }); - } - - document.addEventListener('mousemove', (e) => { - if (dragState) { - const dx = e.clientX - dragState.startX; - const dy = e.clientY - dragState.startY; - - const dxCells = Math.round(dx / CELL_SIZE_PX); - const dyCells = Math.round(dy / CELL_SIZE_PX); - - const newXCell = Math.max(0, Math.min(GRID_COLS - dragState.image.widthCells, dragState.startXCell + dxCells)); - const newYCell = Math.max(0, Math.min(GRID_ROWS - dragState.image.heightCells, dragState.startYCell + dyCells)); - - dragState.image.xCell = newXCell; - dragState.image.yCell = newYCell; - updateImagePosition(dragState.image); - } - - if (resizeState) { - const dx = e.clientX - resizeState.startX; - const dy = e.clientY - resizeState.startY; - - const dxCells = Math.round(dx / CELL_SIZE_PX); - const dyCells = Math.round(dy / CELL_SIZE_PX); - - const dir = resizeState.direction; - const img = resizeState.image; - - let newX = img.xCell; - let newY = img.yCell; - let newW = img.widthCells; - let newH = img.heightCells; - - if (dir.includes('e')) { - const proposedW = Math.max(1, resizeState.startWidthCells + dxCells); - // Clamp to grid boundary - newW = Math.min(proposedW, GRID_COLS - resizeState.startXCell); - } - if (dir.includes('w')) { - const delta = Math.min(dxCells, resizeState.startWidthCells - 1); - const proposedX = resizeState.startXCell + delta; - // Clamp to grid boundary - const clampedX = Math.max(0, proposedX); - newX = clampedX; - newW = resizeState.startWidthCells - (clampedX - resizeState.startXCell); - } - if (dir.includes('s')) { - const proposedH = Math.max(1, resizeState.startHeightCells + dyCells); - // Clamp to grid boundary - newH = Math.min(proposedH, GRID_ROWS - resizeState.startYCell); - } - if (dir.includes('n')) { - const delta = Math.min(dyCells, resizeState.startHeightCells - 1); - const proposedY = resizeState.startYCell + delta; - // Clamp to grid boundary - const clampedY = Math.max(0, proposedY); - newY = clampedY; - newH = resizeState.startHeightCells - (clampedY - resizeState.startYCell); - } - - img.xCell = newX; - img.yCell = newY; - img.widthCells = newW; - img.heightCells = newH; - updateImagePosition(img); - } - }); - - document.addEventListener('mouseup', () => { - if (dragState) { - dragState.image.container.classList.remove('dragging'); - dragState = null; - // Restore the default cursor - document.body.style.cursor = ''; - document.body.classList.remove('dragging'); - } - if (resizeState) { - resizeState.image.container.classList.remove('resizing'); - resizeState = null; - // Restore the default cursor - document.body.style.cursor = ''; - document.body.classList.remove('resizing'); - } - }); - </script> + <script src="script.js"></script> </body> </html> diff --git a/script.js b/script.js @@ -0,0 +1,417 @@ +const MM_TO_PX = 96 / 25.4; +const CELL_SIZE_MM = 4; +const CELL_SIZE_PX = CELL_SIZE_MM * MM_TO_PX; +const GRID_OFFSET_IN = 0.25; +const GRID_OFFSET_PX = GRID_OFFSET_IN * 96; +const GRID_COLS = 50; +const GRID_ROWS = 66; + +const grid = document.getElementById('grid'); +const page = document.querySelector('.page'); + +// Create grid cells +for (let i = 0; i < GRID_COLS * GRID_ROWS; i++) { + const cell = document.createElement('div'); + cell.className = 'grid-cell'; + grid.appendChild(cell); +} + +let images = []; +let dragState = null; +let resizeState = null; + +// Prevent default drag behavior on entire document +document.addEventListener('dragover', (e) => { + e.preventDefault(); + e.stopPropagation(); +}); + +document.addEventListener('drop', async (e) => { + e.preventDefault(); + e.stopPropagation(); + + const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/')); + if (files.length === 0) return; + + // Get drop position relative to the grid + const gridRect = grid.getBoundingClientRect(); + const gridWidth = GRID_COLS * CELL_SIZE_PX; + const gridHeight = GRID_ROWS * CELL_SIZE_PX; + + // Clamp drop position to grid boundaries + let dropX = e.clientX - gridRect.left; + let dropY = e.clientY - gridRect.top; + dropX = Math.max(0, Math.min(gridWidth, dropX)); + dropY = Math.max(0, Math.min(gridHeight, dropY)); + + // Load first image to get base dimensions + let firstImageDimensions = null; + if (files.length > 0) { + const firstFile = files[0]; + const reader = new FileReader(); + const dataUrl = await new Promise(resolve => { + reader.onload = (e) => resolve(e.target.result); + reader.readAsDataURL(firstFile); + }); + + const img = new Image(); + await new Promise(resolve => { + img.onload = () => { + const aspectRatio = img.width / img.height; + let widthCells, heightCells; + if (aspectRatio >= 1) { + heightCells = 5; + widthCells = Math.round(heightCells * aspectRatio); + } else { + widthCells = 5; + heightCells = Math.round(widthCells / aspectRatio); + } + widthCells = Math.min(widthCells, GRID_COLS); + heightCells = Math.min(heightCells, GRID_ROWS); + firstImageDimensions = { widthCells, heightCells }; + resolve(); + }; + img.src = dataUrl; + }); + } + + // Now process all files + files.forEach((file, idx) => { + const reader = new FileReader(); + reader.onload = (event) => { + const img = new Image(); + img.onload = () => { + const aspectRatio = img.width / img.height; + + // Set smaller dimension to 5 cells, calculate larger dimension + let widthCells, heightCells; + if (aspectRatio >= 1) { + // Width is larger or equal + heightCells = 5; + widthCells = Math.round(heightCells * aspectRatio); + } else { + // Height is larger + widthCells = 5; + heightCells = Math.round(widthCells / aspectRatio); + } + + // Clamp to grid boundaries + widthCells = Math.min(widthCells, GRID_COLS); + heightCells = Math.min(heightCells, GRID_ROWS); + + // Calculate position with first image's center under drop point + let xCell = Math.round(dropX / CELL_SIZE_PX - firstImageDimensions.widthCells / 2) + (idx * 2); + let yCell = Math.round(dropY / CELL_SIZE_PX - firstImageDimensions.heightCells / 2); + + // Ensure image stays within grid bounds + xCell = Math.max(0, Math.min(GRID_COLS - widthCells, xCell)); + yCell = Math.max(0, Math.min(GRID_ROWS - heightCells, yCell)); + + addImage(event.target.result, xCell, yCell, widthCells, heightCells, idx + 1); + }; + img.src = event.target.result; + }; + reader.readAsDataURL(file); + }); +}); + +function addImage(src, xCell, yCell, widthCells, heightCells, zIndex = 0) { + const container = document.createElement('div'); + container.className = 'image-container'; + if (zIndex > 0) { + container.style.zIndex = zIndex; + } + + const wrapper = document.createElement('div'); + wrapper.className = 'image-wrapper'; + const img = document.createElement('img'); + img.src = src; + wrapper.appendChild(img); + container.appendChild(wrapper); + + // Add resize handles + const handles = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw']; + handles.forEach(dir => { + const handle = document.createElement('div'); + handle.className = `resize-handle ${dir.length === 1 ? 'edge' : 'corner'} ${dir}`; + handle.dataset.direction = dir; + container.appendChild(handle); + }); + + const imageData = { + container, + xCell, + yCell, + widthCells, + heightCells, + // Image positioning within container (in pixels, relative to center) + panX: 0, + panY: 0, + userScale: 1, // user zoom level (1-5) + // Store natural image dimensions for calculations + naturalWidth: 0, + naturalHeight: 0, + baseScale: 1 // scale needed to cover container + }; + + // Calculate dimensions and scale once image loads + img.onload = () => { + imageData.naturalWidth = img.naturalWidth; + imageData.naturalHeight = img.naturalHeight; + + // Calculate base scale to cover container (mimics object-fit: cover) + const containerWidth = imageData.widthCells * CELL_SIZE_PX; + const containerHeight = imageData.heightCells * CELL_SIZE_PX; + const scaleX = containerWidth / img.naturalWidth; + const scaleY = containerHeight / img.naturalHeight; + imageData.baseScale = Math.max(scaleX, scaleY); + + updateImagePosition(imageData); + }; + images.push(imageData); + + updateImagePosition(imageData); + grid.appendChild(container); + + setupImageHandlers(imageData); +} + +function calculatePanBounds(imageData) { + const containerWidth = imageData.widthCells * CELL_SIZE_PX; + const containerHeight = imageData.heightCells * CELL_SIZE_PX; + + if (imageData.naturalWidth === 0 || imageData.naturalHeight === 0) { + return { maxPanX: 0, maxPanY: 0 }; + } + + // Calculate actual rendered size with both base scale and user scale + const totalScale = imageData.baseScale * imageData.userScale; + const renderedWidth = imageData.naturalWidth * totalScale; + const renderedHeight = imageData.naturalHeight * totalScale; + + // Calculate maximum pan in each direction + const maxPanX = Math.max(0, (renderedWidth - containerWidth) / 2); + const maxPanY = Math.max(0, (renderedHeight - containerHeight) / 2); + + return { maxPanX, maxPanY }; +} + +function clampPan(imageData) { + const { maxPanX, maxPanY } = calculatePanBounds(imageData); + imageData.panX = Math.max(-maxPanX, Math.min(maxPanX, imageData.panX)); + imageData.panY = Math.max(-maxPanY, Math.min(maxPanY, imageData.panY)); +} + +function updateImagePosition(img) { + img.container.style.left = img.xCell * CELL_SIZE_PX + 'px'; + img.container.style.top = img.yCell * CELL_SIZE_PX + 'px'; + img.container.style.width = img.widthCells * CELL_SIZE_PX + 'px'; + img.container.style.height = img.heightCells * CELL_SIZE_PX + 'px'; + + // Recalculate baseScale if container size changed + if (img.naturalWidth > 0 && img.naturalHeight > 0) { + const containerWidth = img.widthCells * CELL_SIZE_PX; + const containerHeight = img.heightCells * CELL_SIZE_PX; + const scaleX = containerWidth / img.naturalWidth; + const scaleY = containerHeight / img.naturalHeight; + img.baseScale = Math.max(scaleX, scaleY); + + // Reclamp pan after recalculating base scale + clampPan(img); + } + + // Apply image positioning and scale using transform + const imgElement = img.container.querySelector('img'); + if (imgElement) { + const totalScale = img.baseScale * img.userScale; + // Transform: translate from center (-50%, -50%), then pan, then scale + imgElement.style.transform = `translate(-50%, -50%) translate(${img.panX}px, ${img.panY}px) scale(${totalScale})`; + } +} + +function setupImageHandlers(imageData) { + const container = imageData.container; + + // Moving / Deleting + container.addEventListener('mousedown', (e) => { + if (e.target.classList.contains('resize-handle')) return; + + e.preventDefault(); + + // Shift-click to delete + if (e.shiftKey) { + const index = images.indexOf(imageData); + if (index > -1) { + images.splice(index, 1); + } + container.remove(); + return; + } + + // Lock cursor to move during drag operation + document.body.style.cursor = 'move'; + document.body.classList.add('dragging'); + + dragState = { + image: imageData, + startX: e.clientX, + startY: e.clientY, + startXCell: imageData.xCell, + startYCell: imageData.yCell + }; + container.classList.add('dragging'); + }); + + // Resizing + container.querySelectorAll('.resize-handle').forEach(handle => { + handle.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + + // Get the cursor style from the handle and apply it to the body + const cursorStyle = window.getComputedStyle(handle).cursor; + document.body.style.cursor = cursorStyle; + document.body.classList.add('resizing'); + + resizeState = { + image: imageData, + direction: handle.dataset.direction, + startX: e.clientX, + startY: e.clientY, + startXCell: imageData.xCell, + startYCell: imageData.yCell, + startWidthCells: imageData.widthCells, + startHeightCells: imageData.heightCells + }; + container.classList.add('resizing'); + }); + }); + + // Pan and Zoom with wheel events (macOS trackpad gestures) + container.addEventListener('wheel', (e) => { + // Don't interfere with dragging or resizing + if (dragState || resizeState) return; + + e.preventDefault(); + e.stopPropagation(); + + // Detect pinch zoom (ctrlKey is set for pinch gestures on macOS trackpad) + if (e.ctrlKey) { + // Zoom under cursor + const rect = container.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + // Calculate mouse position relative to container center + const centerX = rect.width / 2; + const centerY = rect.height / 2; + const offsetX = mouseX - centerX; + const offsetY = mouseY - centerY; + + const oldUserScale = imageData.userScale; + const zoomDelta = -e.deltaY * 0.01; + const newUserScale = Math.max(1, Math.min(5, oldUserScale * (1 + zoomDelta))); + + // Adjust pan to keep point under cursor stationary + const scaleFactor = newUserScale / oldUserScale - 1; + imageData.panX -= offsetX * scaleFactor; + imageData.panY -= offsetY * scaleFactor; + imageData.userScale = newUserScale; + + // Clamp after zooming to prevent whitespace + clampPan(imageData); + } else { + // Pan (two-finger scroll on macOS trackpad) + imageData.panX -= e.deltaX; + imageData.panY -= e.deltaY; + + // Clamp pan to prevent whitespace + clampPan(imageData); + } + + updateImagePosition(imageData); + }, { passive: false }); +} + +document.addEventListener('mousemove', (e) => { + if (dragState) { + const dx = e.clientX - dragState.startX; + const dy = e.clientY - dragState.startY; + + const dxCells = Math.round(dx / CELL_SIZE_PX); + const dyCells = Math.round(dy / CELL_SIZE_PX); + + const newXCell = Math.max(0, Math.min(GRID_COLS - dragState.image.widthCells, dragState.startXCell + dxCells)); + const newYCell = Math.max(0, Math.min(GRID_ROWS - dragState.image.heightCells, dragState.startYCell + dyCells)); + + dragState.image.xCell = newXCell; + dragState.image.yCell = newYCell; + updateImagePosition(dragState.image); + } + + if (resizeState) { + const dx = e.clientX - resizeState.startX; + const dy = e.clientY - resizeState.startY; + + const dxCells = Math.round(dx / CELL_SIZE_PX); + const dyCells = Math.round(dy / CELL_SIZE_PX); + + const dir = resizeState.direction; + const img = resizeState.image; + + let newX = img.xCell; + let newY = img.yCell; + let newW = img.widthCells; + let newH = img.heightCells; + + if (dir.includes('e')) { + const proposedW = Math.max(1, resizeState.startWidthCells + dxCells); + // Clamp to grid boundary + newW = Math.min(proposedW, GRID_COLS - resizeState.startXCell); + } + if (dir.includes('w')) { + const delta = Math.min(dxCells, resizeState.startWidthCells - 1); + const proposedX = resizeState.startXCell + delta; + // Clamp to grid boundary + const clampedX = Math.max(0, proposedX); + newX = clampedX; + newW = resizeState.startWidthCells - (clampedX - resizeState.startXCell); + } + if (dir.includes('s')) { + const proposedH = Math.max(1, resizeState.startHeightCells + dyCells); + // Clamp to grid boundary + newH = Math.min(proposedH, GRID_ROWS - resizeState.startYCell); + } + if (dir.includes('n')) { + const delta = Math.min(dyCells, resizeState.startHeightCells - 1); + const proposedY = resizeState.startYCell + delta; + // Clamp to grid boundary + const clampedY = Math.max(0, proposedY); + newY = clampedY; + newH = resizeState.startHeightCells - (clampedY - resizeState.startYCell); + } + + img.xCell = newX; + img.yCell = newY; + img.widthCells = newW; + img.heightCells = newH; + updateImagePosition(img); + } +}); + +document.addEventListener('mouseup', () => { + if (dragState) { + dragState.image.container.classList.remove('dragging'); + dragState = null; + // Restore the default cursor + document.body.style.cursor = ''; + document.body.classList.remove('dragging'); + } + if (resizeState) { + resizeState.image.container.classList.remove('resizing'); + resizeState = null; + // Restore the default cursor + document.body.style.cursor = ''; + document.body.classList.remove('resizing'); + } +}); diff --git a/style.css b/style.css @@ -0,0 +1,259 @@ +:root { + --color-bg-body: #f5f5f0; + --color-bg-page: #fafaf8; + --color-grid-line: #d0d0d0; + --color-accent: #4a90e2; + --color-white: white; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: var(--color-bg-body); + display: flex; + justify-content: center; + align-items: flex-start; + min-height: 100vh; + padding: 20px; +} + +.page { + width: 8.5in; + height: 11in; + background: var(--color-bg-page); + position: relative; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + transform-origin: top center; +} + +.grid { + position: absolute; + top: calc((11in - 264mm) / 2); + left: calc((8.5in - 200mm) / 2); + display: grid; + grid-template-columns: repeat(50, 4mm); + grid-template-rows: repeat(66, 4mm); + gap: 0; + line-height: 0; +} + +.grid-cell { + width: 4mm; + height: 4mm; + box-sizing: border-box; + border-right: 1px dashed var(--color-grid-line); + border-bottom: 1px dashed var(--color-grid-line); + margin: 0; + padding: 0; + display: block; +} + +.grid-cell:nth-child(-n+50) { + border-top: 1px dashed var(--color-grid-line); +} + +.grid-cell:nth-child(50n+1) { + border-left: 1px dashed var(--color-grid-line); +} + +.image-container { + position: absolute; + cursor: move; + outline: 2px solid transparent; + outline-offset: -2px; + transition: outline-color 0.2s; + background: var(--color-bg-page); +} + +.image-container::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + border: 2px solid transparent; + transition: border-color 0.2s; + z-index: 1; + overflow: hidden; +} + +.image-container .image-wrapper { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; +} + +.image-container:hover::after { + border-color: var(--color-accent); +} + +.image-container.dragging { + opacity: 0.7; +} + +.image-container.dragging::after { + border-color: var(--color-accent); +} + +.image-container.resizing::after { + border-color: var(--color-accent); +} + +body.resizing *, +body.dragging * { + cursor: inherit !important; +} + +.image-container img { + position: absolute; + top: 50%; + left: 50%; + display: block; + pointer-events: none; + transform-origin: center center; + transition: none; +} + +.resize-handle { + position: absolute; + background: var(--color-accent); + opacity: 0; + transition: opacity 0.2s; + z-index: 2; +} + +.image-container:hover .resize-handle { + opacity: 0.8; +} + +.resize-handle.corner { + width: 10px; + height: 10px; + background: transparent; +} + +.resize-handle.corner::before { + content: ''; + position: absolute; + width: 10px; + height: 10px; + background: var(--color-accent); + border-radius: 50%; +} + +.resize-handle.edge { + background: transparent; +} + +.resize-handle.n { top: -5px; left: 5px; right: 5px; height: 10px; cursor: n-resize; } +.resize-handle.s { bottom: -5px; left: 5px; right: 5px; height: 10px; cursor: s-resize; } +.resize-handle.e { right: -5px; top: 5px; bottom: 5px; width: 10px; cursor: e-resize; } +.resize-handle.w { left: -5px; top: 5px; bottom: 5px; width: 10px; cursor: w-resize; } + +.resize-handle.ne { top: -5px; right: -5px; cursor: ne-resize; } +.resize-handle.ne::before { top: 0; right: 0; } + +.resize-handle.nw { top: -5px; left: -5px; cursor: nw-resize; } +.resize-handle.nw::before { top: 0; left: 0; } + +.resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; } +.resize-handle.se::before { bottom: 0; right: 0; } + +.resize-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; } +.resize-handle.sw::before { bottom: 0; left: 0; } + +@media print { + * { + -webkit-print-color-adjust: exact !important; + print-color-adjust: exact !important; + } + + body { + background: var(--color-white); + padding: 0; + margin: 0; + display: block; + align-items: initial; + justify-content: initial; + } + + .page { + width: 8.5in; + height: 11in; + box-shadow: none; + margin: 0; + padding: 0; + background: var(--color-white); + transform: none !important; + position: relative; + } + + .grid { + position: absolute; + top: calc((11in - 264mm) / 2); + left: calc((8.5in - 200mm) / 2); + display: grid; + grid-template-columns: repeat(50, 4mm); + grid-template-rows: repeat(66, 4mm); + } + + .grid-cell { + border-right: 1px dashed var(--color-grid-line) !important; + border-bottom: 1px dashed var(--color-grid-line) !important; + box-sizing: border-box !important; + width: 4mm !important; + height: 4mm !important; + margin: 0 !important; + padding: 0 !important; + } + + .grid-cell:nth-child(-n+50) { + border-top: 1px dashed var(--color-grid-line) !important; + } + + .grid-cell:nth-child(50n+1) { + border-left: 1px dashed var(--color-grid-line) !important; + } + + .image-container { + outline: none !important; + position: absolute !important; + background: var(--color-white) !important; + } + + .image-container .image-wrapper { + position: absolute !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + overflow: hidden !important; + } + + .image-container img { + position: absolute !important; + top: 50% !important; + left: 50% !important; + display: block !important; + pointer-events: none !important; + /* Preserve the transform from the screen view */ + } + + .resize-handle { + display: none !important; + } +} + +@page { + size: 8.5in 11in; + margin: 0; +}