split out html, css, and js
Hunter
Monday, October 20th, 2025 at 12:06 AM
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>memori</title>
7 <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>
8 + <link rel="stylesheet" href="style.css">
9 </head>
10 <body>
11 <div class="page">
12 <div class="grid" id="grid"></div>
13 </div>
14
- <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>
15 + <script src="script.js"></script>
16 </body>
17 </html>
1 +const MM_TO_PX = 96 / 25.4;
2 +const CELL_SIZE_MM = 4;
3 +const CELL_SIZE_PX = CELL_SIZE_MM * MM_TO_PX;
4 +const GRID_OFFSET_IN = 0.25;
5 +const GRID_OFFSET_PX = GRID_OFFSET_IN * 96;
6 +const GRID_COLS = 50;
7 +const GRID_ROWS = 66;
8 +
9 +const grid = document.getElementById('grid');
10 +const page = document.querySelector('.page');
11 +
12 +// Create grid cells
13 +for (let i = 0; i < GRID_COLS * GRID_ROWS; i++) {
14 + const cell = document.createElement('div');
15 + cell.className = 'grid-cell';
16 + grid.appendChild(cell);
17 +}
18 +
19 +let images = [];
20 +let dragState = null;
21 +let resizeState = null;
22 +
23 +// Prevent default drag behavior on entire document
24 +document.addEventListener('dragover', (e) => {
25 + e.preventDefault();
26 + e.stopPropagation();
27 +});
28 +
29 +document.addEventListener('drop', async (e) => {
30 + e.preventDefault();
31 + e.stopPropagation();
32 +
33 + const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/'));
34 + if (files.length === 0) return;
35 +
36 + // Get drop position relative to the grid
37 + const gridRect = grid.getBoundingClientRect();
38 + const gridWidth = GRID_COLS * CELL_SIZE_PX;
39 + const gridHeight = GRID_ROWS * CELL_SIZE_PX;
40 +
41 + // Clamp drop position to grid boundaries
42 + let dropX = e.clientX - gridRect.left;
43 + let dropY = e.clientY - gridRect.top;
44 + dropX = Math.max(0, Math.min(gridWidth, dropX));
45 + dropY = Math.max(0, Math.min(gridHeight, dropY));
46 +
47 + // Load first image to get base dimensions
48 + let firstImageDimensions = null;
49 + if (files.length > 0) {
50 + const firstFile = files[0];
51 + const reader = new FileReader();
52 + const dataUrl = await new Promise(resolve => {
53 + reader.onload = (e) => resolve(e.target.result);
54 + reader.readAsDataURL(firstFile);
55 + });
56 +
57 + const img = new Image();
58 + await new Promise(resolve => {
59 + img.onload = () => {
60 + const aspectRatio = img.width / img.height;
61 + let widthCells, heightCells;
62 + if (aspectRatio >= 1) {
63 + heightCells = 5;
64 + widthCells = Math.round(heightCells * aspectRatio);
65 + } else {
66 + widthCells = 5;
67 + heightCells = Math.round(widthCells / aspectRatio);
68 + }
69 + widthCells = Math.min(widthCells, GRID_COLS);
70 + heightCells = Math.min(heightCells, GRID_ROWS);
71 + firstImageDimensions = { widthCells, heightCells };
72 + resolve();
73 + };
74 + img.src = dataUrl;
75 + });
76 + }
77 +
78 + // Now process all files
79 + files.forEach((file, idx) => {
80 + const reader = new FileReader();
81 + reader.onload = (event) => {
82 + const img = new Image();
83 + img.onload = () => {
84 + const aspectRatio = img.width / img.height;
85 +
86 + // Set smaller dimension to 5 cells, calculate larger dimension
87 + let widthCells, heightCells;
88 + if (aspectRatio >= 1) {
89 + // Width is larger or equal
90 + heightCells = 5;
91 + widthCells = Math.round(heightCells * aspectRatio);
92 + } else {
93 + // Height is larger
94 + widthCells = 5;
95 + heightCells = Math.round(widthCells / aspectRatio);
96 + }
97 +
98 + // Clamp to grid boundaries
99 + widthCells = Math.min(widthCells, GRID_COLS);
100 + heightCells = Math.min(heightCells, GRID_ROWS);
101 +
102 + // Calculate position with first image's center under drop point
103 + let xCell = Math.round(dropX / CELL_SIZE_PX - firstImageDimensions.widthCells / 2) + (idx * 2);
104 + let yCell = Math.round(dropY / CELL_SIZE_PX - firstImageDimensions.heightCells / 2);
105 +
106 + // Ensure image stays within grid bounds
107 + xCell = Math.max(0, Math.min(GRID_COLS - widthCells, xCell));
108 + yCell = Math.max(0, Math.min(GRID_ROWS - heightCells, yCell));
109 +
110 + addImage(event.target.result, xCell, yCell, widthCells, heightCells, idx + 1);
111 + };
112 + img.src = event.target.result;
113 + };
114 + reader.readAsDataURL(file);
115 + });
116 +});
117 +
118 +function addImage(src, xCell, yCell, widthCells, heightCells, zIndex = 0) {
119 + const container = document.createElement('div');
120 + container.className = 'image-container';
121 + if (zIndex > 0) {
122 + container.style.zIndex = zIndex;
123 + }
124 +
125 + const wrapper = document.createElement('div');
126 + wrapper.className = 'image-wrapper';
127 + const img = document.createElement('img');
128 + img.src = src;
129 + wrapper.appendChild(img);
130 + container.appendChild(wrapper);
131 +
132 + // Add resize handles
133 + const handles = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
134 + handles.forEach(dir => {
135 + const handle = document.createElement('div');
136 + handle.className = `resize-handle ${dir.length === 1 ? 'edge' : 'corner'} ${dir}`;
137 + handle.dataset.direction = dir;
138 + container.appendChild(handle);
139 + });
140 +
141 + const imageData = {
142 + container,
143 + xCell,
144 + yCell,
145 + widthCells,
146 + heightCells,
147 + // Image positioning within container (in pixels, relative to center)
148 + panX: 0,
149 + panY: 0,
150 + userScale: 1, // user zoom level (1-5)
151 + // Store natural image dimensions for calculations
152 + naturalWidth: 0,
153 + naturalHeight: 0,
154 + baseScale: 1 // scale needed to cover container
155 + };
156 +
157 + // Calculate dimensions and scale once image loads
158 + img.onload = () => {
159 + imageData.naturalWidth = img.naturalWidth;
160 + imageData.naturalHeight = img.naturalHeight;
161 +
162 + // Calculate base scale to cover container (mimics object-fit: cover)
163 + const containerWidth = imageData.widthCells * CELL_SIZE_PX;
164 + const containerHeight = imageData.heightCells * CELL_SIZE_PX;
165 + const scaleX = containerWidth / img.naturalWidth;
166 + const scaleY = containerHeight / img.naturalHeight;
167 + imageData.baseScale = Math.max(scaleX, scaleY);
168 +
169 + updateImagePosition(imageData);
170 + };
171 + images.push(imageData);
172 +
173 + updateImagePosition(imageData);
174 + grid.appendChild(container);
175 +
176 + setupImageHandlers(imageData);
177 +}
178 +
179 +function calculatePanBounds(imageData) {
180 + const containerWidth = imageData.widthCells * CELL_SIZE_PX;
181 + const containerHeight = imageData.heightCells * CELL_SIZE_PX;
182 +
183 + if (imageData.naturalWidth === 0 || imageData.naturalHeight === 0) {
184 + return { maxPanX: 0, maxPanY: 0 };
185 + }
186 +
187 + // Calculate actual rendered size with both base scale and user scale
188 + const totalScale = imageData.baseScale * imageData.userScale;
189 + const renderedWidth = imageData.naturalWidth * totalScale;
190 + const renderedHeight = imageData.naturalHeight * totalScale;
191 +
192 + // Calculate maximum pan in each direction
193 + const maxPanX = Math.max(0, (renderedWidth - containerWidth) / 2);
194 + const maxPanY = Math.max(0, (renderedHeight - containerHeight) / 2);
195 +
196 + return { maxPanX, maxPanY };
197 +}
198 +
199 +function clampPan(imageData) {
200 + const { maxPanX, maxPanY } = calculatePanBounds(imageData);
201 + imageData.panX = Math.max(-maxPanX, Math.min(maxPanX, imageData.panX));
202 + imageData.panY = Math.max(-maxPanY, Math.min(maxPanY, imageData.panY));
203 +}
204 +
205 +function updateImagePosition(img) {
206 + img.container.style.left = img.xCell * CELL_SIZE_PX + 'px';
207 + img.container.style.top = img.yCell * CELL_SIZE_PX + 'px';
208 + img.container.style.width = img.widthCells * CELL_SIZE_PX + 'px';
209 + img.container.style.height = img.heightCells * CELL_SIZE_PX + 'px';
210 +
211 + // Recalculate baseScale if container size changed
212 + if (img.naturalWidth > 0 && img.naturalHeight > 0) {
213 + const containerWidth = img.widthCells * CELL_SIZE_PX;
214 + const containerHeight = img.heightCells * CELL_SIZE_PX;
215 + const scaleX = containerWidth / img.naturalWidth;
216 + const scaleY = containerHeight / img.naturalHeight;
217 + img.baseScale = Math.max(scaleX, scaleY);
218 +
219 + // Reclamp pan after recalculating base scale
220 + clampPan(img);
221 + }
222 +
223 + // Apply image positioning and scale using transform
224 + const imgElement = img.container.querySelector('img');
225 + if (imgElement) {
226 + const totalScale = img.baseScale * img.userScale;
227 + // Transform: translate from center (-50%, -50%), then pan, then scale
228 + imgElement.style.transform = `translate(-50%, -50%) translate(${img.panX}px, ${img.panY}px) scale(${totalScale})`;
229 + }
230 +}
231 +
232 +function setupImageHandlers(imageData) {
233 + const container = imageData.container;
234 +
235 + // Moving / Deleting
236 + container.addEventListener('mousedown', (e) => {
237 + if (e.target.classList.contains('resize-handle')) return;
238 +
239 + e.preventDefault();
240 +
241 + // Shift-click to delete
242 + if (e.shiftKey) {
243 + const index = images.indexOf(imageData);
244 + if (index > -1) {
245 + images.splice(index, 1);
246 + }
247 + container.remove();
248 + return;
249 + }
250 +
251 + // Lock cursor to move during drag operation
252 + document.body.style.cursor = 'move';
253 + document.body.classList.add('dragging');
254 +
255 + dragState = {
256 + image: imageData,
257 + startX: e.clientX,
258 + startY: e.clientY,
259 + startXCell: imageData.xCell,
260 + startYCell: imageData.yCell
261 + };
262 + container.classList.add('dragging');
263 + });
264 +
265 + // Resizing
266 + container.querySelectorAll('.resize-handle').forEach(handle => {
267 + handle.addEventListener('mousedown', (e) => {
268 + e.preventDefault();
269 + e.stopPropagation();
270 +
271 + // Get the cursor style from the handle and apply it to the body
272 + const cursorStyle = window.getComputedStyle(handle).cursor;
273 + document.body.style.cursor = cursorStyle;
274 + document.body.classList.add('resizing');
275 +
276 + resizeState = {
277 + image: imageData,
278 + direction: handle.dataset.direction,
279 + startX: e.clientX,
280 + startY: e.clientY,
281 + startXCell: imageData.xCell,
282 + startYCell: imageData.yCell,
283 + startWidthCells: imageData.widthCells,
284 + startHeightCells: imageData.heightCells
285 + };
286 + container.classList.add('resizing');
287 + });
288 + });
289 +
290 + // Pan and Zoom with wheel events (macOS trackpad gestures)
291 + container.addEventListener('wheel', (e) => {
292 + // Don't interfere with dragging or resizing
293 + if (dragState || resizeState) return;
294 +
295 + e.preventDefault();
296 + e.stopPropagation();
297 +
298 + // Detect pinch zoom (ctrlKey is set for pinch gestures on macOS trackpad)
299 + if (e.ctrlKey) {
300 + // Zoom under cursor
301 + const rect = container.getBoundingClientRect();
302 + const mouseX = e.clientX - rect.left;
303 + const mouseY = e.clientY - rect.top;
304 +
305 + // Calculate mouse position relative to container center
306 + const centerX = rect.width / 2;
307 + const centerY = rect.height / 2;
308 + const offsetX = mouseX - centerX;
309 + const offsetY = mouseY - centerY;
310 +
311 + const oldUserScale = imageData.userScale;
312 + const zoomDelta = -e.deltaY * 0.01;
313 + const newUserScale = Math.max(1, Math.min(5, oldUserScale * (1 + zoomDelta)));
314 +
315 + // Adjust pan to keep point under cursor stationary
316 + const scaleFactor = newUserScale / oldUserScale - 1;
317 + imageData.panX -= offsetX * scaleFactor;
318 + imageData.panY -= offsetY * scaleFactor;
319 + imageData.userScale = newUserScale;
320 +
321 + // Clamp after zooming to prevent whitespace
322 + clampPan(imageData);
323 + } else {
324 + // Pan (two-finger scroll on macOS trackpad)
325 + imageData.panX -= e.deltaX;
326 + imageData.panY -= e.deltaY;
327 +
328 + // Clamp pan to prevent whitespace
329 + clampPan(imageData);
330 + }
331 +
332 + updateImagePosition(imageData);
333 + }, { passive: false });
334 +}
335 +
336 +document.addEventListener('mousemove', (e) => {
337 + if (dragState) {
338 + const dx = e.clientX - dragState.startX;
339 + const dy = e.clientY - dragState.startY;
340 +
341 + const dxCells = Math.round(dx / CELL_SIZE_PX);
342 + const dyCells = Math.round(dy / CELL_SIZE_PX);
343 +
344 + const newXCell = Math.max(0, Math.min(GRID_COLS - dragState.image.widthCells, dragState.startXCell + dxCells));
345 + const newYCell = Math.max(0, Math.min(GRID_ROWS - dragState.image.heightCells, dragState.startYCell + dyCells));
346 +
347 + dragState.image.xCell = newXCell;
348 + dragState.image.yCell = newYCell;
349 + updateImagePosition(dragState.image);
350 + }
351 +
352 + if (resizeState) {
353 + const dx = e.clientX - resizeState.startX;
354 + const dy = e.clientY - resizeState.startY;
355 +
356 + const dxCells = Math.round(dx / CELL_SIZE_PX);
357 + const dyCells = Math.round(dy / CELL_SIZE_PX);
358 +
359 + const dir = resizeState.direction;
360 + const img = resizeState.image;
361 +
362 + let newX = img.xCell;
363 + let newY = img.yCell;
364 + let newW = img.widthCells;
365 + let newH = img.heightCells;
366 +
367 + if (dir.includes('e')) {
368 + const proposedW = Math.max(1, resizeState.startWidthCells + dxCells);
369 + // Clamp to grid boundary
370 + newW = Math.min(proposedW, GRID_COLS - resizeState.startXCell);
371 + }
372 + if (dir.includes('w')) {
373 + const delta = Math.min(dxCells, resizeState.startWidthCells - 1);
374 + const proposedX = resizeState.startXCell + delta;
375 + // Clamp to grid boundary
376 + const clampedX = Math.max(0, proposedX);
377 + newX = clampedX;
378 + newW = resizeState.startWidthCells - (clampedX - resizeState.startXCell);
379 + }
380 + if (dir.includes('s')) {
381 + const proposedH = Math.max(1, resizeState.startHeightCells + dyCells);
382 + // Clamp to grid boundary
383 + newH = Math.min(proposedH, GRID_ROWS - resizeState.startYCell);
384 + }
385 + if (dir.includes('n')) {
386 + const delta = Math.min(dyCells, resizeState.startHeightCells - 1);
387 + const proposedY = resizeState.startYCell + delta;
388 + // Clamp to grid boundary
389 + const clampedY = Math.max(0, proposedY);
390 + newY = clampedY;
391 + newH = resizeState.startHeightCells - (clampedY - resizeState.startYCell);
392 + }
393 +
394 + img.xCell = newX;
395 + img.yCell = newY;
396 + img.widthCells = newW;
397 + img.heightCells = newH;
398 + updateImagePosition(img);
399 + }
400 +});
401 +
402 +document.addEventListener('mouseup', () => {
403 + if (dragState) {
404 + dragState.image.container.classList.remove('dragging');
405 + dragState = null;
406 + // Restore the default cursor
407 + document.body.style.cursor = '';
408 + document.body.classList.remove('dragging');
409 + }
410 + if (resizeState) {
411 + resizeState.image.container.classList.remove('resizing');
412 + resizeState = null;
413 + // Restore the default cursor
414 + document.body.style.cursor = '';
415 + document.body.classList.remove('resizing');
416 + }
417 +});
1 +:root {
2 + --color-bg-body: #f5f5f0;
3 + --color-bg-page: #fafaf8;
4 + --color-grid-line: #d0d0d0;
5 + --color-accent: #4a90e2;
6 + --color-white: white;
7 +}
8 +
9 +* {
10 + margin: 0;
11 + padding: 0;
12 + box-sizing: border-box;
13 +}
14 +
15 +body {
16 + background: var(--color-bg-body);
17 + display: flex;
18 + justify-content: center;
19 + align-items: flex-start;
20 + min-height: 100vh;
21 + padding: 20px;
22 +}
23 +
24 +.page {
25 + width: 8.5in;
26 + height: 11in;
27 + background: var(--color-bg-page);
28 + position: relative;
29 + box-shadow: 0 2px 10px rgba(0,0,0,0.1);
30 + transform-origin: top center;
31 +}
32 +
33 +.grid {
34 + position: absolute;
35 + top: calc((11in - 264mm) / 2);
36 + left: calc((8.5in - 200mm) / 2);
37 + display: grid;
38 + grid-template-columns: repeat(50, 4mm);
39 + grid-template-rows: repeat(66, 4mm);
40 + gap: 0;
41 + line-height: 0;
42 +}
43 +
44 +.grid-cell {
45 + width: 4mm;
46 + height: 4mm;
47 + box-sizing: border-box;
48 + border-right: 1px dashed var(--color-grid-line);
49 + border-bottom: 1px dashed var(--color-grid-line);
50 + margin: 0;
51 + padding: 0;
52 + display: block;
53 +}
54 +
55 +.grid-cell:nth-child(-n+50) {
56 + border-top: 1px dashed var(--color-grid-line);
57 +}
58 +
59 +.grid-cell:nth-child(50n+1) {
60 + border-left: 1px dashed var(--color-grid-line);
61 +}
62 +
63 +.image-container {
64 + position: absolute;
65 + cursor: move;
66 + outline: 2px solid transparent;
67 + outline-offset: -2px;
68 + transition: outline-color 0.2s;
69 + background: var(--color-bg-page);
70 +}
71 +
72 +.image-container::after {
73 + content: '';
74 + position: absolute;
75 + top: 0;
76 + left: 0;
77 + right: 0;
78 + bottom: 0;
79 + pointer-events: none;
80 + border: 2px solid transparent;
81 + transition: border-color 0.2s;
82 + z-index: 1;
83 + overflow: hidden;
84 +}
85 +
86 +.image-container .image-wrapper {
87 + position: absolute;
88 + top: 0;
89 + left: 0;
90 + right: 0;
91 + bottom: 0;
92 + overflow: hidden;
93 +}
94 +
95 +.image-container:hover::after {
96 + border-color: var(--color-accent);
97 +}
98 +
99 +.image-container.dragging {
100 + opacity: 0.7;
101 +}
102 +
103 +.image-container.dragging::after {
104 + border-color: var(--color-accent);
105 +}
106 +
107 +.image-container.resizing::after {
108 + border-color: var(--color-accent);
109 +}
110 +
111 +body.resizing *,
112 +body.dragging * {
113 + cursor: inherit !important;
114 +}
115 +
116 +.image-container img {
117 + position: absolute;
118 + top: 50%;
119 + left: 50%;
120 + display: block;
121 + pointer-events: none;
122 + transform-origin: center center;
123 + transition: none;
124 +}
125 +
126 +.resize-handle {
127 + position: absolute;
128 + background: var(--color-accent);
129 + opacity: 0;
130 + transition: opacity 0.2s;
131 + z-index: 2;
132 +}
133 +
134 +.image-container:hover .resize-handle {
135 + opacity: 0.8;
136 +}
137 +
138 +.resize-handle.corner {
139 + width: 10px;
140 + height: 10px;
141 + background: transparent;
142 +}
143 +
144 +.resize-handle.corner::before {
145 + content: '';
146 + position: absolute;
147 + width: 10px;
148 + height: 10px;
149 + background: var(--color-accent);
150 + border-radius: 50%;
151 +}
152 +
153 +.resize-handle.edge {
154 + background: transparent;
155 +}
156 +
157 +.resize-handle.n { top: -5px; left: 5px; right: 5px; height: 10px; cursor: n-resize; }
158 +.resize-handle.s { bottom: -5px; left: 5px; right: 5px; height: 10px; cursor: s-resize; }
159 +.resize-handle.e { right: -5px; top: 5px; bottom: 5px; width: 10px; cursor: e-resize; }
160 +.resize-handle.w { left: -5px; top: 5px; bottom: 5px; width: 10px; cursor: w-resize; }
161 +
162 +.resize-handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
163 +.resize-handle.ne::before { top: 0; right: 0; }
164 +
165 +.resize-handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
166 +.resize-handle.nw::before { top: 0; left: 0; }
167 +
168 +.resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
169 +.resize-handle.se::before { bottom: 0; right: 0; }
170 +
171 +.resize-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
172 +.resize-handle.sw::before { bottom: 0; left: 0; }
173 +
174 +@media print {
175 + * {
176 + -webkit-print-color-adjust: exact !important;
177 + print-color-adjust: exact !important;
178 + }
179 +
180 + body {
181 + background: var(--color-white);
182 + padding: 0;
183 + margin: 0;
184 + display: block;
185 + align-items: initial;
186 + justify-content: initial;
187 + }
188 +
189 + .page {
190 + width: 8.5in;
191 + height: 11in;
192 + box-shadow: none;
193 + margin: 0;
194 + padding: 0;
195 + background: var(--color-white);
196 + transform: none !important;
197 + position: relative;
198 + }
199 +
200 + .grid {
201 + position: absolute;
202 + top: calc((11in - 264mm) / 2);
203 + left: calc((8.5in - 200mm) / 2);
204 + display: grid;
205 + grid-template-columns: repeat(50, 4mm);
206 + grid-template-rows: repeat(66, 4mm);
207 + }
208 +
209 + .grid-cell {
210 + border-right: 1px dashed var(--color-grid-line) !important;
211 + border-bottom: 1px dashed var(--color-grid-line) !important;
212 + box-sizing: border-box !important;
213 + width: 4mm !important;
214 + height: 4mm !important;
215 + margin: 0 !important;
216 + padding: 0 !important;
217 + }
218 +
219 + .grid-cell:nth-child(-n+50) {
220 + border-top: 1px dashed var(--color-grid-line) !important;
221 + }
222 +
223 + .grid-cell:nth-child(50n+1) {
224 + border-left: 1px dashed var(--color-grid-line) !important;
225 + }
226 +
227 + .image-container {
228 + outline: none !important;
229 + position: absolute !important;
230 + background: var(--color-white) !important;
231 + }
232 +
233 + .image-container .image-wrapper {
234 + position: absolute !important;
235 + top: 0 !important;
236 + left: 0 !important;
237 + right: 0 !important;
238 + bottom: 0 !important;
239 + overflow: hidden !important;
240 + }
241 +
242 + .image-container img {
243 + position: absolute !important;
244 + top: 50% !important;
245 + left: 50% !important;
246 + display: block !important;
247 + pointer-events: none !important;
248 + /* Preserve the transform from the screen view */
249 + }
250 +
251 + .resize-handle {
252 + display: none !important;
253 + }
254 +}
255 +
256 +@page {
257 + size: 8.5in 11in;
258 + margin: 0;
259 +}