Skip to content

Instantly share code, notes, and snippets.

@sma
Created March 10, 2025 09:13
Show Gist options
  • Save sma/5ea43066466fcb9d4220df76dcbc35b3 to your computer and use it in GitHub Desktop.
Save sma/5ea43066466fcb9d4220df76dcbc35b3 to your computer and use it in GitHub Desktop.
A local web app written by Claude.ai to partly reveal maps on a second browser tab
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TTRPG Map Revealer</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background-color: #1e1e1e;
color: #e0e0e0;
}
.header {
background-color: #2d2d2d;
color: #e0e0e0;
padding: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.tab-identifier {
font-weight: bold;
font-size: 1.2em;
}
.controls {
display: flex;
gap: 10px;
align-items: center;
width: 100%;
}
.gm-controls {
display: flex;
gap: 10px;
align-items: center;
width: 100%;
}
/* Toggle button styles */
.toggle-container {
display: inline-flex;
margin-right: 10px;
border-radius: 4px;
overflow: hidden;
}
.toggle-container button {
border-radius: 0;
margin: 0;
padding: 8px 15px;
}
.toggle-container button:first-child {
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
}
.toggle-container button:last-child {
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
}
.toggle-container button.active {
background-color: #444;
font-weight: bold;
box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.3);
}
.toggle-container button:not(.active) {
background-color: #333;
opacity: 0.8;
}
.player-message {
text-align: center;
margin: 10px;
font-style: italic;
color: #aaa;
}
.main-content {
flex: 1;
display: flex;
overflow: hidden;
}
.map-tabs {
width: 40px;
background-color: #252525;
display: flex;
flex-direction: column;
padding: 5px;
gap: 5px;
}
.map-tab {
width: 30px;
height: 30px;
display: flex;
justify-content: center;
align-items: center;
background-color: #333;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
transition: background-color 0.2s;
}
.map-tab:hover {
background-color: #444;
}
.map-tab.active {
background-color: #555;
}
.map-tab.has-content {
color: #88c0d0;
}
.main-area {
flex: 1;
overflow: hidden;
position: relative;
background-color: #2d2d2d;
display: flex;
justify-content: center;
align-items: center;
}
#dropZone {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.3);
border: 3px dashed #555;
z-index: 10;
color: #aaa;
}
#dropZone.hidden {
display: none;
}
.canvas-container {
position: relative;
overflow: auto;
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
}
canvas {
display: block;
background-color: #333;
}
button {
padding: 8px 12px;
background-color: #444;
color: #e0e0e0;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #555;
}
button.danger-btn {
background-color: #555;
}
button.danger-btn:hover {
background-color: #666;
}
.status {
margin-left: 10px;
font-style: italic;
color: #aaa;
}
/* Zoom controls */
.zoom-controls {
position: absolute;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 5px;
z-index: 20;
}
.zoom-controls button {
width: 40px;
height: 40px;
font-size: 18px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(50, 50, 50, 0.7);
color: white;
border: none;
}
.zoom-controls button:hover {
background-color: rgba(70, 70, 70, 0.9);
}
.zoom-level {
text-align: center;
font-size: 14px;
background-color: rgba(0, 0, 0, 0.5);
padding: 5px;
border-radius: 4px;
}
.empty-map-message {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
color: #666;
text-align: center;
padding: 20px;
}
.empty-map-message h2 {
margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="header" id="header">
<div class="tab-identifier" id="tabType">Detecting...</div>
<div class="controls" id="controlsArea">
<!-- Controls will be inserted here dynamically -->
</div>
</div>
<div class="main-content">
<div class="map-tabs" id="mapTabs">
<!-- Map tabs will be inserted here dynamically -->
</div>
<div class="main-area">
<div id="dropZone">
<h2>Drag & Drop or Paste an Image</h2>
<p>Click anywhere to browse files</p>
</div>
<div id="emptyMapMessage" class="empty-map-message">
<h2>No Map Loaded</h2>
<p>Drag and drop an image or paste from clipboard</p>
</div>
<div class="canvas-container" id="canvasContainer">
<canvas id="mapCanvas"></canvas>
</div>
<div class="zoom-controls" id="zoomControls">
<button id="zoomIn">+</button>
<div class="zoom-level" id="zoomLevel">100%</div>
<button id="zoomOut">-</button>
</div>
</div>
</div>
<input type="file" id="fileInput" style="display: none;" accept="image/*">
<script>
// Check if this is the first tab or a subsequent one
const isGM = !window.location.hash.includes('player');
const tabType = document.getElementById('tabType');
const controlsArea = document.getElementById('controlsArea');
const dropZone = document.getElementById('dropZone');
const canvas = document.getElementById('mapCanvas');
const ctx = canvas.getContext('2d');
const fileInput = document.getElementById('fileInput');
const zoomInBtn = document.getElementById('zoomIn');
const zoomOutBtn = document.getElementById('zoomOut');
const zoomLevelEl = document.getElementById('zoomLevel');
const canvasContainer = document.getElementById('canvasContainer');
const zoomControls = document.getElementById('zoomControls');
const header = document.getElementById('header');
const mapTabs = document.getElementById('mapTabs');
const emptyMapMessage = document.getElementById('emptyMapMessage');
// Setup UI based on tab type
if (isGM) {
tabType.textContent = 'GM Tab';
// Create GM controls
const gmControls = document.createElement('div');
gmControls.className = 'gm-controls';
// Create toggle container for reveal/hide mode
const toggleContainer = document.createElement('div');
toggleContainer.className = 'toggle-container';
const revealModeBtn = document.createElement('button');
revealModeBtn.textContent = 'Reveal Mode';
revealModeBtn.className = 'active';
revealModeBtn.onclick = () => setDrawMode('reveal');
const hideModeBtn = document.createElement('button');
hideModeBtn.textContent = 'Hide Mode';
hideModeBtn.onclick = () => setDrawMode('hide');
toggleContainer.appendChild(revealModeBtn);
toggleContainer.appendChild(hideModeBtn);
// Create toggle container for shape mode
const shapeToggleContainer = document.createElement('div');
shapeToggleContainer.className = 'toggle-container';
const rectModeBtn = document.createElement('button');
rectModeBtn.textContent = 'Rectangle';
rectModeBtn.className = 'active';
rectModeBtn.onclick = () => setShapeMode('rectangle');
const ovalModeBtn = document.createElement('button');
ovalModeBtn.textContent = 'Oval';
ovalModeBtn.onclick = () => setShapeMode('oval');
shapeToggleContainer.appendChild(rectModeBtn);
shapeToggleContainer.appendChild(ovalModeBtn);
const revealBtn = document.createElement('button');
revealBtn.textContent = 'Reveal All';
revealBtn.onclick = revealAll;
const hideBtn = document.createElement('button');
hideBtn.textContent = 'Hide All';
hideBtn.className = 'danger-btn';
hideBtn.onclick = hideAll;
const clearBtn = document.createElement('button');
clearBtn.textContent = 'Clear Map';
clearBtn.className = 'danger-btn';
clearBtn.onclick = clearMap;
const openPlayerBtn = document.createElement('button');
openPlayerBtn.textContent = 'Open Player Tab';
openPlayerBtn.onclick = openPlayerTab;
openPlayerBtn.style.backgroundColor = '#c08e3f';
const status = document.createElement('span');
status.className = 'status';
status.id = 'status';
status.textContent = 'Ready';
gmControls.appendChild(toggleContainer);
gmControls.appendChild(shapeToggleContainer);
gmControls.appendChild(revealBtn);
gmControls.appendChild(hideBtn);
gmControls.appendChild(clearBtn);
gmControls.appendChild(openPlayerBtn);
gmControls.appendChild(status);
controlsArea.appendChild(gmControls);
// Create map tabs
for (let i = 1; i <= 9; i++) {
const tab = document.createElement('div');
tab.className = 'map-tab';
tab.textContent = i;
tab.dataset.index = i;
tab.onclick = () => switchMap(i);
mapTabs.appendChild(tab);
}
} else {
tabType.textContent = 'Player Tab';
// Hide header, zoom controls, and map tabs in player tab
if (header) header.style.display = 'none';
if (zoomControls) zoomControls.style.display = 'none';
if (mapTabs) mapTabs.style.display = 'none';
// Add player message
const playerMessage = document.createElement('div');
playerMessage.className = 'player-message';
playerMessage.textContent = 'This tab displays the map for your players. Wait for the GM tab to upload an image.';
document.body.insertBefore(playerMessage, document.querySelector('.main-content'));
// Hide drop zone in player tab
dropZone.style.display = 'none';
}
// State variables
let currentImage = null;
let maskCanvas = document.createElement('canvas');
let maskCtx = maskCanvas.getContext('2d');
let drawMode = 'reveal'; // 'reveal' or 'hide'
let shapeMode = 'rectangle'; // 'rectangle' or 'oval'
let isDrawing = false;
let startX = 0;
let startY = 0;
// For long press and circles animation
let longPressTimeout = null;
let isLongPress = false;
let circleAnimationFrame = null;
let circleRadius = 0;
let circleX = 0;
let circleY = 0;
let circleGrowth = true;
let isAnimating = false;
// For scaling/panning
let scale = 1;
let zoomStep = 0.1;
// For multiple maps
let currentMapIndex = 1; // Start with map 1
const maps = Array(9).fill().map(() => ({
image: null,
maskData: null,
scale: 1,
scrollX: 0,
scrollY: 0
}));
// Communication channel between tabs
const channel = new BroadcastChannel('map-revealer');
// Listen for messages from other tabs
channel.onmessage = (event) => {
const { type, data } = event.data;
switch (type) {
case 'image-upload':
handleImageUpdate(data.imageData, data.width, data.height, data.mapIndex);
break;
case 'mask-update':
updateMask(data.maskData, data.mapIndex);
break;
case 'reveal-all':
updateMaskAll(true);
break;
case 'hide-all':
updateMaskAll(false);
break;
case 'zoom-update':
updateZoom(data.scale);
break;
case 'scroll-update':
updateScroll(data.scrollX, data.scrollY);
break;
case 'switch-map':
if (!isGM) {
switchMap(data.mapIndex);
}
break;
case 'clear-map':
if (!isGM) {
clearMapData(data.mapIndex);
if (currentMapIndex === data.mapIndex) {
showEmptyState();
}
}
break;
case 'circle-animation':
// Only start animation if not already animating
if (!isAnimating) {
isAnimating = true;
startCircleAnimation(data.x, data.y);
}
break;
case 'stop-circle-animation':
stopCircleAnimation();
isAnimating = false;
break;
}
};
// Initialize the app
function init() {
// Handle file input and drag & drop
fileInput.addEventListener('change', handleFileSelect);
// Set up zoom controls
if (zoomInBtn && zoomOutBtn) {
zoomInBtn.addEventListener('click', () => {
changeZoom(scale + zoomStep);
});
zoomOutBtn.addEventListener('click', () => {
changeZoom(scale - zoomStep);
});
}
// Drag and drop events
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.style.backgroundColor = 'rgba(0, 0, 0, 0.4)';
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.style.backgroundColor = 'rgba(0, 0, 0, 0.3)';
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.style.backgroundColor = 'rgba(0, 0, 0, 0.3)';
if (isGM && e.dataTransfer.files.length > 0) {
processFile(e.dataTransfer.files[0]);
}
});
// Click on drop zone to open file browser
dropZone.addEventListener('click', () => {
if (isGM) {
fileInput.click();
}
});
// Allow pasting images
document.addEventListener('paste', (e) => {
if (isGM && e.clipboardData) {
const items = e.clipboardData.items;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const blob = items[i].getAsFile();
processFile(blob);
break;
}
}
}
});
// Setup canvas drawing events if GM
if (isGM) {
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseup', handleMouseUp);
canvas.addEventListener('mouseleave', handleMouseLeave);
}
// Set up scroll synchronization
if (canvasContainer) {
canvasContainer.addEventListener('scroll', function() {
if (isGM && currentImage) {
// Save scroll position for current map
maps[currentMapIndex - 1].scrollX = canvasContainer.scrollLeft;
maps[currentMapIndex - 1].scrollY = canvasContainer.scrollTop;
broadcastScrollUpdate(canvasContainer.scrollLeft, canvasContainer.scrollTop);
}
});
}
// Initial UI setup
updateMapUI();
// Make first tab active by default
if (isGM) {
const firstTab = document.querySelector('.map-tab[data-index="1"]');
if (firstTab) {
firstTab.classList.add('active');
}
}
}
// Mouse Down Handler - Detect drawing or long press
function handleMouseDown(e) {
if (!currentImage) return;
const rect = canvas.getBoundingClientRect();
startX = (e.clientX - rect.left) / scale;
startY = (e.clientY - rect.top) / scale;
// Start detecting long press (2 seconds)
longPressTimeout = setTimeout(() => {
isLongPress = true;
isDrawing = false; // Disable drawing when in animation mode
isAnimating = true;
// Start animation locally
startCircleAnimation(startX, startY);
// Just broadcast the start command with coordinates
broadcastCircleAnimation(startX, startY);
}, 2000); // 2000ms (2s) for long press
isDrawing = true;
}
// Mouse Move Handler
function handleMouseMove(e) {
if (!currentImage) return;
if (isLongPress || isAnimating) {
// Nothing to do for circle animation while moving
return;
}
// Clear long press timeout
clearTimeout(longPressTimeout);
if (isDrawing) {
// Redraw the image with mask
applyMask();
// Get current position
const rect = canvas.getBoundingClientRect();
const currentX = (e.clientX - rect.left) / scale;
const currentY = (e.clientY - rect.top) / scale;
// Draw the shape preview
ctx.strokeStyle = drawMode === 'reveal' ? 'rgba(0, 255, 0, 0.8)' : 'rgba(255, 0, 0, 0.8)';
ctx.lineWidth = 2;
ctx.save();
ctx.scale(scale, scale);
ctx.beginPath();
const width = currentX - startX;
const height = currentY - startY;
if (shapeMode === 'rectangle') {
// Rectangle mode
ctx.rect(startX, startY, width, height);
} else {
// Oval mode
const centerX = startX + width / 2;
const centerY = startY + height / 2;
const radiusX = Math.abs(width / 2);
const radiusY = Math.abs(height / 2);
ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2);
}
ctx.stroke();
ctx.restore();
}
}
// Mouse Up Handler
function handleMouseUp(e) {
if (!currentImage) return;
// Clear long press timeout
clearTimeout(longPressTimeout);
if (isLongPress || isAnimating) {
// Stop circle animation
stopCircleAnimation();
broadcastStopCircleAnimation();
isLongPress = false;
isAnimating = false;
isDrawing = false;
return;
}
if (!isDrawing) return;
isDrawing = false;
// Get current position
const rect = canvas.getBoundingClientRect();
const endX = (e.clientX - rect.left) / scale;
const endY = (e.clientY - rect.top) / scale;
// Calculate width and height
const width = Math.abs(endX - startX);
const height = Math.abs(endY - startY);
// Skip if the shape is too small
if (width < 5 / scale || height < 5 / scale) {
applyMask();
return;
}
// Update the mask
maskCtx.fillStyle = drawMode === 'reveal' ? 'white' : 'black';
if (shapeMode === 'rectangle') {
// Rectangle mode
const x = Math.min(startX, endX);
const y = Math.min(startY, endY);
maskCtx.fillRect(x, y, width, height);
} else {
// Oval mode
const centerX = (startX + endX) / 2;
const centerY = (startY + endY) / 2;
const radiusX = width / 2;
const radiusY = height / 2;
maskCtx.beginPath();
maskCtx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2);
maskCtx.fill();
}
// Apply mask and broadcast change
applyMask();
broadcastMaskUpdate();
// Update status
updateStatus(`Area ${drawMode === 'reveal' ? 'revealed' : 'hidden'}.`);
}
// Mouse Leave Handler
function handleMouseLeave(e) {
// Clear long press timeout
clearTimeout(longPressTimeout);
if (isLongPress || isAnimating) {
stopCircleAnimation();
broadcastStopCircleAnimation();
isLongPress = false;
isAnimating = false;
}
if (isDrawing) {
isDrawing = false;
applyMask();
}
}
// Start circle animation
function startCircleAnimation(x, y) {
circleX = x;
circleY = y;
circleRadius = 20; // Twice as large, start at 20
circleGrowth = true;
isAnimating = true;
// Stop any existing animation
if (circleAnimationFrame) {
cancelAnimationFrame(circleAnimationFrame);
}
// Start the animation
animateCircles();
}
// Animate concentric circles
function animateCircles() {
// Redraw the canvas with mask
applyMask();
// Draw the circles
ctx.save();
ctx.scale(scale, scale);
// Draw 3 circles with different opacities
drawCircle(circleRadius, 0.8);
if (circleRadius > 30) drawCircle(circleRadius - 30, 0.6);
if (circleRadius > 60) drawCircle(circleRadius - 60, 0.4);
ctx.restore();
// Update the circle radius (oscillate between 20 and 100) - twice as large
if (circleGrowth) {
circleRadius += 4; // Move faster
if (circleRadius >= 100) circleGrowth = false;
} else {
circleRadius -= 4; // Move faster
if (circleRadius <= 20) circleGrowth = true;
}
// Continue animation
circleAnimationFrame = requestAnimationFrame(animateCircles);
}
// Draw a single circle
function drawCircle(radius, opacity) {
ctx.beginPath();
ctx.arc(circleX, circleY, radius, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(255, 0, 0, ${opacity})`; // Bright red instead of yellow
ctx.lineWidth = 4; // Thicker line
ctx.stroke();
}
// Stop circle animation
function stopCircleAnimation() {
if (circleAnimationFrame) {
cancelAnimationFrame(circleAnimationFrame);
circleAnimationFrame = null;
}
applyMask();
}
// Broadcast circle animation to player tab
function broadcastCircleAnimation(x, y) {
channel.postMessage({
type: 'circle-animation',
data: { x, y }
});
}
// Broadcast stop circle animation
function broadcastStopCircleAnimation() {
channel.postMessage({
type: 'stop-circle-animation'
});
}
// Set the shape mode (rectangle or oval)
function setShapeMode(mode) {
shapeMode = mode;
// Update toggle buttons - ensure we're targeting the correct buttons
const shapeToggleContainer = document.querySelector('.toggle-container:nth-child(2)');
if (shapeToggleContainer) {
const buttons = shapeToggleContainer.querySelectorAll('button');
if (buttons.length === 2) {
if (mode === 'rectangle') {
buttons[0].classList.add('active');
buttons[1].classList.remove('active');
} else {
buttons[0].classList.remove('active');
buttons[1].classList.add('active');
}
}
}
updateStatus(`Shape set to ${mode}.`);
}
// Switch between maps
function switchMap(index) {
if (index === currentMapIndex) return;
// Save current map state
if (currentImage) {
saveCurrentMapState();
}
// Update the current map index
currentMapIndex = index;
// Update UI
updateMapUI();
// Load the selected map
loadMapState(currentMapIndex);
// Broadcast map switch to player tab if this is GM
if (isGM) {
broadcastMapSwitch(currentMapIndex);
}
}
// Save the current map state
function saveCurrentMapState() {
const currentMap = maps[currentMapIndex - 1];
currentMap.image = currentImage;
currentMap.maskData = maskCanvas.toDataURL();
currentMap.scale = scale;
currentMap.scrollX = canvasContainer.scrollLeft;
currentMap.scrollY = canvasContainer.scrollTop;
}
// Load a map state
function loadMapState(index) {
const mapData = maps[index - 1];
if (mapData.image) {
// We have data for this map, load it
currentImage = mapData.image;
scale = mapData.scale || 1;
// Update canvas size
canvas.width = currentImage.width;
canvas.height = currentImage.height;
// Draw image
ctx.drawImage(currentImage, 0, 0);
// Load mask
if (mapData.maskData) {
const img = new Image();
img.onload = function() {
maskCanvas.width = currentImage.width;
maskCanvas.height = currentImage.height;
maskCtx.drawImage(img, 0, 0);
// Apply mask and update UI
applyMask();
// Set scroll position
setTimeout(() => {
canvasContainer.scrollLeft = mapData.scrollX || 0;
canvasContainer.scrollTop = mapData.scrollY || 0;
}, 10);
// Hide message and show canvas
if (emptyMapMessage) emptyMapMessage.style.display = 'none';
canvasContainer.style.display = 'block';
dropZone.classList.add('hidden');
};
img.src = mapData.maskData;
} else {
// No mask, create a new one (all hidden)
maskCanvas.width = currentImage.width;
maskCanvas.height = currentImage.height;
maskCtx.fillStyle = 'black';
maskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
// Apply mask and update UI
applyMask();
// Set scroll position
setTimeout(() => {
canvasContainer.scrollLeft = mapData.scrollX || 0;
canvasContainer.scrollTop = mapData.scrollY || 0;
}, 10);
// Hide message and show canvas
if (emptyMapMessage) emptyMapMessage.style.display = 'none';
canvasContainer.style.display = 'block';
dropZone.classList.add('hidden');
}
} else {
// No data for this map, show empty state
showEmptyState();
}
// Update zoom display
updateZoomDisplay();
}
// Show empty state
function showEmptyState() {
currentImage = null;
canvas.width = 300;
canvas.height = 300;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Show message and hide canvas
if (emptyMapMessage) emptyMapMessage.style.display = 'flex';
canvasContainer.style.display = 'none';
if (isGM) {
dropZone.classList.remove('hidden');
}
}
// Clear current map
function clearMap() {
if (!currentImage) return;
// Clear map data
clearMapData(currentMapIndex);
// Show empty state
showEmptyState();
// Broadcast clear map command
broadcastClearMap(currentMapIndex);
// Update UI
updateMapUI();
updateStatus('Map cleared.');
}
// Clear map data
function clearMapData(mapIndex) {
// Clear the map data
maps[mapIndex - 1] = {
image: null,
maskData: null,
scale: 1,
scrollX: 0,
scrollY: 0
};
}
// Broadcast clear map command
function broadcastClearMap(mapIndex) {
channel.postMessage({
type: 'clear-map',
data: {
mapIndex: mapIndex
}
});
}
// Update map UI (tab highlighting)
function updateMapUI() {
if (!isGM) return;
// Update tab highlighting
const tabs = document.querySelectorAll('.map-tab');
tabs.forEach(tab => {
const index = parseInt(tab.dataset.index);
// Update active state
tab.classList.toggle('active', index === currentMapIndex);
// Update "has content" indicator
tab.classList.toggle('has-content', maps[index - 1].image !== null);
});
// Update status message
updateStatus(`Switched to map ${currentMapIndex}`);
}
// Handle file selection from input or drag & drop
function handleFileSelect(e) {
if (e.target.files.length > 0) {
processFile(e.target.files[0]);
}
}
// Process the selected image file
function processFile(file) {
if (!file.type.match('image.*')) {
alert('Please select an image file.');
return;
}
const reader = new FileReader();
reader.onload = function(e) {
const img = new Image();
img.onload = function() {
setupCanvas(img);
currentImage = img;
// Store in the current map slot
maps[currentMapIndex - 1].image = img;
// Update UI to show this slot has content
updateMapUI();
// Broadcast the image to other tabs
broadcastImage(img.src, canvas.width, canvas.height, currentMapIndex);
// Hide drop zone and empty message
dropZone.classList.add('hidden');
if (emptyMapMessage) emptyMapMessage.style.display = 'none';
canvasContainer.style.display = 'block';
// Update status
updateStatus('Image loaded. Draw rectangles to reveal/hide areas.');
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
// Set up canvas with the loaded image
function setupCanvas(img) {
// Set canvas dimensions to match image
canvas.width = img.width;
canvas.height = img.height;
// Draw image on canvas
ctx.drawImage(img, 0, 0);
// Create mask canvas with same dimensions
maskCanvas.width = img.width;
maskCanvas.height = img.height;
// Fill mask with black (hidden)
maskCtx.fillStyle = 'black';
maskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
// Reset zoom
scale = 1;
updateZoomDisplay();
// Apply the mask immediately to ensure it shows properly on GM view
applyMask();
// Force an immediate redraw to ensure the mask is visible right away
setTimeout(() => {
applyMask();
}, 50);
}
// Handle image update (for player tab)
function handleImageUpdate(imageData, width, height, mapIndex) {
const img = new Image();
img.onload = function() {
if (mapIndex !== currentMapIndex && !isGM) {
// If we're receiving an image for a different map, switch to it
switchMap(mapIndex);
}
// Store the image data in the maps array
maps[mapIndex - 1].image = img;
if (mapIndex === currentMapIndex) {
// Set canvas dimensions
canvas.width = width;
canvas.height = height;
// Draw the image
ctx.drawImage(img, 0, 0);
// Create mask canvas with same dimensions
maskCanvas.width = width;
maskCanvas.height = height;
// Fill mask with black (hidden)
maskCtx.fillStyle = 'black';
maskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
// Store current image
currentImage = img;
// Apply the mask
applyMask();
// Hide empty message
if (emptyMapMessage) emptyMapMessage.style.display = 'none';
canvasContainer.style.display = 'block';
}
// Update UI to show this slot has content
updateMapUI();
};
img.src = imageData;
}
// Apply the mask to the canvas
function applyMask() {
if (!currentImage) return;
// Resize canvas based on scale
const scaledWidth = Math.ceil(currentImage.width * scale);
const scaledHeight = Math.ceil(currentImage.height * scale);
// Update canvas size
if (canvas.width !== scaledWidth || canvas.height !== scaledHeight) {
canvas.width = scaledWidth;
canvas.height = scaledHeight;
}
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw the image scaled
ctx.save();
ctx.scale(scale, scale);
ctx.drawImage(currentImage, 0, 0);
ctx.restore();
// Get mask data
const maskData = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height);
const data = maskData.data;
// Get the current canvas content (scaled image)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const imgData = imageData.data;
// Apply mask to the scaled image
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
// Convert the scaled coordinates back to the original image coordinates
const srcX = Math.floor(x / scale);
const srcY = Math.floor(y / scale);
// Make sure we're within bounds of the mask
if (srcX >= 0 && srcX < maskCanvas.width && srcY >= 0 && srcY < maskCanvas.height) {
// Calculate indices
const maskIndex = (srcY * maskCanvas.width + srcX) * 4;
const imgIndex = (y * canvas.width + x) * 4;
// If this pixel is hidden in the mask (black)
if (data[maskIndex] === 0) {
if (isGM) {
// 50% black overlay in GM tab
imgData[imgIndex] = imgData[imgIndex] / 2; // Reduce red
imgData[imgIndex + 1] = imgData[imgIndex + 1] / 2; // Reduce green
imgData[imgIndex + 2] = imgData[imgIndex + 2] / 2; // Reduce blue
// Alpha remains the same
} else {
// Full transparency in player tab - ensure no dimming happens
imgData[imgIndex + 3] = 0; // Set alpha to 0 (transparent)
}
}
}
}
}
// Put the modified image data back
ctx.putImageData(imageData, 0, 0);
// Update zoom level display
updateZoomDisplay();
}
// Update mask with data from another tab
function updateMask(maskData, mapIndex) {
// If we received a mask update for a map other than the current one, just store it
if (mapIndex !== currentMapIndex) {
maps[mapIndex - 1].maskData = maskData;
return;
}
const img = new Image();
img.onload = function() {
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(img, 0, 0);
applyMask();
// Also save in the map data
maps[currentMapIndex - 1].maskData = maskData;
};
img.src = maskData;
}
// Update all pixels in the mask
function updateMaskAll(isRevealed) {
if (!currentImage) return;
maskCtx.fillStyle = isRevealed ? 'white' : 'black';
maskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
applyMask();
// Save the mask in the map data
maps[currentMapIndex - 1].maskData = maskCanvas.toDataURL();
updateStatus(isRevealed ? 'All areas revealed.' : 'All areas hidden.');
}
// Reveal all areas of the map
function revealAll() {
updateMaskAll(true);
channel.postMessage({
type: 'reveal-all',
data: {
mapIndex: currentMapIndex
}
});
}
// Hide all areas of the map
function hideAll() {
updateMaskAll(false);
channel.postMessage({
type: 'hide-all',
data: {
mapIndex: currentMapIndex
}
});
}
// Set the drawing mode
function setDrawMode(mode) {
drawMode = mode;
// Update toggle buttons
const toggleButtons = document.querySelectorAll('.toggle-container:first-child button');
if (toggleButtons.length === 2) {
if (mode === 'reveal') {
toggleButtons[0].classList.add('active');
toggleButtons[1].classList.remove('active');
} else {
toggleButtons[0].classList.remove('active');
toggleButtons[1].classList.add('active');
}
}
updateStatus(`Mode set to ${mode}.`);
}
// Broadcast image to other tabs
function broadcastImage(imageData, width, height, mapIndex) {
channel.postMessage({
type: 'image-upload',
data: {
imageData,
width,
height,
mapIndex
}
});
}
// Broadcast mask update to other tabs
function broadcastMaskUpdate() {
channel.postMessage({
type: 'mask-update',
data: {
maskData: maskCanvas.toDataURL(),
mapIndex: currentMapIndex
}
});
}
// Broadcast map switch to other tabs
function broadcastMapSwitch(mapIndex) {
channel.postMessage({
type: 'switch-map',
data: {
mapIndex
}
});
}
// Open a player tab
function openPlayerTab() {
window.open(window.location.href + '#player', '_blank');
}
// Update status message
function updateStatus(message) {
const status = document.getElementById('status');
if (status) {
status.textContent = message;
// Clear after 3 seconds
setTimeout(() => {
status.textContent = 'Ready';
}, 3000);
}
}
// Change zoom level
function changeZoom(newScale) {
// Limit zoom range
newScale = Math.max(0.1, Math.min(3, newScale));
if (newScale !== scale) {
scale = newScale;
applyMask(); // This will redraw with the new scale
// Store zoom level in map data
maps[currentMapIndex - 1].scale = scale;
// Broadcast zoom change to player tab
broadcastZoomUpdate();
}
}
// Update zoom level display
function updateZoomDisplay() {
if (zoomLevelEl) {
zoomLevelEl.textContent = `${Math.round(scale * 100)}%`;
}
}
// Update zoom from another tab
function updateZoom(newScale) {
scale = newScale;
// Store zoom level in map data
maps[currentMapIndex - 1].scale = scale;
applyMask();
}
// Broadcast zoom update to other tabs
function broadcastZoomUpdate() {
channel.postMessage({
type: 'zoom-update',
data: {
scale: scale,
mapIndex: currentMapIndex
}
});
}
// Broadcast scroll update to other tabs
function broadcastScrollUpdate(scrollX, scrollY) {
channel.postMessage({
type: 'scroll-update',
data: {
scrollX: scrollX,
scrollY: scrollY,
mapIndex: currentMapIndex
}
});
}
// Update scroll from another tab
function updateScroll(newScrollX, newScrollY) {
if (canvasContainer) {
canvasContainer.scrollLeft = newScrollX;
canvasContainer.scrollTop = newScrollY;
// Store scroll position in map data
maps[currentMapIndex - 1].scrollX = newScrollX;
maps[currentMapIndex - 1].scrollY = newScrollY;
}
}
// Initialize the app
init();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment