927 lines
46 KiB
JavaScript
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> · ' + 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();
|
|
}
|
|
})();
|