Mark
It
✦
Clear marks
Load text
↓ PNG
Mark type
— connect with line
◼ highlight
█ redact
⟵ strikethrough
○ circle
Line color
Line weight
Width
1.5
Opacity
.75
Keyword
Press
Enter
to mark all matches in the text.
Text
Apply text
// ── State ── var currentTool = 'line'; var currentColor = '#c0392b'; var lineWidth = 1.5; var lineOpacity = 0.75; var keywords = []; // {word, color, tool} var wordSpans = []; // DOM spans var marks = []; // drawn marks {type, positions, color, lw, op} var animating = false; // ── Colors ── var COLORS = [ '#c0392b','#2980b9','#27ae60','#8e44ad', '#e67e22','#16a085','#2c3e50','#7f8c8d', '#f39c12','#1abc9c' ]; // ── Init ── function init(){ buildColorRow(); buildText(DEFAULT_TEXT); document.getElementById('txt-input').value = DEFAULT_TEXT; bindEvents(); } function buildColorRow(){ var row = document.getElementById('color-row'); row.innerHTML = ''; COLORS.forEach(function(c){ var d = document.createElement('div'); d.className = 'color-dot' + (c===currentColor?' active':''); d.style.background = c; d.addEventListener('click', function(){ currentColor = c; row.querySelectorAll('.color-dot').forEach(function(x){x.classList.remove('active');}); d.classList.add('active'); }); row.appendChild(d); }); } // ── Build text ── function buildText(text){ var layer = document.getElementById('text-layer'); layer.innerHTML = ''; wordSpans = []; var paragraphs = text.split(/\n+/); paragraphs.forEach(function(para, pi){ if(!para.trim()) return; var p = document.createElement('p'); p.style.marginBottom = (pi < paragraphs.length-1) ? '22px' : '0'; // vary font size slightly per paragraph for "notes" feel var sizes = [13, 13.5, 12.5, 14, 13, 12, 13.5, 14]; p.style.fontSize = (sizes[pi % sizes.length]) + 'px'; // slight rotation on some paragraphs if(pi % 3 === 1) p.style.transform = 'rotate(-0.15deg)'; if(pi % 5 === 3) p.style.transform = 'rotate(0.1deg)'; var tokens = para.split(/(\s+)/); tokens.forEach(function(tok){ if(/^\s+$/.test(tok)){ var sp = document.createElement('span'); sp.className = 'word-space'; sp.textContent = tok; p.appendChild(sp); } else { // split on punctuation boundaries but keep them var parts = tok.split(/(?=[,\.;:!?—])|(?<=[,\.;:!?—])/); parts.forEach(function(part){ if(!part) return; var sp = document.createElement('span'); sp.className = 'word-span'; sp.textContent = part; sp.dataset.word = part.replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g,'').toLowerCase(); wordSpans.push(sp); p.appendChild(sp); }); } }); layer.appendChild(p); }); } // ── Keyword matching ── function markKeyword(kw){ var tool = currentTool; var color = currentColor; var lw = lineWidth; var op = lineOpacity; var word = kw.toLowerCase().trim(); if(!word) return; // find matching spans var matched = wordSpans.filter(function(sp){ return sp.dataset.word === word || sp.dataset.word.includes(word) || word.includes(sp.dataset.word) && sp.dataset.word.length > 2; }); if(!matched.length) return; if(tool === 'line'){ // connect matched spans with animated bezier lines applyLineConnections(matched, color, lw, op); } else { matched.forEach(function(sp){ applyMarkToSpan(sp, tool, color); }); } // add to keyword tags keywords.push({word:kw, color:color, tool:tool}); renderTags(); } function applyMarkToSpan(sp, tool, color){ // remove existing mark classes sp.classList.remove('mark-highlight','mark-redact','mark-strike','mark-circle'); if(tool === 'highlight'){ sp.classList.add('mark-highlight'); sp.style.background = hexAlpha(color, 0.4); } else if(tool === 'redact'){ sp.classList.add('mark-redact'); } else if(tool === 'strike'){ sp.classList.add('mark-strike'); sp.style.setProperty('--strike-color', color); // override the ::after color via inline style workaround sp.style.textDecoration = 'line-through'; sp.style.textDecorationColor = color; sp.style.textDecorationThickness = '1.5px'; } else if(tool === 'circle'){ sp.classList.add('mark-circle'); drawCircleAroundSpan(sp, color); } } // ── SVG line connections ── function applyLineConnections(spans, color, lw, op){ if(spans.length < 2) return; var paper = document.getElementById('paper'); var svg = document.getElementById('svg-overlay'); var paperRect = paper.getBoundingClientRect(); // get center of each span relative to paper var pts = spans.map(function(sp){ var r = sp.getBoundingClientRect(); return { x: r.left - paperRect.left + r.width/2, y: r.top - paperRect.top + r.height/2, sp: sp }; }); // highlight the matched spans lightly spans.forEach(function(sp){ sp.style.background = hexAlpha(color, 0.15); sp.style.borderRadius = '2px'; sp.style.padding = '0 1px'; }); // draw path connecting them with bezier curves animatePath(svg, pts, color, lw, op); } function animatePath(svg, pts, color, lw, op){ if(pts.length < 2) return; // build a smooth path through all points var d = buildSmoothPath(pts); var path = document.createElementNS('http://www.w3.org/2000/svg','path'); path.setAttribute('d', d); path.setAttribute('fill','none'); path.setAttribute('stroke', color); path.setAttribute('stroke-width', lw); path.setAttribute('stroke-opacity', op); path.setAttribute('stroke-linecap','round'); path.setAttribute('stroke-linejoin','round'); // slight hand-drawn wobble: use filter var filterId = 'wobble-' + Date.now(); var defs = svg.querySelector('defs') || svg.insertBefore(document.createElementNS('http://www.w3.org/2000/svg','defs'), svg.firstChild); var filt = document.createElementNS('http://www.w3.org/2000/svg','filter'); filt.setAttribute('id', filterId); filt.setAttribute('x','-5%');filt.setAttribute('y','-5%'); filt.setAttribute('width','110%');filt.setAttribute('height','110%'); var turb = document.createElementNS('http://www.w3.org/2000/svg','feTurbulence'); turb.setAttribute('type','turbulence'); turb.setAttribute('baseFrequency','0.05'); turb.setAttribute('numOctaves','2'); turb.setAttribute('result','noise'); var disp = document.createElementNS('http://www.w3.org/2000/svg','feDisplacementMap'); disp.setAttribute('in','SourceGraphic'); disp.setAttribute('in2','noise'); disp.setAttribute('scale','1.5'); disp.setAttribute('xChannelSelector','R'); disp.setAttribute('yChannelSelector','G'); filt.appendChild(turb); filt.appendChild(disp); defs.appendChild(filt); path.setAttribute('filter','url(#'+filterId+')'); svg.appendChild(path); // animate draw-on var length = path.getTotalLength(); path.style.strokeDasharray = length; path.style.strokeDashoffset = length; path.style.transition = 'none'; // small dots at each matched word pts.forEach(function(pt){ var dot = document.createElementNS('http://www.w3.org/2000/svg','circle'); dot.setAttribute('cx', pt.x); dot.setAttribute('cy', pt.y); dot.setAttribute('r', lw * 2.5); dot.setAttribute('fill', color); dot.setAttribute('fill-opacity', op); svg.appendChild(dot); }); requestAnimationFrame(function(){ requestAnimationFrame(function(){ var dur = Math.min(1800, 600 + length * 0.8); path.style.transition = 'stroke-dashoffset ' + dur + 'ms cubic-bezier(0.4,0,0.2,1)'; path.style.strokeDashoffset = 0; }); }); } function buildSmoothPath(pts){ if(pts.length === 2){ // single bezier with slight arc var p0 = pts[0], p1 = pts[1]; var dx = p1.x - p0.x, dy = p1.y - p0.y; var mx = (p0.x+p1.x)/2 + dy*0.25 + (Math.random()-.5)*30; var my = (p0.y+p1.y)/2 - dx*0.25 + (Math.random()-.5)*20; return 'M'+r(p0.x)+','+r(p0.y)+' Q'+r(mx)+','+r(my)+' '+r(p1.x)+','+r(p1.y); } // catmull-rom → bezier for multiple points var d = 'M'+r(pts[0].x)+','+r(pts[0].y); for(var i=0;i
✕'; tag.addEventListener('click', function(){ keywords.splice(i,1); renderTags(); }); c.appendChild(tag); }); } // ── Events ── function bindEvents(){ // tool buttons document.querySelectorAll('.tool-btn').forEach(function(btn){ btn.addEventListener('click',function(){ document.querySelectorAll('.tool-btn').forEach(function(b){b.classList.remove('active');}); btn.classList.add('active'); currentTool = btn.dataset.tool; }); }); // sliders var slLw = document.getElementById('sl-lw'); var vlLw = document.getElementById('vl-lw'); slLw.addEventListener('input',function(){ lineWidth = parseFloat(slLw.value); vlLw.textContent = lineWidth; }); var slOp = document.getElementById('sl-op'); var vlOp = document.getElementById('vl-op'); slOp.addEventListener('input',function(){ lineOpacity = parseFloat(slOp.value); vlOp.textContent = lineOpacity.toFixed(2); }); // keyword input var kwin = document.getElementById('kw-input'); kwin.addEventListener('keydown', function(e){ if(e.key === 'Enter'){ e.preventDefault(); var val = kwin.value.trim(); if(val){ markKeyword(val); kwin.value = ''; } } }); // clear marks document.getElementById('btn-clear-marks').addEventListener('click', function(){ // remove svg children except defs var svg = document.getElementById('svg-overlay'); while(svg.lastChild) svg.removeChild(svg.lastChild); // remove span marks wordSpans.forEach(function(sp){ sp.classList.remove('mark-highlight','mark-redact','mark-strike','mark-circle'); sp.style.background = ''; sp.style.textDecoration = ''; sp.style.padding = ''; sp.style.borderRadius = ''; sp.style.color = ''; }); keywords = []; renderTags(); }); // load text document.getElementById('btn-load-text').addEventListener('click', function(){ var txt = document.getElementById('txt-input').value.trim(); if(txt) buildText(txt); }); document.getElementById('btn-apply-text').addEventListener('click', function(){ var txt = document.getElementById('txt-input').value.trim(); if(txt) buildText(txt); }); // export PNG document.getElementById('btn-export').addEventListener('click', exportPNG); } // ── Export ── function exportPNG(){ var paper = document.getElementById('paper'); // html2canvas-lite approach: open paper in new window for now // TODO: implement proper canvas export alert('Export coming soon — try screenshotting for now!'); } // ── Helpers ── function r(n){ return Math.round(n*10)/10; } function hexAlpha(hex, alpha){ var res = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); if(!res) return hex; return 'rgba('+parseInt(res[1],16)+','+parseInt(res[2],16)+','+parseInt(res[3],16)+','+alpha+')'; } init();