commit 632d0fc7a2f766f8ccc2d767b7527990f277128d
parent 5082d6c887b2982b3b61fa29469c59f05869373b
Author: Hunter
Date: Mon, 20 Oct 2025 00:06:49 -0400
split out html, css, and js
Diffstat:
| M | index.html | | | 682 | +------------------------------------------------------------------------------ |
| A | script.js | | | 417 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | style.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;
+}