The Journey Designer | <span class="notranslate dkj-notranslate" translate="no" data-dkj-skip="1">Journey of Many</span>
Journey of Many GRAPHIC EDITOR
Canvas
Colors
Brand & label
↑
Drag and drop an image here
Headline
Body paragraph
Footer
Background glow
Fits the canvas Preview at 50% Click the graphic to type
Journey of Many

'; var btn = document.getElementById('copyHtmlBtn'); function markCopied(){ var old = btn.textContent; btn.textContent = 'Copied'; setTimeout(function(){ btn.textContent = old; }, 1400); } if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(html).then(markCopied, function(){ showHtmlFallback(html); }); } else { showHtmlFallback(html); } }); function closeHtmlModal(){ document.getElementById('htmlModal').hidden = true; if (lastFocusedEl && document.body.contains(lastFocusedEl)) lastFocusedEl.focus(); lastFocusedEl = null; } document.getElementById('htmlModalClose').addEventListener('click', closeHtmlModal); document.getElementById('htmlModalX').addEventListener('click', closeHtmlModal); document.getElementById('htmlModal').addEventListener('click', function(e){ if (e.target.id === 'htmlModal') closeHtmlModal(); }); function showPngFallback(pngDataUrl){ lastFocusedEl = document.activeElement; document.getElementById('pngOutput').src = pngDataUrl; document.getElementById('pngOpenTab').href = pngDataUrl; document.getElementById('pngModal').hidden = false; document.getElementById('pngModalX').focus(); } // The graphic is painted directly onto a with the 2D API rather // than serialized as an SVG and rasterized via . // That older approach depends on the browser cascading CSS custom // properties and loading @font-face inside an isolated image-decode // context, which multiple real browsers do not do reliably, silently // dropping every color and the custom font. Drawing directly sidesteps // that entire class of bug: every color and font is set explicitly. function parseBoldSegments(line){ var segments = []; var re = /\*\*(.+?)\*\*/g; var lastIndex = 0, m; while ((m = re.exec(line))) { if (m.index > lastIndex) segments.push({ text: line.slice(lastIndex, m.index), bold:false }); segments.push({ text: m[1], bold:true }); lastIndex = re.lastIndex; } if (lastIndex < line.length) segments.push({ text: line.slice(lastIndex), bold:false }); return segments; } function drawSpaced(ctx, text, x, y, letterSpacing, hAlign){ var widths = []; var total = 0; for (var i = 0; i < text.length; i++) { var w = ctx.measureText(text[i]).width; widths.push(w); total += w + (i < text.length - 1 ? letterSpacing : 0); } var startX = x; if (hAlign === 'center') startX = x - total / 2; else if (hAlign === 'right') startX = x - total; var cx = startX; // Each character is placed manually below, so this must always draw as // 'left', regardless of what the caller's ctx.textAlign is currently // set to. Restore it afterward so we don't surprise the caller either. var prevAlign = ctx.textAlign; ctx.textAlign = 'left'; for (var j = 0; j < text.length; j++) { ctx.fillText(text[j], cx, y); cx += widths[j] + letterSpacing; } ctx.textAlign = prevAlign; return total; } function measureSpaced(ctx, text, letterSpacing){ var total = 0; for (var i = 0; i < text.length; i++) { total += ctx.measureText(text[i]).width + (i < text.length - 1 ? letterSpacing : 0); } return total; } function breakLongWord(ctx, word, maxWidth){ // A single word (e.g. a bare URL) can be wider than maxWidth on its // own, in which case normal word-wrap has nothing to break on. Split // it character by character so it never overflows the frame. var chunks = []; var current = ''; for (var i = 0; i < word.length; i++) { var test = current + word[i]; if (current && ctx.measureText(test).width > maxWidth) { chunks.push(current); current = word[i]; } else { current = test; } } if (current) chunks.push(current); return chunks; } function wrapParagraph(ctx, text, maxWidth){ var paragraphs = text.split('\n'); var lines = []; paragraphs.forEach(function(para){ if (para.trim() === '') { lines.push(''); return; } var words = para.split(' '); var current = ''; words.forEach(function(word){ if (ctx.measureText(word).width > maxWidth) { if (current) { lines.push(current); current = ''; } var chunks = breakLongWord(ctx, word, maxWidth); chunks.slice(0, -1).forEach(function(chunk){ lines.push(chunk); }); current = chunks[chunks.length - 1] || ''; return; } var test = current ? current + ' ' + word : word; if (current && ctx.measureText(test).width > maxWidth) { lines.push(current); current = word; } else { current = test; } }); if (current) lines.push(current); }); return lines; } function paintGraphic(ctx, w, h){ // The live preview clips everything to the frame via overflow:hidden. // Canvas does not clip automatically, so an oversized background number // or an overflowing headline would otherwise bleed past the edges of // the exported image in a way the preview never shows. ctx.save(); ctx.beginPath(); ctx.rect(0, 0, w, h); ctx.clip(); ctx.fillStyle = PALETTE.bg; ctx.fillRect(0, 0, w, h); GLOW_POSITIONS[glow].forEach(function(stop){ var cx = stop.cx * w, cy = stop.cy * h, r = ((stop.rx + stop.ry) / 2) * Math.max(w, h); var rg = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); rg.addColorStop(0, hexToRgba(PALETTE.accent, stop.alpha)); rg.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = rg; ctx.fillRect(0, 0, w, h); }); // background watermark, drawn behind everything including the big number if (logoImg && logoMode === 'watermark') { var maxWmW = w * 0.7, maxWmH = h * 0.7; var wmNatW = logoImg.naturalWidth || logoImg.width || 1; var wmNatH = logoImg.naturalHeight || logoImg.height || 1; var wmScale = Math.min(maxWmW / wmNatW, maxWmH / wmNatH); var wmW = wmNatW * wmScale, wmH = wmNatH * wmScale; ctx.save(); ctx.globalAlpha = logoOpacity; ctx.drawImage(logoImg, (w - wmW) / 2, (h - wmH) / 2, wmW, wmH); ctx.restore(); } var padTop = 100, padSide = 92, padBottom = 84; var contentLeft = padSide, contentRight = w - padSide, contentWidth = contentRight - contentLeft; var centered = (align === 'center'); ctx.textBaseline = 'top'; // big faint background number var stepNumText = el.stepNum.value.trim(); if (stepNumText) { ctx.font = '900 300px ' + FONT_DISPLAY; ctx.strokeStyle = hexToRgba(PALETTE.ink, 0.07); ctx.lineWidth = 2; ctx.textAlign = 'right'; ctx.strokeText(stepNumText, contentRight, 36); ctx.textAlign = 'left'; } // brand row: a logo in "mark" mode replaces the text mark entirely if (logoImg && logoMode === 'mark') { var maxLogoH = 56, maxLogoW = 340; var naturalW = logoImg.naturalWidth || logoImg.width || 1; var naturalH = logoImg.naturalHeight || logoImg.height || 1; var logoScale = Math.min(maxLogoH / naturalH, maxLogoW / naturalW, 1); var drawW = naturalW * logoScale, drawH = naturalH * logoScale; ctx.save(); ctx.globalAlpha = logoOpacity; ctx.drawImage(logoImg, contentLeft, padTop, drawW, drawH); ctx.restore(); } else { ctx.font = '800 32px ' + FONT_DISPLAY; var brand1 = (el.brand1.value || '').toUpperCase() + ' '; var brand2 = (el.brand2.value || '').toUpperCase(); ctx.fillStyle = PALETTE.ink; var brand1Width = drawSpaced(ctx, brand1, contentLeft, padTop, 0.6, 'left'); ctx.fillStyle = PALETTE.accent; drawSpaced(ctx, brand2, contentLeft + brand1Width, padTop, 0.6, 'left'); } var eyebrowText = (el.eyebrow.value || '').toUpperCase(); if (eyebrowText) { ctx.font = '400 15px ' + FONT_MONO; ctx.fillStyle = PALETTE.gold; var eyebrowWidth = measureSpaced(ctx, eyebrowText, 3); var dotSize = 7; var eyebrowX = contentRight - eyebrowWidth; ctx.beginPath(); ctx.arc(eyebrowX - 10 - dotSize / 2, padTop + 8, dotSize / 2, 0, Math.PI * 2); ctx.fill(); drawSpaced(ctx, eyebrowText, eyebrowX, padTop + 2, 3, 'left'); } var brandRowBottom = padTop + 40; // footer geometry (computed first so the body block can center against it) var footerRowHeight = 24; var footerBoxHeight = 1 + 36 + footerRowHeight; var footerBorderY = h - padBottom - footerBoxHeight; var footerTextY = footerBorderY + 1 + 36; var bodyTop = brandRowBottom; var bodyBottom = footerBorderY; var bodyMaxWidth = format === 'story' ? 860 : 880; var textMaxWidth = Math.min(contentWidth, bodyMaxWidth) - (centered ? 0 : 0); // measure the stacked block (step tag / headline / paragraph) to center it vertically var stepTagText = (el.stepTag.value || '').toUpperCase(); var headlineSize = +el.headlineSize.value; var subSize = +el.subSize.value; var headlineLines = el.headline.value.split('\n'); var headlineLineHeight = headlineSize * 1.05; var subText = el.sub.value || ''; var subLineHeight = subSize * 1.55; ctx.font = '400 ' + subSize + 'px ' + FONT_BODY; var subLines = subText.trim() ? wrapParagraph(ctx, subText, Math.min(textMaxWidth, 760)) : []; var blocks = []; var gap = 34; if (stepTagText) blocks.push(20); blocks.push(headlineLines.length * headlineLineHeight); if (subLines.length) blocks.push(subLines.length * subLineHeight); var blockHeight = blocks.reduce(function(a, b){ return a + b; }, 0) + gap * (blocks.length - 1); var cursorY = bodyTop + Math.max(0, (bodyBottom - bodyTop - blockHeight) / 2); var textX = centered ? contentLeft + contentWidth / 2 : contentLeft; var hAlign = centered ? 'center' : 'left'; ctx.textAlign = hAlign; if (stepTagText) { ctx.font = '400 17px ' + FONT_MONO; ctx.fillStyle = PALETTE.muted; drawSpaced(ctx, stepTagText, textX, cursorY, 4, hAlign); cursorY += 20 + gap; } ctx.font = '900 ' + headlineSize + 'px ' + FONT_DISPLAY; headlineLines.forEach(function(line){ var segments = parseBoldSegments(line).map(function(seg){ return { text: seg.text.toUpperCase(), bold: seg.bold }; }); var totalWidth = 0; segments.forEach(function(seg){ totalWidth += ctx.measureText(seg.text).width; }); var lineX = textX; if (hAlign === 'center') lineX = textX - totalWidth / 2; var cx = lineX; ctx.textAlign = 'left'; segments.forEach(function(seg){ ctx.fillStyle = seg.bold ? PALETTE.accent : PALETTE.ink; ctx.fillText(seg.text, cx, cursorY); cx += ctx.measureText(seg.text).width; }); ctx.textAlign = hAlign; cursorY += headlineLineHeight; }); if (subLines.length) { cursorY = bodyTop + Math.max(0, (bodyBottom - bodyTop - blockHeight) / 2) + (stepTagText ? 20 + gap : 0) + headlineLines.length * headlineLineHeight + gap; ctx.font = '400 ' + subSize + 'px ' + FONT_BODY; ctx.fillStyle = PALETTE.ink; subLines.forEach(function(line){ if (line) ctx.fillText(line, textX, cursorY); cursorY += subLineHeight; }); } ctx.textAlign = 'left'; // footer ctx.strokeStyle = hexToRgba(PALETTE.ink, 0.16); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(contentLeft, footerBorderY); ctx.lineTo(contentRight, footerBorderY); ctx.stroke(); var footerTagText = (el.footerTag.value || '').toUpperCase(); var footerHandleText = el.footerHandle.value || ''; var footerGap = 24; var footerTagSize = 17, footerHandleSize = 20; var footerTagSpacing = 1.4, footerHandleSpacing = 1.2; var footerScale = 1; // Both sides are drawn at fixed positions rather than a real flex // layout, so two long footer values could otherwise overlap in the // middle. Shrink both proportionally until they fit side by side. for (var shrinkStep = 0; shrinkStep < 12; shrinkStep++) { ctx.font = '400 ' + (footerTagSize * footerScale) + 'px ' + FONT_MONO; var tagW = measureSpaced(ctx, footerTagText, footerTagSpacing * footerScale); ctx.font = '400 ' + (footerHandleSize * footerScale) + 'px ' + FONT_MONO; var handW = measureSpaced(ctx, footerHandleText, footerHandleSpacing * footerScale); if (tagW + footerGap + handW <= contentWidth || footerScale <= 0.55) break; footerScale -= 0.05; } ctx.font = '400 ' + (footerTagSize * footerScale) + 'px ' + FONT_MONO; ctx.fillStyle = PALETTE.muted; drawSpaced(ctx, footerTagText, contentLeft, footerTextY, footerTagSpacing * footerScale, 'left'); ctx.font = '400 ' + (footerHandleSize * footerScale) + 'px ' + FONT_MONO; ctx.fillStyle = PALETTE.gold; var handleWidth = measureSpaced(ctx, footerHandleText, footerHandleSpacing * footerScale); drawSpaced(ctx, footerHandleText, contentRight - handleWidth, footerTextY - 2, footerHandleSpacing * footerScale, 'left'); ctx.restore(); } function buildExportCanvas(){ var w = 1080, h = (format === 'story' ? 1920 : 1080); var scale = 2; var canvas = document.createElement('canvas'); canvas.width = w * scale; canvas.height = h * scale; var ctx = canvas.getContext('2d'); ctx.scale(scale, scale); paintGraphic(ctx, w, h); return canvas; } document.getElementById('downloadBtn').addEventListener('click', function(){ function doExport(){ var canvas = buildExportCanvas(); var pngDataUrl; try { pngDataUrl = canvas.toDataURL('image/png'); } catch (e) { showNotice('Download failed to render in this browser. Use Copy HTML instead and I can render it for you.'); return; } var a = document.createElement('a'); a.href = pngDataUrl; a.download = BRAND.downloadName; document.body.appendChild(a); a.click(); a.remove(); } if (document.fonts && document.fonts.ready) { document.fonts.load('900 100px "Big Shoulders Display"').catch(function(){}).then(function(){ document.fonts.ready.then(doExport); }); } else { doExport(); } }); function closePngModal(){ document.getElementById('pngModal').hidden = true; if (lastFocusedEl && document.body.contains(lastFocusedEl)) lastFocusedEl.focus(); lastFocusedEl = null; } document.getElementById('pngModalClose').addEventListener('click', closePngModal); document.getElementById('pngModalX').addEventListener('click', closePngModal); document.getElementById('pngModal').addEventListener('click', function(e){ if (e.target.id === 'pngModal') closePngModal(); }); var pendingConfirm = null; var noticeOnly = false; function closeConfirm(run){ document.getElementById('confirmModal').hidden = true; document.getElementById('confirmNo').hidden = false; var fn = pendingConfirm; pendingConfirm = null; var wasNotice = noticeOnly; noticeOnly = false; if (run && fn && !wasNotice) fn(); if (lastFocusedEl && document.body.contains(lastFocusedEl)) lastFocusedEl.focus(); lastFocusedEl = null; } function askConfirm(msg, onYes, yesLabel, title){ lastFocusedEl = document.activeElement; noticeOnly = false; pendingConfirm = typeof onYes === 'function' ? onYes : null; document.getElementById('confirmTitle').textContent = title || 'Please confirm'; document.getElementById('confirmMessage').textContent = msg; document.getElementById('confirmYes').textContent = yesLabel || 'Continue'; document.getElementById('confirmNo').hidden = false; document.getElementById('confirmModal').hidden = false; document.getElementById('confirmYes').focus(); } function showNotice(msg){ lastFocusedEl = document.activeElement; noticeOnly = true; pendingConfirm = null; document.getElementById('confirmTitle').textContent = 'Just so you know'; document.getElementById('confirmMessage').textContent = msg; document.getElementById('confirmYes').textContent = 'Done'; document.getElementById('confirmNo').hidden = true; document.getElementById('confirmModal').hidden = false; document.getElementById('confirmYes').focus(); } document.getElementById('confirmYes').addEventListener('click', function(){ closeConfirm(true); }); document.getElementById('confirmNo').addEventListener('click', function(){ closeConfirm(false); }); document.getElementById('confirmModalX').addEventListener('click', function(){ closeConfirm(false); }); document.getElementById('confirmModal').addEventListener('click', function(e){ if (e.target.id === 'confirmModal') closeConfirm(false); }); var DRAFT_KEY = 'jom-designer-draft'; var draftTimer = null; function saveDraft(){ try { var state = captureState(); state.presetIndex = +el.presetSelect.value; localStorage.setItem(DRAFT_KEY, JSON.stringify(state)); } catch (err) { try { var slim = captureState(); slim.logoDataUrl = null; slim.presetIndex = +el.presetSelect.value; localStorage.setItem(DRAFT_KEY, JSON.stringify(slim)); } catch (err2) {} } } function scheduleDraft(){ clearTimeout(draftTimer); draftTimer = setTimeout(saveDraft, 400); } function loadDraft(){ try { var raw = localStorage.getItem(DRAFT_KEY); return raw ? JSON.parse(raw) : null; } catch (err) { return null; } } function applyBrandChrome(){ document.title = 'The Journey Designer | ' + BRAND.name; var home = document.getElementById('brandHomeLink'); if (home) home.setAttribute('href', BRAND.homeUrl); var mark = document.getElementById('brandMarkText'); if (mark) mark.innerHTML = escapeHtml(BRAND.word1) + ' ' + escapeHtml(BRAND.word2) + ''; var label = document.getElementById('editorLabel'); if (label) label.textContent = BRAND.editorLabel; var png = document.getElementById('pngOutput'); if (png) png.alt = BRAND.pngAlt; } var _render = render; render = function(){ _render(); scheduleDraft(); }; document.addEventListener('keydown', function(e){ if (e.key === 'Escape') { if (!document.getElementById('htmlModal').hidden) closeHtmlModal(); if (!document.getElementById('pngModal').hidden) closePngModal(); if (!document.getElementById('confirmModal').hidden) closeConfirm(false); return; } if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 'z' || e.key === 'Z')) { var tag = (document.activeElement && document.activeElement.tagName) || ''; if (tag === 'INPUT' || tag === 'TEXTAREA') return; if (document.activeElement && document.activeElement.getAttribute('contenteditable') === 'true') return; e.preventDefault(); undo(); return; } trapFocus(e); }); // init applyBrandChrome(); var draft = loadDraft(); if (draft && draft.values) { if (typeof draft.presetIndex === 'number') { el.presetSelect.value = String(draft.presetIndex); lastPresetIndex = draft.presetIndex; } restoreState(draft); } else { loadPreset(PRESETS[0]); setFormat('square'); setAlign('left'); } refreshColorInputs(); applyPaletteToPreview(); updateUndoButton(); historyReady = true; scalePreview(); [50, 200, 600, 1200].forEach(function(ms){ setTimeout(scalePreview, ms); }); })();