commenting-visual-explainers/public/widget.js
hiroki ito bc8a281e4c first commit
Made-with: Cursor
2026-03-19 13:59:28 +09:00

927 lines
46 KiB
JavaScript

/**
* Icons: Lucide (https://lucide.dev)
* ISC License - Copyright (c) Lucide Contributors 2026
*/
(function () {
'use strict';
var SCRIPT = document.currentScript;
var API_BASE = SCRIPT ? SCRIPT.src.replace(/\/widget\.js.*$/, '') : '';
var API_TOKEN = SCRIPT ? (SCRIPT.dataset.token || '') : '';
var USERNAME_KEY = 'fb-username';
var SIDEBAR_WIDTH_KEY = 'fb-sidebar-width';
var PRIORITY_CYCLE = { must: 'better', better: 'want', want: 'must' };
var PRIORITY_COLORS = {
must: { bg: '#ef4444', text: '#fff', light: 'rgba(239,68,68,0.1)', border: 'rgba(239,68,68,0.3)' },
better: { bg: '#f59e0b', text: '#fff', light: 'rgba(245,158,11,0.1)', border: 'rgba(245,158,11,0.3)' },
want: { bg: '#22c55e', text: '#fff', light: 'rgba(34,197,94,0.1)', border: 'rgba(34,197,94,0.3)' },
};
var SVG = {
message: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"/></svg>',
check: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
rotateCcw: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>',
pencil: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg>',
trash: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
user: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
x: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>',
panelRight: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/></svg>',
};
function icon(name, size, strokeWidth) {
size = size || 14;
strokeWidth = strokeWidth || 2;
return (SVG[name] || '').replace(/SIZE/g, size).replace(/SW/g, strokeWidth);
}
var state = {
username: localStorage.getItem(USERNAME_KEY) || '',
comments: [],
filter: 'unresolved',
sidebarOpen: false,
selectedText: '',
selectedQuoteContext: { beforeText: '', afterText: '' },
selectedRect: null,
popupContent: '',
editingId: null,
editContent: '',
editPriority: 'want',
replyingTo: null,
replyText: '',
editingName: false,
nameInput: '',
popupPriority: 'must',
sidebarWidth: parseInt(localStorage.getItem(SIDEBAR_WIDTH_KEY), 10) || 400,
};
var slug = (function () {
return window.location.href
.replace(/^https?:\/\//, '')
.replace(/[^a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF]/g, '_')
.substring(0, 100);
})();
function genId() {
return Date.now().toString(36) + Math.random().toString(36).substring(2, 8);
}
function esc(s) {
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function fmtTime(ts) {
var d = Date.now() - ts;
if (d < 60000) return 'たった今';
if (d < 3600000) return Math.floor(d / 60000) + '分前';
if (d < 86400000) return Math.floor(d / 3600000) + '時間前';
return new Date(ts).toLocaleDateString('ja-JP', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
// ===== API =====
function api(method, params) {
var url = API_BASE + '/api/comments';
var hdrs = { 'Content-Type': 'application/json' };
if (API_TOKEN) hdrs['Authorization'] = 'Bearer ' + API_TOKEN;
var opts = { method: method, headers: hdrs };
if (method === 'GET') {
url += '?slug=' + encodeURIComponent(params.slug);
} else if (method === 'DELETE') {
url += '?id=' + encodeURIComponent(params.id);
} else {
opts.body = JSON.stringify(params);
}
return fetch(url, opts).then(function (r) { return r.json(); });
}
function loadComments() {
return api('GET', { slug: slug }).then(function (c) {
if (Array.isArray(c)) state.comments = c;
render();
applyHighlights();
}).catch(function () {
render();
applyHighlights();
});
}
// ===== CSS =====
function injectStyles() {
var style = document.createElement('style');
style.id = 'fb-widget-styles';
style.textContent = [
':root{--fb-bg:#ffffff;--fb-fg:#0a0a0a;--fb-muted:#f5f5f5;--fb-muted-fg:#737373;--fb-border:#e5e5e5;--fb-primary:#171717;--fb-primary-fg:#fafafa;--fb-accent:#3b82f6;--fb-destructive:#ef4444;--fb-font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Hiragino Sans",sans-serif}',
/* Toggle button */
'#fb-toggle{position:fixed;right:0;top:50%;transform:translateY(-50%);width:36px;background:var(--fb-bg);border:1px solid var(--fb-border);border-right:none;border-radius:8px 0 0 8px;cursor:pointer;z-index:99999;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;font-family:var(--fb-font);transition:all .15s;box-shadow:-2px 0 8px rgba(0,0,0,0.06);padding:8px 0}',
'#fb-toggle:hover{background:var(--fb-muted);box-shadow:-2px 0 12px rgba(0,0,0,0.1)}',
'#fb-toggle .fb-toggle-icon{color:var(--fb-muted-fg);display:flex;align-items:center;justify-content:center;position:relative}',
'#fb-toggle .fb-toggle-label{font-size:10px;color:var(--fb-accent);font-weight:700;writing-mode:vertical-rl;letter-spacing:1px;line-height:1;white-space:nowrap}',
'#fb-toggle .fb-badge{position:absolute;top:-6px;left:-6px;background:var(--fb-destructive);color:#fff;font-size:9px;font-weight:700;min-width:16px;height:16px;border-radius:8px;display:flex;align-items:center;justify-content:center;padding:0 3px;line-height:1}',
/* Sidebar */
'#fb-sidebar{position:fixed;top:0;height:100vh;background:rgba(245,245,245,0.5);border-left:1px solid var(--fb-border);z-index:99998;transition:right .3s ease;display:flex;flex-direction:column;font-family:var(--fb-font);font-size:14px;color:var(--fb-fg)}',
'#fb-sidebar *{box-sizing:border-box}',
/* Resize handle */
'.fb-resize-handle{position:absolute;left:-3px;top:0;width:6px;height:100%;cursor:col-resize;z-index:1}',
'.fb-resize-handle:hover{background:rgba(59,130,246,0.15)}',
'.fb-resize-handle.active{background:rgba(59,130,246,0.3)}',
'body.fb-resizing{cursor:col-resize !important;-webkit-user-select:none !important;user-select:none !important}',
'body.fb-resizing *{cursor:col-resize !important}',
/* Header */
'.fb-header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--fb-border)}',
'.fb-header-left{display:flex;align-items:center;gap:8px}',
'.fb-header-title{font-size:14px;font-weight:700;color:var(--fb-accent)}',
'.fb-header-count{background:var(--fb-muted);color:var(--fb-muted-fg);font-size:11px;padding:1px 7px;border-radius:10px}',
'.fb-header-actions{display:flex;align-items:center;gap:2px}',
'.fb-hdr-btn{background:none;border:none;cursor:pointer;padding:6px;border-radius:6px;color:var(--fb-muted-fg);transition:all .15s;display:inline-flex;align-items:center;gap:4px;font-family:var(--fb-font);font-size:12px}',
'.fb-hdr-btn:hover{background:var(--fb-muted);color:var(--fb-fg)}',
/* User row */
'.fb-user-row{display:flex;align-items:center;padding:8px 16px;border-bottom:1px solid var(--fb-border);font-size:13px;color:var(--fb-muted-fg);cursor:pointer;transition:color .15s;gap:6px}',
'.fb-user-row:hover{color:var(--fb-fg)}',
/* Filters */
'.fb-filters{display:flex;border-bottom:1px solid var(--fb-border)}',
'.fb-filter{flex:1;padding:10px 0;text-align:center;font-size:13px;color:var(--fb-muted-fg);border:none;background:none;cursor:pointer;border-bottom:2px solid transparent;transition:all .15s;font-family:var(--fb-font)}',
'.fb-filter:hover{color:var(--fb-fg)}',
'.fb-filter.active{color:var(--fb-fg);border-bottom-color:var(--fb-accent)}',
'.fb-filter .cnt{font-size:11px;margin-left:4px;padding:1px 5px;border-radius:8px;background:var(--fb-muted);color:var(--fb-muted-fg)}',
'.fb-filter.active .cnt{background:rgba(59,130,246,0.1);color:var(--fb-accent)}',
/* List */
'.fb-list{flex:1;overflow-y:auto;padding:8px}',
'.fb-empty{text-align:center;padding:60px 20px;color:var(--fb-muted-fg);font-size:13px}',
'.fb-empty svg{margin:0 auto 12px;display:block;color:var(--fb-border)}',
/* Card */
'.fb-card{background:var(--fb-bg);border:1px solid var(--fb-border);border-left:3px solid var(--fb-border);border-radius:12px;padding:14px;margin-bottom:6px;transition:box-shadow .3s,background .3s;cursor:pointer}',
'.fb-card:hover{box-shadow:0 1px 3px rgba(0,0,0,0.05)}',
'.fb-card.resolved{opacity:.5}',
/* Card header */
'.fb-card-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}',
'.fb-card-head-left{display:flex;align-items:center;gap:6px}',
'.fb-avatar{width:22px;height:22px;border-radius:50%;background:var(--fb-primary);color:var(--fb-primary-fg);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0}',
'.fb-author{font-size:13px;font-weight:700;color:var(--fb-fg)}',
'.fb-time{font-size:11px;color:var(--fb-muted-fg)}',
'.fb-resolved-mark{font-size:11px;color:#22c55e;margin-left:4px;display:inline-flex;align-items:center;gap:2px}',
/* Priority badge */
'.fb-badge-p{font-size:11px;font-weight:700;padding:2px 8px;border-radius:4px;color:#fff;cursor:default;transition:all .15s}',
'.fb-badge-p.own{cursor:pointer}',
'.fb-badge-p.own:hover{transform:scale(1.1);box-shadow:0 0 0 2px rgba(0,0,0,0.08)}',
/* Quote */
'.fb-quote{font-size:12px;color:var(--fb-muted-fg);padding:6px 10px;background:var(--fb-muted);border-left:2px solid var(--fb-primary);border-radius:0 4px 4px 0;margin-bottom:8px;font-style:italic;line-height:1.5;cursor:pointer;transition:background .15s}',
'.fb-quote:hover{background:var(--fb-border)}',
/* Body */
'.fb-body{font-size:13px;color:var(--fb-muted-fg);line-height:1.6;margin-bottom:6px;white-space:pre-wrap}',
/* Actions */
'.fb-actions{display:flex;gap:2px;flex-wrap:wrap}',
'.fb-act{font-size:12px;color:#a3a3a3;background:none;border:none;padding:4px 8px;border-radius:4px;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:3px;font-family:var(--fb-font)}',
'.fb-act:hover{color:var(--fb-fg)}',
'.fb-act.del:hover{color:var(--fb-destructive)}',
'.fb-act.res:hover{color:#22c55e}',
/* Replies */
'.fb-replies{margin-top:8px;padding-top:8px;border-top:1px solid var(--fb-muted)}',
'.fb-reply-item{display:flex;gap:8px;padding:6px 0}',
'.fb-reply-item+.fb-reply-item{border-top:1px solid var(--fb-muted)}',
'.fb-reply-avatar{width:20px;height:20px;border-radius:50%;background:var(--fb-primary);color:var(--fb-primary-fg);display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:700;flex-shrink:0;margin-top:2px}',
'.fb-reply-meta{font-size:12px;color:var(--fb-muted-fg);margin-bottom:2px}',
'.fb-reply-meta strong{color:#525252;font-weight:700}',
'.fb-reply-text{font-size:13px;color:var(--fb-muted-fg);line-height:1.5}',
/* Reply input */
'.fb-reply-input{display:flex;gap:8px;margin-top:8px}',
'.fb-reply-input textarea{flex:1;padding:8px 10px;border:1px solid var(--fb-border);border-radius:6px;font-size:13px;font-family:var(--fb-font);resize:none;outline:none;min-height:36px;color:var(--fb-fg)}',
'.fb-reply-input textarea:focus{border-color:var(--fb-accent)}',
'.fb-reply-input button{padding:6px 14px;background:var(--fb-primary);color:var(--fb-primary-fg);border:none;border-radius:6px;font-size:12px;cursor:pointer;font-family:var(--fb-font);align-self:flex-end}',
'.fb-reply-input button:disabled{opacity:.4;cursor:default}',
/* Edit area */
'.fb-edit-area textarea{width:100%;padding:8px 10px;border:1px solid var(--fb-border);border-radius:6px;font-size:13px;font-family:var(--fb-font);resize:vertical;outline:none;min-height:50px;margin-bottom:6px;color:var(--fb-fg)}',
'.fb-edit-area textarea:focus{border-color:var(--fb-accent)}',
'.fb-edit-btns{display:flex;gap:6px}',
'.fb-edit-btns button{padding:4px 12px;border-radius:6px;font-size:12px;cursor:pointer;border:1px solid var(--fb-border);background:var(--fb-bg);color:var(--fb-muted-fg);font-family:var(--fb-font)}',
'.fb-edit-btns button.save{background:var(--fb-primary);color:var(--fb-primary-fg);border-color:var(--fb-primary)}',
'.fb-edit-pri{display:flex;gap:4px;margin-bottom:6px}',
'.fb-edit-pri button{padding:2px 10px;border-radius:4px;font-size:11px;font-weight:700;cursor:pointer;border:1px solid var(--fb-border);background:var(--fb-bg);color:var(--fb-muted-fg);font-family:var(--fb-font);transition:all .15s}',
/* Popup */
'.fb-popup{position:fixed;z-index:100000;width:400px;background:var(--fb-bg);border:1px solid var(--fb-border);border-radius:12px;padding:16px;box-shadow:0 10px 25px rgba(0,0,0,0.1);font-family:var(--fb-font);display:none}',
'.fb-popup.show{display:block}',
'.fb-popup-head{margin-bottom:10px}',
'.fb-popup-head span{font-size:14px;font-weight:700;color:var(--fb-fg)}',
'.fb-popup-quote{font-size:13px;color:var(--fb-muted-fg);padding:8px 12px;background:var(--fb-muted);border-left:2px solid var(--fb-accent);border-radius:0 6px 6px 0;margin-bottom:10px;font-style:italic;line-height:1.5}',
'.fb-popup textarea{width:100%;min-height:70px;padding:10px 12px;border:1px solid var(--fb-border);border-radius:8px;font-size:14px;font-family:var(--fb-font);resize:vertical;outline:none;margin-bottom:10px;color:var(--fb-fg)}',
'.fb-popup textarea:focus{border-color:var(--fb-accent)}',
'.fb-popup textarea::placeholder{color:var(--fb-muted-fg)}',
'.fb-popup-pri{display:flex;gap:6px;margin-bottom:10px}',
'.fb-popup-pri button{flex:1;padding:7px 8px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;border:2px solid transparent;transition:all .15s;font-family:var(--fb-font)}',
'.fb-popup-actions{display:flex;gap:8px;justify-content:flex-end}',
'.fb-popup-actions button{padding:8px 18px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s;font-family:var(--fb-font)}',
'.fb-popup-actions .cancel{background:none;border:1px solid var(--fb-border);color:var(--fb-muted-fg)}',
'.fb-popup-actions .cancel:hover{background:var(--fb-muted);color:var(--fb-fg)}',
'.fb-popup-actions .submit{border:none;color:#fff}',
/* Name dialog */
'.fb-name-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:100001}',
'.fb-name-box{background:var(--fb-bg);border-radius:12px;padding:28px;width:360px;box-shadow:0 20px 40px rgba(0,0,0,0.15)}',
'.fb-name-box h2{font-size:18px;font-weight:700;margin:0 0 6px;color:var(--fb-fg)}',
'.fb-name-box p{font-size:14px;color:var(--fb-muted-fg);margin:0 0 16px}',
'.fb-name-box input{width:100%;padding:10px 14px;border:1px solid var(--fb-border);border-radius:8px;font-size:15px;outline:none;margin-bottom:12px;font-family:var(--fb-font);color:var(--fb-fg)}',
'.fb-name-box input:focus{border-color:var(--fb-accent)}',
'.fb-name-box input::placeholder{color:var(--fb-muted-fg)}',
'.fb-name-box button{width:100%;padding:10px;background:var(--fb-primary);color:var(--fb-primary-fg);border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:var(--fb-font)}',
'.fb-name-box button:disabled{opacity:.4;cursor:default}',
/* Inline name edit */
'.fb-name-input{width:100%;padding:4px 8px;border:1px solid var(--fb-border);border-radius:4px;font-size:12px;outline:none;font-family:var(--fb-font);color:var(--fb-fg)}',
'.fb-name-input:focus{border-color:var(--fb-accent)}',
/* Highlights */
'.fb-highlight{padding:1px 0;cursor:pointer;transition:background .15s}',
'.fb-highlight-must{background:rgba(239,68,68,0.15);border-bottom:2px solid #ef4444}',
'.fb-highlight-must:hover{background:rgba(239,68,68,0.25)}',
'.fb-highlight-better{background:rgba(245,158,11,0.15);border-bottom:2px solid #f59e0b}',
'.fb-highlight-better:hover{background:rgba(245,158,11,0.25)}',
'.fb-highlight-want{background:rgba(34,197,94,0.15);border-bottom:2px solid #22c55e}',
'.fb-highlight-want:hover{background:rgba(34,197,94,0.25)}',
'@keyframes fb-pulse{0%,100%{opacity:1}50%{opacity:0.4}}',
'.fb-highlight.fb-pulse{animation:fb-pulse 1s ease-in-out infinite}',
'.fb-card.fb-focused{box-shadow:0 0 0 2px var(--fb-accent);background:rgba(59,130,246,0.04)}',
'#fb-sidebar svg,#fb-toggle svg,.fb-popup svg{pointer-events:none}',
].join('\n');
document.head.appendChild(style);
}
// ===== DOM =====
function el(tag, attrs, children) {
var e = document.createElement(tag);
if (attrs) Object.keys(attrs).forEach(function (k) {
if (k === 'className') e.className = attrs[k];
else if (k === 'innerHTML') e.innerHTML = attrs[k];
else if (k.startsWith('on')) e.addEventListener(k.substring(2).toLowerCase(), attrs[k]);
else e.setAttribute(k, attrs[k]);
});
if (children) {
if (typeof children === 'string') e.textContent = children;
else if (Array.isArray(children)) children.forEach(function (c) { if (c) e.appendChild(c); });
else e.appendChild(children);
}
return e;
}
// ===== Render =====
function render() {
renderToggle();
renderSidebar();
renderPopup();
renderNameDialog();
}
function renderToggle() {
var btn = document.getElementById('fb-toggle');
if (!btn) {
btn = el('button', { id: 'fb-toggle', onClick: toggleSidebar });
document.body.appendChild(btn);
}
var unresolvedCount = state.comments.filter(function (c) { return !c.parentId && !c.resolved; }).length;
var h = '<span class="fb-toggle-icon">' + icon('panelRight', 16);
if (unresolvedCount > 0) h += '<span class="fb-badge">' + unresolvedCount + '</span>';
h += '</span>';
h += '<span class="fb-toggle-label">コメント</span>';
btn.innerHTML = h;
btn.style.display = state.sidebarOpen ? 'none' : '';
}
function applySidebarWidth() {
var sb = document.getElementById('fb-sidebar');
if (!sb) return;
var w = state.sidebarWidth;
sb.style.width = w + 'px';
sb.style.right = state.sidebarOpen ? '0px' : (-(w + 20)) + 'px';
document.body.style.marginRight = state.sidebarOpen ? w + 'px' : '';
}
function toggleSidebar() {
state.sidebarOpen = !state.sidebarOpen;
var btn = document.getElementById('fb-toggle');
if (btn) btn.style.display = state.sidebarOpen ? 'none' : '';
document.body.style.transition = 'margin-right 0.3s ease';
applySidebarWidth();
}
function topLevel() { return state.comments.filter(function (c) { return !c.parentId; }); }
function getReplies(id) { return state.comments.filter(function (c) { return c.parentId === id; }).sort(function (a, b) { return a.timestamp - b.timestamp; }); }
function filtered() {
var tl = topLevel();
if (state.filter === 'resolved') return tl.filter(function (c) { return c.resolved; });
if (state.filter === 'all') return tl;
return tl.filter(function (c) { return !c.resolved; });
}
function renderSidebar() {
var sb = document.getElementById('fb-sidebar');
var isNew = !sb;
if (isNew) {
sb = el('div', { id: 'fb-sidebar' });
document.body.appendChild(sb);
sb.addEventListener('mouseover', function (e) {
var card = e.target.closest('.fb-card');
if (!card) return;
var mark = document.querySelector('.fb-highlight[data-comment-id="' + card.dataset.id + '"]');
if (mark) mark.classList.add('fb-pulse');
});
sb.addEventListener('mouseout', function (e) {
var card = e.target.closest('.fb-card');
if (!card) return;
if (e.relatedTarget && card.contains(e.relatedTarget)) return;
var mark = document.querySelector('.fb-highlight[data-comment-id="' + card.dataset.id + '"]');
if (mark) mark.classList.remove('fb-pulse');
});
}
applySidebarWidth();
var tl = topLevel();
var counts = {
unresolved: tl.filter(function (c) { return !c.resolved; }).length,
resolved: tl.filter(function (c) { return c.resolved; }).length,
all: tl.length,
};
var html = '<div class="fb-resize-handle"></div>';
// Header
html += '<div class="fb-header">';
html += '<div class="fb-header-left"><span class="fb-header-title">コメント</span><span class="fb-header-count">' + counts.all + '</span></div>';
html += '<div class="fb-header-actions">';
html += '<button class="fb-hdr-btn" data-action="close" title="閉じる">' + icon('x', 18) + '</button>';
html += '</div></div>';
// User
if (state.username) {
if (state.editingName) {
html += '<div class="fb-user-row"><input class="fb-name-input" value="' + esc(state.nameInput) + '" data-action="name-input" autofocus></div>';
} else {
html += '<div class="fb-user-row" data-action="edit-name">' + icon('user', 14) + esc(state.username) + '</div>';
}
}
// Filters
html += '<div class="fb-filters">';
['unresolved', 'resolved', 'all'].forEach(function (f) {
var label = f === 'unresolved' ? '未解決' : f === 'resolved' ? '解決済' : 'すべて';
html += '<button class="fb-filter' + (state.filter === f ? ' active' : '') + '" data-filter="' + f + '">' + label + '<span class="cnt">' + counts[f] + '</span></button>';
});
html += '</div>';
// List
var items = filtered().sort(function (a, b) { return b.timestamp - a.timestamp; });
html += '<div class="fb-list">';
if (items.length === 0) {
html += '<div class="fb-empty">' + icon('message', 40) + 'コメントはまだありません<br><span style="font-size:11px">テキストを選択してコメントを追加</span></div>';
} else {
items.forEach(function (c) { html += renderCard(c); });
}
html += '</div>';
sb.innerHTML = html;
if (isNew) bindSidebarEvents(sb);
}
function renderCard(c) {
var isOwn = c.author === state.username;
var pc = PRIORITY_COLORS[c.priority] || PRIORITY_COLORS.want;
var h = '<div class="fb-card' + (c.resolved ? ' resolved' : '') + '" style="border-left-color:' + pc.bg + '" data-id="' + c.id + '">';
// Header
h += '<div class="fb-card-head"><div class="fb-card-head-left">';
h += '<div class="fb-avatar">' + esc(c.author.charAt(0)) + '</div>';
h += '<span class="fb-author">' + esc(c.author) + '</span>';
h += '<span class="fb-time">' + fmtTime(c.timestamp) + '</span>';
if (c.resolved) h += '<span class="fb-resolved-mark">' + icon('check', 12) + ' 解決済</span>';
h += '</div>';
h += '<span class="fb-badge-p' + (isOwn ? ' own' : '') + '" style="background:' + pc.bg + '" data-action="cycle" data-id="' + c.id + '">' + esc(c.priority.charAt(0).toUpperCase() + c.priority.slice(1)) + '</span>';
h += '</div>';
// Quote
if (c.quote) {
var q = c.quote.length > 100 ? c.quote.substring(0, 100) + '...' : c.quote;
h += '<div class="fb-quote" style="border-left-color:' + pc.bg + '" data-action="scroll-quote" data-id="' + c.id + '">' + esc(q) + '</div>';
}
// Body / Edit
if (state.editingId === c.id) {
h += '<div class="fb-edit-area">';
h += '<div class="fb-edit-pri">';
['must', 'better', 'want'].forEach(function (p) {
var sel = state.editPriority === p;
var pc2 = PRIORITY_COLORS[p];
h += '<button data-action="set-edit-pri" data-pri="' + p + '" style="' + (sel ? 'background:' + pc2.bg + ';color:#fff;border-color:' + pc2.bg : '') + '">' + p.charAt(0).toUpperCase() + p.slice(1) + '</button>';
});
h += '</div>';
h += '<textarea data-action="edit-textarea">' + esc(state.editContent) + '</textarea>';
h += '<div class="fb-edit-btns"><button data-action="cancel-edit">キャンセル</button><button class="save" data-action="save-edit" data-id="' + c.id + '">保存</button></div>';
h += '</div>';
} else {
h += '<div class="fb-body">' + esc(c.content) + '</div>';
}
// Actions
if (state.editingId !== c.id) {
h += '<div class="fb-actions">';
h += '<button class="fb-act" data-action="reply" data-id="' + c.id + '">' + icon('message', 12) + '返信</button>';
h += '<button class="fb-act res" data-action="resolve" data-id="' + c.id + '">' + (c.resolved ? icon('rotateCcw', 12) : icon('check', 12)) + (c.resolved ? '戻す' : '解決') + '</button>';
if (isOwn) h += '<button class="fb-act" data-action="edit" data-id="' + c.id + '">' + icon('pencil', 12) + '編集</button>';
if (isOwn) h += '<button class="fb-act del" data-action="delete" data-id="' + c.id + '">' + icon('trash', 12) + '削除</button>';
h += '</div>';
}
// Replies
var replies = getReplies(c.id);
if (replies.length > 0) {
h += '<div class="fb-replies">';
replies.forEach(function (r) {
var isOwnReply = r.author === state.username;
h += '<div class="fb-reply-item"><div class="fb-reply-avatar">' + esc(r.author.charAt(0)) + '</div><div style="flex:1;min-width:0"><div class="fb-reply-meta"><strong>' + esc(r.author) + '</strong> &middot; ' + fmtTime(r.timestamp) + '</div>';
if (state.editingId === r.id) {
h += '<div class="fb-edit-area"><textarea data-action="edit-textarea">' + esc(state.editContent) + '</textarea><div class="fb-edit-btns"><button data-action="cancel-edit">キャンセル</button><button class="save" data-action="save-edit" data-id="' + r.id + '">保存</button></div></div>';
} else {
h += '<div class="fb-reply-text">' + esc(r.content) + '</div>';
h += '<div class="fb-actions" style="margin-top:4px">';
h += '<button class="fb-act" data-action="reply" data-id="' + c.id + '">' + icon('message', 12) + '返信</button>';
if (isOwnReply) h += '<button class="fb-act" data-action="edit" data-id="' + r.id + '">' + icon('pencil', 12) + '編集</button>';
if (isOwnReply) h += '<button class="fb-act del" data-action="delete-reply" data-id="' + r.id + '">' + icon('trash', 12) + '削除</button>';
h += '</div>';
}
h += '</div></div>';
});
h += '</div>';
}
// Reply input
if (state.replyingTo === c.id) {
h += '<div class="fb-reply-input"><textarea placeholder="返信を入力..." data-action="reply-textarea">' + esc(state.replyText) + '</textarea><button data-action="submit-reply" data-id="' + c.id + '"' + (state.replyText.trim() ? '' : ' disabled') + '>送信</button></div>';
}
h += '</div>';
return h;
}
function bindSidebarEvents(sb) {
sb.addEventListener('click', function (e) {
var t = e.target.closest('[data-action]');
if (t) {
var action = t.dataset.action;
var id = t.dataset.id;
if (action === 'close') { toggleSidebar(); }
else if (action === 'edit-name') { state.editingName = true; state.nameInput = state.username; render(); }
else if (action === 'cycle' && id) { cyclePriority(id); }
else if (action === 'scroll-quote' && id) { scrollToQuote(id); }
else if (action === 'reply' && id) { state.replyingTo = state.replyingTo === id ? null : id; state.replyText = ''; render(); }
else if (action === 'resolve' && id) { resolveComment(id); }
else if (action === 'edit' && id) { var c = state.comments.find(function (x) { return x.id === id; }); if (c) { state.editingId = id; state.editContent = c.content; state.editPriority = c.priority; render(); } }
else if (action === 'delete' && id) { deleteComment(id); }
else if (action === 'delete-reply' && id) { deleteReply(id); }
else if (action === 'cancel-edit') { state.editingId = null; render(); }
else if (action === 'save-edit' && id) { saveEdit(id); }
else if (action === 'submit-reply' && id) { submitReply(id); }
else if (action === 'set-edit-pri') { state.editPriority = t.dataset.pri; render(); }
return;
}
var filterBtn = e.target.closest('[data-filter]');
if (filterBtn) { state.filter = filterBtn.dataset.filter; render(); return; }
var card = e.target.closest('.fb-card');
if (card && card.dataset.id) {
scrollToQuote(card.dataset.id);
}
});
sb.addEventListener('mousedown', function (e) {
if (!e.target.closest('.fb-resize-handle')) return;
e.preventDefault();
var startX = e.clientX;
var startWidth = state.sidebarWidth;
var handle = e.target.closest('.fb-resize-handle');
document.body.classList.add('fb-resizing');
handle.classList.add('active');
function onMove(ev) {
state.sidebarWidth = Math.min(800, Math.max(300, startWidth + (startX - ev.clientX)));
var s = document.getElementById('fb-sidebar');
if (s) s.style.width = state.sidebarWidth + 'px';
document.body.style.transition = 'none';
document.body.style.marginRight = state.sidebarWidth + 'px';
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.classList.remove('fb-resizing');
handle.classList.remove('active');
document.body.style.transition = '';
localStorage.setItem(SIDEBAR_WIDTH_KEY, String(state.sidebarWidth));
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
sb.addEventListener('input', function (e) {
var t = e.target;
if (t.dataset.action === 'name-input') { state.nameInput = t.value; }
else if (t.dataset.action === 'edit-textarea') { state.editContent = t.value; }
else if (t.dataset.action === 'reply-textarea') { state.replyText = t.value; var btn = sb.querySelector('[data-action="submit-reply"]'); if (btn) btn.disabled = !state.replyText.trim(); }
});
sb.addEventListener('keydown', function (e) {
var t = e.target;
if (t.dataset.action === 'name-input') {
if (e.key === 'Enter') { finishNameEdit(); }
else if (e.key === 'Escape') { state.editingName = false; render(); }
}
if (t.dataset.action === 'reply-textarea' && (e.metaKey || e.ctrlKey) && e.key === 'Enter') {
var id = sb.querySelector('[data-action="submit-reply"]')?.dataset.id;
if (id) submitReply(id);
}
});
sb.addEventListener('blur', function (e) {
if (e.target.dataset?.action === 'name-input') { finishNameEdit(); }
}, true);
}
// ===== Popup =====
function renderPopup() {
var popup = document.getElementById('fb-popup');
var isNew = !popup;
if (isNew) {
popup = el('div', { id: 'fb-popup', className: 'fb-popup' });
document.body.appendChild(popup);
popup.addEventListener('click', function (e) {
var t = e.target.closest('[data-action]');
if (!t) return;
if (t.dataset.action === 'cancel-popup') { closePopup(); }
else if (t.dataset.action === 'set-popup-pri') { state.popupPriority = t.dataset.pri; render(); }
else if (t.dataset.action === 'submit-popup') { submitComment(state.popupPriority); }
});
popup.addEventListener('input', function (e) {
if (e.target.dataset.action === 'popup-textarea') state.popupContent = e.target.value;
});
popup.addEventListener('keydown', function (e) {
if (e.target.dataset.action === 'popup-textarea' && (e.metaKey || e.ctrlKey) && e.key === 'Enter') {
submitComment(state.popupPriority);
}
});
}
if (!state.selectedRect) { popup.classList.remove('show'); return; }
popup.classList.add('show');
var q = state.selectedText.length > 120 ? state.selectedText.substring(0, 120) + '...' : state.selectedText;
var selPc = PRIORITY_COLORS[state.popupPriority];
var h = '<div class="fb-popup-head"><span>コメントを追加</span></div>';
h += '<div class="fb-popup-quote">' + esc(q) + '</div>';
h += '<textarea placeholder="フィードバックを入力..." data-action="popup-textarea">' + esc(state.popupContent) + '</textarea>';
h += '<div class="fb-popup-pri">';
['must', 'better', 'want'].forEach(function (p) {
var pc = PRIORITY_COLORS[p];
var sel = state.popupPriority === p;
var style = sel
? 'background:' + pc.light + ';color:' + pc.bg + ';border-color:' + pc.bg
: 'background:var(--fb-bg);color:var(--fb-muted-fg);border-color:var(--fb-border)';
h += '<button style="' + style + '" data-action="set-popup-pri" data-pri="' + p + '">' + p.charAt(0).toUpperCase() + p.slice(1) + '</button>';
});
h += '</div>';
h += '<div class="fb-popup-actions">';
h += '<button class="cancel" data-action="cancel-popup">キャンセル</button>';
h += '<button class="submit" style="background:' + selPc.bg + '" data-action="submit-popup">送信</button>';
h += '</div>';
popup.innerHTML = h;
var rect = state.selectedRect;
var pw = 400, ph = 300, m = 12;
var top = rect.bottom + m;
if (top + ph > window.innerHeight) top = rect.top - ph - m;
if (top < m) top = Math.max(m, (window.innerHeight - ph) / 2);
var availW = state.sidebarOpen ? window.innerWidth - state.sidebarWidth : window.innerWidth;
var left = Math.max(m, Math.min(rect.left, availW - pw - m));
popup.style.top = top + 'px';
popup.style.left = left + 'px';
}
function closePopup() {
state.selectedText = '';
state.selectedRect = null;
state.popupContent = '';
state.popupPriority = 'must';
render();
}
function renderNameDialog() {
var existing = document.getElementById('fb-name-overlay');
if (state.username) { if (existing) existing.remove(); return; }
if (existing) return;
var overlay = el('div', { id: 'fb-name-overlay', className: 'fb-name-overlay' });
overlay.innerHTML = '<div class="fb-name-box"><h2>ようこそ</h2><p>コメントに表示される名前を入力してください。</p><input placeholder="例: 田中太郎" id="fb-name-field"><button id="fb-name-submit" disabled>始める</button></div>';
document.body.appendChild(overlay);
var input = document.getElementById('fb-name-field');
var btn = document.getElementById('fb-name-submit');
input.addEventListener('input', function () { btn.disabled = !input.value.trim(); });
input.addEventListener('keydown', function (e) { if (e.key === 'Enter' && input.value.trim()) { setName(input.value.trim()); } });
btn.addEventListener('click', function () { if (input.value.trim()) setName(input.value.trim()); });
setTimeout(function () { input.focus(); }, 100);
}
function setName(name) {
state.username = name;
localStorage.setItem(USERNAME_KEY, name);
var overlay = document.getElementById('fb-name-overlay');
if (overlay) overlay.remove();
render();
}
function finishNameEdit() {
if (state.nameInput.trim() && state.nameInput.trim() !== state.username) {
var oldName = state.username;
state.username = state.nameInput.trim();
localStorage.setItem(USERNAME_KEY, state.username);
api('PUT', { id: '_rename', author: state.username, oldAuthor: oldName, projectSlug: slug }).then(loadComments);
}
state.editingName = false;
render();
}
// ===== Actions =====
function submitComment(priority) {
if (!state.username || !state.selectedText) return;
var c = {
id: genId(), author: state.username, type: 'comment',
quote: state.selectedText, quoteContext: state.selectedQuoteContext,
content: state.popupContent.trim(),
priority: priority, parentId: null, pageUrl: window.location.href,
projectSlug: slug, timestamp: Date.now(),
};
state.comments.push(c);
closePopup();
applyHighlights();
api('POST', c).then(loadComments);
}
function resolveComment(id) {
var c = state.comments.find(function (x) { return x.id === id; });
if (!c) return;
var now = !c.resolved;
c.resolved = now;
c.resolvedBy = now ? state.username : null;
c.resolvedAt = now ? Date.now() : null;
render(); applyHighlights();
api('PUT', { id: id, resolved: now, resolvedBy: c.resolvedBy, resolvedAt: c.resolvedAt });
}
function cyclePriority(id) {
var c = state.comments.find(function (x) { return x.id === id; });
if (!c || c.author !== state.username) return;
c.priority = PRIORITY_CYCLE[c.priority] || 'must';
render(); applyHighlights();
api('PUT', { id: id, priority: c.priority });
}
function deleteComment(id) {
state.comments = state.comments.filter(function (c) { return c.id !== id && c.parentId !== id; });
render(); applyHighlights();
api('DELETE', { id: id });
}
function deleteReply(id) {
state.comments = state.comments.filter(function (c) { return c.id !== id; });
render();
api('DELETE', { id: id });
}
function saveEdit(id) {
var c = state.comments.find(function (x) { return x.id === id; });
if (!c) return;
c.content = state.editContent;
c.priority = state.editPriority;
state.editingId = null;
render(); applyHighlights();
api('PUT', { id: id, content: c.content, priority: c.priority });
}
function submitReply(parentId) {
if (!state.replyText.trim() || !state.username) return;
var r = {
id: genId(), author: state.username, type: 'comment',
quote: '', quoteContext: { beforeText: '', afterText: '' },
content: state.replyText.trim(), priority: 'want',
parentId: parentId, pageUrl: window.location.href,
projectSlug: slug, timestamp: Date.now(),
};
state.comments.push(r);
state.replyingTo = null;
state.replyText = '';
render();
api('POST', r);
}
// ===== Text Selection =====
function setupTextSelection() {
document.addEventListener('mouseup', function (e) {
if (e.target.closest('#fb-sidebar,#fb-toggle,#fb-popup')) return;
var sel = window.getSelection();
var text = sel?.toString().trim();
if (!text || !sel || sel.rangeCount === 0 || !state.username) return;
var range = sel.getRangeAt(0);
state.selectedText = text.replace(/[\s\u00A0]+/g, ' ').substring(0, 200);
state.selectedRect = range.getBoundingClientRect();
state.selectedQuoteContext = getQuoteContext(range);
state.popupContent = '';
render();
});
document.addEventListener('mousedown', function (e) {
if (e.target.closest('#fb-popup')) return;
if (state.selectedRect) closePopup();
});
}
function getQuoteContext(range) {
var before = '', after = '';
try {
var br = document.createRange();
br.setStart(document.body, 0);
br.setEnd(range.startContainer, range.startOffset);
before = br.toString().slice(-50).replace(/[\s\u00A0]+/g, ' ').trim();
var ar = document.createRange();
ar.setStart(range.endContainer, range.endOffset);
ar.setEnd(document.body, document.body.childNodes.length);
after = ar.toString().slice(0, 50).replace(/[\s\u00A0]+/g, ' ').trim();
} catch (e) { /* ignore */ }
return { beforeText: before, afterText: after };
}
// ===== Highlights =====
function collectTextNodes() {
var nodes = [];
var tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
acceptNode: function (n) {
var p = n.parentElement;
if (!p) return NodeFilter.FILTER_REJECT;
if (p.tagName === 'SCRIPT' || p.tagName === 'STYLE') return NodeFilter.FILTER_REJECT;
if (p.closest('#fb-sidebar,#fb-toggle,#fb-popup,.fb-highlight')) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
});
var n;
while ((n = tw.nextNode())) nodes.push(n);
return nodes;
}
function mapNormToOrig(orig, normStart, normEnd) {
var ws = /[\s\u00A0]+/g;
var normalized = orig.replace(ws, ' ');
if (normStart >= normalized.length) return null;
var origIdx = 0, normIdx = 0;
var origStart = -1, origEnd = -1;
while (origIdx < orig.length) {
if (normIdx === normStart && origStart === -1) origStart = origIdx;
if (normIdx === normEnd) { origEnd = origIdx; break; }
if (normIdx < normalized.length && orig[origIdx] === normalized[normIdx]) {
origIdx++; normIdx++;
} else {
origIdx++;
}
}
if (origEnd === -1 && normIdx >= normEnd) origEnd = origIdx;
if (origStart === -1 || origEnd === -1 || origStart >= origEnd) return null;
return [origStart, origEnd];
}
function wrapTextRange(node, start, end, comment) {
var orig = node.textContent;
var before = document.createTextNode(orig.substring(0, start));
var mark = document.createElement('mark');
mark.className = 'fb-highlight fb-highlight-' + comment.priority;
mark.dataset.commentId = comment.id;
mark.textContent = orig.substring(start, end);
(function (id) {
mark.addEventListener('click', function () { scrollToCard(id); });
})(comment.id);
var after = document.createTextNode(orig.substring(end));
node.parentNode.insertBefore(before, node);
node.parentNode.insertBefore(mark, node);
node.parentNode.insertBefore(after, node);
node.parentNode.removeChild(node);
}
function applyHighlights() {
document.querySelectorAll('.fb-highlight').forEach(function (el) {
var t = document.createTextNode(el.textContent);
el.parentNode.replaceChild(t, el);
});
document.body.normalize();
state.comments.filter(function (c) { return !c.parentId && !c.resolved && c.quote && c.quote.length >= 2; }).forEach(function (c) {
var search = c.quote.replace(/[\s\u00A0]+/g, ' ').trim();
var textNodes = collectTextNodes();
var found = false;
for (var i = 0; i < textNodes.length; i++) {
var node = textNodes[i];
var orig = node.textContent;
var di = orig.indexOf(search);
if (di !== -1) {
try { wrapTextRange(node, di, di + search.length, c); found = true; } catch (e) {}
break;
}
var norm = orig.replace(/[\s\u00A0]+/g, ' ');
var ni = norm.indexOf(search);
if (ni === -1) continue;
var range = mapNormToOrig(orig, ni, ni + search.length);
if (range) {
try { wrapTextRange(node, range[0], range[1], c); found = true; } catch (e) {}
break;
}
}
if (!found) {
var concat = '';
var nodeMap = [];
for (var j = 0; j < textNodes.length; j++) {
var s = concat.length;
concat += textNodes[j].textContent;
nodeMap.push({ node: textNodes[j], start: s, end: concat.length });
}
var concatNorm = concat.replace(/[\s\u00A0]+/g, ' ');
var ci = concatNorm.indexOf(search);
if (ci !== -1) {
var range = mapNormToOrig(concat, ci, ci + search.length);
if (range) {
var mStart = range[0], mEnd = range[1];
for (var k = nodeMap.length - 1; k >= 0; k--) {
var nm = nodeMap[k];
if (nm.end <= mStart || nm.start >= mEnd) continue;
var ls = Math.max(0, mStart - nm.start);
var le = Math.min(nm.node.textContent.length, mEnd - nm.start);
try { wrapTextRange(nm.node, ls, le, c); } catch (e) {}
}
}
}
}
});
}
var PRIORITY_FLASH = { must: 'rgba(239,68,68,0.4)', better: 'rgba(245,158,11,0.4)', want: 'rgba(34,197,94,0.4)' };
function scrollToQuote(id) {
var mark = document.querySelector('.fb-highlight[data-comment-id="' + id + '"]');
if (!mark) return;
mark.scrollIntoView({ behavior: 'smooth', block: 'center' });
var c = state.comments.find(function (x) { return x.id === id; });
var flashColor = c ? (PRIORITY_FLASH[c.priority] || PRIORITY_FLASH.want) : PRIORITY_FLASH.want;
var orig = mark.style.backgroundColor;
mark.style.backgroundColor = flashColor;
mark.style.transition = 'background-color 0.3s';
setTimeout(function () { mark.style.backgroundColor = orig; }, 1500);
}
function scrollToCard(id) {
var wasClosed = !state.sidebarOpen;
if (wasClosed) { toggleSidebar(); }
setTimeout(function () {
var card = document.querySelector('.fb-card[data-id="' + id + '"]');
if (!card) return;
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.classList.add('fb-focused');
setTimeout(function () { card.classList.remove('fb-focused'); }, 1500);
}, wasClosed ? 350 : 0);
}
// ===== Init =====
function init() {
injectStyles();
render();
setupTextSelection();
loadComments();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();