pptxdiff 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +85 -0
- package/bin/cli.js +46 -0
- package/package.json +38 -0
- package/src/pptxdiff/index.html +3872 -0
- package/src/pptxdiff/sample-pptx.js +300 -0
- package/src/pptxdiff/support.js +1603 -0
|
@@ -0,0 +1,3872 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<script src="./support.js"></script>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<x-dc>
|
|
10
|
+
<helmet>
|
|
11
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
12
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
13
|
+
<link href="https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
|
|
14
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
|
15
|
+
<style>
|
|
16
|
+
*{box-sizing:border-box}
|
|
17
|
+
body{margin:0}
|
|
18
|
+
::selection{background:#C9684A33}
|
|
19
|
+
.sd-scroll::-webkit-scrollbar{width:10px;height:10px}
|
|
20
|
+
.sd-scroll::-webkit-scrollbar-thumb{background:#D8D2C4;border-radius:6px;border:3px solid transparent;background-clip:content-box}
|
|
21
|
+
@keyframes sd-spin{to{transform:rotate(360deg)}}
|
|
22
|
+
@media print{
|
|
23
|
+
.no-print{display:none !important}
|
|
24
|
+
body{background:#fff !important}
|
|
25
|
+
.sd-pair-card{break-inside:avoid;page-break-inside:avoid}
|
|
26
|
+
}
|
|
27
|
+
</style>
|
|
28
|
+
<meta name="omelette-owns-print" content="1">
|
|
29
|
+
</helmet>
|
|
30
|
+
|
|
31
|
+
<div class="sd-scroll" style="min-height:100vh;background:{{ theme.bg }};color:{{ theme.text }};font-family:-apple-system,'Helvetica Neue',Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;padding:32px 28px 80px;transition:background .15s,color .15s">
|
|
32
|
+
<div style="max-width:1180px;margin:0 auto">
|
|
33
|
+
|
|
34
|
+
<!-- Header -->
|
|
35
|
+
<header style="display:flex;justify-content:space-between;align-items:flex-end;flex-wrap:wrap;gap:20px;padding-bottom:22px;border-bottom:1px solid {{ theme.border }}">
|
|
36
|
+
<div>
|
|
37
|
+
<div style="display:flex;align-items:center;gap:11px">
|
|
38
|
+
<div style="width:11px;height:11px;border-radius:50%;background:{{ accent }}"></div>
|
|
39
|
+
<div style="font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:{{ theme.muted }};font-weight:600">PowerPoint review</div>
|
|
40
|
+
</div>
|
|
41
|
+
<h1 style="font-family:'Spectral',Georgia,serif;font-weight:500;font-size:40px;line-height:1.05;margin:10px 0 0;letter-spacing:-.01em">Slide Diff</h1>
|
|
42
|
+
<p style="margin:8px 0 0;color:{{ theme.muted }};font-size:15px;max-width:48ch;line-height:1.45">Compare two slides side by side — rendered with full fidelity, then diffed on text, fonts, color, alignment and position. Drop in a pair of <code style="font-family:ui-monospace,Menlo,monospace;font-size:13px;background:#EBE6D8;padding:1px 5px;border-radius:4px">.pptx</code> files or review the sample.</p>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="no-print" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
|
45
|
+
<button onClick="{{ toggleTheme }}" style="font:inherit;font-size:13px;font-weight:600;color:{{ theme.muted }};background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:8px;padding:9px 15px;cursor:pointer">{{ themeToggleLabel }}</button>
|
|
46
|
+
<div style="display:flex;background:{{ theme.chip }};border:1px solid {{ theme.border }};border-radius:9px;padding:3px;gap:2px">
|
|
47
|
+
<button onClick="{{ goSingle }}" style="font:inherit;font-size:13px;font-weight:600;border:none;border-radius:6px;padding:7px 13px;cursor:pointer;white-space:nowrap;background:{{ singleTabBg }};color:{{ singleTabFg }}">Single pair</button>
|
|
48
|
+
<button onClick="{{ goAll }}" style="font:inherit;font-size:13px;font-weight:600;border:none;border-radius:6px;padding:7px 13px;cursor:pointer;white-space:nowrap;background:{{ allTabBg }};color:{{ allTabFg }}">All pairs ({{ nPairsLabel }})</button>
|
|
49
|
+
<button onClick="{{ goBatch }}" style="font:inherit;font-size:13px;font-weight:600;border:none;border-radius:6px;padding:7px 13px;cursor:pointer;background:{{ batchTabBg }};color:{{ batchTabFg }}">Batch</button>
|
|
50
|
+
</div>
|
|
51
|
+
<button onClick="{{ toggleShortcutsHelp }}" title="Keyboard shortcuts (press ?)" style="font:inherit;font-size:11.5px;color:{{ theme.muted }};background:none;border:1px solid {{ theme.border }};border-radius:8px;padding:6px 10px;cursor:pointer;user-select:none">⌨ Shortcuts</button>
|
|
52
|
+
<button onClick="{{ toggleIntegrations }}" style="font:inherit;font-size:13px;font-weight:600;color:{{ theme.muted }};background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:8px;padding:9px 15px;cursor:pointer">Live push…</button>
|
|
53
|
+
<button onClick="{{ toggleHistory }}" style="font:inherit;font-size:13px;font-weight:600;color:{{ theme.muted }};background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:8px;padding:9px 15px;cursor:pointer">History</button>
|
|
54
|
+
<sc-if value="{{ hasAnyDecisions }}" hint-placeholder-val="{{ false }}">
|
|
55
|
+
<details style="position:relative">
|
|
56
|
+
<summary style="list-style:none;font:inherit;font-size:13px;font-weight:600;color:#B23A2E;background:{{ theme.card }};border:1px solid #B23A2E;border-radius:8px;padding:9px 15px;cursor:pointer">Clear decisions ▾</summary>
|
|
57
|
+
<div style="position:absolute;left:0;top:calc(100% + 6px);background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:10px;box-shadow:0 8px 24px -8px rgba(31,30,27,.25);padding:14px;display:flex;flex-direction:column;gap:8px;min-width:260px;max-width:min(90vw,300px);z-index:10">
|
|
58
|
+
<div style="font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:{{ theme.muted }}">Scope</div>
|
|
59
|
+
<div style="display:flex;flex-direction:column;gap:4px">
|
|
60
|
+
<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:{{ theme.text }};cursor:pointer"><input type="radio" checked="{{ clearScopeAllChecked }}" onChange="{{ setClearScopeAll }}" style="cursor:pointer">All decisions, everyone</label>
|
|
61
|
+
<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:{{ theme.text }};cursor:pointer"><input type="radio" checked="{{ clearScopeMineChecked }}" onChange="{{ setClearScopeMine }}" style="cursor:pointer">Only my votes</label>
|
|
62
|
+
<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:{{ theme.text }};cursor:pointer"><input type="radio" checked="{{ clearScopeReviewerChecked }}" onChange="{{ setClearScopeReviewer }}" style="cursor:pointer">Specific reviewer</label>
|
|
63
|
+
<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:{{ theme.text }};cursor:pointer"><input type="radio" checked="{{ clearScopeRangeChecked }}" onChange="{{ setClearScopeRange }}" style="cursor:pointer">Specific slide range</label>
|
|
64
|
+
</div>
|
|
65
|
+
<sc-if value="{{ isClearScopeReviewer }}" hint-placeholder-val="{{ false }}">
|
|
66
|
+
<input value="{{ clearDecisionsReviewerHandle }}" onChange="{{ onClearReviewerHandleChange }}" placeholder="@handle" list="reviewer-handle-options" style="font:inherit;font-size:13px;border:1px solid {{ theme.border }};border-radius:8px;padding:7px 10px;background:{{ theme.bg }};color:{{ theme.text }}">
|
|
67
|
+
<datalist id="reviewer-handle-options">
|
|
68
|
+
<sc-for list="{{ reviewerHandleOptions }}" as="rho" hint-placeholder-count="0">
|
|
69
|
+
<option value="{{ rho.handle }}"></option>
|
|
70
|
+
</sc-for>
|
|
71
|
+
</datalist>
|
|
72
|
+
</sc-if>
|
|
73
|
+
<sc-if value="{{ isClearScopeRange }}" hint-placeholder-val="{{ false }}">
|
|
74
|
+
<div style="display:flex;align-items:center;gap:6px">
|
|
75
|
+
<span style="font-size:12px;color:{{ theme.muted }}">Rows</span>
|
|
76
|
+
<input type="number" min="1" value="{{ clearDecisionsRangeFrom }}" onChange="{{ onClearRangeFromChange }}" style="width:60px;font:inherit;font-size:13px;border:1px solid {{ clearRangeBorderColor }};border-radius:8px;padding:7px 8px;background:{{ theme.bg }};color:{{ theme.text }}">
|
|
77
|
+
<span style="font-size:12px;color:{{ theme.muted }}">to</span>
|
|
78
|
+
<input type="number" min="1" value="{{ clearDecisionsRangeTo }}" onChange="{{ onClearRangeToChange }}" style="width:60px;font:inherit;font-size:13px;border:1px solid {{ clearRangeBorderColor }};border-radius:8px;padding:7px 8px;background:{{ theme.bg }};color:{{ theme.text }}">
|
|
79
|
+
<span style="font-size:11px;color:{{ theme.muted }}">(as shown in All pairs view)</span>
|
|
80
|
+
</div>
|
|
81
|
+
<sc-if value="{{ isClearRangeInvalid }}" hint-placeholder-val="{{ false }}">
|
|
82
|
+
<div style="font-size:11.5px;color:#B23A2E;font-weight:600">"From" is after "To" — will be treated as a single row ({{ clearDecisionsRangeFrom }}).</div>
|
|
83
|
+
</sc-if>
|
|
84
|
+
</sc-if>
|
|
85
|
+
<button onClick="{{ requestClearAllDecisions }}" style="font:inherit;font-size:13px;font-weight:700;color:#fff;background:#B23A2E;border:none;border-radius:8px;padding:8px 14px;cursor:pointer;margin-top:4px">Clear decisions…</button>
|
|
86
|
+
</div>
|
|
87
|
+
</details>
|
|
88
|
+
</sc-if>
|
|
89
|
+
<button onClick="{{ runSelfTests }}" style="font:inherit;font-size:13px;font-weight:600;color:{{ theme.muted }};background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:8px;padding:9px 15px;cursor:pointer">Run self-tests</button>
|
|
90
|
+
<button onClick="{{ loadSample }}" style="font:inherit;font-size:13px;font-weight:600;color:{{ theme.muted }};background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:8px;padding:9px 15px;cursor:pointer">Reset to sample</button>
|
|
91
|
+
<details style="position:relative">
|
|
92
|
+
<summary style="list-style:none;font:inherit;font-size:13px;font-weight:700;color:#fff;background:{{ accent }};border:1px solid {{ accent }};border-radius:8px;padding:9px 15px;cursor:pointer">Export ▾</summary>
|
|
93
|
+
<div style="position:absolute;left:0;top:calc(100% + 6px);background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:10px;box-shadow:0 8px 24px -8px rgba(31,30,27,.25);padding:6px;display:flex;flex-direction:column;gap:2px;min-width:220px;max-width:min(90vw, 260px);z-index:10">
|
|
94
|
+
<button onClick="{{ exportPdf }}" style="font:inherit;font-size:13px;text-align:left;background:none;border:none;padding:8px 10px;border-radius:6px;cursor:pointer;color:{{ theme.text }}">PDF report</button>
|
|
95
|
+
<button onClick="{{ exportHtmlReport }}" style="font:inherit;font-size:13px;text-align:left;background:none;border:none;padding:8px 10px;border-radius:6px;cursor:pointer;color:{{ theme.text }}">HTML report</button>
|
|
96
|
+
<button onClick="{{ exportJsonReport }}" style="font:inherit;font-size:13px;text-align:left;background:none;border:none;padding:8px 10px;border-radius:6px;cursor:pointer;color:{{ theme.text }}">JSON report</button>
|
|
97
|
+
<div style="height:1px;background:{{ theme.border }};margin:4px 2px"></div>
|
|
98
|
+
<button onClick="{{ exportDecisionsCsv }}" style="font:inherit;font-size:13px;text-align:left;background:none;border:none;padding:8px 10px;border-radius:6px;cursor:pointer;color:{{ theme.text }}">Decisions → CSV</button>
|
|
99
|
+
<button onClick="{{ exportDecisionsJson }}" style="font:inherit;font-size:13px;text-align:left;background:none;border:none;padding:8px 10px;border-radius:6px;cursor:pointer;color:{{ theme.text }}">Decisions → JSON</button>
|
|
100
|
+
<div style="height:1px;background:{{ theme.border }};margin:4px 2px"></div>
|
|
101
|
+
<button onClick="{{ exportMarkdownSummary }}" style="font:inherit;font-size:13px;text-align:left;background:none;border:none;padding:8px 10px;border-radius:6px;cursor:pointer;color:{{ theme.text }}">Markdown summary</button>
|
|
102
|
+
<button onClick="{{ exportNotionMarkdown }}" style="font:inherit;font-size:13px;text-align:left;background:none;border:none;padding:8px 10px;border-radius:6px;cursor:pointer;color:{{ theme.text }}">Notion (Markdown import)</button>
|
|
103
|
+
<button onClick="{{ exportConfluencePage }}" style="font:inherit;font-size:13px;text-align:left;background:none;border:none;padding:8px 10px;border-radius:6px;cursor:pointer;color:{{ theme.text }}">Confluence (wiki markup)</button>
|
|
104
|
+
<div style="height:1px;background:{{ theme.border }};margin:4px 2px"></div>
|
|
105
|
+
<label style="font:inherit;font-size:13px;text-align:left;padding:8px 10px;border-radius:6px;cursor:pointer;color:{{ theme.text }};display:block">Import report JSON…
|
|
106
|
+
<input type="file" accept=".json" onChange="{{ importReportJson }}" style="display:none">
|
|
107
|
+
</label>
|
|
108
|
+
<button onClick="{{ exportMergePlan }}" style="font:inherit;font-size:13px;text-align:left;background:none;border:none;padding:8px 10px;border-radius:6px;cursor:pointer;color:{{ theme.text }}">Merge plan → JSON</button>
|
|
109
|
+
<button onClick="{{ exportMergedPptx }}" style="font:inherit;font-size:13px;text-align:left;background:none;border:none;padding:8px 10px;border-radius:6px;cursor:pointer;color:{{ theme.text }}">Merged .pptx (beta)</button>
|
|
110
|
+
<button onClick="{{ toggleMergePreview }}" style="font:inherit;font-size:13px;text-align:left;background:none;border:none;padding:8px 10px;border-radius:6px;cursor:pointer;color:{{ theme.text }}">Preview merge winners…</button>
|
|
111
|
+
<div style="height:1px;background:{{ theme.border }};margin:4px 2px"></div>
|
|
112
|
+
<button onClick="{{ exportSlackMessage }}" style="font:inherit;font-size:13px;text-align:left;background:none;border:none;padding:8px 10px;border-radius:6px;cursor:pointer;color:{{ theme.text }}">Copy Slack message</button>
|
|
113
|
+
<button onClick="{{ exportEmailSummary }}" style="font:inherit;font-size:13px;text-align:left;background:none;border:none;padding:8px 10px;border-radius:6px;cursor:pointer;color:{{ theme.text }}">Email summary</button>
|
|
114
|
+
<button onClick="{{ exportShareLink }}" style="font:inherit;font-size:13px;text-align:left;background:none;border:none;padding:8px 10px;border-radius:6px;cursor:pointer;color:{{ theme.text }}">Copy shareable link</button>
|
|
115
|
+
</div>
|
|
116
|
+
</details>
|
|
117
|
+
</div>
|
|
118
|
+
</header>
|
|
119
|
+
|
|
120
|
+
<sc-if value="{{ hasToast }}" hint-placeholder-val="{{ false }}">
|
|
121
|
+
<div class="no-print" role="status" aria-live="polite" style="position:fixed;bottom:20px;right:20px;background:{{ theme.text }};color:{{ theme.bg }};font-size:13px;font-weight:600;padding:10px 16px;border-radius:10px;box-shadow:0 8px 24px -8px rgba(0,0,0,.4);z-index:50">{{ toastMsg }}</div>
|
|
122
|
+
</sc-if>
|
|
123
|
+
|
|
124
|
+
<!-- Reviewers -->
|
|
125
|
+
<div class="no-print" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:16px">
|
|
126
|
+
<span style="font-size:12px;color:{{ theme.muted }};font-weight:600">Reviewers:</span>
|
|
127
|
+
<sc-for list="{{ reviewerChips }}" as="rc" hint-placeholder-count="0">
|
|
128
|
+
<div style="display:flex;align-items:center;gap:6px;background:{{ rc.bg }};color:{{ rc.fg }};border:1px solid {{ theme.border }};border-radius:20px;padding:5px 6px 5px 10px;font-size:12.5px;font-weight:600;cursor:pointer">
|
|
129
|
+
<input type="checkbox" checked="{{ rc.selected }}" onClick="{{ rc.onToggleSelect }}" style="width:13px;height:13px;cursor:pointer">
|
|
130
|
+
<span onClick="{{ rc.onClick }}">{{ rc.display }}</span>
|
|
131
|
+
<button onClick="{{ rc.onRemove }}" aria-label="Remove reviewer {{ rc.display }}" style="font:inherit;border:none;background:none;color:inherit;opacity:.7;cursor:pointer;padding:2px 4px">✕</button>
|
|
132
|
+
</div>
|
|
133
|
+
</sc-for>
|
|
134
|
+
<sc-if value="{{ hasReviewers }}" hint-placeholder-val="{{ false }}">
|
|
135
|
+
<button onClick="{{ selectAllReviewers }}" style="font:inherit;font-size:11.5px;font-weight:600;color:{{ theme.muted }};background:none;border:1px solid {{ theme.border }};border-radius:6px;padding:4px 9px;cursor:pointer">Select all</button>
|
|
136
|
+
<button onClick="{{ selectNoneReviewers }}" style="font:inherit;font-size:11.5px;font-weight:600;color:{{ theme.muted }};background:none;border:1px solid {{ theme.border }};border-radius:6px;padding:4px 9px;cursor:pointer">Select none</button>
|
|
137
|
+
<sc-if value="{{ nSelectedReviewers }}" hint-placeholder-val="{{ false }}">
|
|
138
|
+
<button onClick="{{ requestRemoveSelectedReviewers }}" style="font:inherit;font-size:11.5px;font-weight:600;color:#B23A2E;background:none;border:1px solid #B23A2E;border-radius:6px;padding:4px 9px;cursor:pointer">Remove selected ({{ nSelectedReviewers }})</button>
|
|
139
|
+
</sc-if>
|
|
140
|
+
</sc-if>
|
|
141
|
+
<input value="{{ handleDraft }}" onChange="{{ onHandleChange }}" placeholder="@handle or email" style="font:inherit;font-size:12.5px;border:1px solid {{ theme.border }};border-radius:8px;padding:6px 10px;width:150px;background:{{ theme.card }};color:{{ theme.text }}">
|
|
142
|
+
<input value="{{ nameDraft }}" onChange="{{ onNameChange }}" placeholder="Name (optional)" style="font:inherit;font-size:12.5px;border:1px solid {{ theme.border }};border-radius:8px;padding:6px 10px;width:130px;background:{{ theme.card }};color:{{ theme.text }}">
|
|
143
|
+
<button onClick="{{ onAddReviewer }}" style="font:inherit;font-size:12.5px;font-weight:600;color:#fff;background:{{ accent }};border:none;border-radius:8px;padding:6px 12px;cursor:pointer">Add reviewer</button>
|
|
144
|
+
<span style="font-size:12px;color:{{ theme.muted }}">Voting as: <strong>{{ activeLabel }}</strong></span>
|
|
145
|
+
<div style="position:relative;margin-left:auto">
|
|
146
|
+
<button onClick="{{ toggleNotifications }}" aria-label="Notifications" style="position:relative;font:inherit;font-size:12.5px;font-weight:600;color:{{ theme.text }};background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:8px;padding:6px 12px;cursor:pointer">
|
|
147
|
+
Notifications
|
|
148
|
+
<sc-if value="{{ unreadCount }}" hint-placeholder-val="{{ false }}">
|
|
149
|
+
<span style="position:absolute;top:-6px;right:-6px;background:#B23A2E;color:#fff;font-size:10px;font-weight:700;border-radius:9px;padding:1px 6px">{{ unreadCount }}</span>
|
|
150
|
+
</sc-if>
|
|
151
|
+
</button>
|
|
152
|
+
<sc-if value="{{ showNotifications }}" hint-placeholder-val="{{ false }}">
|
|
153
|
+
<div style="position:absolute;right:0;top:calc(100% + 6px);width:280px;background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:10px;box-shadow:0 8px 24px -8px rgba(31,30,27,.25);padding:10px;z-index:20;max-height:260px;overflow:auto">
|
|
154
|
+
<sc-if value="{{ hasNotifications }}" hint-placeholder-val="{{ false }}">
|
|
155
|
+
<sc-for list="{{ myNotifications }}" as="nt" hint-placeholder-count="1">
|
|
156
|
+
<div style="font-size:12.5px;padding:6px 4px;border-bottom:1px solid {{ theme.border }}">
|
|
157
|
+
<strong style="color:{{ theme.text }}">{{ nt.from }}</strong> <span style="color:{{ theme.muted }}">· {{ nt.timeStr }} · {{ nt.slideLabel }}</span>
|
|
158
|
+
<div style="color:{{ theme.text }};margin-top:2px">{{ nt.text }}</div>
|
|
159
|
+
</div>
|
|
160
|
+
</sc-for>
|
|
161
|
+
</sc-if>
|
|
162
|
+
<sc-if value="{{ noNotifications }}" hint-placeholder-val="{{ false }}">
|
|
163
|
+
<div style="font-size:12.5px;color:{{ theme.muted }};padding:4px">No notifications yet — mention a reviewer's @handle in a comment to notify them.</div>
|
|
164
|
+
</sc-if>
|
|
165
|
+
</div>
|
|
166
|
+
</sc-if>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<!-- History log -->
|
|
171
|
+
<sc-if value="{{ showHistory }}" hint-placeholder-val="{{ false }}">
|
|
172
|
+
<div class="no-print" style="margin-top:18px;background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:12px;padding:14px 18px">
|
|
173
|
+
<div onClick="{{ toggleHistoryCollapse }}" style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:{{ theme.muted }};margin-bottom:8px"><span>{{ historyChevron }}</span>Approval history</div>
|
|
174
|
+
<sc-if value="{{ historyExpanded }}" hint-placeholder-val="{{ true }}">
|
|
175
|
+
<sc-if value="{{ hasHistory }}" hint-placeholder-val="{{ false }}">
|
|
176
|
+
<div class="no-print" style="display:flex;align-items:center;gap:6px;margin-bottom:8px">
|
|
177
|
+
<button onClick="{{ selectAllHistory }}" style="font:inherit;font-size:11.5px;font-weight:600;color:{{ theme.muted }};background:none;border:1px solid {{ theme.border }};border-radius:6px;padding:4px 9px;cursor:pointer">Select all</button>
|
|
178
|
+
<button onClick="{{ selectNoneHistory }}" style="font:inherit;font-size:11.5px;font-weight:600;color:{{ theme.muted }};background:none;border:1px solid {{ theme.border }};border-radius:6px;padding:4px 9px;cursor:pointer">Select none</button>
|
|
179
|
+
<sc-if value="{{ nSelectedHistory }}" hint-placeholder-val="{{ false }}">
|
|
180
|
+
<button onClick="{{ clearSelectedHistory }}" style="font:inherit;font-size:11.5px;font-weight:600;color:#B23A2E;background:none;border:1px solid #B23A2E;border-radius:6px;padding:4px 9px;cursor:pointer">Clear selected ({{ nSelectedHistory }})</button>
|
|
181
|
+
</sc-if>
|
|
182
|
+
</div>
|
|
183
|
+
</sc-if>
|
|
184
|
+
<div style="max-height:220px;overflow:auto">
|
|
185
|
+
<sc-if value="{{ hasHistory }}" hint-placeholder-val="{{ false }}">
|
|
186
|
+
<sc-for list="{{ historyRows }}" as="h" hint-placeholder-count="2">
|
|
187
|
+
<div style="display:flex;gap:10px;font-size:13px;padding:4px 0;border-bottom:1px solid {{ theme.border }}">
|
|
188
|
+
<input type="checkbox" checked="{{ h.selected2 }}" onClick="{{ h.onToggleSelect }}" class="no-print" style="width:13px;height:13px;cursor:pointer;flex:none;margin-top:2px">
|
|
189
|
+
<span style="color:{{ theme.muted }};font-variant-numeric:tabular-nums;flex:none">{{ h.timeStr }}</span>
|
|
190
|
+
<span style="color:{{ theme.text }}">{{ h.text }}</span>
|
|
191
|
+
</div>
|
|
192
|
+
</sc-for>
|
|
193
|
+
</sc-if>
|
|
194
|
+
<sc-if value="{{ noHistory }}" hint-placeholder-val="{{ false }}">
|
|
195
|
+
<div style="font-size:13px;color:{{ theme.muted }}">No approvals, rejections, or comments yet.</div>
|
|
196
|
+
</sc-if>
|
|
197
|
+
</div>
|
|
198
|
+
</sc-if>
|
|
199
|
+
</div>
|
|
200
|
+
</sc-if>
|
|
201
|
+
|
|
202
|
+
<!-- Self-test results -->
|
|
203
|
+
<sc-if value="{{ showSelfTests }}" hint-placeholder-val="{{ false }}">
|
|
204
|
+
<div class="no-print" style="margin-top:18px;background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:12px;padding:14px 18px">
|
|
205
|
+
<div onClick="{{ toggleSelfTestsCollapse }}" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;cursor:pointer">
|
|
206
|
+
<div style="display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:{{ theme.muted }}"><span>{{ selfTestsChevron }}</span>Regression self-tests</div>
|
|
207
|
+
<sc-if value="{{ hasSelfTests }}" hint-placeholder-val="{{ false }}">
|
|
208
|
+
<div style="font-size:12.5px;font-weight:600;color:{{ theme.text }}">{{ selfTestSummary }}</div>
|
|
209
|
+
</sc-if>
|
|
210
|
+
</div>
|
|
211
|
+
<sc-if value="{{ selfTestsExpanded }}" hint-placeholder-val="{{ true }}">
|
|
212
|
+
<div style="max-height:240px;overflow:auto">
|
|
213
|
+
<sc-for list="{{ selfTestRows }}" as="t" hint-placeholder-count="3">
|
|
214
|
+
<div style="display:flex;gap:10px;font-size:13px;padding:4px 0;border-bottom:1px solid {{ theme.border }}">
|
|
215
|
+
<span style="color:{{ t.color }};font-weight:700;flex:none">{{ t.icon }}</span>
|
|
216
|
+
<span style="color:{{ theme.text }}">{{ t.name }}<sc-if value="{{ t.detail }}" hint-placeholder-val="{{ false }}"><span style="color:{{ theme.muted }}"> — {{ t.detail }}</span></sc-if></span>
|
|
217
|
+
</div>
|
|
218
|
+
</sc-for>
|
|
219
|
+
</div>
|
|
220
|
+
</sc-if>
|
|
221
|
+
</div>
|
|
222
|
+
</sc-if>
|
|
223
|
+
|
|
224
|
+
<!-- Upload bar -->
|
|
225
|
+
<div class="no-print" style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:22px">
|
|
226
|
+
<sc-for list="{{ uploaders }}" as="u" hint-placeholder-count="2">
|
|
227
|
+
<div style="display:flex;align-items:center;gap:13px;background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:12px;padding:13px 15px">
|
|
228
|
+
<div style="width:34px;height:34px;border-radius:8px;flex:none;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;color:#FBFAF5;background:{{ u.dot }}">{{ u.tag }}</div>
|
|
229
|
+
<div style="min-width:0;flex:1">
|
|
230
|
+
<div style="font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:{{ theme.muted }};font-weight:600">{{ u.role }}</div>
|
|
231
|
+
<div style="font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px">{{ u.name }}</div>
|
|
232
|
+
</div>
|
|
233
|
+
<label style="font:inherit;font-size:13px;font-weight:600;color:{{ u.dot }};border:1px solid {{ theme.border }};background:{{ theme.card }};border-radius:8px;padding:7px 13px;cursor:pointer;flex:none">
|
|
234
|
+
Replace
|
|
235
|
+
<input type="file" accept=".pptx" onChange="{{ u.onChange }}" style="display:none">
|
|
236
|
+
</label>
|
|
237
|
+
</div>
|
|
238
|
+
</sc-for>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<!-- Merge winner preview -->
|
|
242
|
+
<sc-if value="{{ showMergePreview }}" hint-placeholder-val="{{ false }}">
|
|
243
|
+
<div class="no-print" style="margin-top:18px;background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:12px;padding:14px 18px;max-height:280px;overflow:auto">
|
|
244
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
|
|
245
|
+
<div style="font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:{{ theme.muted }}">Merge winner preview — per slide</div>
|
|
246
|
+
<button onClick="{{ exportMergedPptx }}" style="font:inherit;font-size:12.5px;font-weight:700;color:#fff;background:{{ accent }};border:none;border-radius:8px;padding:6px 14px;cursor:pointer">Download merged .pptx</button>
|
|
247
|
+
</div>
|
|
248
|
+
<label style="display:flex;align-items:center;gap:8px;font-size:12.5px;color:{{ theme.text }};margin-bottom:10px;cursor:pointer">
|
|
249
|
+
<input type="checkbox" checked="{{ mergeDuplicateSlides }}" onChange="{{ onToggleMergeDuplicateSlides }}" style="cursor:pointer">
|
|
250
|
+
Skip Before-only slides that closely resemble an After slide elsewhere (avoids exporting near-duplicate content twice)
|
|
251
|
+
</label>
|
|
252
|
+
<sc-for list="{{ mergePreviewRows }}" as="mr" hint-placeholder-count="2">
|
|
253
|
+
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:13px;padding:5px 0;border-bottom:1px solid {{ theme.border }}">
|
|
254
|
+
<span style="color:{{ theme.text }}">{{ mr.label }}</span>
|
|
255
|
+
<div style="display:flex;align-items:center;gap:8px">
|
|
256
|
+
<span style="font-weight:700;color:{{ mr.winnerColor }}">{{ mr.winnerLabel }}<sc-if value="{{ mr.isOverridden }}" hint-placeholder-val="{{ false }}"> (overridden)</sc-if></span>
|
|
257
|
+
<sc-if value="{{ mr.overridable }}" hint-placeholder-val="{{ false }}">
|
|
258
|
+
<div style="display:flex;gap:4px">
|
|
259
|
+
<button onClick="{{ mr.onOverrideBefore }}" title="Force Before as winner" style="font:inherit;font-size:11px;font-weight:700;border:1px solid #8A6D3B;border-radius:5px;padding:2px 8px;cursor:pointer;background:{{ mr.beforeBg }};color:{{ mr.beforeFg }}">Before</button>
|
|
260
|
+
<button onClick="{{ mr.onOverrideAfter }}" title="Force After as winner" style="font:inherit;font-size:11px;font-weight:700;border:1px solid #2A6FDB;border-radius:5px;padding:2px 8px;cursor:pointer;background:{{ mr.afterBg }};color:{{ mr.afterFg }}">After</button>
|
|
261
|
+
</div>
|
|
262
|
+
</sc-if>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
</sc-for>
|
|
266
|
+
</div>
|
|
267
|
+
</sc-if>
|
|
268
|
+
|
|
269
|
+
<!-- Duplicate slide detection -->
|
|
270
|
+
<sc-if value="{{ hasDuplicates }}" hint-placeholder-val="{{ false }}">
|
|
271
|
+
<div style="margin-top:20px;background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:12px;padding:14px 18px">
|
|
272
|
+
<div style="font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:{{ theme.muted }};margin-bottom:8px">Possible duplicate slides</div>
|
|
273
|
+
<sc-for list="{{ duplicateNotes }}" as="dn" hint-placeholder-count="1">
|
|
274
|
+
<div style="font-size:13px;color:{{ theme.text }};padding:3px 0">{{ dn }}</div>
|
|
275
|
+
</sc-for>
|
|
276
|
+
</div>
|
|
277
|
+
</sc-if>
|
|
278
|
+
|
|
279
|
+
<!-- Cross-deck duplicate detection -->
|
|
280
|
+
<sc-if value="{{ hasCrossDeckDuplicates }}" hint-placeholder-val="{{ false }}">
|
|
281
|
+
<div style="margin-top:20px;background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:12px;padding:14px 18px">
|
|
282
|
+
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:8px">
|
|
283
|
+
<div style="font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:{{ theme.muted }}">Possible cross-deck duplicates</div>
|
|
284
|
+
<div class="no-print" style="display:flex;align-items:center;gap:8px">
|
|
285
|
+
<span style="font-size:11px;color:{{ theme.muted }}">Sensitivity: {{ crossDupThresholdPct }}%</span>
|
|
286
|
+
<input type="range" min="0.7" max="0.99" step="0.01" value="{{ crossDupThreshold }}" onChange="{{ onCrossDupThresholdChange }}" style="width:100px;cursor:pointer" title="Raise to ignore looser near-matches">
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
<sc-if value="{{ crossDupSkipped }}" hint-placeholder-val="{{ false }}">
|
|
290
|
+
<div style="font-size:13px;color:{{ theme.muted }};margin-bottom:6px">Skipped ({{ crossDupTotalComparisons }} slide-pair comparisons — too many for a quick scan). <button onClick="{{ crossDupForceLarge }}" class="no-print" style="font:inherit;font-size:12.5px;font-weight:600;color:{{ accent }};background:none;border:none;cursor:pointer;padding:0;text-decoration:underline">Run anyway</button></div>
|
|
291
|
+
</sc-if>
|
|
292
|
+
<sc-for list="{{ crossDeckDuplicateNotes }}" as="cn" hint-placeholder-count="1">
|
|
293
|
+
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:13px;color:{{ theme.text }};padding:3px 0">
|
|
294
|
+
<span>{{ cn.text }}</span>
|
|
295
|
+
<button onClick="{{ cn.onIgnore }}" class="no-print" style="font:inherit;font-size:11px;font-weight:600;color:{{ theme.muted }};background:none;border:1px solid {{ theme.border }};border-radius:6px;padding:2px 8px;cursor:pointer;flex:none">Ignore</button>
|
|
296
|
+
</div>
|
|
297
|
+
</sc-for>
|
|
298
|
+
</div>
|
|
299
|
+
</sc-if>
|
|
300
|
+
|
|
301
|
+
<!-- Deck-level differences -->
|
|
302
|
+
<sc-if value="{{ hasDeckDiffs }}" hint-placeholder-val="{{ false }}">
|
|
303
|
+
<div style="margin-top:20px;background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:12px;padding:14px 18px">
|
|
304
|
+
<div style="font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:{{ theme.muted }};margin-bottom:8px">Presentation-level differences</div>
|
|
305
|
+
<sc-for list="{{ deckDiffs }}" as="dd" hint-placeholder-count="1">
|
|
306
|
+
<div style="display:grid;grid-template-columns:140px 1fr;gap:14px;font-size:13.5px;padding:6px 0">
|
|
307
|
+
<div style="font-weight:600;color:{{ theme.text }}">{{ dd.type }}</div>
|
|
308
|
+
<div style="display:flex;align-items:center;gap:9px;color:{{ theme.text }}"><span style="color:{{ theme.muted }};text-decoration:line-through">{{ dd.oldText }}</span><span style="color:{{ theme.muted }}">→</span><span style="font-weight:600">{{ dd.newText }}</span></div>
|
|
309
|
+
</div>
|
|
310
|
+
</sc-for>
|
|
311
|
+
</div>
|
|
312
|
+
</sc-if>
|
|
313
|
+
|
|
314
|
+
<!-- Summary -->
|
|
315
|
+
<sc-if value="{{ viewModeSingle }}" hint-placeholder-val="{{ true }}">
|
|
316
|
+
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin-top:20px">
|
|
317
|
+
<div style="font-family:'Spectral',Georgia,serif;font-size:18px;color:{{ theme.text }};margin-right:4px">{{ summaryText }}</div>
|
|
318
|
+
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
|
319
|
+
<div style="display:flex;align-items:center;gap:7px;background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:20px;padding:5px 12px;font-size:13px;font-weight:600">
|
|
320
|
+
<span style="width:9px;height:9px;border-radius:2px;background:{{ accent }}"></span>{{ nChanges }} changed
|
|
321
|
+
</div>
|
|
322
|
+
<div style="display:flex;align-items:center;gap:7px;background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:20px;padding:5px 12px;font-size:13px;font-weight:600">
|
|
323
|
+
<span style="width:9px;height:9px;border-radius:2px;background:#3E7C5A"></span>{{ nAdds }} added
|
|
324
|
+
</div>
|
|
325
|
+
<div style="display:flex;align-items:center;gap:7px;background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:20px;padding:5px 12px;font-size:13px;font-weight:600">
|
|
326
|
+
<span style="width:9px;height:9px;border-radius:2px;background:#B23A2E"></span>{{ nRemoves }} removed
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
<div class="no-print" style="display:flex;gap:8px;margin-left:auto">
|
|
330
|
+
<button onClick="{{ onApproveCur }}" style="font:inherit;font-size:13px;font-weight:700;border:1px solid #3E7C5A;border-radius:8px;padding:8px 14px;cursor:pointer;background:{{ curApproveBg }};color:{{ curApproveFg }}">✓ Approve slide</button>
|
|
331
|
+
<button onClick="{{ onRejectCur }}" style="font:inherit;font-size:13px;font-weight:700;border:1px solid #B23A2E;border-radius:8px;padding:8px 14px;cursor:pointer;background:{{ curRejectBg }};color:{{ curRejectFg }}">✕ Reject slide</button>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
|
|
335
|
+
<sc-if value="{{ curRollupShow }}" hint-placeholder-val="{{ false }}">
|
|
336
|
+
<div class="no-print" style="margin-top:8px;font-size:12.5px;font-weight:600;color:{{ curRollupColor }}">{{ curRollupText }}</div>
|
|
337
|
+
</sc-if>
|
|
338
|
+
|
|
339
|
+
<sc-if value="{{ hasCurBadges }}" hint-placeholder-val="{{ false }}">
|
|
340
|
+
<div class="no-print" style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">
|
|
341
|
+
<sc-for list="{{ curBadges }}" as="cb" hint-placeholder-count="0">
|
|
342
|
+
<span style="font-size:11.5px;font-weight:600;color:#fff;background:{{ cb.bg }};border-radius:6px;padding:3px 9px">{{ cb.label }}: {{ cb.action }}</span>
|
|
343
|
+
</sc-for>
|
|
344
|
+
</div>
|
|
345
|
+
</sc-if>
|
|
346
|
+
|
|
347
|
+
<!-- Master / layout -->
|
|
348
|
+
<sc-if value="{{ viewModeSingle }}" hint-placeholder-val="{{ true }}">
|
|
349
|
+
<div class="no-print" style="margin-top:14px;background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:12px;padding:14px 16px">
|
|
350
|
+
<div style="font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:{{ theme.muted }};margin-bottom:10px">Slide master & layout <sc-if value="{{ themeMismatchNote }}" hint-placeholder-val="{{ false }}"><span style="color:{{ accent }};text-transform:none;font-weight:600">— {{ themeMismatchNote }}</span></sc-if></div>
|
|
351
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
|
|
352
|
+
<div>
|
|
353
|
+
<div style="position:relative;container-type:inline-size;width:100%;aspect-ratio:{{ masterA.aspect }};background:#fff;border:2px solid {{ layoutBorderA }};border-radius:8px;overflow:hidden">
|
|
354
|
+
<sc-for list="{{ masterA.rects }}" as="r" hint-placeholder-count="2">
|
|
355
|
+
<div style="position:absolute;box-sizing:border-box;border:1px dashed #C9C2B0;left:{{ r.lpct }}%;top:{{ r.tpct }}%;width:{{ r.wpct }}%;height:{{ r.hpct }}%"></div>
|
|
356
|
+
</sc-for>
|
|
357
|
+
</div>
|
|
358
|
+
<div style="font-size:12px;color:{{ theme.muted }};margin-top:6px">{{ masterA.layoutName }} · {{ masterA.themeName }}</div>
|
|
359
|
+
</div>
|
|
360
|
+
<div>
|
|
361
|
+
<div style="position:relative;container-type:inline-size;width:100%;aspect-ratio:{{ masterB.aspect }};background:#fff;border:2px solid {{ layoutBorderB }};border-radius:8px;overflow:hidden">
|
|
362
|
+
<sc-for list="{{ masterB.rects }}" as="r" hint-placeholder-count="2">
|
|
363
|
+
<div style="position:absolute;box-sizing:border-box;border:1px dashed #C9C2B0;left:{{ r.lpct }}%;top:{{ r.tpct }}%;width:{{ r.wpct }}%;height:{{ r.hpct }}%"></div>
|
|
364
|
+
</sc-for>
|
|
365
|
+
</div>
|
|
366
|
+
<div style="font-size:12px;color:{{ theme.muted }};margin-top:6px">{{ masterB.layoutName }} · {{ masterB.themeName }}</div>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
<div class="no-print" style="display:flex;align-items:center;gap:10px;margin-top:12px;padding-top:12px;border-top:1px solid {{ theme.border }}">
|
|
370
|
+
<span style="font-size:12px;color:{{ theme.muted }}">Transition: <strong style="color:{{ theme.text }};text-transform:capitalize">{{ transitionTypeB }}</strong></span>
|
|
371
|
+
<button onClick="{{ onPlayPreview }}" style="font:inherit;font-size:12.5px;font-weight:600;color:#fff;background:{{ accent }};border:none;border-radius:8px;padding:6px 14px;cursor:pointer">Play transition preview</button>
|
|
372
|
+
<div style="position:relative;width:80px;height:45px;background:#8A8273;border:1px solid {{ theme.border }};border-radius:6px;overflow:hidden">
|
|
373
|
+
<div style="position:absolute;inset:0;background:{{ accent }};opacity:{{ previewAfterOpacity }};transition:opacity 1.1s ease"></div>
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
</sc-if>
|
|
378
|
+
|
|
379
|
+
<!-- Comments -->
|
|
380
|
+
<sc-if value="{{ viewModeSingle }}" hint-placeholder-val="{{ true }}">
|
|
381
|
+
<div class="no-print" style="margin-top:12px;background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:12px;padding:12px 16px">
|
|
382
|
+
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:8px">
|
|
383
|
+
<div style="font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:{{ theme.muted }}">Comments on this slide</div>
|
|
384
|
+
<sc-if value="{{ hasCurComments }}" hint-placeholder-val="{{ false }}">
|
|
385
|
+
<div style="display:flex;align-items:center;gap:6px">
|
|
386
|
+
<button onClick="{{ selectAllCommentsCur }}" style="font:inherit;font-size:11.5px;font-weight:600;color:{{ theme.muted }};background:none;border:1px solid {{ theme.border }};border-radius:6px;padding:4px 9px;cursor:pointer">Select all</button>
|
|
387
|
+
<button onClick="{{ selectNoneComments }}" style="font:inherit;font-size:11.5px;font-weight:600;color:{{ theme.muted }};background:none;border:1px solid {{ theme.border }};border-radius:6px;padding:4px 9px;cursor:pointer">Select none</button>
|
|
388
|
+
<sc-if value="{{ nSelectedComments }}" hint-placeholder-val="{{ false }}">
|
|
389
|
+
<button onClick="{{ deleteSelectedCommentsCur }}" style="font:inherit;font-size:11.5px;font-weight:600;color:#B23A2E;background:none;border:1px solid #B23A2E;border-radius:6px;padding:4px 9px;cursor:pointer">Delete selected ({{ nSelectedComments }})</button>
|
|
390
|
+
</sc-if>
|
|
391
|
+
</div>
|
|
392
|
+
</sc-if>
|
|
393
|
+
</div>
|
|
394
|
+
<sc-if value="{{ hasCurComments }}" hint-placeholder-val="{{ false }}">
|
|
395
|
+
<sc-for list="{{ curComments }}" as="cm" hint-placeholder-count="1">
|
|
396
|
+
<div style="display:flex;gap:8px;font-size:13px;padding:8px 0;border-bottom:1px solid {{ theme.border }}">
|
|
397
|
+
<input type="checkbox" checked="{{ cm.selected2 }}" onClick="{{ cm.onToggleSelect }}" style="width:13px;height:13px;cursor:pointer;flex:none;margin-top:2px">
|
|
398
|
+
<div style="flex:1;min-width:0">
|
|
399
|
+
<strong>{{ cm.author }}</strong> <span style="color:{{ theme.muted }}">· {{ cm.timeStr }}</span>
|
|
400
|
+
<button onClick="{{ cm.onDelete }}" aria-label="Delete this comment" title="Delete this comment" style="font:inherit;font-size:11px;color:{{ theme.muted }};background:none;border:none;cursor:pointer;padding:2px 4px;float:right">✕</button>
|
|
401
|
+
<div style="margin-top:2px;color:{{ theme.text }}">{{ cm.text }}</div>
|
|
402
|
+
<sc-if value="{{ cm.hasReplies }}" hint-placeholder-val="{{ false }}">
|
|
403
|
+
<div style="margin-top:6px;padding-left:16px;border-left:2px solid {{ theme.border }};display:flex;flex-direction:column;gap:6px">
|
|
404
|
+
<sc-for list="{{ cm.replies }}" as="rp" hint-placeholder-count="1">
|
|
405
|
+
<div style="font-size:12.5px;display:flex;justify-content:space-between;gap:8px"><span><strong>{{ rp.author }}</strong> <span style="color:{{ theme.muted }}">· {{ rp.timeStr }}</span><div style="margin-top:1px;color:{{ theme.text }}">{{ rp.text }}</div></span><button onClick="{{ rp.onDelete }}" aria-label="Delete this reply" title="Delete this reply" style="font:inherit;font-size:10.5px;color:{{ theme.muted }};background:none;border:none;cursor:pointer;padding:2px 4px;flex:none">✕</button></div>
|
|
406
|
+
</sc-for>
|
|
407
|
+
</div>
|
|
408
|
+
</sc-if>
|
|
409
|
+
<sc-if value="{{ cm.isReplying }}" hint-placeholder-val="{{ false }}">
|
|
410
|
+
<div style="display:flex;gap:6px;margin-top:8px;padding-left:16px">
|
|
411
|
+
<input value="{{ replyDraft }}" onChange="{{ onReplyChange }}" placeholder="Write a reply…" style="flex:1;font:inherit;font-size:12.5px;border:1px solid {{ theme.border }};border-radius:8px;padding:6px 10px;background:{{ theme.card }};color:{{ theme.text }}">
|
|
412
|
+
<button onClick="{{ cm.onSubmitReply }}" style="font:inherit;font-size:12.5px;font-weight:600;color:#fff;background:{{ accent }};border:none;border-radius:8px;padding:6px 12px;cursor:pointer">Reply</button>
|
|
413
|
+
<button onClick="{{ cm.onCancelReply }}" style="font:inherit;font-size:12.5px;font-weight:600;color:{{ theme.muted }};background:none;border:1px solid {{ theme.border }};border-radius:8px;padding:6px 12px;cursor:pointer">Cancel</button>
|
|
414
|
+
</div>
|
|
415
|
+
</sc-if>
|
|
416
|
+
<sc-if value="{{ cm.notReplying }}" hint-placeholder-val="{{ true }}">
|
|
417
|
+
<button onClick="{{ cm.onStartReply }}" style="font:inherit;font-size:12px;font-weight:600;color:{{ accent }};background:none;border:none;padding:4px 0;cursor:pointer;margin-top:4px">Reply</button>
|
|
418
|
+
</sc-if>
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
</sc-for>
|
|
422
|
+
</sc-if>
|
|
423
|
+
<div style="display:flex;gap:8px;margin-top:10px">
|
|
424
|
+
<input value="{{ commentDraft }}" onChange="{{ onCommentChange }}" ref="{{ commentInputRef }}" placeholder="Add a comment…" style="flex:1;font:inherit;font-size:13px;border:1px solid {{ theme.border }};border-radius:8px;padding:8px 12px;background:#fff">
|
|
425
|
+
<button onClick="{{ onAddComment }}" style="font:inherit;font-size:13px;font-weight:600;color:#fff;background:{{ accent }};border:none;border-radius:8px;padding:8px 14px;cursor:pointer">Post</button>
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
</sc-if>
|
|
429
|
+
|
|
430
|
+
<!-- Stages -->
|
|
431
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:14px">
|
|
432
|
+
<sc-for list="{{ stages }}" as="st" hint-placeholder-count="2">
|
|
433
|
+
<div>
|
|
434
|
+
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:9px">
|
|
435
|
+
<div style="display:flex;align-items:center;gap:9px;min-width:0">
|
|
436
|
+
<span style="font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:#fff;font-weight:700;background:{{ st.dot }};border-radius:5px;padding:3px 8px;flex:none">{{ st.role }}</span>
|
|
437
|
+
<span style="font-size:13px;color:{{ theme.muted }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ st.name }}</span>
|
|
438
|
+
</div>
|
|
439
|
+
<div style="display:flex;align-items:center;gap:6px;flex:none" class="no-print">
|
|
440
|
+
<button onClick="{{ st.prev }}" aria-label="Previous slide" style="font:inherit;width:26px;height:26px;border-radius:6px;border:1px solid {{ theme.border }};background:{{ theme.card }};color:{{ theme.muted }};cursor:pointer;line-height:1">‹</button>
|
|
441
|
+
<span style="font-size:12px;color:{{ theme.muted }};font-variant-numeric:tabular-nums;min-width:62px;text-align:center">Slide {{ st.cur }} / {{ st.count }}</span>
|
|
442
|
+
<button onClick="{{ st.next }}" aria-label="Next slide" style="font:inherit;width:26px;height:26px;border-radius:6px;border:1px solid {{ theme.border }};background:{{ theme.card }};color:{{ theme.muted }};cursor:pointer;line-height:1">›</button>
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
<div style="position:relative;container-type:inline-size;width:100%;aspect-ratio:{{ st.aspect }};background:#fff;border:1px solid {{ theme.border }};border-radius:10px;box-shadow:0 1px 2px rgba(31,30,27,.06),0 8px 24px -16px rgba(31,30,27,.25);overflow:hidden">
|
|
446
|
+
<!-- High-fidelity render mount -->
|
|
447
|
+
<div ref="{{ st.mountRef }}" style="position:absolute;inset:0;overflow:hidden"></div>
|
|
448
|
+
<!-- Schematic fallback content -->
|
|
449
|
+
<sc-if value="{{ st.schematic }}" hint-placeholder-val="{{ true }}">
|
|
450
|
+
<div style="position:absolute;inset:0">
|
|
451
|
+
<sc-for list="{{ st.shapes }}" as="s" hint-placeholder-count="0">
|
|
452
|
+
<div style="position:absolute;box-sizing:border-box;display:flex;flex-direction:column;justify-content:center;gap:1px;padding:3px 6px;overflow:hidden;line-height:1.16;left:{{ s.lpct }}%;top:{{ s.tpct }}%;width:{{ s.wpct }}%;height:{{ s.hpct }}%;font-size:{{ s.fontCqw }}cqw;color:{{ s.color }};font-weight:{{ s.weight }};font-style:{{ s.fontStyle }};font-family:{{ s.fontFamily }};text-align:{{ s.textAlign }}">
|
|
453
|
+
<sc-for list="{{ s.paras }}" as="p" hint-placeholder-count="1">
|
|
454
|
+
<div style="white-space:pre-wrap">{{ p.prefix }}{{ p.text }}</div>
|
|
455
|
+
</sc-for>
|
|
456
|
+
</div>
|
|
457
|
+
</sc-for>
|
|
458
|
+
</div>
|
|
459
|
+
</sc-if>
|
|
460
|
+
<!-- Change highlight overlay -->
|
|
461
|
+
<div style="position:absolute;inset:0;pointer-events:none">
|
|
462
|
+
<sc-for list="{{ st.shapes }}" as="s" hint-placeholder-count="0">
|
|
463
|
+
<div onClick="{{ s.onClick }}" title="{{ s.title }}" style="position:absolute;box-sizing:border-box;border-radius:4px;transition:outline-color .12s,background .12s,box-shadow .12s;left:{{ s.lpct }}%;top:{{ s.tpct }}%;width:{{ s.wpct }}%;height:{{ s.hpct }}%;outline:{{ s.outline }};box-shadow:{{ s.boxShadow }};background:{{ s.bg }};pointer-events:{{ s.pe }};cursor:{{ s.cursor }}"></div>
|
|
464
|
+
</sc-for>
|
|
465
|
+
<sc-for list="{{ st.media }}" as="m" hint-placeholder-count="0">
|
|
466
|
+
<div onClick="{{ m.onClick }}" title="{{ m.title }}" style="position:absolute;box-sizing:border-box;border-radius:4px;display:flex;align-items:flex-start;justify-content:flex-end;padding:4px;transition:outline-color .12s,background .12s,box-shadow .12s;left:{{ m.lpct }}%;top:{{ m.tpct }}%;width:{{ m.wpct }}%;height:{{ m.hpct }}%;outline:{{ m.outline }};box-shadow:{{ m.boxShadow }};background:{{ m.bg }};pointer-events:{{ m.pe }};cursor:{{ m.cursor }}">
|
|
467
|
+
<sc-if value="{{ st.schematic }}" hint-placeholder-val="{{ false }}">
|
|
468
|
+
<span style="font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:{{ theme.muted }};background:#FFFFFFE6;border:1px solid {{ theme.border }};border-radius:4px;padding:2px 6px">{{ m.kindLabel }}</span>
|
|
469
|
+
</sc-if>
|
|
470
|
+
</div>
|
|
471
|
+
</sc-for>
|
|
472
|
+
</div>
|
|
473
|
+
<sc-if value="{{ st.empty }}" hint-placeholder-val="{{ false }}">
|
|
474
|
+
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:{{ theme.muted }};font-size:13px;font-family:ui-monospace,Menlo,monospace">no text on this slide</div>
|
|
475
|
+
</sc-if>
|
|
476
|
+
<sc-if value="{{ st.loading }}" hint-placeholder-val="{{ false }}">
|
|
477
|
+
<div style="position:absolute;left:10px;bottom:10px;display:flex;align-items:center;gap:8px;background:#FFFFFFE6;border:1px solid {{ theme.border }};border-radius:20px;padding:5px 12px 5px 9px;font-size:12px;color:{{ theme.muted }};font-weight:600">
|
|
478
|
+
<span style="width:12px;height:12px;border-radius:50%;border:2px solid #D8D2C4;border-top-color:{{ accent }};animation:sd-spin .7s linear infinite"></span>Rendering…
|
|
479
|
+
</div>
|
|
480
|
+
</sc-if>
|
|
481
|
+
<div style="position:absolute;right:9px;top:9px;font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;font-weight:700;border-radius:5px;padding:3px 7px;background:{{ st.modeBg }};color:{{ st.modeFg }};border:1px solid {{ st.modeBorder }}">{{ st.modeLabel }}</div>
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
</sc-for>
|
|
485
|
+
</div>
|
|
486
|
+
|
|
487
|
+
<!-- Diff list -->
|
|
488
|
+
<section style="margin-top:28px;background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:14px;overflow:hidden">
|
|
489
|
+
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid {{ theme.border }};flex-wrap:wrap;gap:8px">
|
|
490
|
+
<h2 style="font-family:'Spectral',Georgia,serif;font-weight:500;font-size:20px;margin:0;color:{{ theme.text }}">Differences <span style="color:{{ theme.muted }};font-size:16px">({{ diffCount }})</span></h2>
|
|
491
|
+
<div class="no-print" style="display:flex;align-items:center;gap:6px">
|
|
492
|
+
<button onClick="{{ selectAllDiffsCur }}" style="font:inherit;font-size:11.5px;font-weight:600;color:{{ theme.muted }};background:none;border:1px solid {{ theme.border }};border-radius:6px;padding:4px 9px;cursor:pointer">Select all</button>
|
|
493
|
+
<button onClick="{{ selectNoneDiffs }}" style="font:inherit;font-size:11.5px;font-weight:600;color:{{ theme.muted }};background:none;border:1px solid {{ theme.border }};border-radius:6px;padding:4px 9px;cursor:pointer">Select none</button>
|
|
494
|
+
<sc-if value="{{ nSelectedDiffs }}" hint-placeholder-val="{{ false }}">
|
|
495
|
+
<button onClick="{{ bulkApproveDiffs }}" style="font:inherit;font-size:11.5px;font-weight:600;color:#3E7C5A;background:none;border:1px solid #3E7C5A;border-radius:6px;padding:4px 9px;cursor:pointer">Approve selected ({{ nSelectedDiffs }})</button>
|
|
496
|
+
<button onClick="{{ bulkRejectDiffs }}" style="font:inherit;font-size:11.5px;font-weight:600;color:#B23A2E;background:none;border:1px solid #B23A2E;border-radius:6px;padding:4px 9px;cursor:pointer">Reject selected ({{ nSelectedDiffs }})</button>
|
|
497
|
+
</sc-if>
|
|
498
|
+
</div>
|
|
499
|
+
<div style="font-size:12px;color:{{ theme.muted }};width:100%">Click a row to highlight it on both slides</div>
|
|
500
|
+
</div>
|
|
501
|
+
|
|
502
|
+
<sc-if value="{{ hasDiffs }}" hint-placeholder-val="{{ true }}">
|
|
503
|
+
<div>
|
|
504
|
+
<sc-for list="{{ diffs }}" as="d" hint-placeholder-count="4">
|
|
505
|
+
<div onClick="{{ d.onClick }}" style="display:grid;grid-template-columns:24px 104px 150px 1fr;gap:16px;align-items:baseline;padding:13px 20px;border-bottom:1px solid {{ theme.border }};cursor:pointer;transition:background .1s;background:{{ d.rowBg }}">
|
|
506
|
+
<input type="checkbox" checked="{{ d.selected2 }}" onClick="{{ d.onToggleSelect }}" class="no-print" style="width:14px;height:14px;cursor:pointer;align-self:center">
|
|
507
|
+
<div style="justify-self:start;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:{{ d.sev }};background:{{ d.sevTint }};border:1px solid {{ d.sevBorder }};border-radius:6px;padding:3px 9px">{{ d.type }}</div>
|
|
508
|
+
<div style="font-size:13.5px;font-weight:600;color:{{ theme.text }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ d.label }}</div>
|
|
509
|
+
<div style="min-width:0;font-size:13.5px;color:{{ theme.text }}">
|
|
510
|
+
<sc-if value="{{ d.isText }}" hint-placeholder-val="{{ false }}">
|
|
511
|
+
<div style="display:flex;flex-direction:column;gap:3px">
|
|
512
|
+
<div style="line-height:1.5"><sc-for list="{{ d.oldTokens }}" as="t" hint-placeholder-count="3"><span style="{{ t.style }}">{{ t.w }}</span></sc-for></div>
|
|
513
|
+
<div style="line-height:1.5"><sc-for list="{{ d.newTokens }}" as="t" hint-placeholder-count="3"><span style="{{ t.style }}">{{ t.w }}</span></sc-for></div>
|
|
514
|
+
</div>
|
|
515
|
+
</sc-if>
|
|
516
|
+
<sc-if value="{{ d.isColor }}" hint-placeholder-val="{{ false }}">
|
|
517
|
+
<div style="display:flex;align-items:center;gap:9px;flex-wrap:wrap">
|
|
518
|
+
<span style="display:inline-flex;align-items:center;gap:6px"><span style="width:14px;height:14px;border-radius:4px;border:1px solid #00000022;background:{{ d.oldText }}"></span><code style="font-family:ui-monospace,Menlo,monospace;font-size:12.5px;color:{{ theme.muted }}">{{ d.oldText }}</code></span>
|
|
519
|
+
<span style="color:{{ theme.muted }}">→</span>
|
|
520
|
+
<span style="display:inline-flex;align-items:center;gap:6px"><span style="width:14px;height:14px;border-radius:4px;border:1px solid #00000022;background:{{ d.newText }}"></span><code style="font-family:ui-monospace,Menlo,monospace;font-size:12.5px;color:{{ theme.text }};font-weight:600">{{ d.newText }}</code></span>
|
|
521
|
+
</div>
|
|
522
|
+
</sc-if>
|
|
523
|
+
<sc-if value="{{ d.isPlain }}" hint-placeholder-val="{{ false }}">
|
|
524
|
+
<div style="display:flex;align-items:center;gap:9px;flex-wrap:wrap"><span style="color:{{ theme.muted }};text-decoration:line-through">{{ d.oldText }}</span><span style="color:{{ theme.muted }}">→</span><span style="font-weight:600">{{ d.newText }}</span></div>
|
|
525
|
+
</sc-if>
|
|
526
|
+
<sc-if value="{{ d.isNote }}" hint-placeholder-val="{{ false }}">
|
|
527
|
+
<div style="color:{{ theme.muted }}">{{ d.oldText }}</div>
|
|
528
|
+
</sc-if>
|
|
529
|
+
<div class="no-print" style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;margin-top:8px">
|
|
530
|
+
<button onClick="{{ d.onApproveDiff }}" aria-label="Approve this difference" style="font:inherit;font-size:11px;font-weight:700;border:1px solid #3E7C5A;border-radius:5px;padding:2px 7px;cursor:pointer;background:{{ d.approveBg }};color:{{ d.approveFg }}">✓</button>
|
|
531
|
+
<button onClick="{{ d.onRejectDiff }}" aria-label="Reject this difference" style="font:inherit;font-size:11px;font-weight:700;border:1px solid #B23A2E;border-radius:5px;padding:2px 7px;cursor:pointer;background:{{ d.rejectBg }};color:{{ d.rejectFg }}">✕</button>
|
|
532
|
+
<span style="width:1px;height:14px;background:{{ theme.border }};margin:0 4px"></span>
|
|
533
|
+
<button onClick="{{ d.onKeepBefore }}" style="font:inherit;font-size:10.5px;font-weight:600;border:1px solid {{ accent }};border-radius:5px;padding:2px 8px;cursor:pointer;background:{{ d.keepBeforeBg }};color:{{ d.keepBeforeFg }}">Keep before</button>
|
|
534
|
+
<button onClick="{{ d.onKeepAfter }}" style="font:inherit;font-size:10.5px;font-weight:600;border:1px solid {{ accent }};border-radius:5px;padding:2px 8px;cursor:pointer;background:{{ d.keepAfterBg }};color:{{ d.keepAfterFg }}">Keep after</button>
|
|
535
|
+
<button onClick="{{ d.onKeepCustom }}" style="font:inherit;font-size:10.5px;font-weight:600;border:1px solid {{ accent }};border-radius:5px;padding:2px 8px;cursor:pointer;background:{{ d.keepCustomBg }};color:{{ d.keepCustomFg }}">Custom</button>
|
|
536
|
+
</div>
|
|
537
|
+
</div>
|
|
538
|
+
</div>
|
|
539
|
+
</sc-for>
|
|
540
|
+
</div>
|
|
541
|
+
</sc-if>
|
|
542
|
+
<sc-if value="{{ noDiffs }}" hint-placeholder-val="{{ false }}">
|
|
543
|
+
<div style="padding:46px 20px;text-align:center;color:{{ theme.muted }}">
|
|
544
|
+
<div style="font-family:'Spectral',Georgia,serif;font-size:22px;color:#3E7C5A">Identical</div>
|
|
545
|
+
<div style="font-size:14px;margin-top:6px">These two slides match on every property checked.</div>
|
|
546
|
+
</div>
|
|
547
|
+
</sc-if>
|
|
548
|
+
</section>
|
|
549
|
+
</sc-if>
|
|
550
|
+
|
|
551
|
+
<!-- Batch -->
|
|
552
|
+
<sc-if value="{{ viewModeBatch }}" hint-placeholder-val="{{ false }}">
|
|
553
|
+
<div style="margin-top:20px;background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:12px;padding:16px 18px">
|
|
554
|
+
<div style="font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:{{ theme.muted }};margin-bottom:10px">Batch compare multiple deck pairs</div>
|
|
555
|
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
|
|
556
|
+
<span style="font-size:12px;color:{{ theme.muted }}">Pair files by:</span>
|
|
557
|
+
<div style="display:flex;background:{{ theme.chip }};border:1px solid {{ theme.border }};border-radius:9px;padding:3px;gap:2px">
|
|
558
|
+
<button onClick="{{ setBatchOrder }}" style="font:inherit;font-size:12.5px;font-weight:600;border:none;border-radius:6px;padding:6px 12px;cursor:pointer;background:{{ batchOrderBg }};color:{{ batchOrderFg }}">Upload order</button>
|
|
559
|
+
<button onClick="{{ setBatchFilename }}" style="font:inherit;font-size:12.5px;font-weight:600;border:none;border-radius:6px;padding:6px 12px;cursor:pointer;background:{{ batchFilenameBg }};color:{{ batchFilenameFg }}">Filename match</button>
|
|
560
|
+
</div>
|
|
561
|
+
</div>
|
|
562
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:12px">
|
|
563
|
+
<label style="display:flex;flex-direction:column;gap:6px;font-size:12.5px;color:{{ theme.muted }}">Before files (multiple)
|
|
564
|
+
<input type="file" accept=".pptx" multiple onChange="{{ onBatchA }}" style="font:inherit;font-size:12.5px">
|
|
565
|
+
</label>
|
|
566
|
+
<label style="display:flex;flex-direction:column;gap:6px;font-size:12.5px;color:{{ theme.muted }}">After files (multiple, same order)
|
|
567
|
+
<input type="file" accept=".pptx" multiple onChange="{{ onBatchB }}" style="font:inherit;font-size:12.5px">
|
|
568
|
+
</label>
|
|
569
|
+
</div>
|
|
570
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:12px">
|
|
571
|
+
<div>
|
|
572
|
+
<sc-if value="{{ hasBatchListA }}" hint-placeholder-val="{{ false }}">
|
|
573
|
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
|
574
|
+
<span style="font-size:11px;color:{{ theme.muted }}">Drag to reorder:</span>
|
|
575
|
+
<button onClick="{{ selectAllBatchA }}" style="font:inherit;font-size:11px;font-weight:600;color:{{ theme.muted }};background:none;border:1px solid {{ theme.border }};border-radius:6px;padding:3px 8px;cursor:pointer">Select all</button>
|
|
576
|
+
<button onClick="{{ selectNoneBatchA }}" style="font:inherit;font-size:11px;font-weight:600;color:{{ theme.muted }};background:none;border:1px solid {{ theme.border }};border-radius:6px;padding:3px 8px;cursor:pointer">Select none</button>
|
|
577
|
+
<sc-if value="{{ nSelectedBatchA }}" hint-placeholder-val="{{ false }}">
|
|
578
|
+
<button onClick="{{ removeSelectedBatchA }}" style="font:inherit;font-size:11px;font-weight:600;color:#B23A2E;background:none;border:1px solid #B23A2E;border-radius:6px;padding:3px 8px;cursor:pointer">Remove selected ({{ nSelectedBatchA }})</button>
|
|
579
|
+
</sc-if>
|
|
580
|
+
</div>
|
|
581
|
+
<sc-for list="{{ batchListA }}" as="bf" hint-placeholder-count="0">
|
|
582
|
+
<div draggable="true" data-batch-row data-batch-side="A" data-batch-idx="{{ bf.idx }}" onDragStart="{{ bf.onDragStart }}" onDragOver="{{ bf.onDragOver }}" onDrop="{{ bf.onDrop }}" onTouchStart="{{ bf.onTouchStart }}" onTouchMove="{{ bf.onTouchMove }}" onTouchEnd="{{ bf.onTouchEnd }}" style="display:flex;align-items:center;gap:8px;background:{{ theme.bg }};border:1px solid {{ theme.border }};border-radius:6px;padding:5px 8px;margin-bottom:4px;cursor:grab;touch-action:none;font-size:12.5px;color:{{ theme.text }}">
|
|
583
|
+
<input type="checkbox" checked="{{ bf.selected }}" onClick="{{ bf.onToggleSelect }}" style="width:13px;height:13px;cursor:pointer">
|
|
584
|
+
<span style="color:{{ theme.muted }}">⠿</span>
|
|
585
|
+
<span style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ bf.name }}</span>
|
|
586
|
+
<button onClick="{{ bf.onRemove }}" aria-label="Remove file" style="font:inherit;border:none;background:none;color:{{ theme.muted }};cursor:pointer;padding:0 2px">✕</button>
|
|
587
|
+
</div>
|
|
588
|
+
</sc-for>
|
|
589
|
+
</sc-if>
|
|
590
|
+
</div>
|
|
591
|
+
<div>
|
|
592
|
+
<sc-if value="{{ hasBatchListB }}" hint-placeholder-val="{{ false }}">
|
|
593
|
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
|
594
|
+
<span style="font-size:11px;color:{{ theme.muted }}">Drag to reorder:</span>
|
|
595
|
+
<button onClick="{{ selectAllBatchB }}" style="font:inherit;font-size:11px;font-weight:600;color:{{ theme.muted }};background:none;border:1px solid {{ theme.border }};border-radius:6px;padding:3px 8px;cursor:pointer">Select all</button>
|
|
596
|
+
<button onClick="{{ selectNoneBatchB }}" style="font:inherit;font-size:11px;font-weight:600;color:{{ theme.muted }};background:none;border:1px solid {{ theme.border }};border-radius:6px;padding:3px 8px;cursor:pointer">Select none</button>
|
|
597
|
+
<sc-if value="{{ nSelectedBatchB }}" hint-placeholder-val="{{ false }}">
|
|
598
|
+
<button onClick="{{ removeSelectedBatchB }}" style="font:inherit;font-size:11px;font-weight:600;color:#B23A2E;background:none;border:1px solid #B23A2E;border-radius:6px;padding:3px 8px;cursor:pointer">Remove selected ({{ nSelectedBatchB }})</button>
|
|
599
|
+
</sc-if>
|
|
600
|
+
</div>
|
|
601
|
+
<sc-for list="{{ batchListB }}" as="bf" hint-placeholder-count="0">
|
|
602
|
+
<div draggable="true" data-batch-row data-batch-side="B" data-batch-idx="{{ bf.idx }}" onDragStart="{{ bf.onDragStart }}" onDragOver="{{ bf.onDragOver }}" onDrop="{{ bf.onDrop }}" onTouchStart="{{ bf.onTouchStart }}" onTouchMove="{{ bf.onTouchMove }}" onTouchEnd="{{ bf.onTouchEnd }}" style="display:flex;align-items:center;gap:8px;background:{{ theme.bg }};border:1px solid {{ theme.border }};border-radius:6px;padding:5px 8px;margin-bottom:4px;cursor:grab;touch-action:none;font-size:12.5px;color:{{ theme.text }}">
|
|
603
|
+
<input type="checkbox" checked="{{ bf.selected }}" onClick="{{ bf.onToggleSelect }}" style="width:13px;height:13px;cursor:pointer">
|
|
604
|
+
<span style="color:{{ theme.muted }}">⠿</span>
|
|
605
|
+
<span style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ bf.name }}</span>
|
|
606
|
+
<button onClick="{{ bf.onRemove }}" aria-label="Remove file" style="font:inherit;border:none;background:none;color:{{ theme.muted }};cursor:pointer;padding:0 2px">✕</button>
|
|
607
|
+
</div>
|
|
608
|
+
</sc-for>
|
|
609
|
+
</sc-if>
|
|
610
|
+
</div>
|
|
611
|
+
</div>
|
|
612
|
+
<button onClick="{{ runBatch }}" style="font:inherit;font-size:13px;font-weight:700;color:#fff;background:{{ accent }};border:none;border-radius:8px;padding:8px 16px;cursor:pointer">Compare batch</button>
|
|
613
|
+
<sc-if value="{{ batchBusy }}" hint-placeholder-val="{{ false }}">
|
|
614
|
+
<div style="display:flex;align-items:center;gap:10px;margin-top:10px">
|
|
615
|
+
<div style="flex:1;height:8px;background:{{ theme.bg }};border-radius:4px;overflow:hidden">
|
|
616
|
+
<div style="height:100%;width:{{ batchProgress }}%;background:{{ accent }};transition:width .2s"></div>
|
|
617
|
+
</div>
|
|
618
|
+
<span style="font-size:12px;color:{{ theme.muted }};white-space:nowrap">{{ batchProgressLabel }}</span>
|
|
619
|
+
</div>
|
|
620
|
+
</sc-if>
|
|
621
|
+
<sc-if value="{{ hasBatchResults }}" hint-placeholder-val="{{ false }}">
|
|
622
|
+
<div style="display:flex;gap:8px;margin-top:14px">
|
|
623
|
+
<button onClick="{{ exportBatchReportCsv }}" style="font:inherit;font-size:12.5px;font-weight:600;color:{{ theme.text }};background:{{ theme.bg }};border:1px solid {{ theme.border }};border-radius:8px;padding:6px 12px;cursor:pointer">Batch report → CSV</button>
|
|
624
|
+
<button onClick="{{ exportBatchReportJson }}" style="font:inherit;font-size:12.5px;font-weight:600;color:{{ theme.text }};background:{{ theme.bg }};border:1px solid {{ theme.border }};border-radius:8px;padding:6px 12px;cursor:pointer">Batch report → JSON</button>
|
|
625
|
+
</div>
|
|
626
|
+
<div style="margin-top:10px;display:flex;flex-direction:column;gap:8px">
|
|
627
|
+
<sc-for list="{{ batchResults }}" as="br" hint-placeholder-count="0">
|
|
628
|
+
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;background:{{ theme.bg }};border:1px solid {{ theme.border }};border-radius:8px;padding:10px 14px">
|
|
629
|
+
<div style="min-width:0">
|
|
630
|
+
<div style="font-size:13px;font-weight:600;color:{{ theme.text }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ br.nameA }} → {{ br.nameB }}</div>
|
|
631
|
+
<div style="font-size:12px;color:{{ theme.muted }}">{{ br.summary }}</div>
|
|
632
|
+
</div>
|
|
633
|
+
<button onClick="{{ br.onOpen }}" style="font:inherit;font-size:12.5px;font-weight:600;color:#fff;background:{{ accent }};border:none;border-radius:8px;padding:7px 14px;cursor:pointer;flex:none">Open</button>
|
|
634
|
+
</div>
|
|
635
|
+
</sc-for>
|
|
636
|
+
</div>
|
|
637
|
+
</sc-if>
|
|
638
|
+
</div>
|
|
639
|
+
</sc-if>
|
|
640
|
+
|
|
641
|
+
<!-- All pairs -->
|
|
642
|
+
<sc-if value="{{ viewModeAll }}" hint-placeholder-val="{{ false }}">
|
|
643
|
+
<div class="no-print" style="display:flex;align-items:center;gap:8px;margin-top:20px;flex-wrap:wrap">
|
|
644
|
+
<span style="font-size:12px;color:{{ theme.muted }};font-weight:600">Show:</span>
|
|
645
|
+
<div style="display:flex;background:{{ theme.chip }};border:1px solid {{ theme.border }};border-radius:9px;padding:3px;gap:2px">
|
|
646
|
+
<button onClick="{{ setFilterAll }}" style="font:inherit;font-size:12.5px;font-weight:600;border:none;border-radius:6px;padding:6px 12px;cursor:pointer;background:{{ filterAllBg }};color:{{ filterAllFg }}">All slides</button>
|
|
647
|
+
<button onClick="{{ setFilterChanged }}" style="font:inherit;font-size:12.5px;font-weight:600;border:none;border-radius:6px;padding:6px 12px;cursor:pointer;background:{{ filterChangedBg }};color:{{ filterChangedFg }}">Changed only</button>
|
|
648
|
+
<button onClick="{{ setFilterPending }}" style="font:inherit;font-size:12.5px;font-weight:600;border:none;border-radius:6px;padding:6px 12px;cursor:pointer;background:{{ filterPendingBg }};color:{{ filterPendingFg }}">Pending ({{ nPending }})</button>
|
|
649
|
+
<button onClick="{{ setFilterApproved }}" style="font:inherit;font-size:12.5px;font-weight:600;border:none;border-radius:6px;padding:6px 12px;cursor:pointer;background:{{ filterApprovedBg }};color:{{ filterApprovedFg }}">Approved ({{ nApproved }})</button>
|
|
650
|
+
<button onClick="{{ setFilterRejected }}" style="font:inherit;font-size:12.5px;font-weight:600;border:none;border-radius:6px;padding:6px 12px;cursor:pointer;background:{{ filterRejectedBg }};color:{{ filterRejectedFg }}">Rejected ({{ nRejected }})</button>
|
|
651
|
+
<span style="width:1px;background:{{ theme.border }};margin:2px 2px"></span>
|
|
652
|
+
<button onClick="{{ setFilterDiffRejected }}" style="font:inherit;font-size:12.5px;font-weight:600;border:none;border-radius:6px;padding:6px 12px;cursor:pointer;white-space:nowrap;background:{{ filterDiffRejectedBg }};color:{{ filterDiffRejectedFg }}">Has rejected diffs ({{ nDiffRejected }})</button>
|
|
653
|
+
<button onClick="{{ setFilterDiffPending }}" style="font:inherit;font-size:12.5px;font-weight:600;border:none;border-radius:6px;padding:6px 12px;cursor:pointer;white-space:nowrap;background:{{ filterDiffPendingBg }};color:{{ filterDiffPendingFg }}">Unreviewed diffs ({{ nDiffPending }})</button>
|
|
654
|
+
</div>
|
|
655
|
+
<button onClick="{{ toggleShowSelectedOnly }}" title="Show only selected pairs" aria-label="Show only selected pairs" style="font:inherit;font-size:12.5px;font-weight:600;border:1px solid {{ theme.border }};border-radius:8px;padding:6px 12px;cursor:pointer;display:flex;align-items:center;gap:6px;background:{{ selectedOnlyBg }};color:{{ selectedOnlyFg }}">⚑ Selected ({{ nSelected }})</button>
|
|
656
|
+
<button onClick="{{ toggleAllPairsCollapsed }}" style="font:inherit;font-size:12.5px;font-weight:600;border:1px solid {{ theme.border }};border-radius:8px;padding:6px 12px;cursor:pointer;background:{{ theme.card }};color:{{ theme.muted }}">{{ collapseAllLabel }}</button>
|
|
657
|
+
</div>
|
|
658
|
+
<div style="margin-top:14px">
|
|
659
|
+
<sc-for list="{{ pairs }}" as="pr" hint-placeholder-count="3">
|
|
660
|
+
<div class="sd-pair-card" style="background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:12px;padding:14px 16px;margin-bottom:14px;transition:border-color .12s">
|
|
661
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;flex-wrap:wrap;gap:8px">
|
|
662
|
+
<div onClick="{{ pr.onClick }}" style="display:flex;align-items:center;gap:10px;cursor:pointer;flex:1;min-width:0">
|
|
663
|
+
<input type="checkbox" checked="{{ pr.isSelected }}" onClick="{{ pr.onToggleSelect }}" style="width:16px;height:16px;flex:none;cursor:pointer" />
|
|
664
|
+
<span onClick="{{ pr.onToggleCollapse }}" style="cursor:pointer;color:{{ theme.muted }};flex:none;font-size:13px">{{ pr.collapseIcon }}</span>
|
|
665
|
+
<div style="font-family:'Spectral',Georgia,serif;font-size:16px;color:{{ theme.text }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ pr.label }}</div>
|
|
666
|
+
</div>
|
|
667
|
+
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
668
|
+
<span style="font-size:11px;font-weight:700;letter-spacing:.03em;text-transform:uppercase;color:{{ pr.primaryTagColor }};background:{{ pr.primaryTagBg }};border:1px solid {{ theme.border }};border-radius:6px;padding:3px 9px">{{ pr.primaryTag }}</span>
|
|
669
|
+
<sc-if value="{{ pr.hasExtraTags }}" hint-placeholder-val="{{ false }}">
|
|
670
|
+
<sc-for list="{{ pr.extraTags }}" as="et" hint-placeholder-count="0">
|
|
671
|
+
<span style="font-size:11px;font-weight:700;letter-spacing:.03em;text-transform:uppercase;color:#fff;background:{{ et.bg }};border-radius:6px;padding:3px 9px">{{ et.label }}</span>
|
|
672
|
+
</sc-for>
|
|
673
|
+
</sc-if>
|
|
674
|
+
<sc-if value="{{ pr.hasChanges }}" hint-placeholder-val="{{ false }}">
|
|
675
|
+
<span style="font-size:11px;font-weight:700;letter-spacing:.03em;text-transform:uppercase;color:{{ accent }};background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:6px;padding:3px 9px">{{ pr.total }} changes</span>
|
|
676
|
+
</sc-if>
|
|
677
|
+
<sc-if value="{{ pr.commentCount }}" hint-placeholder-val="{{ false }}">
|
|
678
|
+
<span style="font-size:11px;font-weight:600;color:{{ theme.muted }};background:{{ theme.chip }};border-radius:6px;padding:3px 8px">{{ pr.commentCount }} comment(s)</span>
|
|
679
|
+
</sc-if>
|
|
680
|
+
<sc-if value="{{ pr.rollup.hasRollup }}" hint-placeholder-val="{{ false }}">
|
|
681
|
+
<span style="font-size:11px;font-weight:600;color:#fff;background:{{ pr.rollup.bg }};border-radius:6px;padding:3px 8px">{{ pr.rollup.text }}</span>
|
|
682
|
+
</sc-if>
|
|
683
|
+
<div class="no-print" style="display:flex;gap:6px">
|
|
684
|
+
<button onClick="{{ pr.onApprove }}" title="Approve" style="font:inherit;font-size:11.5px;font-weight:700;border:1px solid #3E7C5A;border-radius:6px;padding:4px 9px;cursor:pointer;background:{{ pr.approveBg }};color:{{ pr.approveFg }}">✓ Approve</button>
|
|
685
|
+
<button onClick="{{ pr.onReject }}" title="Reject" style="font:inherit;font-size:11.5px;font-weight:700;border:1px solid #B23A2E;border-radius:6px;padding:4px 9px;cursor:pointer;background:{{ pr.rejectBg }};color:{{ pr.rejectFg }}">✕ Reject</button>
|
|
686
|
+
</div>
|
|
687
|
+
</div>
|
|
688
|
+
</div>
|
|
689
|
+
<sc-if value="{{ pr.isExpanded }}" hint-placeholder-val="{{ true }}">
|
|
690
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
|
691
|
+
<sc-for list="{{ pr.pairStages }}" as="ps" hint-placeholder-count="2">
|
|
692
|
+
<div style="position:relative;container-type:inline-size;width:100%;aspect-ratio:{{ ps.aspect }};background:#fff;border:1px solid {{ theme.border }};border-radius:8px;overflow:hidden">
|
|
693
|
+
<div ref="{{ ps.mountRef }}" style="position:absolute;inset:0;overflow:hidden"></div>
|
|
694
|
+
<sc-if value="{{ ps.schematic }}" hint-placeholder-val="{{ true }}">
|
|
695
|
+
<div style="position:absolute;inset:0">
|
|
696
|
+
<sc-for list="{{ ps.shapes }}" as="s" hint-placeholder-count="0">
|
|
697
|
+
<div style="position:absolute;box-sizing:border-box;display:flex;flex-direction:column;justify-content:center;gap:1px;padding:3px 6px;overflow:hidden;line-height:1.16;left:{{ s.lpct }}%;top:{{ s.tpct }}%;width:{{ s.wpct }}%;height:{{ s.hpct }}%;font-size:{{ s.fontCqw }}cqw;color:{{ s.color }};font-weight:{{ s.weight }};font-style:{{ s.fontStyle }};font-family:{{ s.fontFamily }};text-align:{{ s.textAlign }}">
|
|
698
|
+
<sc-for list="{{ s.paras }}" as="p" hint-placeholder-count="1">
|
|
699
|
+
<div style="white-space:pre-wrap">{{ p.prefix }}{{ p.text }}</div>
|
|
700
|
+
</sc-for>
|
|
701
|
+
</div>
|
|
702
|
+
</sc-for>
|
|
703
|
+
</div>
|
|
704
|
+
</sc-if>
|
|
705
|
+
<div style="position:absolute;inset:0;pointer-events:none">
|
|
706
|
+
<sc-for list="{{ ps.shapes }}" as="s" hint-placeholder-count="0">
|
|
707
|
+
<div style="position:absolute;box-sizing:border-box;border-radius:4px;left:{{ s.lpct }}%;top:{{ s.tpct }}%;width:{{ s.wpct }}%;height:{{ s.hpct }}%;outline:{{ s.outline }}"></div>
|
|
708
|
+
</sc-for>
|
|
709
|
+
<sc-for list="{{ ps.media }}" as="m" hint-placeholder-count="0">
|
|
710
|
+
<div style="position:absolute;box-sizing:border-box;border-radius:4px;left:{{ m.lpct }}%;top:{{ m.tpct }}%;width:{{ m.wpct }}%;height:{{ m.hpct }}%;outline:{{ m.outline }}"></div>
|
|
711
|
+
</sc-for>
|
|
712
|
+
</div>
|
|
713
|
+
<sc-if value="{{ ps.missing }}" hint-placeholder-val="{{ false }}">
|
|
714
|
+
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:{{ theme.muted }};font-size:12.5px;font-family:ui-monospace,Menlo,monospace">no slide here</div>
|
|
715
|
+
</sc-if>
|
|
716
|
+
</div>
|
|
717
|
+
</sc-for>
|
|
718
|
+
</div>
|
|
719
|
+
</sc-if>
|
|
720
|
+
</div>
|
|
721
|
+
</sc-for>
|
|
722
|
+
</div>
|
|
723
|
+
</sc-if>
|
|
724
|
+
|
|
725
|
+
<sc-if value="{{ hasError }}" hint-placeholder-val="{{ false }}">
|
|
726
|
+
<div style="margin-top:16px;background:#FBEFEC;border:1px solid #E7C4BB;color:#9A3322;border-radius:10px;padding:13px 16px;font-size:13.5px">{{ errorMsg }}</div>
|
|
727
|
+
</sc-if>
|
|
728
|
+
|
|
729
|
+
<sc-if value="{{ hasTouchGhost }}" hint-placeholder-val="{{ false }}">
|
|
730
|
+
<div style="position:fixed;left:{{ touchGhostStyle.left }};top:{{ touchGhostStyle.top }};pointer-events:none;z-index:200;background:{{ theme.card }};border:1px solid {{ accent }};border-radius:6px;padding:6px 12px;font-size:12.5px;font-weight:600;color:{{ theme.text }};box-shadow:0 6px 16px -4px rgba(0,0,0,.35);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">⠿ {{ touchGhostName }}</div>
|
|
731
|
+
</sc-if>
|
|
732
|
+
|
|
733
|
+
<sc-if value="{{ showShortcutsHelp }}" hint-placeholder-val="{{ false }}">
|
|
734
|
+
<div onClick="{{ toggleShortcutsHelp }}" class="no-print" style="position:fixed;inset:0;background:rgba(20,18,14,.45);z-index:100;display:flex;align-items:center;justify-content:center">
|
|
735
|
+
<div onClick="{{ stopPropagationNoop }}" style="background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:14px;padding:24px 28px;max-width:420px;width:90%;box-shadow:0 20px 50px -12px rgba(0,0,0,.4)">
|
|
736
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px">
|
|
737
|
+
<div style="font-family:'Spectral',Georgia,serif;font-size:19px;color:{{ theme.text }}">Keyboard shortcuts</div>
|
|
738
|
+
<button onClick="{{ toggleShortcutsHelp }}" aria-label="Close" style="font:inherit;font-size:18px;border:none;background:none;color:{{ theme.muted }};cursor:pointer;line-height:1">✕</button>
|
|
739
|
+
</div>
|
|
740
|
+
<div style="display:flex;flex-direction:column;gap:10px">
|
|
741
|
+
<div style="display:flex;justify-content:space-between;font-size:13.5px"><span style="color:{{ theme.muted }}">Approve current slide</span><kbd style="background:{{ theme.chip }};border:1px solid {{ theme.border }};border-radius:5px;padding:2px 8px;font-family:ui-monospace,Menlo,monospace">A</kbd></div>
|
|
742
|
+
<div style="display:flex;justify-content:space-between;font-size:13.5px"><span style="color:{{ theme.muted }}">Reject current slide</span><kbd style="background:{{ theme.chip }};border:1px solid {{ theme.border }};border-radius:5px;padding:2px 8px;font-family:ui-monospace,Menlo,monospace">R</kbd></div>
|
|
743
|
+
<div style="display:flex;justify-content:space-between;font-size:13.5px"><span style="color:{{ theme.muted }}">Previous / next slide</span><kbd style="background:{{ theme.chip }};border:1px solid {{ theme.border }};border-radius:5px;padding:2px 8px;font-family:ui-monospace,Menlo,monospace">← →</kbd></div>
|
|
744
|
+
<div style="display:flex;justify-content:space-between;font-size:13.5px"><span style="color:{{ theme.muted }}">Toggle Single / All-pairs view</span><kbd style="background:{{ theme.chip }};border:1px solid {{ theme.border }};border-radius:5px;padding:2px 8px;font-family:ui-monospace,Menlo,monospace">V</kbd></div>
|
|
745
|
+
<div style="display:flex;justify-content:space-between;font-size:13.5px"><span style="color:{{ theme.muted }}">Toggle dark mode</span><kbd style="background:{{ theme.chip }};border:1px solid {{ theme.border }};border-radius:5px;padding:2px 8px;font-family:ui-monospace,Menlo,monospace">D</kbd></div>
|
|
746
|
+
<div style="display:flex;justify-content:space-between;font-size:13.5px"><span style="color:{{ theme.muted }}">Focus comment box</span><kbd style="background:{{ theme.chip }};border:1px solid {{ theme.border }};border-radius:5px;padding:2px 8px;font-family:ui-monospace,Menlo,monospace">C</kbd></div>
|
|
747
|
+
<div style="display:flex;justify-content:space-between;font-size:13.5px"><span style="color:{{ theme.muted }}">Jump to next changed slide</span><kbd style="background:{{ theme.chip }};border:1px solid {{ theme.border }};border-radius:5px;padding:2px 8px;font-family:ui-monospace,Menlo,monospace">N</kbd></div>
|
|
748
|
+
<div style="display:flex;justify-content:space-between;font-size:13.5px"><span style="color:{{ theme.muted }}">Show this help</span><kbd style="background:{{ theme.chip }};border:1px solid {{ theme.border }};border-radius:5px;padding:2px 8px;font-family:ui-monospace,Menlo,monospace">?</kbd></div>
|
|
749
|
+
</div>
|
|
750
|
+
<div style="margin-top:16px;font-size:12px;color:{{ theme.muted }}">Shortcuts are disabled while typing in a text field, and most don't apply in Batch view.</div>
|
|
751
|
+
</div>
|
|
752
|
+
</div>
|
|
753
|
+
</sc-if>
|
|
754
|
+
|
|
755
|
+
<!-- Shared confirmation-modal component: every destructive action (bulk reviewer removal,
|
|
756
|
+
bulk/single comment delete, bulk history clear, batch-file removal, clear decisions)
|
|
757
|
+
renders through this ONE modal, driven by pendingConfirm.{title,body,confirmLabel}. -->
|
|
758
|
+
<sc-if value="{{ hasPendingConfirm }}" hint-placeholder-val="{{ false }}">
|
|
759
|
+
<div class="no-print" style="position:fixed;inset:0;background:rgba(20,18,14,.45);z-index:110;display:flex;align-items:center;justify-content:center">
|
|
760
|
+
<div style="background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:14px;padding:24px 28px;max-width:400px;width:90%;box-shadow:0 20px 50px -12px rgba(0,0,0,.4)">
|
|
761
|
+
<div style="font-family:'Spectral',Georgia,serif;font-size:19px;color:{{ theme.text }};margin-bottom:8px">{{ pendingConfirmTitle }}</div>
|
|
762
|
+
<div style="font-size:13.5px;color:{{ theme.muted }};margin-bottom:18px">{{ pendingConfirmBody }}</div>
|
|
763
|
+
<div style="display:flex;gap:8px;justify-content:flex-end">
|
|
764
|
+
<button onClick="{{ cancelConfirm }}" style="font:inherit;font-size:13px;font-weight:600;color:{{ theme.muted }};background:none;border:1px solid {{ theme.border }};border-radius:8px;padding:8px 14px;cursor:pointer">Cancel</button>
|
|
765
|
+
<button onClick="{{ confirmPendingYes }}" style="font:inherit;font-size:13px;font-weight:700;color:#fff;background:#B23A2E;border:none;border-radius:8px;padding:8px 14px;cursor:pointer">{{ pendingConfirmLabel }}</button>
|
|
766
|
+
</div>
|
|
767
|
+
</div>
|
|
768
|
+
</div>
|
|
769
|
+
</sc-if>
|
|
770
|
+
|
|
771
|
+
<sc-if value="{{ showImportChoice }}" hint-placeholder-val="{{ false }}">
|
|
772
|
+
<div class="no-print" style="position:fixed;inset:0;background:rgba(20,18,14,.45);z-index:110;display:flex;align-items:center;justify-content:center">
|
|
773
|
+
<div style="background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:14px;padding:24px 28px;max-width:400px;width:90%;box-shadow:0 20px 50px -12px rgba(0,0,0,.4)">
|
|
774
|
+
<div style="font-family:'Spectral',Georgia,serif;font-size:19px;color:{{ theme.text }};margin-bottom:8px">Import "{{ pendingImportName }}"</div>
|
|
775
|
+
<div style="font-size:13.5px;color:{{ theme.muted }};margin-bottom:18px">You have decisions, comments, or UI state from the current session that aren't in this file. Merge keeps everything from both (your current session's choices win on conflicts) — Overwrite replaces your current session entirely with the imported file.</div>
|
|
776
|
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px">
|
|
777
|
+
<span style="font-size:12px;color:{{ theme.muted }}">On conflict:</span>
|
|
778
|
+
<div style="display:flex;background:{{ theme.chip }};border:1px solid {{ theme.border }};border-radius:9px;padding:3px;gap:2px">
|
|
779
|
+
<button onClick="{{ setImportWinnerCurrent }}" style="font:inherit;font-size:12px;font-weight:600;border:none;border-radius:6px;padding:5px 10px;cursor:pointer;background:{{ importWinnerCurrentBg }};color:{{ importWinnerCurrentFg }}">Current session wins</button>
|
|
780
|
+
<button onClick="{{ setImportWinnerImported }}" style="font:inherit;font-size:12px;font-weight:600;border:none;border-radius:6px;padding:5px 10px;cursor:pointer;background:{{ importWinnerImportedBg }};color:{{ importWinnerImportedFg }}">Imported file wins</button>
|
|
781
|
+
</div>
|
|
782
|
+
</div>
|
|
783
|
+
<div style="display:flex;gap:8px;justify-content:flex-end">
|
|
784
|
+
<button onClick="{{ onImportCancel }}" style="font:inherit;font-size:13px;font-weight:600;color:{{ theme.muted }};background:none;border:1px solid {{ theme.border }};border-radius:8px;padding:8px 14px;cursor:pointer">Cancel</button>
|
|
785
|
+
<button onClick="{{ onImportOverwrite }}" style="font:inherit;font-size:13px;font-weight:700;color:#B23A2E;background:none;border:1px solid #B23A2E;border-radius:8px;padding:8px 14px;cursor:pointer">Overwrite</button>
|
|
786
|
+
<button onClick="{{ onImportMerge }}" style="font:inherit;font-size:13px;font-weight:700;color:#fff;background:{{ accent }};border:none;border-radius:8px;padding:8px 14px;cursor:pointer">Merge</button>
|
|
787
|
+
</div>
|
|
788
|
+
</div>
|
|
789
|
+
</div>
|
|
790
|
+
</sc-if>
|
|
791
|
+
|
|
792
|
+
<sc-if value="{{ showIntegrations }}" hint-placeholder-val="{{ false }}">
|
|
793
|
+
<div onClick="{{ toggleIntegrations }}" class="no-print" style="position:fixed;inset:0;background:rgba(20,18,14,.45);z-index:100;display:flex;align-items:center;justify-content:center">
|
|
794
|
+
<div onClick="{{ stopPropagationNoop }}" style="background:{{ theme.card }};border:1px solid {{ theme.border }};border-radius:14px;padding:24px 28px;max-width:440px;width:90%;max-height:85vh;overflow:auto;box-shadow:0 20px 50px -12px rgba(0,0,0,.4)">
|
|
795
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
|
|
796
|
+
<div style="font-family:'Spectral',Georgia,serif;font-size:19px;color:{{ theme.text }}">Live push</div>
|
|
797
|
+
<button onClick="{{ toggleIntegrations }}" aria-label="Close" style="font:inherit;font-size:18px;border:none;background:none;color:{{ theme.muted }};cursor:pointer;line-height:1">✕</button>
|
|
798
|
+
</div>
|
|
799
|
+
<div style="font-size:12.5px;color:{{ theme.muted }};margin-bottom:8px">Notion and Confluence's APIs typically block direct browser requests (CORS) — if a push fails for that reason, use the file-based export instead. Each field below has its own "Remember" checkbox — off by default; unchecking one immediately drops just that field from browser storage.</div>
|
|
800
|
+
<div style="display:flex;gap:8px;margin-bottom:12px">
|
|
801
|
+
<button onClick="{{ selectAllCredFields }}" style="font:inherit;font-size:11.5px;font-weight:600;color:{{ theme.muted }};background:none;border:1px solid {{ theme.border }};border-radius:6px;padding:4px 9px;cursor:pointer">Remember all</button>
|
|
802
|
+
<button onClick="{{ selectNoneCredFields }}" style="font:inherit;font-size:11.5px;font-weight:600;color:{{ theme.muted }};background:none;border:1px solid {{ theme.border }};border-radius:6px;padding:4px 9px;cursor:pointer">Forget all</button>
|
|
803
|
+
</div>
|
|
804
|
+
|
|
805
|
+
<div style="font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:{{ theme.muted }};margin:14px 0 8px">Slack webhook</div>
|
|
806
|
+
<div style="display:flex;gap:6px;align-items:center;margin-bottom:8px">
|
|
807
|
+
<input value="{{ slackWebhookUrl }}" onChange="{{ onSlackUrlChange }}" placeholder="https://hooks.slack.com/services/…" style="flex:1;box-sizing:border-box;font:inherit;font-size:13px;border:1px solid {{ theme.border }};border-radius:8px;padding:8px 10px;background:{{ theme.bg }};color:{{ theme.text }}">
|
|
808
|
+
<label title="Remember in this browser" style="display:flex;align-items:center;gap:4px;font-size:11px;color:{{ theme.muted }};cursor:pointer;flex:none"><input type="checkbox" checked="{{ credRememberSlackWebhookUrl }}" onChange="{{ onRememberSlackWebhookUrl }}" style="cursor:pointer">Remember</label>
|
|
809
|
+
</div>
|
|
810
|
+
<button onClick="{{ pushToSlack }}" style="font:inherit;font-size:13px;font-weight:700;color:#fff;background:{{ accent }};border:none;border-radius:8px;padding:8px 14px;cursor:pointer">Push to Slack</button>
|
|
811
|
+
|
|
812
|
+
<div style="font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:{{ theme.muted }};margin:18px 0 8px">Notion</div>
|
|
813
|
+
<div style="display:flex;gap:6px;align-items:center;margin-bottom:8px">
|
|
814
|
+
<input value="{{ notionToken }}" onChange="{{ onNotionTokenChange }}" placeholder="Integration token (secret_…)" style="flex:1;box-sizing:border-box;font:inherit;font-size:13px;border:1px solid {{ theme.border }};border-radius:8px;padding:8px 10px;background:{{ theme.bg }};color:{{ theme.text }}">
|
|
815
|
+
<label title="Remember in this browser" style="display:flex;align-items:center;gap:4px;font-size:11px;color:{{ theme.muted }};cursor:pointer;flex:none"><input type="checkbox" checked="{{ credRememberNotionToken }}" onChange="{{ onRememberNotionToken }}" style="cursor:pointer">Remember</label>
|
|
816
|
+
</div>
|
|
817
|
+
<div style="display:flex;gap:6px;align-items:center;margin-bottom:8px">
|
|
818
|
+
<input value="{{ notionPageId }}" onChange="{{ onNotionPageIdChange }}" placeholder="Page ID" style="flex:1;box-sizing:border-box;font:inherit;font-size:13px;border:1px solid {{ theme.border }};border-radius:8px;padding:8px 10px;background:{{ theme.bg }};color:{{ theme.text }}">
|
|
819
|
+
<label title="Remember in this browser" style="display:flex;align-items:center;gap:4px;font-size:11px;color:{{ theme.muted }};cursor:pointer;flex:none"><input type="checkbox" checked="{{ credRememberNotionPageId }}" onChange="{{ onRememberNotionPageId }}" style="cursor:pointer">Remember</label>
|
|
820
|
+
</div>
|
|
821
|
+
<button onClick="{{ pushToNotion }}" style="font:inherit;font-size:13px;font-weight:700;color:#fff;background:{{ accent }};border:none;border-radius:8px;padding:8px 14px;cursor:pointer">Push to Notion</button>
|
|
822
|
+
|
|
823
|
+
<div style="font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:{{ theme.muted }};margin:18px 0 8px">Confluence</div>
|
|
824
|
+
<div style="display:flex;gap:6px;align-items:center;margin-bottom:8px">
|
|
825
|
+
<input value="{{ confluenceBaseUrl }}" onChange="{{ onConfluenceBaseUrlChange }}" placeholder="https://yoursite.atlassian.net/wiki" style="flex:1;box-sizing:border-box;font:inherit;font-size:13px;border:1px solid {{ theme.border }};border-radius:8px;padding:8px 10px;background:{{ theme.bg }};color:{{ theme.text }}">
|
|
826
|
+
<label title="Remember in this browser" style="display:flex;align-items:center;gap:4px;font-size:11px;color:{{ theme.muted }};cursor:pointer;flex:none"><input type="checkbox" checked="{{ credRememberConfluenceBaseUrl }}" onChange="{{ onRememberConfluenceBaseUrl }}" style="cursor:pointer">Remember</label>
|
|
827
|
+
</div>
|
|
828
|
+
<div style="display:flex;gap:6px;align-items:center;margin-bottom:8px">
|
|
829
|
+
<input value="{{ confluenceEmail }}" onChange="{{ onConfluenceEmailChange }}" placeholder="Account email" style="flex:1;box-sizing:border-box;font:inherit;font-size:13px;border:1px solid {{ theme.border }};border-radius:8px;padding:8px 10px;background:{{ theme.bg }};color:{{ theme.text }}">
|
|
830
|
+
<label title="Remember in this browser" style="display:flex;align-items:center;gap:4px;font-size:11px;color:{{ theme.muted }};cursor:pointer;flex:none"><input type="checkbox" checked="{{ credRememberConfluenceEmail }}" onChange="{{ onRememberConfluenceEmail }}" style="cursor:pointer">Remember</label>
|
|
831
|
+
</div>
|
|
832
|
+
<div style="display:flex;gap:6px;align-items:center;margin-bottom:8px">
|
|
833
|
+
<input value="{{ confluenceToken }}" onChange="{{ onConfluenceTokenChange }}" placeholder="API token" style="flex:1;box-sizing:border-box;font:inherit;font-size:13px;border:1px solid {{ theme.border }};border-radius:8px;padding:8px 10px;background:{{ theme.bg }};color:{{ theme.text }}">
|
|
834
|
+
<label title="Remember in this browser" style="display:flex;align-items:center;gap:4px;font-size:11px;color:{{ theme.muted }};cursor:pointer;flex:none"><input type="checkbox" checked="{{ credRememberConfluenceToken }}" onChange="{{ onRememberConfluenceToken }}" style="cursor:pointer">Remember</label>
|
|
835
|
+
</div>
|
|
836
|
+
<div style="display:flex;gap:6px;align-items:center;margin-bottom:8px">
|
|
837
|
+
<input value="{{ confluencePageId }}" onChange="{{ onConfluencePageIdChange }}" placeholder="Page ID" style="flex:1;box-sizing:border-box;font:inherit;font-size:13px;border:1px solid {{ theme.border }};border-radius:8px;padding:8px 10px;background:{{ theme.bg }};color:{{ theme.text }}">
|
|
838
|
+
<label title="Remember in this browser" style="display:flex;align-items:center;gap:4px;font-size:11px;color:{{ theme.muted }};cursor:pointer;flex:none"><input type="checkbox" checked="{{ credRememberConfluencePageId }}" onChange="{{ onRememberConfluencePageId }}" style="cursor:pointer">Remember</label>
|
|
839
|
+
</div>
|
|
840
|
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
|
|
841
|
+
<span style="font-size:12px;color:{{ theme.muted }}">On push:</span>
|
|
842
|
+
<div style="display:flex;background:{{ theme.chip }};border:1px solid {{ theme.border }};border-radius:9px;padding:3px;gap:2px">
|
|
843
|
+
<button onClick="{{ setConfluenceAppend }}" style="font:inherit;font-size:12px;font-weight:600;border:none;border-radius:6px;padding:5px 10px;cursor:pointer;background:{{ confluenceAppendBg }};color:{{ confluenceAppendFg }}">Append</button>
|
|
844
|
+
<button onClick="{{ setConfluenceReplace }}" style="font:inherit;font-size:12px;font-weight:600;border:none;border-radius:6px;padding:5px 10px;cursor:pointer;background:{{ confluenceReplaceBg }};color:{{ confluenceReplaceFg }}">Replace</button>
|
|
845
|
+
</div>
|
|
846
|
+
</div>
|
|
847
|
+
<button onClick="{{ pushToConfluence }}" style="font:inherit;font-size:13px;font-weight:700;color:#fff;background:{{ accent }};border:none;border-radius:8px;padding:8px 14px;cursor:pointer">Push to Confluence</button>
|
|
848
|
+
</div>
|
|
849
|
+
</div>
|
|
850
|
+
</sc-if>
|
|
851
|
+
|
|
852
|
+
</div>
|
|
853
|
+
</div>
|
|
854
|
+
</x-dc>
|
|
855
|
+
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1180,"height":880},"accentColor":{"editor":"color","default":"#C9684A","tsType":"string","options":["#C9684A","#2A6FDB","#1F8A5B","#7A5BD6"]},"wordLevelDiff":{"editor":"boolean","default":true,"tsType":"boolean"}}">
|
|
856
|
+
class Component extends DCLogic {
|
|
857
|
+
constructor(props) {
|
|
858
|
+
super(props);
|
|
859
|
+
const s = this.buildSample();
|
|
860
|
+
this.state = {
|
|
861
|
+
slidesA: s.A, slidesB: s.B,
|
|
862
|
+
nameA: 'Q3-Review_v1.pptx', nameB: 'Q3-Review_v2.pptx',
|
|
863
|
+
idxA: 0, idxB: 0, selectedKey: null, errorMsg: null,
|
|
864
|
+
libOkA: false, libOkB: false, loadingA: false, loadingB: false,
|
|
865
|
+
viewMode: 'single', pairsFilter: 'all', fontsA: [], fontsB: [], sectionsA: [], sectionsB: [], decisions: {},
|
|
866
|
+
reviewers: [], activeHandle: null, handleDraft: '', nameDraft: '',
|
|
867
|
+
comments: {}, history: [], commentDraft: '', showHistory: false, uiTheme: 'light',
|
|
868
|
+
replyDraft: '', replyingKey: null,
|
|
869
|
+
diffDecisions: {}, mergeChoices: {},
|
|
870
|
+
batchFilesA: [], batchFilesB: [], batchResults: [], batchBusy: false, batchPairMode: 'order',
|
|
871
|
+
batchProgress: 0, batchTotal: 0,
|
|
872
|
+
selfTestResults: null, showSelfTests: false, historyCollapsed: false, selfTestsCollapsed: false,
|
|
873
|
+
notifications: {}, showNotifications: false,
|
|
874
|
+
collapsedPairs: {}, selectedPairs: {}, showSelectedOnly: false, allPairsCollapsed: false,
|
|
875
|
+
showMergePreview: false, mergeWinnerOverrides: {}, showShortcutsHelp: false,
|
|
876
|
+
previewPlaying: false, toastMsg: '', touchGhost: null,
|
|
877
|
+
notionToken: '', notionPageId: '', confluenceBaseUrl: '', confluenceEmail: '', confluenceToken: '', confluencePageId: '', slackWebhookUrl: '',
|
|
878
|
+
confluencePushMode: 'append', persistedCredFields: {},
|
|
879
|
+
showIntegrations: false, showImportChoice: false, importConflictWinner: 'current',
|
|
880
|
+
selectedBatchFilesA: {}, selectedBatchFilesB: {}, selectedReviewers: {},
|
|
881
|
+
selectedDiffs: {}, selectedComments: {}, selectedHistory: {},
|
|
882
|
+
pendingConfirm: null,
|
|
883
|
+
clearDecisionsScope: 'all', clearDecisionsReviewerHandle: '', clearDecisionsRangeFrom: 1, clearDecisionsRangeTo: 1,
|
|
884
|
+
crossDupThreshold: 0.92, crossDupForceLarge: false, ignoredCrossDupKeys: {}, mergeDuplicateSlides: false
|
|
885
|
+
};
|
|
886
|
+
this.batchBuffers = [];
|
|
887
|
+
// Non-state runtime handles
|
|
888
|
+
this.libMod = null; this.buildPptx = null;
|
|
889
|
+
this.presA = null; this.presB = null;
|
|
890
|
+
this.viewerA = null; this.viewerB = null;
|
|
891
|
+
this.tokA = 0; this.tokB = 0;
|
|
892
|
+
this.mounts = new Map(); this.meta = new Map(); this.handles = new Map(); this.sigs = new Map();
|
|
893
|
+
this._ro = new WeakSet();
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/* ---------- constants ---------- */
|
|
897
|
+
EMU = 914400;
|
|
898
|
+
LIB_URL = 'https://esm.sh/@aiden0z/pptx-renderer@1.1.0';
|
|
899
|
+
LS_KEY = 'slideDiffReviewerState_v1';
|
|
900
|
+
LS_CREDS_KEY = 'slideDiffLivePushCreds_v1';
|
|
901
|
+
COL = { change: '#C9684A', add: '#3E7C5A', remove: '#B23A2E' };
|
|
902
|
+
accent() { return this.props.accentColor || '#C9684A'; }
|
|
903
|
+
|
|
904
|
+
/* ---------- reviewer-state persistence (localStorage) ---------- */
|
|
905
|
+
restorePersisted() {
|
|
906
|
+
try {
|
|
907
|
+
const raw = localStorage.getItem(this.LS_KEY);
|
|
908
|
+
if (!raw) return;
|
|
909
|
+
const saved = JSON.parse(raw);
|
|
910
|
+
this.setState({
|
|
911
|
+
reviewers: saved.reviewers || [], activeHandle: saved.activeHandle || null,
|
|
912
|
+
decisions: saved.decisions || {}, comments: saved.comments || {}, history: saved.history || [],
|
|
913
|
+
diffDecisions: saved.diffDecisions || {}, mergeChoices: saved.mergeChoices || {},
|
|
914
|
+
collapsedPairs: saved.collapsedPairs || {}, selectedPairs: saved.selectedPairs || {},
|
|
915
|
+
mergeWinnerOverrides: saved.mergeWinnerOverrides || {}
|
|
916
|
+
});
|
|
917
|
+
} catch (e) { console.warn('could not restore reviewer state', e); }
|
|
918
|
+
this.restorePersistedCreds();
|
|
919
|
+
}
|
|
920
|
+
/* Live-push credentials are opt-in persistence, kept in a SEPARATE localStorage key from
|
|
921
|
+
everything else — so opting out (or never opting in) guarantees zero secrets ever touch
|
|
922
|
+
storage, and clearing them can't accidentally wipe unrelated reviewer state. */
|
|
923
|
+
restorePersistedCreds() {
|
|
924
|
+
try {
|
|
925
|
+
const raw = localStorage.getItem(this.LS_CREDS_KEY);
|
|
926
|
+
if (!raw) return;
|
|
927
|
+
const saved = JSON.parse(raw);
|
|
928
|
+
const persistedCredFields = {};
|
|
929
|
+
Object.keys(saved).forEach(k => { persistedCredFields[k] = true; });
|
|
930
|
+
this.setState(Object.assign({}, saved, { persistedCredFields }));
|
|
931
|
+
} catch (e) { console.warn('could not restore live-push credentials', e); }
|
|
932
|
+
}
|
|
933
|
+
persistCredsIfOptedIn() {
|
|
934
|
+
try {
|
|
935
|
+
const payload = this.buildCredsPayload(this.state, this.state.persistedCredFields);
|
|
936
|
+
if (Object.keys(payload).length) localStorage.setItem(this.LS_CREDS_KEY, JSON.stringify(payload));
|
|
937
|
+
else localStorage.removeItem(this.LS_CREDS_KEY);
|
|
938
|
+
} catch (e) { console.warn('could not persist live-push credentials', e); }
|
|
939
|
+
}
|
|
940
|
+
toggleFieldPersistence(field, ev) {
|
|
941
|
+
const on = ev.target.checked;
|
|
942
|
+
this.setState(s => ({ persistedCredFields: Object.assign({}, s.persistedCredFields, { [field]: on }) }));
|
|
943
|
+
setTimeout(() => this.persistCredsIfOptedIn(), 0);
|
|
944
|
+
}
|
|
945
|
+
selectAllCredFields() { this.setState({ persistedCredFields: this.buildSelectAllMap(this.CRED_FIELD_NAMES) }); setTimeout(() => this.persistCredsIfOptedIn(), 0); }
|
|
946
|
+
selectNoneCredFields() { this.setState({ persistedCredFields: this.buildSelectNoneMap() }); setTimeout(() => this.persistCredsIfOptedIn(), 0); }
|
|
947
|
+
persistState() {
|
|
948
|
+
try {
|
|
949
|
+
const st = this.state;
|
|
950
|
+
const payload = {
|
|
951
|
+
reviewers: st.reviewers, activeHandle: st.activeHandle, decisions: st.decisions,
|
|
952
|
+
comments: st.comments, history: st.history, diffDecisions: st.diffDecisions, mergeChoices: st.mergeChoices,
|
|
953
|
+
collapsedPairs: st.collapsedPairs, selectedPairs: st.selectedPairs, mergeWinnerOverrides: st.mergeWinnerOverrides
|
|
954
|
+
};
|
|
955
|
+
localStorage.setItem(this.LS_KEY, JSON.stringify(payload));
|
|
956
|
+
} catch (e) { console.warn('could not persist reviewer state', e); }
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/* ---------- lifecycle ---------- */
|
|
960
|
+
async componentDidMount() {
|
|
961
|
+
this.onKeyDown = (e) => {
|
|
962
|
+
const tag = (e.target && e.target.tagName) || '';
|
|
963
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target && e.target.isContentEditable)) return;
|
|
964
|
+
const st = this.state;
|
|
965
|
+
if (e.key === 'a' || e.key === 'A') { e.preventDefault(); const key = this.pairKeyForCurrentView(); if (key) this.setDecision(key.key, 'approved', key.label); }
|
|
966
|
+
else if (e.key === 'r' || e.key === 'R') { e.preventDefault(); const key = this.pairKeyForCurrentView(); if (key) this.setDecision(key.key, 'rejected', key.label); }
|
|
967
|
+
else if (e.key === 'ArrowRight') { e.preventDefault(); this.move('A', 1); this.move('B', 1); }
|
|
968
|
+
else if (e.key === 'ArrowLeft') { e.preventDefault(); this.move('A', -1); this.move('B', -1); }
|
|
969
|
+
else if (e.key === 'v' || e.key === 'V') { e.preventDefault(); this.setView(st.viewMode === 'single' ? 'all' : 'single'); }
|
|
970
|
+
else if (e.key === 'd' || e.key === 'D') { e.preventDefault(); this.toggleThemeMode(); }
|
|
971
|
+
else if (e.key === 'c' || e.key === 'C') { e.preventDefault(); this.focusComments(); }
|
|
972
|
+
else if (e.key === 'n' || e.key === 'N') { e.preventDefault(); this.jumpToNextChanged(); }
|
|
973
|
+
else if (e.key === '?') { e.preventDefault(); this.toggleShortcutsHelp(); }
|
|
974
|
+
else if (e.key === 'Escape' && st.showShortcutsHelp) { e.preventDefault(); this.setState({ showShortcutsHelp: false }); }
|
|
975
|
+
};
|
|
976
|
+
window.addEventListener('keydown', this.onKeyDown);
|
|
977
|
+
this.restorePersisted();
|
|
978
|
+
try {
|
|
979
|
+
const [lib, sp] = await Promise.all([
|
|
980
|
+
import(this.LIB_URL),
|
|
981
|
+
import('./sample-pptx.js')
|
|
982
|
+
]);
|
|
983
|
+
this.libMod = lib; this.buildPptx = sp.buildPptx;
|
|
984
|
+
await this.loadSampleBuffers();
|
|
985
|
+
} catch (e) {
|
|
986
|
+
console.warn('High-fidelity renderer unavailable, using schematic preview.', e);
|
|
987
|
+
this.setState({ errorMsg: null });
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
componentDidUpdate() { for (const key of this.meta.keys()) this.maybeRenderKey(key); }
|
|
991
|
+
componentWillUnmount() {
|
|
992
|
+
window.removeEventListener('keydown', this.onKeyDown);
|
|
993
|
+
for (const h of this.handles.values()) { try { h && h.dispose(); } catch (e) {} }
|
|
994
|
+
[this.viewerA, this.viewerB].forEach(v => { try { v && v.destroy(); } catch (e) {} });
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/* ---------- sample spec ---------- */
|
|
998
|
+
mk(name, x, y, cx, cy, fmt, paras) {
|
|
999
|
+
return Object.assign({ name, x, y, cx, cy, font: 'Arial', size: 18, bold: false, italic: false, color: '#1F1E1B', align: 'l' }, fmt, { paras });
|
|
1000
|
+
}
|
|
1001
|
+
buildSample() {
|
|
1002
|
+
const E = this.EMU;
|
|
1003
|
+
const A = [{
|
|
1004
|
+
w: Math.round(13.333 * E), h: 7.5 * E,
|
|
1005
|
+
shapes: [
|
|
1006
|
+
this.mk('Title', 0.75 * E, 0.55 * E, 11.8 * E, 1.2 * E, { font: 'Georgia', size: 40, bold: true, color: '#1F1E1B', align: 'l' }, [{ text: 'Q3 Business Review', bullet: false }]),
|
|
1007
|
+
this.mk('Subtitle', 0.75 * E, 1.8 * E, 9 * E, 0.55 * E, { font: 'Arial', size: 18, color: '#6B655A' }, [{ text: 'Prepared by the Strategy Team', bullet: false }]),
|
|
1008
|
+
this.mk('Key points', 1.0 * E, 3.0 * E, 8.2 * E, 3.0 * E, { font: 'Arial', size: 22, color: '#1F1E1B', align: 'l' }, [
|
|
1009
|
+
{ text: 'Revenue up 12% year over year', bullet: true },
|
|
1010
|
+
{ text: 'Churn down to 4.1%', bullet: true },
|
|
1011
|
+
{ text: 'Three new markets launched', bullet: true }
|
|
1012
|
+
]),
|
|
1013
|
+
this.mk('Note', 8.0 * E, 4.9 * E, 4.4 * E, 1.2 * E, { font: 'Arial', size: 14, italic: true, color: '#9A9486', align: 'l' }, [{ text: 'Draft — internal only', bullet: false }]),
|
|
1014
|
+
this.mk('Footer', 0.75 * E, 6.85 * E, 6 * E, 0.4 * E, { font: 'Arial', size: 12, color: '#9A9486' }, [{ text: 'Confidential — 2026', bullet: false }])
|
|
1015
|
+
],
|
|
1016
|
+
tables: [{ name: 'Metrics table', x: 0.9 * E, y: 4.55 * E, cx: 6 * E, cy: 1.7 * E, rows: [['Metric', 'Q2', 'Q3'], ['Revenue', '$4.1M', '$4.6M'], ['Churn', '4.3%', '4.1%']] }],
|
|
1017
|
+
bg: '#FFFFFF'
|
|
1018
|
+
}];
|
|
1019
|
+
const B = [{
|
|
1020
|
+
w: Math.round(13.333 * E), h: 7.5 * E,
|
|
1021
|
+
shapes: [
|
|
1022
|
+
this.mk('Title', 0.75 * E, 0.55 * E, 11.8 * E, 1.2 * E, { font: 'Arial', size: 44, bold: true, color: '#C9684A', align: 'l' }, [{ text: 'Q3 Business Review', bullet: false }]),
|
|
1023
|
+
this.mk('Subtitle', 0.75 * E, 1.8 * E, 9 * E, 0.55 * E, { font: 'Arial', size: 18, color: '#6B655A' }, [{ text: 'Prepared by Strategy & Finance', bullet: false }]),
|
|
1024
|
+
this.mk('Key points', 1.0 * E, 3.0 * E, 8.2 * E, 3.0 * E, { font: 'Arial', size: 22, color: '#1F1E1B', align: 'l' }, [
|
|
1025
|
+
{ text: 'Revenue up 15% year over year', bullet: true },
|
|
1026
|
+
{ text: 'Churn down to 4.1%', bullet: true },
|
|
1027
|
+
{ text: 'Three new markets launched', bullet: true }
|
|
1028
|
+
]),
|
|
1029
|
+
this.mk('Callout', 9.0 * E, 3.1 * E, 3.5 * E, 1.3 * E, { font: 'Georgia', size: 20, italic: true, color: '#C9684A', align: 'ctr' }, [{ text: 'On track for a record Q4', bullet: false }]),
|
|
1030
|
+
this.mk('Footer', 6.6 * E, 6.85 * E, 6 * E, 0.4 * E, { font: 'Arial', size: 12, color: '#9A9486', align: 'r' }, [{ text: 'Confidential — 2026', bullet: false }])
|
|
1031
|
+
],
|
|
1032
|
+
tables: [{ name: 'Metrics table', x: 0.9 * E, y: 4.55 * E, cx: 6 * E, cy: 1.7 * E, rows: [['Metric', 'Q2', 'Q3'], ['Revenue', '$4.1M', '$4.6M'], ['Churn', '4.3%', '4.1%'], ['New logos', '18', '27']] }],
|
|
1033
|
+
bg: '#FBF8F1'
|
|
1034
|
+
}];
|
|
1035
|
+
|
|
1036
|
+
// RED slide (index 1): a difference planted in every new diff category.
|
|
1037
|
+
const redA = {
|
|
1038
|
+
w: Math.round(13.333 * E), h: 7.5 * E, bg: '#FFFFFF',
|
|
1039
|
+
transition: { type: 'fade', spd: 'med', advTm: null },
|
|
1040
|
+
notes: 'Walk through the roadmap slide slowly — leadership cares about Q4 dates.',
|
|
1041
|
+
shapes: [
|
|
1042
|
+
this.mk('Title', 0.75 * E, 0.5 * E, 11.8 * E, 1.0 * E, { font: 'Georgia', size: 32, bold: true }, [{ text: 'Feature Showcase', bullet: false }]),
|
|
1043
|
+
Object.assign(this.mk('Details link', 0.75 * E, 1.6 * E, 6 * E, 0.5 * E, { font: 'Arial', size: 16, color: '#2A6FDB' }, [{ text: 'Full roadmap and details', bullet: false }]), { link: 'https://example.com/roadmap-2026-draft', wrap: 'square' }),
|
|
1044
|
+
Object.assign(this.mk('Wrapped note', 7.1 * E, 1.55 * E, 2.2 * E, 0.9 * E, { font: 'Arial', size: 13, color: '#6B655A' }, [{ text: 'This note wraps across lines inside a narrow box.', bullet: false }]), { wrap: 'square' }),
|
|
1045
|
+
Object.assign(this.mk('Bordered callout', 0.75 * E, 5.35 * E, 4.6 * E, 1.15 * E, { font: 'Arial', size: 16, bold: true, color: '#1F1E1B' }, [{ text: 'On track for GA', bullet: false }]), { border: { w: 1, color: '#3E7C5A' } }),
|
|
1046
|
+
this.mk('Footer', 0.75 * E, 6.9 * E, 6 * E, 0.4 * E, { font: 'Arial', size: 12, color: '#9A9486' }, [{ text: 'Confidential — draft v1', bullet: false }])
|
|
1047
|
+
],
|
|
1048
|
+
images: [{ name: 'Product shot', x: 9.3 * E, y: 0.55 * E, cx: 3.2 * E, cy: 1.9 * E, bytes: this._pngSeed('red-v1') }],
|
|
1049
|
+
tables: [{ name: 'Formatting table', x: 0.75 * E, y: 2.65 * E, cx: 6.2 * E, cy: 2.4 * E, rows: [
|
|
1050
|
+
[{ text: 'Region', bg: '#EAE4D6' }, { text: 'Owner', bg: '#EAE4D6' }, { text: 'Status', bg: '#EAE4D6' }],
|
|
1051
|
+
[{ text: 'AMER', bg: '#FFFFFF', border: { w: 0.75, color: '#CCCCCC' } }, { text: 'A. Chen' }, { text: 'Green', bg: '#E4F3E9' }],
|
|
1052
|
+
[{ text: 'EMEA', bg: '#FFFFFF' }, { text: 'R. Diaz' }, { text: 'At risk', bg: '#FBEFEC' }]
|
|
1053
|
+
] }],
|
|
1054
|
+
charts: [{ name: 'Adoption chart', x: 9.3 * E, y: 2.65 * E, cx: 3.2 * E, cy: 2.4 * E, chartType: 'bar', series: [{ name: 'Adoption', values: [12, 18, 24] }] }],
|
|
1055
|
+
smartArt: [{ name: 'Process diagram', x: 9.3 * E, y: 5.2 * E, cx: 3.2 * E, cy: 1.3 * E, texts: ['Plan', 'Build', 'Launch'] }],
|
|
1056
|
+
media: [Object.assign({ name: 'Demo clip', x: 4.6 * E, y: 5.35 * E, cx: 2.0 * E, cy: 1.15 * E }, { kind: 'video' }, this._mediaSeed('video', 'red-v1', 24))]
|
|
1057
|
+
};
|
|
1058
|
+
const redB = {
|
|
1059
|
+
w: Math.round(13.333 * E), h: 7.5 * E, bg: '#F4EFE3',
|
|
1060
|
+
transition: { type: 'push', spd: 'fast', advTm: 4000 },
|
|
1061
|
+
notes: { text: 'Walk through the roadmap slide slowly — leadership cares about Q4 dates. Mention the EMEA slip.', bold: true, color: '#C9684A' },
|
|
1062
|
+
shapes: [
|
|
1063
|
+
this.mk('Title', 0.75 * E, 0.5 * E, 11.8 * E, 1.0 * E, { font: 'Georgia', size: 32, bold: true }, [{ text: 'Feature Showcase', bullet: false }]),
|
|
1064
|
+
Object.assign(this.mk('Details link', 0.75 * E, 1.6 * E, 6 * E, 0.5 * E, { font: 'Arial', size: 16, color: '#2A6FDB' }, [{ text: 'Full roadmap and details', bullet: false }]), { link: 'https://example.com/roadmap-2026-final', wrap: 'square' }),
|
|
1065
|
+
Object.assign(this.mk('Wrapped note', 7.1 * E, 1.55 * E, 2.2 * E, 0.9 * E, { font: 'Arial', size: 13, color: '#6B655A' }, [{ text: 'This note wraps across lines inside a narrow box.', bullet: false }]), { wrap: 'none' }),
|
|
1066
|
+
Object.assign(this.mk('Bordered callout', 0.75 * E, 5.35 * E, 4.6 * E, 1.15 * E, { font: 'Arial', size: 16, bold: true, color: '#1F1E1B' }, [{ text: 'On track for GA', bullet: false }]), { border: { w: 3, color: '#C9684A' } }),
|
|
1067
|
+
this.mk('Footer', 0.75 * E, 6.9 * E, 6 * E, 0.4 * E, { font: 'Arial', size: 12, color: '#9A9486' }, [{ text: 'Confidential — final', bullet: false }])
|
|
1068
|
+
],
|
|
1069
|
+
images: [{ name: 'Product shot', x: 9.3 * E, y: 0.55 * E, cx: 3.2 * E, cy: 1.9 * E, bytes: this._pngSeed('red-v2') }],
|
|
1070
|
+
tables: [{ name: 'Formatting table', x: 0.75 * E, y: 2.65 * E, cx: 6.2 * E, cy: 2.4 * E, rows: [
|
|
1071
|
+
[{ text: 'Region', bg: '#EAE4D6' }, { text: 'Owner', bg: '#EAE4D6' }, { text: 'Status', bg: '#EAE4D6' }],
|
|
1072
|
+
[{ text: 'AMER', bg: '#FFFFFF', border: { w: 2, color: '#3E7C5A' } }, { text: 'A. Chen' }, { text: 'Green', bg: '#E4F3E9' }],
|
|
1073
|
+
[{ text: 'EMEA', bg: '#FBEFEC' }, { text: 'R. Diaz' }, { text: 'Blocked', bg: '#FBEFEC' }]
|
|
1074
|
+
] }],
|
|
1075
|
+
charts: [{ name: 'Adoption chart', x: 9.3 * E, y: 2.65 * E, cx: 3.2 * E, cy: 2.4 * E, chartType: 'bar', series: [{ name: 'Adoption', values: [12, 18, 31] }] }],
|
|
1076
|
+
smartArt: [{ name: 'Process diagram', x: 9.3 * E, y: 5.2 * E, cx: 3.2 * E, cy: 1.3 * E, texts: ['Plan', 'Build', 'Launch', 'Iterate'] }],
|
|
1077
|
+
media: [Object.assign({ name: 'Demo clip', x: 4.6 * E, y: 5.35 * E, cx: 2.0 * E, cy: 1.15 * E }, { kind: 'video' }, this._mediaSeed('video', 'red-v2', 30))]
|
|
1078
|
+
};
|
|
1079
|
+
|
|
1080
|
+
// GREEN slide (index 2): identical copy across every category — proves no false positives.
|
|
1081
|
+
const greenSpec = {
|
|
1082
|
+
w: Math.round(13.333 * E), h: 7.5 * E, bg: '#FFFFFF',
|
|
1083
|
+
transition: { type: 'fade', spd: 'slow', advTm: null },
|
|
1084
|
+
notes: 'This slide should show zero differences — a control for the diff engine.',
|
|
1085
|
+
shapes: [
|
|
1086
|
+
this.mk('Title', 0.75 * E, 0.5 * E, 11.8 * E, 1.0 * E, { font: 'Georgia', size: 32, bold: true }, [{ text: 'Stable Slide (control)', bullet: false }]),
|
|
1087
|
+
Object.assign(this.mk('Link', 0.75 * E, 1.6 * E, 6 * E, 0.5 * E, { font: 'Arial', size: 16, color: '#2A6FDB' }, [{ text: 'Unchanged reference link', bullet: false }]), { link: 'https://example.com/stable', wrap: 'square' }),
|
|
1088
|
+
Object.assign(this.mk('Bordered box', 0.75 * E, 2.3 * E, 4.6 * E, 1.15 * E, { font: 'Arial', size: 16, bold: true }, [{ text: 'No changes here', bullet: false }]), { border: { w: 1.5, color: '#8A8273' } })
|
|
1089
|
+
],
|
|
1090
|
+
images: [{ name: 'Static shot', x: 6.0 * E, y: 2.3 * E, cx: 2.6 * E, cy: 1.6 * E, bytes: this._pngSeed('green-const') }],
|
|
1091
|
+
tables: [{ name: 'Control table', x: 0.75 * E, y: 4.0 * E, cx: 6.2 * E, cy: 1.4 * E, rows: [
|
|
1092
|
+
[{ text: 'Item', bg: '#EAE4D6' }, { text: 'Value', bg: '#EAE4D6' }],
|
|
1093
|
+
[{ text: 'Uptime', bg: '#FFFFFF' }, { text: '99.98%' }]
|
|
1094
|
+
] }],
|
|
1095
|
+
smartArt: [{ name: 'Static diagram', x: 0.75 * E, y: 5.6 * E, cx: 4.6 * E, cy: 1.2 * E, texts: ['Same', 'Same', 'Same'] }]
|
|
1096
|
+
};
|
|
1097
|
+
const greenA = structuredClone(greenSpec);
|
|
1098
|
+
const greenB = structuredClone(greenSpec);
|
|
1099
|
+
|
|
1100
|
+
// Deletion + reorder examples: a slide only in A (removed), and two slides that swap order between A and B (moved).
|
|
1101
|
+
const E2 = this.EMU;
|
|
1102
|
+
const deprecatedSlide = {
|
|
1103
|
+
w: Math.round(13.333 * E2), h: 7.5 * E2, bg: '#FFFFFF',
|
|
1104
|
+
shapes: [
|
|
1105
|
+
this.mk('Title', 0.75 * E2, 0.55 * E2, 11.8 * E2, 1.0 * E2, { font: 'Georgia', size: 32, bold: true }, [{ text: 'Deprecated Process (remove in v2)', bullet: false }]),
|
|
1106
|
+
this.mk('Body', 0.75 * E2, 1.9 * E2, 10 * E2, 2 * E2, { font: 'Arial', size: 18 }, [{ text: 'This workflow was retired ahead of the v2 review.', bullet: false }])
|
|
1107
|
+
]
|
|
1108
|
+
};
|
|
1109
|
+
const onboardingSlide = {
|
|
1110
|
+
w: Math.round(13.333 * E2), h: 7.5 * E2, bg: '#FFFFFF',
|
|
1111
|
+
shapes: [
|
|
1112
|
+
this.mk('Title', 0.75 * E2, 0.55 * E2, 11.8 * E2, 1.0 * E2, { font: 'Georgia', size: 32, bold: true }, [{ text: 'Onboarding Flow', bullet: false }]),
|
|
1113
|
+
this.mk('Body', 0.75 * E2, 1.9 * E2, 10 * E2, 2 * E2, { font: 'Arial', size: 18 }, [{ text: 'New-user activation steps and drop-off metrics.', bullet: false }])
|
|
1114
|
+
]
|
|
1115
|
+
};
|
|
1116
|
+
const billingSlide = {
|
|
1117
|
+
w: Math.round(13.333 * E2), h: 7.5 * E2, bg: '#FFFFFF',
|
|
1118
|
+
shapes: [
|
|
1119
|
+
this.mk('Title', 0.75 * E2, 0.55 * E2, 11.8 * E2, 1.0 * E2, { font: 'Georgia', size: 32, bold: true }, [{ text: 'Billing Flow', bullet: false }]),
|
|
1120
|
+
this.mk('Body', 0.75 * E2, 1.9 * E2, 10 * E2, 2 * E2, { font: 'Arial', size: 18 }, [{ text: 'Invoice generation and payment retry logic.', bullet: false }])
|
|
1121
|
+
]
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
A.push(redA, greenA, deprecatedSlide, onboardingSlide, billingSlide);
|
|
1125
|
+
B.push(redB, greenB, billingSlide, onboardingSlide);
|
|
1126
|
+
return { A, B };
|
|
1127
|
+
}
|
|
1128
|
+
_PNG_DEFAULT = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
|
1129
|
+
_PNG_ALT = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
|
|
1130
|
+
_pngSeed(seed) {
|
|
1131
|
+
const b64 = seed === 'red-v2' ? this._PNG_ALT : this._PNG_DEFAULT;
|
|
1132
|
+
const bin = atob(b64);
|
|
1133
|
+
const u8 = new Uint8Array(bin.length);
|
|
1134
|
+
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
|
|
1135
|
+
return u8;
|
|
1136
|
+
}
|
|
1137
|
+
_mediaSeed(kind, seed, kb) {
|
|
1138
|
+
const len = kb * 1024;
|
|
1139
|
+
const u8 = new Uint8Array(len);
|
|
1140
|
+
let h = 0; const str = kind + ':' + seed;
|
|
1141
|
+
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
|
|
1142
|
+
for (let i = 0; i < len; i++) { h = (h * 1103515245 + 12345) >>> 0; u8[i] = h & 0xff; }
|
|
1143
|
+
return { bytes: u8 };
|
|
1144
|
+
}
|
|
1145
|
+
async loadSampleBuffers() {
|
|
1146
|
+
if (!this.buildPptx) return;
|
|
1147
|
+
const E = this.EMU, cx = Math.round(13.333 * E), cy = Math.round(7.5 * E);
|
|
1148
|
+
const s = this.buildSample();
|
|
1149
|
+
const bufA = await this.buildPptx(s.A, cx, cy, { embeddedFonts: ['Brand Sans'] });
|
|
1150
|
+
const bufB = await this.buildPptx(s.B, cx, cy, { embeddedFonts: ['Brand Sans', 'Brand Mono'] });
|
|
1151
|
+
await this.ingest('A', bufA, 'Q3-Review_v1.pptx', ['Brand Sans']);
|
|
1152
|
+
await this.ingest('B', bufB, 'Q3-Review_v2.pptx', ['Brand Sans', 'Brand Mono']);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/* ---------- ingest (parse for diff + parse for render) ---------- */
|
|
1156
|
+
async parseDeckMeta(buf) {
|
|
1157
|
+
const zip = await JSZip.loadAsync(buf);
|
|
1158
|
+
const f = zip.file('ppt/presentation.xml');
|
|
1159
|
+
if (!f) return { fonts: [], sections: [] };
|
|
1160
|
+
const doc = new DOMParser().parseFromString(await f.async('string'), 'application/xml');
|
|
1161
|
+
const fonts = this.qn(doc.documentElement, 'embeddedFont').map(ef => { const fEl = this.qn(ef, 'font')[0]; return fEl ? fEl.getAttribute('typeface') : null; }).filter(Boolean);
|
|
1162
|
+
let sections = [];
|
|
1163
|
+
try {
|
|
1164
|
+
const mainLst = this.qn(doc.documentElement, 'sldIdLst')[0];
|
|
1165
|
+
const idToIndex = {};
|
|
1166
|
+
if (mainLst) this.qn(mainLst, 'sldId').forEach((el, idx) => { idToIndex[el.getAttribute('id')] = idx; });
|
|
1167
|
+
sections = this.qn(doc.documentElement, 'section').map(sec => {
|
|
1168
|
+
const lst = this.qn(sec, 'sldIdLst')[0];
|
|
1169
|
+
const ids = lst ? this.qn(lst, 'sldId').map(el => el.getAttribute('id')) : [];
|
|
1170
|
+
return { name: sec.getAttribute('name') || '(unnamed section)', slideIndices: ids.map(id => idToIndex[id]).filter(v => v != null) };
|
|
1171
|
+
});
|
|
1172
|
+
} catch (e) { console.warn('section parse failed', e); }
|
|
1173
|
+
return { fonts, sections };
|
|
1174
|
+
}
|
|
1175
|
+
async ingest(side, buf, name, knownFonts) {
|
|
1176
|
+
const slides = await this.parseBuffer(buf.slice(0));
|
|
1177
|
+
if (!slides.length) throw new Error('No slides with text found in ' + name + '.');
|
|
1178
|
+
let deckMeta = { fonts: knownFonts || [], sections: [] };
|
|
1179
|
+
if (!knownFonts) { try { deckMeta = await this.parseDeckMeta(buf.slice(0)); } catch (e) { console.warn('deck meta parse failed', e); } }
|
|
1180
|
+
let pres = null, libOk = false;
|
|
1181
|
+
if (this.libMod) {
|
|
1182
|
+
try {
|
|
1183
|
+
const files = await this.libMod.parseZip(buf.slice(0), this.libMod.RECOMMENDED_ZIP_LIMITS);
|
|
1184
|
+
pres = this.libMod.buildPresentation(files);
|
|
1185
|
+
libOk = !!(pres && pres.slides && pres.slides.length);
|
|
1186
|
+
} catch (e) { console.warn('Renderer parse failed; schematic fallback.', e); libOk = false; }
|
|
1187
|
+
}
|
|
1188
|
+
if (side === 'A') {
|
|
1189
|
+
if (pres) { if (!this.viewerA) this.viewerA = new this.libMod.PptxViewer(document.createElement('div'), { fitMode: 'contain' }); this.viewerA.load(pres); }
|
|
1190
|
+
this.presA = pres; this.tokA++;
|
|
1191
|
+
this.setState({ slidesA: slides, nameA: name, idxA: 0, selectedKey: null, errorMsg: null, libOkA: libOk, fontsA: deckMeta.fonts, sectionsA: deckMeta.sections, decisions: {} });
|
|
1192
|
+
} else {
|
|
1193
|
+
if (pres) { if (!this.viewerB) this.viewerB = new this.libMod.PptxViewer(document.createElement('div'), { fitMode: 'contain' }); this.viewerB.load(pres); }
|
|
1194
|
+
this.presB = pres; this.tokB++;
|
|
1195
|
+
this.setState({ slidesB: slides, nameB: name, idxB: 0, selectedKey: null, errorMsg: null, libOkB: libOk, fontsB: deckMeta.fonts, sectionsB: deckMeta.sections, decisions: {} });
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
pairKey(aIdx, bIdx) { return 'a' + (aIdx === null || aIdx === undefined ? 'x' : aIdx) + ':b' + (bIdx === null || bIdx === undefined ? 'x' : bIdx); }
|
|
1199
|
+
activeReviewerLabel() {
|
|
1200
|
+
const h = this.state.activeHandle;
|
|
1201
|
+
if (!h) return 'Anonymous';
|
|
1202
|
+
const r = this.state.reviewers.find(x => x.handle === h);
|
|
1203
|
+
return r && r.name ? (r.name + ' (' + h + ')') : h;
|
|
1204
|
+
}
|
|
1205
|
+
addReviewer() {
|
|
1206
|
+
const handle = (this.state.handleDraft || '').trim();
|
|
1207
|
+
if (!handle) return;
|
|
1208
|
+
const name = (this.state.nameDraft || '').trim();
|
|
1209
|
+
this.setState(s => {
|
|
1210
|
+
const exists = s.reviewers.some(r => r.handle === handle);
|
|
1211
|
+
const reviewers = exists ? s.reviewers : s.reviewers.concat([{ handle, name }]);
|
|
1212
|
+
return { reviewers, activeHandle: s.activeHandle || handle, handleDraft: '', nameDraft: '' };
|
|
1213
|
+
});
|
|
1214
|
+
setTimeout(() => this.persistState(), 0);
|
|
1215
|
+
}
|
|
1216
|
+
/* ---------- shared confirmation-modal component ---------- */
|
|
1217
|
+
/* Pure: builds the {title, body, confirmLabel} shown by the ONE generic confirm modal, for
|
|
1218
|
+
every destructive action in the app. Kept separate from the DOM/state so each kind's copy is
|
|
1219
|
+
unit-testable without needing to actually open a modal. Params vary per kind:
|
|
1220
|
+
reviewers:{n} comments:{n} history:{n} singleComment:{} batchFile:{} decisions:{scopeLabel} */
|
|
1221
|
+
buildConfirmConfig(kind, params) {
|
|
1222
|
+
params = params || {};
|
|
1223
|
+
const configs = {
|
|
1224
|
+
reviewers: { title: 'Remove ' + params.n + ' reviewer(s)?', body: 'This removes them from the reviewer list. Their past votes and comments stay in the history log — only their entry in the reviewer chips goes away.', confirmLabel: 'Remove' },
|
|
1225
|
+
comments: { title: 'Delete ' + params.n + ' comment(s)?', body: "This permanently removes the selected comments (and their replies) from this slide. This can't be undone.", confirmLabel: 'Delete' },
|
|
1226
|
+
history: { title: 'Clear ' + params.n + ' history entries?', body: "This permanently removes the selected entries from the approval history log. This can't be undone.", confirmLabel: 'Clear' },
|
|
1227
|
+
singleComment: { title: 'Delete this comment?', body: "This permanently removes the comment (and its replies). This can't be undone.", confirmLabel: 'Delete' },
|
|
1228
|
+
batchFile: { title: 'Remove this file?', body: "This removes the file from the batch list — its drag-reordered position will be lost if re-added.", confirmLabel: 'Remove' },
|
|
1229
|
+
decisions: { title: 'Clear decisions' + (params.scopeLabel ? ' — ' + params.scopeLabel : '') + '?', body: (params.body || 'This resets the affected Approve/Reject status. Comments and history are not affected.') + " This can't be undone.", confirmLabel: 'Clear' }
|
|
1230
|
+
};
|
|
1231
|
+
return configs[kind] || { title: 'Are you sure?', body: '', confirmLabel: 'OK' };
|
|
1232
|
+
}
|
|
1233
|
+
/* Generic open/close: any destructive action opens with its config + a bound callback; the ONE
|
|
1234
|
+
modal in the template reads pendingConfirm.title/body/confirmLabel and wires Cancel/Confirm
|
|
1235
|
+
to closeConfirm()/confirmPendingYes() — no per-action modal markup anywhere. */
|
|
1236
|
+
openConfirm(config, onConfirm) { this.setState({ pendingConfirm: Object.assign({}, config, { onConfirm }) }); }
|
|
1237
|
+
closeConfirm() { this.setState({ pendingConfirm: null }); }
|
|
1238
|
+
confirmPendingYes() {
|
|
1239
|
+
const p = this.state.pendingConfirm;
|
|
1240
|
+
if (!p) return;
|
|
1241
|
+
p.onConfirm();
|
|
1242
|
+
this.setState({ pendingConfirm: null });
|
|
1243
|
+
}
|
|
1244
|
+
removeReviewer(handle, ev) {
|
|
1245
|
+
if (ev) ev.stopPropagation();
|
|
1246
|
+
this.setState(s => ({ reviewers: s.reviewers.filter(r => r.handle !== handle), activeHandle: s.activeHandle === handle ? null : s.activeHandle }));
|
|
1247
|
+
setTimeout(() => this.persistState(), 0);
|
|
1248
|
+
}
|
|
1249
|
+
setActiveReviewer(handle) { this.setState({ activeHandle: handle }); setTimeout(() => this.persistState(), 0); }
|
|
1250
|
+
toggleReviewerSelect(handle, ev) { if (ev) ev.stopPropagation(); this.setState(s => ({ selectedReviewers: Object.assign({}, s.selectedReviewers, { [handle]: !s.selectedReviewers[handle] }) })); }
|
|
1251
|
+
selectAllReviewers() { this.setState(s => ({ selectedReviewers: this.buildSelectAllMap(s.reviewers.map(r => r.handle)) })); }
|
|
1252
|
+
selectNoneReviewers() { this.setState({ selectedReviewers: this.buildSelectNoneMap() }); }
|
|
1253
|
+
/* Bulk reviewer removal is destructive so it's gated behind the shared confirm modal, unlike
|
|
1254
|
+
single-reviewer removal which stays immediate. */
|
|
1255
|
+
requestRemoveSelectedReviewers() {
|
|
1256
|
+
const n = Object.values(this.state.selectedReviewers).filter(Boolean).length;
|
|
1257
|
+
if (!n) return;
|
|
1258
|
+
this.openConfirm(this.buildConfirmConfig('reviewers', { n }), () => this.doRemoveSelectedReviewers());
|
|
1259
|
+
}
|
|
1260
|
+
doRemoveSelectedReviewers() {
|
|
1261
|
+
this.setState(s => {
|
|
1262
|
+
const remaining = s.reviewers.filter(r => !s.selectedReviewers[r.handle]);
|
|
1263
|
+
const activeStillThere = remaining.some(r => r.handle === s.activeHandle);
|
|
1264
|
+
return { reviewers: remaining, selectedReviewers: {}, activeHandle: activeStillThere ? s.activeHandle : null };
|
|
1265
|
+
});
|
|
1266
|
+
setTimeout(() => this.persistState(), 0);
|
|
1267
|
+
}
|
|
1268
|
+
slideStatus(decMap) {
|
|
1269
|
+
if (!decMap) return 'pending';
|
|
1270
|
+
const vals = Object.values(decMap);
|
|
1271
|
+
if (vals.includes('rejected')) return 'rejected';
|
|
1272
|
+
if (vals.includes('approved')) return 'approved';
|
|
1273
|
+
return 'pending';
|
|
1274
|
+
}
|
|
1275
|
+
/* Rollup of per-diff decisions for a slide pair: how many of its individual diffs are approved/rejected/pending. */
|
|
1276
|
+
diffRollup(diffsList, pairKeyStr) {
|
|
1277
|
+
let nA = 0, nR = 0, nP = 0;
|
|
1278
|
+
diffsList.forEach(d => {
|
|
1279
|
+
const votes = this.state.diffDecisions[pairKeyStr + '::' + d.key] || {};
|
|
1280
|
+
const st = this.slideStatus(votes);
|
|
1281
|
+
if (st === 'approved') nA++; else if (st === 'rejected') nR++; else nP++;
|
|
1282
|
+
});
|
|
1283
|
+
return { total: diffsList.length, nA, nR, nP };
|
|
1284
|
+
}
|
|
1285
|
+
/* ---------- select-all/none + bulk actions: diff list, comments, history ---------- */
|
|
1286
|
+
toggleDiffSelect(dKey, ev) { if (ev) ev.stopPropagation(); this.setState(s => ({ selectedDiffs: Object.assign({}, s.selectedDiffs, { [dKey]: !s.selectedDiffs[dKey] }) })); }
|
|
1287
|
+
selectAllDiffs(dKeys) { this.setState({ selectedDiffs: this.buildSelectAllMap(dKeys) }); }
|
|
1288
|
+
selectNoneDiffs() { this.setState({ selectedDiffs: this.buildSelectNoneMap() }); }
|
|
1289
|
+
bulkSetDiffDecision(val) {
|
|
1290
|
+
const handle = this.state.activeHandle || 'anonymous';
|
|
1291
|
+
this.setState(s => {
|
|
1292
|
+
const diffDecisions = Object.assign({}, s.diffDecisions);
|
|
1293
|
+
Object.keys(s.selectedDiffs).filter(k => s.selectedDiffs[k]).forEach(dKey => {
|
|
1294
|
+
const cur = diffDecisions[dKey] || {};
|
|
1295
|
+
const next = cur[handle] === val ? undefined : val;
|
|
1296
|
+
const perDiff = Object.assign({}, cur);
|
|
1297
|
+
if (next) perDiff[handle] = next; else delete perDiff[handle];
|
|
1298
|
+
diffDecisions[dKey] = perDiff;
|
|
1299
|
+
});
|
|
1300
|
+
return { diffDecisions };
|
|
1301
|
+
});
|
|
1302
|
+
setTimeout(() => this.persistState(), 0);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
toggleCommentSelect(cKey, ev) { if (ev) ev.stopPropagation(); this.setState(s => ({ selectedComments: Object.assign({}, s.selectedComments, { [cKey]: !s.selectedComments[cKey] }) })); }
|
|
1306
|
+
selectAllComments(cKeys) { this.setState({ selectedComments: this.buildSelectAllMap(cKeys) }); }
|
|
1307
|
+
selectNoneComments() { this.setState({ selectedComments: this.buildSelectNoneMap() }); }
|
|
1308
|
+
deleteSelectedComments(pairKeyStr) {
|
|
1309
|
+
this.setState(s => {
|
|
1310
|
+
const list = (s.comments[pairKeyStr] || []).filter((c, ci) => !s.selectedComments[pairKeyStr + ':' + ci]);
|
|
1311
|
+
return { comments: Object.assign({}, s.comments, { [pairKeyStr]: list }), selectedComments: {} };
|
|
1312
|
+
});
|
|
1313
|
+
setTimeout(() => this.persistState(), 0);
|
|
1314
|
+
}
|
|
1315
|
+
/* Single-comment delete: removes exactly one comment (by index within its slide's list).
|
|
1316
|
+
Confirmation-gated (this session) via the same request/cancel/confirmYes triad used for the
|
|
1317
|
+
bulk destructive actions — a deleted comment's text/thread cannot be recovered. */
|
|
1318
|
+
deleteComment(pairKeyStr, idx) {
|
|
1319
|
+
this.setState(s => ({ comments: Object.assign({}, s.comments, { [pairKeyStr]: (s.comments[pairKeyStr] || []).filter((_, ci) => ci !== idx) }) }));
|
|
1320
|
+
setTimeout(() => this.persistState(), 0);
|
|
1321
|
+
}
|
|
1322
|
+
requestDeleteComment(pairKeyStr, idx) {
|
|
1323
|
+
this.openConfirm(this.buildConfirmConfig('singleComment'), () => this.deleteComment(pairKeyStr, idx));
|
|
1324
|
+
}
|
|
1325
|
+
/* Reply delete: removes one reply within one comment's thread. Kept immediate/unconfirmed —
|
|
1326
|
+
a reply is a smaller, more granular unit than a whole comment, consistent with this app's
|
|
1327
|
+
convention of confirming only the "larger" deletes (whole comments, bulk actions). */
|
|
1328
|
+
deleteReply(pairKeyStr, commentIdx, replyIdx) {
|
|
1329
|
+
this.setState(s => {
|
|
1330
|
+
const list = (s.comments[pairKeyStr] || []).slice();
|
|
1331
|
+
if (!list[commentIdx]) return {};
|
|
1332
|
+
const replies = (list[commentIdx].replies || []).filter((_, ri) => ri !== replyIdx);
|
|
1333
|
+
list[commentIdx] = Object.assign({}, list[commentIdx], { replies });
|
|
1334
|
+
return { comments: Object.assign({}, s.comments, { [pairKeyStr]: list }) };
|
|
1335
|
+
});
|
|
1336
|
+
setTimeout(() => this.persistState(), 0);
|
|
1337
|
+
}
|
|
1338
|
+
/* Bulk comment delete is confirmation-gated (mirrors bulk reviewer removal) — deleting several
|
|
1339
|
+
comments at once is easy to trigger by accident via Select all, and comments (unlike a
|
|
1340
|
+
reviewer chip) carry no way to recover their exact text/thread once gone. */
|
|
1341
|
+
requestDeleteSelectedComments(pairKeyStr) {
|
|
1342
|
+
const n = Object.values(this.state.selectedComments).filter(Boolean).length;
|
|
1343
|
+
if (!n) return;
|
|
1344
|
+
this.openConfirm(this.buildConfirmConfig('comments', { n }), () => this.deleteSelectedComments(pairKeyStr));
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/* Stable signature for a history entry (order-independent identity — history entries have no
|
|
1348
|
+
inherent index since the array can be filtered/re-sorted) — same shape used for de-dup in
|
|
1349
|
+
mergeReportPatch, reused here as the selection key. */
|
|
1350
|
+
historySig(h) { return h.ts + '|' + h.reviewer + '|' + h.action + '|' + h.label + '|' + (h.note || ''); }
|
|
1351
|
+
toggleHistorySelect(sig, ev) { if (ev) ev.stopPropagation(); this.setState(s => ({ selectedHistory: Object.assign({}, s.selectedHistory, { [sig]: !s.selectedHistory[sig] }) })); }
|
|
1352
|
+
selectAllHistory() { this.setState(s => ({ selectedHistory: this.buildSelectAllMap(s.history.map(h => this.historySig(h))) })); }
|
|
1353
|
+
selectNoneHistory() { this.setState({ selectedHistory: this.buildSelectNoneMap() }); }
|
|
1354
|
+
clearSelectedHistory() {
|
|
1355
|
+
this.setState(s => ({ history: s.history.filter(h => !s.selectedHistory[this.historySig(h)]), selectedHistory: {} }));
|
|
1356
|
+
setTimeout(() => this.persistState(), 0);
|
|
1357
|
+
}
|
|
1358
|
+
/* Bulk history clear is confirmation-gated for the same reason as bulk comment delete — the
|
|
1359
|
+
approval/rejection/comment log is otherwise the only audit trail in the app. */
|
|
1360
|
+
requestClearSelectedHistory() {
|
|
1361
|
+
const n = Object.values(this.state.selectedHistory).filter(Boolean).length;
|
|
1362
|
+
if (!n) return;
|
|
1363
|
+
this.openConfirm(this.buildConfirmConfig('history', { n }), () => this.clearSelectedHistory());
|
|
1364
|
+
}
|
|
1365
|
+
/* "Clear decisions" wipes slide-level approve/reject votes (st.decisions), scoped one of four
|
|
1366
|
+
ways: 'all' (everything), 'mine' (only the active reviewer's votes, every slide), 'reviewer'
|
|
1367
|
+
(only one chosen reviewer's votes, every slide), 'range' (every reviewer's votes, but only for
|
|
1368
|
+
slides within a chosen row-position range in the current All-pairs order). Pure decision logic
|
|
1369
|
+
lives in computeDecisionsToClear so it's testable without touching state/DOM. Does NOT touch
|
|
1370
|
+
per-diff decisions (diffDecisions), comments, or history — those have their own controls. */
|
|
1371
|
+
computeDecisionsToClear(decisions, scope, opts) {
|
|
1372
|
+
opts = opts || {};
|
|
1373
|
+
if (scope === 'all') return {};
|
|
1374
|
+
if (scope === 'mine' || scope === 'reviewer') {
|
|
1375
|
+
const handle = scope === 'mine' ? opts.activeHandle : opts.reviewerHandle;
|
|
1376
|
+
const out = {};
|
|
1377
|
+
Object.keys(decisions).forEach(k => {
|
|
1378
|
+
const votes = Object.assign({}, decisions[k]);
|
|
1379
|
+
delete votes[handle];
|
|
1380
|
+
out[k] = votes;
|
|
1381
|
+
});
|
|
1382
|
+
return out;
|
|
1383
|
+
}
|
|
1384
|
+
if (scope === 'range') {
|
|
1385
|
+
const rowKeys = opts.rowKeys || [];
|
|
1386
|
+
const from = Math.max(1, opts.from || 1), to = Math.max(from, opts.to || from);
|
|
1387
|
+
const inRange = new Set(rowKeys.slice(from - 1, to));
|
|
1388
|
+
const out = {};
|
|
1389
|
+
Object.keys(decisions).forEach(k => { if (!inRange.has(k)) out[k] = decisions[k]; });
|
|
1390
|
+
return out;
|
|
1391
|
+
}
|
|
1392
|
+
return decisions;
|
|
1393
|
+
}
|
|
1394
|
+
clearDecisionsScopeLabel(scope, opts) {
|
|
1395
|
+
opts = opts || {};
|
|
1396
|
+
if (scope === 'mine') return 'only my votes';
|
|
1397
|
+
if (scope === 'reviewer') return 'only ' + (opts.reviewerHandle || 'that reviewer') + "'s votes";
|
|
1398
|
+
if (scope === 'range') return 'slides ' + (opts.from || 1) + '–' + (opts.to || 1) + ' (all reviewers)';
|
|
1399
|
+
return 'everyone, every slide';
|
|
1400
|
+
}
|
|
1401
|
+
requestClearAllDecisions() {
|
|
1402
|
+
const st = this.state;
|
|
1403
|
+
if (!Object.keys(st.decisions).length) return;
|
|
1404
|
+
const scope = st.clearDecisionsScope;
|
|
1405
|
+
const opts = { activeHandle: st.activeHandle || 'anonymous', reviewerHandle: st.clearDecisionsReviewerHandle, from: st.clearDecisionsRangeFrom, to: st.clearDecisionsRangeTo, rowKeys: this.computeAlignment().map(al => this.pairKey(al.aIdx, al.bIdx)) };
|
|
1406
|
+
const scopeLabel = this.clearDecisionsScopeLabel(scope, opts);
|
|
1407
|
+
this.openConfirm(this.buildConfirmConfig('decisions', { scopeLabel }), () => {
|
|
1408
|
+
this.setState(s => ({ decisions: this.computeDecisionsToClear(s.decisions, scope, opts) }));
|
|
1409
|
+
setTimeout(() => this.persistState(), 0);
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
setDecision(key, val, label, ev) {
|
|
1413
|
+
if (ev) ev.stopPropagation();
|
|
1414
|
+
const handle = this.state.activeHandle || 'anonymous';
|
|
1415
|
+
const reviewer = this.activeReviewerLabel();
|
|
1416
|
+
this.setState(s => {
|
|
1417
|
+
const cur = (s.decisions[key] || {})[handle];
|
|
1418
|
+
const nextVal = cur === val ? undefined : val;
|
|
1419
|
+
const perSlide = Object.assign({}, s.decisions[key]);
|
|
1420
|
+
if (nextVal) perSlide[handle] = nextVal; else delete perSlide[handle];
|
|
1421
|
+
const decisions = Object.assign({}, s.decisions, { [key]: perSlide });
|
|
1422
|
+
const history = nextVal ? [{ ts: Date.now(), reviewer, action: nextVal, label }].concat(s.history) : s.history;
|
|
1423
|
+
return { decisions, history };
|
|
1424
|
+
});
|
|
1425
|
+
setTimeout(() => this.persistState(), 0);
|
|
1426
|
+
}
|
|
1427
|
+
/* Detect @handle mentions among registered reviewers and queue an in-app notification for each. */
|
|
1428
|
+
notifyMentions(text, fromReviewer, slideLabel) {
|
|
1429
|
+
const handles = this.state.reviewers.map(r => r.handle);
|
|
1430
|
+
if (!handles.length) return;
|
|
1431
|
+
const mentioned = handles.filter(h => text.toLowerCase().includes(h.toLowerCase()) && h !== fromReviewer);
|
|
1432
|
+
if (!mentioned.length) return;
|
|
1433
|
+
this.setState(s => {
|
|
1434
|
+
const notifications = Object.assign({}, s.notifications);
|
|
1435
|
+
mentioned.forEach(h => {
|
|
1436
|
+
notifications[h] = (notifications[h] || []).concat([{ ts: Date.now(), from: fromReviewer, text, slideLabel, read: false }]);
|
|
1437
|
+
});
|
|
1438
|
+
return { notifications };
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
markNotificationsRead(handle) {
|
|
1442
|
+
this.setState(s => ({ notifications: Object.assign({}, s.notifications, { [handle]: (s.notifications[handle] || []).map(n => Object.assign({}, n, { read: true })) }) }));
|
|
1443
|
+
}
|
|
1444
|
+
toggleNotifications() { this.setState(s => ({ showNotifications: !s.showNotifications })); }
|
|
1445
|
+
toggleHistoryCollapse() { this.setState(s => ({ historyCollapsed: !s.historyCollapsed })); }
|
|
1446
|
+
toggleSelfTestsCollapse() { this.setState(s => ({ selfTestsCollapsed: !s.selfTestsCollapsed })); }
|
|
1447
|
+
togglePairCollapse(key, ev) { if (ev) ev.stopPropagation(); this.setState(s => ({ collapsedPairs: Object.assign({}, s.collapsedPairs, { [key]: !s.collapsedPairs[key] }) })); setTimeout(() => this.persistState(), 0); }
|
|
1448
|
+
togglePairSelect(key, ev) { if (ev) ev.stopPropagation(); this.setState(s => ({ selectedPairs: Object.assign({}, s.selectedPairs, { [key]: !s.selectedPairs[key] }) })); setTimeout(() => this.persistState(), 0); }
|
|
1449
|
+
toggleShowSelectedOnly() { this.setState(s => ({ showSelectedOnly: !s.showSelectedOnly })); }
|
|
1450
|
+
addComment(key, label) {
|
|
1451
|
+
const text = (this.state.commentDraft || '').trim();
|
|
1452
|
+
if (!text) return;
|
|
1453
|
+
const reviewer = this.activeReviewerLabel();
|
|
1454
|
+
this.setState(s => ({
|
|
1455
|
+
comments: Object.assign({}, s.comments, { [key]: (s.comments[key] || []).concat([{ author: reviewer, text, ts: Date.now(), replies: [] }]) }),
|
|
1456
|
+
history: [{ ts: Date.now(), reviewer, action: 'comment', label, note: text }].concat(s.history),
|
|
1457
|
+
commentDraft: ''
|
|
1458
|
+
}));
|
|
1459
|
+
this.notifyMentions(text, this.state.activeHandle, label);
|
|
1460
|
+
setTimeout(() => this.persistState(), 0);
|
|
1461
|
+
}
|
|
1462
|
+
startReply(replyKey) { this.setState({ replyingKey: replyKey, replyDraft: '' }); }
|
|
1463
|
+
cancelReply() { this.setState({ replyingKey: null, replyDraft: '' }); }
|
|
1464
|
+
submitReply(key, idx, label) {
|
|
1465
|
+
const text = (this.state.replyDraft || '').trim();
|
|
1466
|
+
if (!text) return;
|
|
1467
|
+
const reviewer = this.activeReviewerLabel();
|
|
1468
|
+
this.setState(s => {
|
|
1469
|
+
const list = (s.comments[key] || []).slice();
|
|
1470
|
+
const c = Object.assign({}, list[idx], { replies: (list[idx].replies || []).concat([{ author: reviewer, text, ts: Date.now() }]) });
|
|
1471
|
+
list[idx] = c;
|
|
1472
|
+
return {
|
|
1473
|
+
comments: Object.assign({}, s.comments, { [key]: list }),
|
|
1474
|
+
history: [{ ts: Date.now(), reviewer, action: 'reply', label, note: text }].concat(s.history),
|
|
1475
|
+
replyDraft: '', replyingKey: null
|
|
1476
|
+
};
|
|
1477
|
+
});
|
|
1478
|
+
this.notifyMentions(text, this.state.activeHandle, label);
|
|
1479
|
+
setTimeout(() => this.persistState(), 0);
|
|
1480
|
+
}
|
|
1481
|
+
/* ---------- deck-level slide alignment (handles inserted/removed/reordered slides) ---------- */
|
|
1482
|
+
wordSet(text) { return new Set(String(text || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)); }
|
|
1483
|
+
slideWords(s) {
|
|
1484
|
+
let all = '';
|
|
1485
|
+
(s.shapes || []).forEach(sh => { all += ' ' + this.shapeText(sh); });
|
|
1486
|
+
(s.tables || []).forEach(t => t.rows.forEach(r => r.forEach(c => { all += ' ' + this.cellText(c); })));
|
|
1487
|
+
return this.wordSet(all);
|
|
1488
|
+
}
|
|
1489
|
+
/* ---------- static-analysis regression guard: catches stale this.xxx() refs after a rename ---------- */
|
|
1490
|
+
/* Walks the FULL renderVals() output (including nested arrays/objects like reviewerChips,
|
|
1491
|
+
diffs, curComments, batchListA/B, historyRows) and, for every function value found, inspects
|
|
1492
|
+
its source text for `this.<name>(` call expressions — then checks that `<name>` still exists
|
|
1493
|
+
as a method on this instance. This is exactly the bug class that caused the "nothing renders"
|
|
1494
|
+
incident: a request/confirm-flow refactor renamed/removed several methods (e.g.
|
|
1495
|
+
removeSelectedReviewers -> doRemoveSelectedReviewers) but a few renderVals bindings still
|
|
1496
|
+
said `() => this.removeSelectedReviewers()` — a LAZY closure, so it never throws at render
|
|
1497
|
+
time, only when a user actually clicks that button. Source-scanning catches it immediately,
|
|
1498
|
+
without needing to click every button by hand. */
|
|
1499
|
+
checkStaleMethodRefs() {
|
|
1500
|
+
let vals;
|
|
1501
|
+
try { vals = this.renderVals(); } catch (e) { return ['renderVals() itself threw: ' + e.message]; }
|
|
1502
|
+
const problems = [];
|
|
1503
|
+
const seenNames = new Set();
|
|
1504
|
+
const scanFn = (fn) => {
|
|
1505
|
+
let src; try { src = fn.toString(); } catch (e) { return; }
|
|
1506
|
+
const re = /this\.([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/g;
|
|
1507
|
+
let m;
|
|
1508
|
+
while ((m = re.exec(src))) {
|
|
1509
|
+
const name = m[1];
|
|
1510
|
+
if (seenNames.has(name)) continue;
|
|
1511
|
+
seenNames.add(name);
|
|
1512
|
+
if (typeof this[name] !== 'function') problems.push(name);
|
|
1513
|
+
}
|
|
1514
|
+
};
|
|
1515
|
+
const walk = (v, depth) => {
|
|
1516
|
+
if (v == null || depth > 4) return;
|
|
1517
|
+
if (typeof v === 'function') { scanFn(v); return; }
|
|
1518
|
+
if (Array.isArray(v)) { v.forEach(x => walk(x, depth + 1)); return; }
|
|
1519
|
+
if (typeof v === 'object') { Object.values(v).forEach(x => walk(x, depth + 1)); }
|
|
1520
|
+
};
|
|
1521
|
+
walk(vals, 0);
|
|
1522
|
+
return problems;
|
|
1523
|
+
}
|
|
1524
|
+
/* ---------- lightweight in-browser regression suite (no external test runner needed) ---------- */
|
|
1525
|
+
async runSelfTests() {
|
|
1526
|
+
const tests = [];
|
|
1527
|
+
const assert = (name, cond, detail) => tests.push({ name, pass: !!cond, detail: detail || '' });
|
|
1528
|
+
|
|
1529
|
+
// RED/GREEN regression guard for the "nothing renders" incident: a renderVals binding that
|
|
1530
|
+
// still calls this.someRenamedOrRemovedMethod() after a refactor. This is checked FIRST so a
|
|
1531
|
+
// future rename/refactor mistake is caught here rather than shipping a silently-broken button.
|
|
1532
|
+
const staleRefs = this.checkStaleMethodRefs();
|
|
1533
|
+
assert('renderVals() output has no stale this.xxx() references to renamed/removed methods', staleRefs.length === 0, staleRefs.join(', '));
|
|
1534
|
+
|
|
1535
|
+
// RED/GREEN regression guard for a second bug class found in the same audit: a method
|
|
1536
|
+
// defined but never actually invoked from anywhere (e.g. componentDidMount silently dropped
|
|
1537
|
+
// its call to restorePersisted() during an earlier refactor, so saved reviewer state/decisions
|
|
1538
|
+
// were never restored on reload — no error, just quietly-lost persistence). Static-checks the
|
|
1539
|
+
// SOURCE of componentDidMount rather than re-invoking it (which would re-run real async
|
|
1540
|
+
// network/import side effects unsafely from within a test).
|
|
1541
|
+
assert('componentDidMount() calls restorePersisted() so saved reviewer state/decisions actually restore on load', this.componentDidMount.toString().includes('this.restorePersisted()'));
|
|
1542
|
+
|
|
1543
|
+
// RED/GREEN for extended cross-deck duplicate detection: threshold, performance cap, sections.
|
|
1544
|
+
const cdSlideA = (txt) => ({ w: 9144000, h: 6858000, shapes: [{ name: 's', x: 0, y: 0, cx: 1, cy: 1, font: 'Arial', size: 18, bold: false, italic: false, color: '#000', align: 'l', paras: [{ text: txt, bullet: false }], wrap: 'square', border: null, links: [] }], tables: [], images: [], media: [], charts: [], smartArt: [] });
|
|
1545
|
+
const cdA = [cdSlideA('Roughly similar quarterly review content one'), cdSlideA('Roughly similar quarterly review content two')];
|
|
1546
|
+
const cdB = [cdSlideA('Roughly similar quarterly review content zzz'), cdSlideA('Totally unrelated closing remarks page')];
|
|
1547
|
+
const loose = this.findCrossDeckDuplicates(cdA, cdB, [], 0.5);
|
|
1548
|
+
assert('findCrossDeckDuplicates: a low threshold surfaces a loose near-match', loose.pairs.length > 0);
|
|
1549
|
+
const strict = this.findCrossDeckDuplicates(cdA, cdB, [], 0.99);
|
|
1550
|
+
assert('findCrossDeckDuplicates: a strict threshold filters out the same loose near-match', strict.pairs.length === 0);
|
|
1551
|
+
const capped = this.findCrossDeckDuplicates(cdA, cdB, [], 0.5, 1);
|
|
1552
|
+
assert('findCrossDeckDuplicates: a low maxPairs cap skips the scan instead of hanging on a large deck', capped.skipped === true && capped.pairs.length === 0 && capped.totalComparisons === 4);
|
|
1553
|
+
const forced = this.findCrossDeckDuplicates(cdA, cdB, [], 0.5, 1, true);
|
|
1554
|
+
assert('findCrossDeckDuplicates: force=true runs the full scan even past the cap', forced.skipped === false && forced.pairs.length > 0);
|
|
1555
|
+
|
|
1556
|
+
const sections = [{ name: 'Intro', slideIndices: [0, 1] }, { name: 'Appendix', slideIndices: [2] }];
|
|
1557
|
+
assert('sectionForSlideIndex finds the section containing a given slide', this.sectionForSlideIndex(sections, 1) === 'Intro');
|
|
1558
|
+
assert('sectionForSlideIndex returns null for a slide in no section', this.sectionForSlideIndex(sections, 5) === null);
|
|
1559
|
+
const textSlide = (txt) => ({ w: 9144000, h: 6858000, shapes: [{ name: 's', x: 0, y: 0, cx: 1, cy: 1, font: 'Arial', size: 18, bold: false, italic: false, color: '#000', align: 'l', paras: [{ text: txt, bullet: false }], wrap: 'square', border: null, links: [] }], tables: [], images: [], media: [], charts: [], smartArt: [] });
|
|
1560
|
+
|
|
1561
|
+
const wd = this.wordDiff('the quick fox jumps', 'the slow fox jumps');
|
|
1562
|
+
assert('wordDiff flags the changed word', wd.oldT.some(t => t.w === 'quick' && t.ch) && wd.newT.some(t => t.w === 'slow' && t.ch), 'quick→slow should both be marked changed');
|
|
1563
|
+
assert('wordDiff keeps unchanged words unmarked', wd.oldT.some(t => t.w === 'the' && !t.ch), '"the" should be unchanged');
|
|
1564
|
+
|
|
1565
|
+
const s1 = textSlide('Quarterly revenue results for the leadership team');
|
|
1566
|
+
const s2 = textSlide('Quarterly revenue results for the leadership team');
|
|
1567
|
+
const s3 = textSlide('Completely unrelated onboarding checklist content');
|
|
1568
|
+
assert('simSlides = 1 for identical slide content', this.simSlides(s1, s2) === 1);
|
|
1569
|
+
assert('simSlides is low for unrelated slides', this.simSlides(s1, s3) < 0.3, 'sim=' + this.simSlides(s1, s3).toFixed(2));
|
|
1570
|
+
|
|
1571
|
+
const alRemove = this.alignSlides([s1, s3], [s1]);
|
|
1572
|
+
assert('alignSlides detects a removed slide', alRemove.some(e => e.bIdx == null && e.aIdx != null));
|
|
1573
|
+
|
|
1574
|
+
const alAdd = this.alignSlides([s1], [s1, s3]);
|
|
1575
|
+
assert('alignSlides detects an added slide', alAdd.some(e => e.aIdx == null && e.bIdx != null));
|
|
1576
|
+
|
|
1577
|
+
const moveA = [s1, s3], moveB = [s3, s1];
|
|
1578
|
+
const refined = this.refineMoves(this.alignSlides(moveA, moveB), moveA, moveB);
|
|
1579
|
+
assert('refineMoves detects a moved (reordered) slide pair', refined.some(e => e.moved === true));
|
|
1580
|
+
|
|
1581
|
+
// RED/GREEN regression for the "IDENTICAL+MOVED tag" bug: a same-position identical slide
|
|
1582
|
+
// (5->5) must NOT be tagged moved even when it ties in LIS length with a genuinely-moved
|
|
1583
|
+
// slide (6->4) whose bIdx sequence is decreasing relative to it.
|
|
1584
|
+
const sSame = textSlide('Same position control slide unique content marker one');
|
|
1585
|
+
const sMoved = textSlide('Reordered slide unique content marker two moved');
|
|
1586
|
+
const samePosPairs = [{ aIdx: 4, bIdx: 4 }, { aIdx: 5, bIdx: 3 }]; // 5th slide->5th (same pos), 6th slide->4th (moved)
|
|
1587
|
+
this.markMovedByLIS(samePosPairs);
|
|
1588
|
+
const samePos = samePosPairs.find(p => p.aIdx === 4 && p.bIdx === 4);
|
|
1589
|
+
const movedPos = samePosPairs.find(p => p.aIdx === 5 && p.bIdx === 3);
|
|
1590
|
+
assert('markMovedByLIS: same-position pair (5->5) is NOT tagged moved', samePos && samePos.moved === false);
|
|
1591
|
+
assert('markMovedByLIS: genuinely reordered pair (6->4) IS tagged moved', movedPos && movedPos.moved === true);
|
|
1592
|
+
|
|
1593
|
+
const dupSlides = [s1, s3, textSlide('Quarterly revenue results for the leadership team')];
|
|
1594
|
+
const dupGroups = this.findDuplicateSlides(dupSlides);
|
|
1595
|
+
assert('findDuplicateSlides flags near-identical slides in one deck', dupGroups.length === 1 && dupGroups[0].includes(0) && dupGroups[0].includes(2));
|
|
1596
|
+
|
|
1597
|
+
const ctx = { themeColors: { accent1: '#FF0000', dk1: '#111111' }, colorMap: { accent1: 'accent1', tx1: 'dk1' } };
|
|
1598
|
+
assert('resolveSchemeColor resolves accent1 via theme', this.resolveSchemeColor('accent1', ctx) === '#FF0000');
|
|
1599
|
+
assert('resolveSchemeColor resolves tx1 via color-map indirection to dk1', this.resolveSchemeColor('tx1', ctx) === '#111111');
|
|
1600
|
+
|
|
1601
|
+
assert('pairKey is distinct for different slide-index pairs', this.pairKey(0, 0) !== this.pairKey(1, 1));
|
|
1602
|
+
assert('pairKey handles null (added/removed) sides', this.pairKey(null, 2) === 'ax:b2');
|
|
1603
|
+
|
|
1604
|
+
const rollup = this.diffRollup([{ key: 'a' }, { key: 'b' }], '__selftest__');
|
|
1605
|
+
assert('diffRollup with no decisions reports all pending', rollup.nP === 2 && rollup.nA === 0 && rollup.nR === 0);
|
|
1606
|
+
|
|
1607
|
+
assert('filenameSim scores an exact match as 1', this.filenameSim('Deck-v1.pptx', 'Deck-v1.pptx') === 1);
|
|
1608
|
+
assert('filenameSim scores a clearly different name lower than a close one',
|
|
1609
|
+
this.filenameSim('Q3-Review-v1.pptx', 'Q3-Review-v2-final.pptx') > this.filenameSim('Q3-Review-v1.pptx', 'Totally-Different-File.pptx'));
|
|
1610
|
+
|
|
1611
|
+
// Merged .pptx export: build a real file from two synthetic slides via the same writer used by
|
|
1612
|
+
// the "Merged .pptx" export button, then re-parse it with our own reader to confirm it round-trips.
|
|
1613
|
+
if (this.buildPptx) {
|
|
1614
|
+
try {
|
|
1615
|
+
const specA = this.slideToBuildSpec(Object.assign({}, s1, { transition: { type: 'None' } }));
|
|
1616
|
+
const specB = this.slideToBuildSpec(Object.assign({}, s3, { transition: { type: 'None' } }));
|
|
1617
|
+
const mergedBuf = await this.buildPptx([specA, specB], s1.w, s1.h);
|
|
1618
|
+
assert('Merged .pptx export produces a non-empty file', mergedBuf && mergedBuf.byteLength > 0, 'byteLength=' + (mergedBuf ? mergedBuf.byteLength : 0));
|
|
1619
|
+
const reparsed = await this.parseBuffer(mergedBuf.slice(0));
|
|
1620
|
+
assert('Merged .pptx round-trips back to 2 slides', reparsed.length === 2, 'got ' + reparsed.length);
|
|
1621
|
+
const text0 = this.shapeText(reparsed[0].shapes[0] || { paras: [] });
|
|
1622
|
+
assert('Merged .pptx preserves slide 1 text', text0.includes('Quarterly revenue results'), 'got: ' + text0);
|
|
1623
|
+
} catch (e) {
|
|
1624
|
+
assert('Merged .pptx export round-trip', false, e.message);
|
|
1625
|
+
}
|
|
1626
|
+
} else {
|
|
1627
|
+
assert('Merged .pptx export round-trip', false, 'pptx writer not loaded yet — open the app and wait for the sample deck to load, then re-run tests');
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// Merge-winner preview must reflect the same pickMergeWinner logic used by the real export
|
|
1631
|
+
// (no drift between the "preview" and "download" code paths).
|
|
1632
|
+
const prevKey = '__selftest_merge__';
|
|
1633
|
+
const realDiffs = this.compare(s1, s3).diffs;
|
|
1634
|
+
assert('setup: s1 vs s3 produces at least one diff to pick from', realDiffs.length > 0);
|
|
1635
|
+
const savedChoices = Object.assign({}, this.state.mergeChoices);
|
|
1636
|
+
realDiffs.forEach(d => { this.state.mergeChoices[prevKey + '::' + d.key] = 'before'; });
|
|
1637
|
+
const winner = this.pickMergeWinner(s1, s3, prevKey);
|
|
1638
|
+
assert('pickMergeWinner picks Before when all its diffs are Keep-Before', winner === s1);
|
|
1639
|
+
this.state.mergeChoices = savedChoices;
|
|
1640
|
+
|
|
1641
|
+
// RED/GREEN for the touch drag-preview ghost: onBatchTouchStart must populate a ghost with the
|
|
1642
|
+
// dragged file's name and starting coordinates; onBatchTouchEnd must clear it.
|
|
1643
|
+
const savedBatchFilesA = this.state.batchFilesA;
|
|
1644
|
+
this.setState({ batchFilesA: [{ name: 'deck-v1.pptx' }, { name: 'deck-v2.pptx' }] });
|
|
1645
|
+
this.onBatchTouchStart('A', 0, { touches: [{ clientX: 10, clientY: 20 }] });
|
|
1646
|
+
assert('onBatchTouchStart sets a touch ghost with the dragged filename', this.state.touchGhost && this.state.touchGhost.name === 'deck-v1.pptx');
|
|
1647
|
+
this.onBatchTouchEnd({});
|
|
1648
|
+
assert('onBatchTouchEnd clears the touch ghost', this.state.touchGhost === null);
|
|
1649
|
+
this.touchDrag = null;
|
|
1650
|
+
this.setState({ batchFilesA: savedBatchFilesA });
|
|
1651
|
+
|
|
1652
|
+
// RED/GREEN for report-JSON round-trip import: a previously-exported report shape must parse
|
|
1653
|
+
// back into the exact decisions/comments/uiState it was built from.
|
|
1654
|
+
const fakeReport = {
|
|
1655
|
+
history: [{ ts: 1, reviewer: 'x', action: 'approved', label: 'Slide 1' }],
|
|
1656
|
+
slides: [{ key: 'a0:b0', label: 'Slide 1', reviewerVotes: { alice: 'approved' }, comments: [{ author: 'alice', text: 'looks good', ts: 1 }] }],
|
|
1657
|
+
uiState: { collapsedPairs: { 'a0:b0': true }, selectedPairs: { 'a0:b0': true }, mergeWinnerOverrides: { 'a0:b0': 'after' } }
|
|
1658
|
+
};
|
|
1659
|
+
const imported = this.parseReportForImport(fakeReport);
|
|
1660
|
+
assert('parseReportForImport restores decisions keyed by slide key', imported.decisions['a0:b0'] && imported.decisions['a0:b0'].alice === 'approved');
|
|
1661
|
+
assert('parseReportForImport restores comments keyed by slide key', imported.comments['a0:b0'].length === 1 && imported.comments['a0:b0'][0].text === 'looks good');
|
|
1662
|
+
assert('parseReportForImport restores collapsedPairs/selectedPairs/mergeWinnerOverrides from uiState', imported.collapsedPairs['a0:b0'] === true && imported.selectedPairs['a0:b0'] === true && imported.mergeWinnerOverrides['a0:b0'] === 'after');
|
|
1663
|
+
assert('parseReportForImport skips slides missing a key (older exports)', Object.keys(this.parseReportForImport({ slides: [{ label: 'no key here' }] }).decisions).length === 0);
|
|
1664
|
+
|
|
1665
|
+
// RED/GREEN for merge-vs-overwrite import: current session's own choices must win on a
|
|
1666
|
+
// per-key conflict; anything only present in the import must still be adopted.
|
|
1667
|
+
const cur = {
|
|
1668
|
+
decisions: { 'a0:b0': { alice: 'rejected' } },
|
|
1669
|
+
comments: { 'a0:b0': [{ author: 'alice', text: 'existing comment', ts: 5 }] },
|
|
1670
|
+
history: [{ ts: 5, reviewer: 'alice', action: 'comment', label: 'Slide 1', note: 'existing comment' }],
|
|
1671
|
+
collapsedPairs: { 'a0:b0': false }, selectedPairs: {}, mergeWinnerOverrides: {}
|
|
1672
|
+
};
|
|
1673
|
+
const imp = {
|
|
1674
|
+
decisions: { 'a0:b0': { alice: 'approved', bob: 'approved' }, 'a1:b1': { carol: 'approved' } },
|
|
1675
|
+
comments: { 'a0:b0': [{ author: 'alice', text: 'existing comment', ts: 5 }, { author: 'bob', text: 'new comment', ts: 6 }] },
|
|
1676
|
+
history: [{ ts: 5, reviewer: 'alice', action: 'comment', label: 'Slide 1', note: 'existing comment' }, { ts: 6, reviewer: 'bob', action: 'comment', label: 'Slide 1', note: 'new comment' }],
|
|
1677
|
+
collapsedPairs: { 'a0:b0': true, 'a1:b1': true }, selectedPairs: { 'a1:b1': true }, mergeWinnerOverrides: {}
|
|
1678
|
+
};
|
|
1679
|
+
const merged = this.mergeReportPatch(cur, imp);
|
|
1680
|
+
assert('mergeReportPatch: current reviewer vote wins on conflict', merged.decisions['a0:b0'].alice === 'rejected');
|
|
1681
|
+
assert('mergeReportPatch: imported-only reviewer vote on an existing slide is adopted', merged.decisions['a0:b0'].bob === 'approved');
|
|
1682
|
+
assert('mergeReportPatch: imported-only slide is adopted', merged.decisions['a1:b1'].carol === 'approved');
|
|
1683
|
+
assert('mergeReportPatch: comments are unioned without duplicating an identical existing comment', merged.comments['a0:b0'].length === 2);
|
|
1684
|
+
assert('mergeReportPatch: history is unioned and de-duplicated', merged.history.length === 2);
|
|
1685
|
+
assert('mergeReportPatch: current collapsedPairs value wins on conflict', merged.collapsedPairs['a0:b0'] === false);
|
|
1686
|
+
assert('mergeReportPatch: imported-only collapsedPairs/selectedPairs keys are adopted', merged.collapsedPairs['a1:b1'] === true && merged.selectedPairs['a1:b1'] === true);
|
|
1687
|
+
|
|
1688
|
+
// RED/GREEN for configurable merge-conflict winner: with conflictWinner='imported', the
|
|
1689
|
+
// imported side must win the same conflicts current won by default above.
|
|
1690
|
+
const mergedImportedWins = this.mergeReportPatch(cur, imp, 'imported');
|
|
1691
|
+
assert('mergeReportPatch(conflictWinner="imported"): imported reviewer vote wins on conflict', mergedImportedWins.decisions['a0:b0'].alice === 'approved');
|
|
1692
|
+
assert('mergeReportPatch(conflictWinner="imported"): imported collapsedPairs value wins on conflict', mergedImportedWins.collapsedPairs['a0:b0'] === true);
|
|
1693
|
+
assert('mergeReportPatch(conflictWinner="imported") still adopts current-only untouched keys correctly', mergedImportedWins.selectedPairs['a1:b1'] === true);
|
|
1694
|
+
|
|
1695
|
+
// RED/GREEN for live-push payload builders (pure functions, no network needed to test shape).
|
|
1696
|
+
const slackPayload = this.buildSlackPayload('hello world');
|
|
1697
|
+
assert('buildSlackPayload wraps text in a Slack-shaped payload', slackPayload.text === 'hello world');
|
|
1698
|
+
const sampleRows = [{ label: 'Slide 1', decision: 'pending', diffs: [{ type: 'Text', oldText: 'foo', newText: 'bar' }] }, { label: 'Slide 2', decision: 'approved', diffs: [] }];
|
|
1699
|
+
const notionBlocks = this.buildNotionChildren(sampleRows);
|
|
1700
|
+
assert('buildNotionChildren emits a heading block per slide plus content blocks', notionBlocks.filter(b => b.type === 'heading_2').length === 2 && notionBlocks.some(b => b.type === 'bulleted_list_item') && notionBlocks.some(b => b.type === 'paragraph'));
|
|
1701
|
+
const confluenceBody = this.buildConfluenceStorageBody(sampleRows);
|
|
1702
|
+
assert('buildConfluenceStorageBody emits a <table> for slides with diffs and <em>Identical</em> for slides without', confluenceBody.includes('<table>') && confluenceBody.includes('<em>Identical</em>'));
|
|
1703
|
+
|
|
1704
|
+
// RED/GREEN for Confluence replace-vs-append.
|
|
1705
|
+
assert('buildConfluenceNewBody: append mode keeps the existing body and adds new content after a divider', this.buildConfluenceNewBody('<p>old</p>', '<p>new</p>', 'append') === '<p>old</p><hr/><p>new</p>');
|
|
1706
|
+
assert('buildConfluenceNewBody: replace mode discards the existing body entirely', this.buildConfluenceNewBody('<p>old</p>', '<p>new</p>', 'replace') === '<p>new</p>');
|
|
1707
|
+
assert('buildConfluenceNewBody: append mode tolerates a missing/undefined existing body', this.buildConfluenceNewBody(undefined, '<p>new</p>', 'append') === '<hr/><p>new</p>');
|
|
1708
|
+
|
|
1709
|
+
// RED/GREEN for the Slack delivery-confirmation outcome interpreter.
|
|
1710
|
+
assert('interpretSlackDeliveryOutcome: "load" is reported as sent', this.interpretSlackDeliveryOutcome('load').status === 'sent');
|
|
1711
|
+
assert('interpretSlackDeliveryOutcome: "error" is reported as failed', this.interpretSlackDeliveryOutcome('error').status === 'failed');
|
|
1712
|
+
assert('interpretSlackDeliveryOutcome: "timeout" is reported as unknown (not silently success)', this.interpretSlackDeliveryOutcome('timeout').status === 'unknown');
|
|
1713
|
+
|
|
1714
|
+
// RED/GREEN for live-push credential persistence payload shape.
|
|
1715
|
+
const allFieldsSelected = { notionToken: true, notionPageId: true, confluenceBaseUrl: true, confluenceEmail: true, confluenceToken: true, confluencePageId: true, slackWebhookUrl: true };
|
|
1716
|
+
const credsPayload = this.buildCredsPayload({ notionToken: 'nt', notionPageId: 'np', confluenceBaseUrl: 'cb', confluenceEmail: 'ce', confluenceToken: 'ct', confluencePageId: 'cp', slackWebhookUrl: 'sw', unrelatedField: 'should not leak in' }, allFieldsSelected);
|
|
1717
|
+
assert('buildCredsPayload with all 7 fields selected captures exactly those fields, nothing else', Object.keys(credsPayload).length === 7 && !('unrelatedField' in credsPayload) && credsPayload.slackWebhookUrl === 'sw');
|
|
1718
|
+
|
|
1719
|
+
// RED/GREEN for the duplicate-slide 4th alignment state: a slide that duplicates another
|
|
1720
|
+
// slide within its OWN deck must surface a DUPLICATE tag, independent of (and stackable with)
|
|
1721
|
+
// the MOVED tag.
|
|
1722
|
+
const dupOnlyTags = this.computeExtraTags({ aIdx: 0, bIdx: 0, moved: false }, new Set([0, 2]), new Set());
|
|
1723
|
+
assert('computeExtraTags: a same-deck duplicate slide gets a DUPLICATE tag', dupOnlyTags.length === 1 && dupOnlyTags[0].label === 'DUPLICATE');
|
|
1724
|
+
const movedOnlyTags = this.computeExtraTags({ aIdx: 5, bIdx: 3, moved: true }, new Set(), new Set());
|
|
1725
|
+
assert('computeExtraTags: a reordered slide gets a MOVED tag', movedOnlyTags.length === 1 && movedOnlyTags[0].label === 'MOVED');
|
|
1726
|
+
const bothTags = this.computeExtraTags({ aIdx: 5, bIdx: 3, moved: true }, new Set([5]), new Set());
|
|
1727
|
+
assert('computeExtraTags: MOVED and DUPLICATE stack independently when both are true', bothTags.length === 2 && bothTags.some(t => t.label === 'MOVED') && bothTags.some(t => t.label === 'DUPLICATE'));
|
|
1728
|
+
const noTags = this.computeExtraTags({ aIdx: 1, bIdx: 1, moved: false }, new Set([0, 2]), new Set());
|
|
1729
|
+
assert('computeExtraTags: a plain unmoved, non-duplicate slide gets no extra tags', noTags.length === 0);
|
|
1730
|
+
|
|
1731
|
+
// RED/GREEN for cross-deck duplicate detection.
|
|
1732
|
+
const textSlide2 = (txt) => ({ w: 9144000, h: 6858000, shapes: [{ name: 's', x: 0, y: 0, cx: 1, cy: 1, font: 'Arial', size: 18, bold: false, italic: false, color: '#000', align: 'l', paras: [{ text: txt, bullet: false }], wrap: 'square', border: null, links: [] }], tables: [], images: [], media: [], charts: [], smartArt: [] });
|
|
1733
|
+
const crossA = [textSlide2('Alpha unique introduction content'), textSlide2('Bravo shared repeated recurring boilerplate text')];
|
|
1734
|
+
const crossB = [textSlide2('Charlie totally different closing content'), textSlide2('Bravo shared repeated recurring boilerplate text')];
|
|
1735
|
+
const noAlignment = []; // simulate nothing aligned, so any high-similarity pair should surface
|
|
1736
|
+
const crossDupes = this.findCrossDeckDuplicates(crossA, crossB, noAlignment);
|
|
1737
|
+
assert('findCrossDeckDuplicates finds an unaligned near-identical Before/After pair', crossDupes.pairs.some(d => d.aIdx === 1 && d.bIdx === 1));
|
|
1738
|
+
const alignedOut = this.findCrossDeckDuplicates(crossA, crossB, [{ aIdx: 1, bIdx: 1 }]);
|
|
1739
|
+
assert('findCrossDeckDuplicates excludes a pair that IS already aligned to each other', !alignedOut.pairs.some(d => d.aIdx === 1 && d.bIdx === 1));
|
|
1740
|
+
|
|
1741
|
+
// RED/GREEN for the shared select-all/select-none helpers used by credential fields, batch
|
|
1742
|
+
// file rows, and the reviewer list.
|
|
1743
|
+
const allMap = this.buildSelectAllMap(['x', 'y', 'z']);
|
|
1744
|
+
assert('buildSelectAllMap marks every given key true', allMap.x === true && allMap.y === true && allMap.z === true && Object.keys(allMap).length === 3);
|
|
1745
|
+
assert('buildSelectNoneMap returns an empty object', Object.keys(this.buildSelectNoneMap()).length === 0);
|
|
1746
|
+
|
|
1747
|
+
// RED/GREEN for bulk reviewer removal requiring confirmation (no immediate removal).
|
|
1748
|
+
const savedReviewers = this.state.reviewers, savedSelectedReviewers = this.state.selectedReviewers;
|
|
1749
|
+
this.setState({ reviewers: [{ handle: '@alice', name: '' }, { handle: '@bob', name: '' }], selectedReviewers: { '@alice': true }, pendingConfirm: null });
|
|
1750
|
+
this.requestRemoveSelectedReviewers();
|
|
1751
|
+
assert('requestRemoveSelectedReviewers opens the shared confirm modal instead of removing immediately', !!this.state.pendingConfirm && this.state.reviewers.length === 2);
|
|
1752
|
+
this.confirmPendingYes();
|
|
1753
|
+
assert('confirmPendingYes actually removes the selected reviewer after confirmation', this.state.reviewers.length === 1 && this.state.reviewers[0].handle === '@bob' && this.state.pendingConfirm === null);
|
|
1754
|
+
this.setState({ reviewers: savedReviewers, selectedReviewers: savedSelectedReviewers, pendingConfirm: null });
|
|
1755
|
+
|
|
1756
|
+
// RED/GREEN for bulk diff decisions.
|
|
1757
|
+
const savedDiffDecisions = this.state.diffDecisions, savedSelectedDiffs = this.state.selectedDiffs, savedActiveHandle = this.state.activeHandle;
|
|
1758
|
+
this.setState({ diffDecisions: {}, selectedDiffs: { 'k1::d1': true, 'k1::d2': true }, activeHandle: '@alice' });
|
|
1759
|
+
this.bulkSetDiffDecision('approved');
|
|
1760
|
+
assert('bulkSetDiffDecision approves every selected diff key for the active reviewer', this.state.diffDecisions['k1::d1']['@alice'] === 'approved' && this.state.diffDecisions['k1::d2']['@alice'] === 'approved');
|
|
1761
|
+
this.setState({ diffDecisions: savedDiffDecisions, selectedDiffs: savedSelectedDiffs, activeHandle: savedActiveHandle });
|
|
1762
|
+
|
|
1763
|
+
// RED/GREEN for bulk comment deletion.
|
|
1764
|
+
const savedComments = this.state.comments, savedSelectedComments = this.state.selectedComments;
|
|
1765
|
+
this.setState({ comments: { 'k1': [{ author: 'a', text: 'keep me', ts: 1 }, { author: 'b', text: 'delete me', ts: 2 }] }, selectedComments: { 'k1:1': true } });
|
|
1766
|
+
this.deleteSelectedComments('k1');
|
|
1767
|
+
assert('deleteSelectedComments removes only the selected comment, keeping the rest', this.state.comments['k1'].length === 1 && this.state.comments['k1'][0].text === 'keep me');
|
|
1768
|
+
this.setState({ comments: savedComments, selectedComments: savedSelectedComments });
|
|
1769
|
+
|
|
1770
|
+
// RED/GREEN for bulk history clearing (signature-based selection, not index-based).
|
|
1771
|
+
const savedHistory = this.state.history, savedSelectedHistory = this.state.selectedHistory;
|
|
1772
|
+
const h1 = { ts: 1, reviewer: 'a', action: 'approved', label: 'Slide 1', note: '' };
|
|
1773
|
+
const h2 = { ts: 2, reviewer: 'b', action: 'rejected', label: 'Slide 2', note: '' };
|
|
1774
|
+
this.setState({ history: [h1, h2], selectedHistory: { [this.historySig(h1)]: true } });
|
|
1775
|
+
this.clearSelectedHistory();
|
|
1776
|
+
assert('clearSelectedHistory removes only the selected entry by signature', this.state.history.length === 1 && this.state.history[0].label === 'Slide 2');
|
|
1777
|
+
this.setState({ history: savedHistory, selectedHistory: savedSelectedHistory });
|
|
1778
|
+
|
|
1779
|
+
// RED/GREEN for single-comment delete (immediate helper — confirmation wraps it, tested below).
|
|
1780
|
+
this.setState({ comments: { 'k2': [{ author: 'a', text: 'first', ts: 1 }, { author: 'b', text: 'second', ts: 2 }] }, selectedComments: {} });
|
|
1781
|
+
this.deleteComment('k2', 0);
|
|
1782
|
+
assert('deleteComment removes exactly the targeted comment by index', this.state.comments['k2'].length === 1 && this.state.comments['k2'][0].text === 'second');
|
|
1783
|
+
this.setState({ comments: savedComments, selectedComments: savedSelectedComments });
|
|
1784
|
+
|
|
1785
|
+
// RED/GREEN for bulk comment delete requiring confirmation via the shared modal.
|
|
1786
|
+
this.setState({ comments: { 'k3': [{ author: 'a', text: 'keep', ts: 1 }, { author: 'b', text: 'nuke', ts: 2 }] }, selectedComments: { 'k3:1': true }, pendingConfirm: null });
|
|
1787
|
+
this.requestDeleteSelectedComments('k3');
|
|
1788
|
+
assert('requestDeleteSelectedComments opens the shared confirm modal instead of deleting immediately', !!this.state.pendingConfirm && this.state.comments['k3'].length === 2);
|
|
1789
|
+
this.confirmPendingYes();
|
|
1790
|
+
assert('confirmPendingYes actually deletes after confirmation', this.state.comments['k3'].length === 1 && this.state.comments['k3'][0].text === 'keep' && this.state.pendingConfirm === null);
|
|
1791
|
+
this.setState({ comments: savedComments, selectedComments: savedSelectedComments, pendingConfirm: null });
|
|
1792
|
+
|
|
1793
|
+
// RED/GREEN for bulk history clear requiring confirmation.
|
|
1794
|
+
const h3 = { ts: 3, reviewer: 'c', action: 'approved', label: 'Slide 3', note: '' };
|
|
1795
|
+
const h4 = { ts: 4, reviewer: 'd', action: 'rejected', label: 'Slide 4', note: '' };
|
|
1796
|
+
this.setState({ history: [h3, h4], selectedHistory: { [this.historySig(h3)]: true }, pendingConfirm: null });
|
|
1797
|
+
this.requestClearSelectedHistory();
|
|
1798
|
+
assert('requestClearSelectedHistory opens the shared confirm modal instead of clearing immediately', !!this.state.pendingConfirm && this.state.history.length === 2);
|
|
1799
|
+
this.confirmPendingYes();
|
|
1800
|
+
assert('confirmPendingYes actually clears after confirmation', this.state.history.length === 1 && this.state.history[0].label === 'Slide 4' && this.state.pendingConfirm === null);
|
|
1801
|
+
this.setState({ history: savedHistory, selectedHistory: savedSelectedHistory, pendingConfirm: null });
|
|
1802
|
+
|
|
1803
|
+
// RED/GREEN for reply delete capability.
|
|
1804
|
+
const savedComments2 = this.state.comments;
|
|
1805
|
+
this.setState({ comments: { 'k4': [{ author: 'a', text: 'root', ts: 1, replies: [{ author: 'x', text: 'reply one', ts: 2 }, { author: 'y', text: 'reply two', ts: 3 }] }] } });
|
|
1806
|
+
this.deleteReply('k4', 0, 0);
|
|
1807
|
+
assert('deleteReply removes exactly the targeted reply, keeping the others', this.state.comments['k4'][0].replies.length === 1 && this.state.comments['k4'][0].replies[0].text === 'reply two');
|
|
1808
|
+
this.setState({ comments: savedComments2 });
|
|
1809
|
+
|
|
1810
|
+
// RED/GREEN for single-comment delete requiring confirmation via the shared modal.
|
|
1811
|
+
this.setState({ comments: { 'k5': [{ author: 'a', text: 'only one', ts: 1 }] }, pendingConfirm: null });
|
|
1812
|
+
this.requestDeleteComment('k5', 0);
|
|
1813
|
+
assert('requestDeleteComment opens the shared confirm modal instead of deleting immediately', !!this.state.pendingConfirm && this.state.comments['k5'].length === 1);
|
|
1814
|
+
this.confirmPendingYes();
|
|
1815
|
+
assert('confirmPendingYes actually deletes after confirmation', this.state.comments['k5'].length === 0 && this.state.pendingConfirm === null);
|
|
1816
|
+
this.setState({ comments: savedComments2, pendingConfirm: null });
|
|
1817
|
+
|
|
1818
|
+
// RED/GREEN for batch-file removal requiring confirmation via the shared modal.
|
|
1819
|
+
const savedBatchFilesA2 = this.state.batchFilesA;
|
|
1820
|
+
this.setState({ batchFilesA: [{ name: 'one.pptx' }, { name: 'two.pptx' }], pendingConfirm: null });
|
|
1821
|
+
this.requestRemoveBatchFile('A', 0, null);
|
|
1822
|
+
assert('requestRemoveBatchFile opens the shared confirm modal instead of removing immediately', !!this.state.pendingConfirm && this.state.batchFilesA.length === 2);
|
|
1823
|
+
this.confirmPendingYes();
|
|
1824
|
+
assert('confirmPendingYes actually removes after confirmation', this.state.batchFilesA.length === 1 && this.state.batchFilesA[0].name === 'two.pptx' && this.state.pendingConfirm === null);
|
|
1825
|
+
this.setState({ batchFilesA: savedBatchFilesA2, pendingConfirm: null });
|
|
1826
|
+
|
|
1827
|
+
// RED/GREEN for the shared confirm-config builder (pure — one per destructive-action kind).
|
|
1828
|
+
assert('buildConfirmConfig("reviewers") mentions the count and says Remove', this.buildConfirmConfig('reviewers', { n: 3 }).title.includes('3') && this.buildConfirmConfig('reviewers', { n: 3 }).confirmLabel === 'Remove');
|
|
1829
|
+
assert('buildConfirmConfig("singleComment") has fixed copy with no count', this.buildConfirmConfig('singleComment').title === 'Delete this comment?');
|
|
1830
|
+
assert('buildConfirmConfig("decisions") includes the given scope label', this.buildConfirmConfig('decisions', { scopeLabel: 'only my votes' }).title.includes('only my votes'));
|
|
1831
|
+
|
|
1832
|
+
// RED/GREEN for "clear decisions" scoping: all / mine / specific reviewer / slide range.
|
|
1833
|
+
const savedDecisions2 = this.state.decisions;
|
|
1834
|
+
const decisionsFixture = { 'k_a': { alice: 'approved', bob: 'rejected' }, 'k_b': { alice: 'approved' }, 'k_c': { bob: 'approved' } };
|
|
1835
|
+
const clearedAll = this.computeDecisionsToClear(decisionsFixture, 'all', {});
|
|
1836
|
+
assert('computeDecisionsToClear("all") wipes every key', Object.keys(clearedAll).length === 0);
|
|
1837
|
+
const clearedMine = this.computeDecisionsToClear(decisionsFixture, 'mine', { activeHandle: 'alice' });
|
|
1838
|
+
assert('computeDecisionsToClear("mine") removes only the active reviewer\'s votes, keeping others', !('alice' in clearedMine['k_a']) && clearedMine['k_a'].bob === 'rejected' && !('alice' in clearedMine['k_b']));
|
|
1839
|
+
const clearedReviewer = this.computeDecisionsToClear(decisionsFixture, 'reviewer', { reviewerHandle: 'bob' });
|
|
1840
|
+
assert('computeDecisionsToClear("reviewer") removes only the specified reviewer\'s votes', !('bob' in clearedReviewer['k_a']) && clearedReviewer['k_a'].alice === 'approved' && clearedReviewer['k_c'].bob === undefined);
|
|
1841
|
+
const clearedRange = this.computeDecisionsToClear(decisionsFixture, 'range', { from: 2, to: 3, rowKeys: ['k_a', 'k_b', 'k_c'] });
|
|
1842
|
+
assert('computeDecisionsToClear("range") clears every reviewer\'s votes but only for keys in the row range', !('k_b' in clearedRange) && !('k_c' in clearedRange) && 'k_a' in clearedRange);
|
|
1843
|
+
|
|
1844
|
+
// RED/GREEN: requestClearAllDecisions opens the shared modal (scoped by clearDecisionsScope) rather than clearing immediately.
|
|
1845
|
+
this.setState({ decisions: { 'a0:b0': { alice: 'approved' }, 'a1:b1': { bob: 'rejected' } }, clearDecisionsScope: 'all', pendingConfirm: null });
|
|
1846
|
+
this.requestClearAllDecisions();
|
|
1847
|
+
assert('requestClearAllDecisions opens the shared confirm modal instead of clearing immediately', !!this.state.pendingConfirm && Object.keys(this.state.decisions).length === 2);
|
|
1848
|
+
this.confirmPendingYes();
|
|
1849
|
+
assert('confirmPendingYes wipes every decision after confirmation (scope=all)', Object.keys(this.state.decisions).length === 0 && this.state.pendingConfirm === null);
|
|
1850
|
+
this.setState({ decisions: savedDecisions2, pendingConfirm: null, clearDecisionsScope: 'all' });
|
|
1851
|
+
|
|
1852
|
+
return tests;
|
|
1853
|
+
}
|
|
1854
|
+
async triggerSelfTests() {
|
|
1855
|
+
let results;
|
|
1856
|
+
try { results = await this.runSelfTests(); } catch (e) { results = [{ name: 'Self-test runner', pass: false, detail: e.message }]; }
|
|
1857
|
+
this.setState({ selfTestResults: results, showSelfTests: true });
|
|
1858
|
+
}
|
|
1859
|
+
/* Pure: the extra status tags (beyond the primary IDENTICAL/CHANGED/ADDED/DELETED) for one
|
|
1860
|
+
aligned pair — MOVED when its relative order changed (see markMovedByLIS), DUPLICATE when
|
|
1861
|
+
either side's slide is part of a near-identical group within its OWN deck (the "4th alignment
|
|
1862
|
+
state": a slide can be added/removed/moved/modified AND ALSO be a duplicate of another slide
|
|
1863
|
+
in the same deck — these are independent facts, so DUPLICATE is layered on top rather than
|
|
1864
|
+
replacing the primary tag). Returns [{label,bg}] for direct template rendering. */
|
|
1865
|
+
computeExtraTags(al, dupIndicesA, dupIndicesB) {
|
|
1866
|
+
const tags = [];
|
|
1867
|
+
if (al.moved) tags.push({ label: 'MOVED', bg: '#7A5BD6' });
|
|
1868
|
+
if ((al.aIdx != null && dupIndicesA.has(al.aIdx)) || (al.bIdx != null && dupIndicesB.has(al.bIdx))) tags.push({ label: 'DUPLICATE', bg: '#0E7C86' });
|
|
1869
|
+
return tags;
|
|
1870
|
+
}
|
|
1871
|
+
CRED_FIELD_NAMES = ['notionToken', 'notionPageId', 'confluenceBaseUrl', 'confluenceEmail', 'confluenceToken', 'confluencePageId', 'slackWebhookUrl'];
|
|
1872
|
+
/* Pure, reused by all three "select all / select none" features (credential fields, batch file
|
|
1873
|
+
rows, reviewer list) — one generic pair of helpers instead of three near-duplicate ones. */
|
|
1874
|
+
buildSelectAllMap(keys) { const m = {}; keys.forEach(k => { m[k] = true; }); return m; }
|
|
1875
|
+
buildSelectNoneMap() { return {}; }
|
|
1876
|
+
/* Slides within ONE deck that are near-identical to each other (likely duplicated). */
|
|
1877
|
+
findDuplicateSlides(slides) {
|
|
1878
|
+
const groups = [];
|
|
1879
|
+
const seen = new Set();
|
|
1880
|
+
for (let i = 0; i < slides.length; i++) {
|
|
1881
|
+
if (seen.has(i)) continue;
|
|
1882
|
+
const group = [i];
|
|
1883
|
+
for (let j = i + 1; j < slides.length; j++) {
|
|
1884
|
+
if (seen.has(j)) continue;
|
|
1885
|
+
if (this.simSlides(slides[i], slides[j]) > 0.92) { group.push(j); seen.add(j); }
|
|
1886
|
+
}
|
|
1887
|
+
if (group.length > 1) { groups.push(group); seen.add(i); }
|
|
1888
|
+
}
|
|
1889
|
+
return groups;
|
|
1890
|
+
}
|
|
1891
|
+
/* Cross-deck duplicate detection: a Before slide that closely resembles a DIFFERENT After slide
|
|
1892
|
+
it isn't aligned to (i.e. not each other's matched pair) — distinct from same-deck
|
|
1893
|
+
findDuplicateSlides (which looks within one deck) and from ordinary alignment/move detection
|
|
1894
|
+
(which is about a slide's OWN matched partner). `alignment` is the current aligned-pairs list
|
|
1895
|
+
(from refineMoves/alignSlides) so already-matched pairs are excluded from the result.
|
|
1896
|
+
`threshold` (default 0.92) lets a caller loosen/tighten what counts as "near", so a user can
|
|
1897
|
+
dismiss over-eager near-matches by raising it. `maxPairs` (default 6000 comparisons) is a
|
|
1898
|
+
PERFORMANCE GUARD for large decks: this is an O(|slidesA|*|slidesB|) scan (each comparison
|
|
1899
|
+
itself does word-set work), which is fine for typical decks (tens to low hundreds of slides)
|
|
1900
|
+
but would be a real cost on a many-hundred-slide deck. Past the cap, returns
|
|
1901
|
+
{pairs:[], skipped:true, totalComparisons} instead of silently freezing the UI; the caller can
|
|
1902
|
+
re-run with `force:true` to opt into the full scan anyway. */
|
|
1903
|
+
findCrossDeckDuplicates(slidesA, slidesB, alignment, threshold, maxPairs, force) {
|
|
1904
|
+
const thr = threshold == null ? 0.92 : threshold;
|
|
1905
|
+
const cap = maxPairs == null ? 6000 : maxPairs;
|
|
1906
|
+
const total = slidesA.length * slidesB.length;
|
|
1907
|
+
if (total > cap && !force) return { pairs: [], skipped: true, totalComparisons: total };
|
|
1908
|
+
const alignedSet = new Set(alignment.filter(al => al.aIdx != null && al.bIdx != null).map(al => al.aIdx + ':' + al.bIdx));
|
|
1909
|
+
const out = [];
|
|
1910
|
+
for (let i = 0; i < slidesA.length; i++) {
|
|
1911
|
+
for (let j = 0; j < slidesB.length; j++) {
|
|
1912
|
+
if (alignedSet.has(i + ':' + j)) continue;
|
|
1913
|
+
const sim = this.simSlides(slidesA[i], slidesB[j]);
|
|
1914
|
+
if (sim > thr) out.push({ aIdx: i, bIdx: j, sim });
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
return { pairs: out, skipped: false, totalComparisons: total };
|
|
1918
|
+
}
|
|
1919
|
+
/* Pure: which section (if any) a slide index belongs to, given the deck's parsed section list
|
|
1920
|
+
(see parseDeckMeta's sectionLst parsing). Returns the section name or null. */
|
|
1921
|
+
sectionForSlideIndex(sections, idx) {
|
|
1922
|
+
if (!sections || !sections.length) return null;
|
|
1923
|
+
const hit = sections.find(s => (s.slideIndices || []).includes(idx));
|
|
1924
|
+
return hit ? hit.name : null;
|
|
1925
|
+
}
|
|
1926
|
+
simSlides(a, b) {
|
|
1927
|
+
const wa = this.slideWords(a), wb = this.slideWords(b);
|
|
1928
|
+
if (!wa.size && !wb.size) return 1;
|
|
1929
|
+
let inter = 0; wa.forEach(w => { if (wb.has(w)) inter++; });
|
|
1930
|
+
const uni = new Set([...wa, ...wb]).size;
|
|
1931
|
+
return uni ? inter / uni : 0;
|
|
1932
|
+
}
|
|
1933
|
+
alignSlides(A, B) {
|
|
1934
|
+
const n = A.length, m = B.length;
|
|
1935
|
+
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
1936
|
+
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) {
|
|
1937
|
+
const s = this.simSlides(A[i], B[j]);
|
|
1938
|
+
dp[i][j] = Math.max(dp[i + 1][j + 1] + s, dp[i + 1][j], dp[i][j + 1]);
|
|
1939
|
+
}
|
|
1940
|
+
const out = []; let i = 0, j = 0;
|
|
1941
|
+
while (i < n && j < m) {
|
|
1942
|
+
const s = this.simSlides(A[i], B[j]);
|
|
1943
|
+
if (Math.abs(dp[i][j] - (dp[i + 1][j + 1] + s)) < 1e-9 && s > 0.18) { out.push({ aIdx: i, bIdx: j }); i++; j++; }
|
|
1944
|
+
else if (Math.abs(dp[i][j] - dp[i + 1][j]) < 1e-9) { out.push({ aIdx: i, bIdx: null }); i++; }
|
|
1945
|
+
else { out.push({ aIdx: null, bIdx: j }); j++; }
|
|
1946
|
+
}
|
|
1947
|
+
while (i < n) { out.push({ aIdx: i, bIdx: null }); i++; }
|
|
1948
|
+
while (j < m) { out.push({ aIdx: null, bIdx: j }); j++; }
|
|
1949
|
+
return out;
|
|
1950
|
+
}
|
|
1951
|
+
/* Detect slides that moved (removed+added pair with high content similarity) vs true add/remove. */
|
|
1952
|
+
refineMoves(alignment, A, B) {
|
|
1953
|
+
const removedIdx = alignment.filter(e => e.aIdx != null && e.bIdx == null).map(e => e.aIdx);
|
|
1954
|
+
const addedIdx = alignment.filter(e => e.bIdx != null && e.aIdx == null).map(e => e.bIdx);
|
|
1955
|
+
const matched = alignment.filter(e => e.aIdx != null && e.bIdx != null).map(e => ({ aIdx: e.aIdx, bIdx: e.bIdx }));
|
|
1956
|
+
const out = matched.slice();
|
|
1957
|
+
if (removedIdx.length && addedIdx.length) {
|
|
1958
|
+
// Order-preserving second pass over just the leftovers: a whole reordered SECTION (several
|
|
1959
|
+
// adjacent slides moved together) still appears in the same relative order within the
|
|
1960
|
+
// leftovers on both sides, so re-running LCS-style alignment here (instead of greedy
|
|
1961
|
+
// nearest-neighbor) links section members pairwise in sequence instead of crossing them.
|
|
1962
|
+
const subA = removedIdx.map(i => A[i]), subB = addedIdx.map(j => B[j]);
|
|
1963
|
+
const subAlignment = this.alignSlides(subA, subB);
|
|
1964
|
+
subAlignment.forEach(se => {
|
|
1965
|
+
if (se.aIdx != null && se.bIdx != null) {
|
|
1966
|
+
const sim = this.simSlides(subA[se.aIdx], subB[se.bIdx]);
|
|
1967
|
+
if (sim > 0.5) out.push({ aIdx: removedIdx[se.aIdx], bIdx: addedIdx[se.bIdx] });
|
|
1968
|
+
else { out.push({ aIdx: removedIdx[se.aIdx], bIdx: null }); out.push({ aIdx: null, bIdx: addedIdx[se.bIdx] }); }
|
|
1969
|
+
} else if (se.aIdx != null) out.push({ aIdx: removedIdx[se.aIdx], bIdx: null });
|
|
1970
|
+
else if (se.bIdx != null) out.push({ aIdx: null, bIdx: addedIdx[se.bIdx] });
|
|
1971
|
+
});
|
|
1972
|
+
} else {
|
|
1973
|
+
removedIdx.forEach(i => out.push({ aIdx: i, bIdx: null }));
|
|
1974
|
+
addedIdx.forEach(j => out.push({ aIdx: null, bIdx: j }));
|
|
1975
|
+
}
|
|
1976
|
+
// "Moved" is a GLOBAL property, not a local one: a matched pair only counts as moved if its
|
|
1977
|
+
// relative order (aIdx vs bIdx) is inconsistent with the other matched pairs — i.e. it is not
|
|
1978
|
+
// part of the Longest Increasing Subsequence (by bIdx, sorted by aIdx) across ALL matched pairs.
|
|
1979
|
+
// This is what makes "slide N -> N" correctly NOT moved even when it was found via the leftover
|
|
1980
|
+
// pass, and correctly flags true reorders regardless of which pass discovered the match.
|
|
1981
|
+
this.markMovedByLIS(out);
|
|
1982
|
+
out.sort((x, y) => { const xi = x.bIdx != null ? x.bIdx : (x.aIdx != null ? x.aIdx + 0.5 : 1e9); const yi = y.bIdx != null ? y.bIdx : (y.aIdx != null ? y.aIdx + 0.5 : 1e9); return xi - yi; });
|
|
1983
|
+
return out;
|
|
1984
|
+
}
|
|
1985
|
+
/* Mark p.moved=true on every matched pair (aIdx,bIdx both non-null) NOT part of the longest
|
|
1986
|
+
increasing subsequence of bIdx values (sorted by aIdx) — i.e. slides whose relative order
|
|
1987
|
+
changed between decks. Patience-sorting LIS, O(n log n). */
|
|
1988
|
+
markMovedByLIS(pairs) {
|
|
1989
|
+
const withBoth = pairs.filter(p => p.aIdx != null && p.bIdx != null).sort((a, b) => a.aIdx - b.aIdx);
|
|
1990
|
+
const n = withBoth.length;
|
|
1991
|
+
// O(n^2) DP (n = slide count, always small): length[i] = longest increasing-by-bIdx chain
|
|
1992
|
+
// ending at i; selfCount[i] = how many same-position (aIdx===bIdx) pairs that chain contains.
|
|
1993
|
+
// Tie-break on selfCount so a same-position slide is never arbitrarily bumped to "moved" just
|
|
1994
|
+
// because it ties in LIS length with a genuinely-moved slide.
|
|
1995
|
+
const length = new Array(n).fill(1), selfCount = new Array(n).fill(0), prev = new Array(n).fill(-1);
|
|
1996
|
+
for (let i = 0; i < n; i++) selfCount[i] = withBoth[i].aIdx === withBoth[i].bIdx ? 1 : 0;
|
|
1997
|
+
for (let i = 0; i < n; i++) {
|
|
1998
|
+
for (let j = 0; j < i; j++) {
|
|
1999
|
+
if (withBoth[j].bIdx < withBoth[i].bIdx) {
|
|
2000
|
+
const candLen = length[j] + 1;
|
|
2001
|
+
const candSelf = selfCount[j] + (withBoth[i].aIdx === withBoth[i].bIdx ? 1 : 0);
|
|
2002
|
+
if (candLen > length[i] || (candLen === length[i] && candSelf > selfCount[i])) {
|
|
2003
|
+
length[i] = candLen; selfCount[i] = candSelf; prev[i] = j;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
let bestI = -1;
|
|
2009
|
+
for (let i = 0; i < n; i++) {
|
|
2010
|
+
if (bestI === -1 || length[i] > length[bestI] || (length[i] === length[bestI] && selfCount[i] > selfCount[bestI])) bestI = i;
|
|
2011
|
+
}
|
|
2012
|
+
const lisSet = new Set();
|
|
2013
|
+
let k = bestI;
|
|
2014
|
+
while (k !== -1) { lisSet.add(k); k = prev[k]; }
|
|
2015
|
+
withBoth.forEach((p, i) => { p.moved = !lisSet.has(i); });
|
|
2016
|
+
}
|
|
2017
|
+
/* ---------- per-diff decisions & merge choices ---------- */
|
|
2018
|
+
setDiffDecision(key, val, ev) {
|
|
2019
|
+
if (ev) ev.stopPropagation();
|
|
2020
|
+
const handle = this.state.activeHandle || 'anonymous';
|
|
2021
|
+
this.setState(s => {
|
|
2022
|
+
const cur = (s.diffDecisions[key] || {})[handle];
|
|
2023
|
+
const next = cur === val ? undefined : val;
|
|
2024
|
+
const perDiff = Object.assign({}, s.diffDecisions[key]);
|
|
2025
|
+
if (next) perDiff[handle] = next; else delete perDiff[handle];
|
|
2026
|
+
return { diffDecisions: Object.assign({}, s.diffDecisions, { [key]: perDiff }) };
|
|
2027
|
+
});
|
|
2028
|
+
setTimeout(() => this.persistState(), 0);
|
|
2029
|
+
}
|
|
2030
|
+
setMergeChoice(key, choice, ev) {
|
|
2031
|
+
if (ev) ev.stopPropagation();
|
|
2032
|
+
this.setState(s => ({ mergeChoices: Object.assign({}, s.mergeChoices, { [key]: s.mergeChoices[key] === choice ? undefined : choice }) }));
|
|
2033
|
+
setTimeout(() => this.persistState(), 0);
|
|
2034
|
+
}
|
|
2035
|
+
exportMergePlan() {
|
|
2036
|
+
const st = this.state;
|
|
2037
|
+
const rows = this.buildReportRows();
|
|
2038
|
+
const plan = [];
|
|
2039
|
+
rows.forEach(r => r.diffs.forEach((d) => {
|
|
2040
|
+
const key = r.key + '::' + d.key;
|
|
2041
|
+
plan.push({ slide: r.label, type: d.type, before: d.oldText, after: d.newText, choice: st.mergeChoices[key] || 'undecided' });
|
|
2042
|
+
}));
|
|
2043
|
+
this.downloadBlob('merge-plan.json', JSON.stringify(plan, null, 2), 'application/json');
|
|
2044
|
+
}
|
|
2045
|
+
downloadBinary(filename, arrayBuffer, mime) {
|
|
2046
|
+
const blob = new Blob([arrayBuffer], { type: mime });
|
|
2047
|
+
const url = URL.createObjectURL(blob);
|
|
2048
|
+
const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove();
|
|
2049
|
+
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
|
2050
|
+
}
|
|
2051
|
+
/* Convert a PARSED slide (from our own reader) back into the shape buildPptx expects, so a
|
|
2052
|
+
merge decision can produce a real, valid, downloadable .pptx. Images/charts/SmartArt/media
|
|
2053
|
+
are not reconstructed (we only kept a content hash/target for those, not bytes to re-embed) —
|
|
2054
|
+
text, tables, background, notes, and transition ARE carried over since our parser and writer
|
|
2055
|
+
share the same field shapes. */
|
|
2056
|
+
slideToBuildSpec(slide) {
|
|
2057
|
+
return {
|
|
2058
|
+
w: slide.w, h: slide.h,
|
|
2059
|
+
bg: slide.background && slide.background.color ? slide.background.color : undefined,
|
|
2060
|
+
transition: slide.transition && slide.transition.type !== 'None' ? slide.transition : undefined,
|
|
2061
|
+
notes: slide.notes && slide.notes.text ? slide.notes : undefined,
|
|
2062
|
+
shapes: (slide.shapes || []).map(s => ({
|
|
2063
|
+
name: s.name, x: s.x, y: s.y, cx: s.cx, cy: s.cy,
|
|
2064
|
+
font: s.font, size: s.size, bold: s.bold, italic: s.italic, color: s.color, align: s.align,
|
|
2065
|
+
paras: s.paras, wrap: s.wrap, border: s.border, link: (s.links && s.links[0]) || undefined
|
|
2066
|
+
})),
|
|
2067
|
+
tables: (slide.tables || []).map(t => ({ name: t.name, x: t.x, y: t.y, cx: t.cx, cy: t.cy, rows: t.rows }))
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
/* Per-slide merge winner: majority of that slide's individually-chosen Keep-before/Keep-after
|
|
2071
|
+
picks decides which side's ORIGINAL slide is used whole; ties/no picks default to After. */
|
|
2072
|
+
pickMergeWinner(pa, pb, key) {
|
|
2073
|
+
const override = this.state.mergeWinnerOverrides[key];
|
|
2074
|
+
if (override === 'before') return pa;
|
|
2075
|
+
if (override === 'after') return pb;
|
|
2076
|
+
const cmp = this.compare(pa, pb);
|
|
2077
|
+
let nBefore = 0, nAfter = 0;
|
|
2078
|
+
cmp.diffs.forEach(d => {
|
|
2079
|
+
const choice = this.state.mergeChoices[key + '::' + d.key];
|
|
2080
|
+
if (choice === 'before') nBefore++; else if (choice === 'after') nAfter++;
|
|
2081
|
+
});
|
|
2082
|
+
return nBefore > nAfter ? pa : pb;
|
|
2083
|
+
}
|
|
2084
|
+
setMergeWinnerOverride(key, side, ev) {
|
|
2085
|
+
if (ev) ev.stopPropagation();
|
|
2086
|
+
this.setState(s => ({ mergeWinnerOverrides: Object.assign({}, s.mergeWinnerOverrides, { [key]: s.mergeWinnerOverrides[key] === side ? undefined : side }) }));
|
|
2087
|
+
setTimeout(() => this.persistState(), 0);
|
|
2088
|
+
}
|
|
2089
|
+
async exportMergedPptx() {
|
|
2090
|
+
if (!this.buildPptx) { this.showToast('Renderer still loading — try again in a moment'); return; }
|
|
2091
|
+
const st = this.state;
|
|
2092
|
+
const alignment = this.refineMoves(this.alignSlides(st.slidesA, st.slidesB), st.slidesA, st.slidesB);
|
|
2093
|
+
const crossDupPairs = st.mergeDuplicateSlides ? this.findCrossDeckDuplicates(st.slidesA, st.slidesB, alignment, st.crossDupThreshold, undefined, true).pairs.filter(d => !st.ignoredCrossDupKeys[d.aIdx + ':' + d.bIdx]) : [];
|
|
2094
|
+
const mergedSlides = [];
|
|
2095
|
+
alignment.forEach(al => {
|
|
2096
|
+
const pa = al.aIdx != null ? st.slidesA[al.aIdx] : null, pb = al.bIdx != null ? st.slidesB[al.bIdx] : null;
|
|
2097
|
+
if (pa && !pb && crossDupPairs.some(d => d.aIdx === al.aIdx)) return; // skip: duplicate of an After slide already included
|
|
2098
|
+
const winner = pa && pb ? this.pickMergeWinner(pa, pb, this.pairKey(al.aIdx, al.bIdx)) : (pb || pa);
|
|
2099
|
+
if (winner) mergedSlides.push(this.slideToBuildSpec(winner));
|
|
2100
|
+
});
|
|
2101
|
+
if (!mergedSlides.length) { this.showToast('Nothing to merge'); return; }
|
|
2102
|
+
const base = st.slidesB[0] || st.slidesA[0] || { w: 12192000, h: 6858000 };
|
|
2103
|
+
try {
|
|
2104
|
+
const buf = await this.buildPptx(mergedSlides, base.w, base.h);
|
|
2105
|
+
this.downloadBinary('merged-deck.pptx', buf, 'application/vnd.openxmlformats-officedocument.presentationml.presentation');
|
|
2106
|
+
this.showToast('Merged .pptx downloaded (text/tables/background/notes — no images/charts)');
|
|
2107
|
+
} catch (e) { this.showToast('Merge export failed: ' + e.message); }
|
|
2108
|
+
}
|
|
2109
|
+
/* ---------- transition preview ---------- */
|
|
2110
|
+
playTransitionPreview() {
|
|
2111
|
+
if (this.state.previewPlaying) return;
|
|
2112
|
+
this.setState({ previewPlaying: true });
|
|
2113
|
+
setTimeout(() => this.setState({ previewPlaying: false }), 1300);
|
|
2114
|
+
}
|
|
2115
|
+
/* ---------- toast ---------- */
|
|
2116
|
+
showToast(msg) {
|
|
2117
|
+
this.setState({ toastMsg: msg });
|
|
2118
|
+
clearTimeout(this._toastT);
|
|
2119
|
+
this._toastT = setTimeout(() => this.setState({ toastMsg: '' }), 2600);
|
|
2120
|
+
}
|
|
2121
|
+
/* ---------- share / slack / email exports ---------- */
|
|
2122
|
+
buildPlainSummary() {
|
|
2123
|
+
const rows = this.buildReportRows();
|
|
2124
|
+
const changed = rows.filter(r => r.diffs.length > 0).length;
|
|
2125
|
+
const lines = [
|
|
2126
|
+
'Slide Diff report: ' + this.state.nameA + ' vs ' + this.state.nameB,
|
|
2127
|
+
rows.length + ' slide pair(s), ' + changed + ' with differences.', ''
|
|
2128
|
+
];
|
|
2129
|
+
rows.forEach(r => { lines.push('• ' + r.label + ' — ' + (r.diffs.length ? r.diffs.length + ' change(s) [' + r.decision + ']' : 'identical')); });
|
|
2130
|
+
return lines.join('\n');
|
|
2131
|
+
}
|
|
2132
|
+
async exportSlackMessage() {
|
|
2133
|
+
const text = this.buildPlainSummary();
|
|
2134
|
+
try { await navigator.clipboard.writeText(text); this.showToast('Slack-formatted summary copied to clipboard'); }
|
|
2135
|
+
catch (e) { this.downloadBlob('slack-summary.txt', text, 'text/plain'); }
|
|
2136
|
+
}
|
|
2137
|
+
exportEmailSummary() {
|
|
2138
|
+
const subject = encodeURIComponent('Slide Diff report: ' + this.state.nameA + ' vs ' + this.state.nameB);
|
|
2139
|
+
const body = encodeURIComponent(this.buildPlainSummary());
|
|
2140
|
+
window.open('mailto:?subject=' + subject + '&body=' + body, '_blank');
|
|
2141
|
+
}
|
|
2142
|
+
async exportShareLink() {
|
|
2143
|
+
const rows = this.buildReportRows();
|
|
2144
|
+
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
2145
|
+
const body = rows.map(r => `<section style="margin:0 0 24px;padding-bottom:16px;border-bottom:1px solid #ddd"><h3>${esc(r.label)} — ${esc(r.decision)}</h3>${r.diffs.length ? '<ul>' + r.diffs.map(d => `<li><strong>${esc(d.type)}:</strong> ${esc(d.oldText)} → ${esc(d.newText || '')}</li>`).join('') + '</ul>' : '<p>Identical</p>'}</section>`).join('');
|
|
2146
|
+
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Slide Diff Report</title></head><body style="font-family:sans-serif;max-width:800px;margin:40px auto"><h1>Slide Diff Report</h1><p>${esc(this.state.nameA)} → ${esc(this.state.nameB)}</p>${body}</body></html>`;
|
|
2147
|
+
const url = 'data:text/html;base64,' + btoa(unescape(encodeURIComponent(html)));
|
|
2148
|
+
try { await navigator.clipboard.writeText(url); this.showToast('Shareable link copied (self-contained; paste into a browser)'); }
|
|
2149
|
+
catch (e) { this.showToast('Could not copy — export HTML instead'); }
|
|
2150
|
+
}
|
|
2151
|
+
/* ---------- batch upload ---------- */
|
|
2152
|
+
/* Fuzzy filename matching for batch pairing: normalize, then bigram Dice coefficient. */
|
|
2153
|
+
normalizeFilename(name) { return String(name || '').replace(/\.[^.]+$/, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); }
|
|
2154
|
+
bigrams(s) { const out = []; for (let i = 0; i < s.length - 1; i++) out.push(s.slice(i, i + 2)); return out; }
|
|
2155
|
+
filenameSim(a, b) {
|
|
2156
|
+
const ba = this.bigrams(this.normalizeFilename(a)), bb = this.bigrams(this.normalizeFilename(b));
|
|
2157
|
+
if (!ba.length || !bb.length) return this.normalizeFilename(a) === this.normalizeFilename(b) ? 1 : 0;
|
|
2158
|
+
const counts = {}; ba.forEach(g => { counts[g] = (counts[g] || 0) + 1; });
|
|
2159
|
+
let inter = 0;
|
|
2160
|
+
bb.forEach(g => { if (counts[g] > 0) { inter++; counts[g]--; } });
|
|
2161
|
+
return (2 * inter) / (ba.length + bb.length);
|
|
2162
|
+
}
|
|
2163
|
+
pairFilesByName(filesA, filesB) {
|
|
2164
|
+
const usedB = new Set();
|
|
2165
|
+
const pairs = filesA.map((fa, ai) => {
|
|
2166
|
+
let best = -1, bestSim = 0.3;
|
|
2167
|
+
filesB.forEach((fb, bi) => { if (usedB.has(bi)) return; const s = this.filenameSim(fa.name, fb.name); if (s > bestSim) { bestSim = s; best = bi; } });
|
|
2168
|
+
if (best >= 0) { usedB.add(best); return { fa, fb: filesB[best] }; }
|
|
2169
|
+
return { fa, fb: null };
|
|
2170
|
+
});
|
|
2171
|
+
filesB.forEach((fb, bi) => { if (!usedB.has(bi)) pairs.push({ fa: null, fb }); });
|
|
2172
|
+
return pairs;
|
|
2173
|
+
}
|
|
2174
|
+
onBatchFiles(side, ev) {
|
|
2175
|
+
const files = Array.from(ev.target.files || []);
|
|
2176
|
+
this.setState({ [side === 'A' ? 'batchFilesA' : 'batchFilesB']: files });
|
|
2177
|
+
}
|
|
2178
|
+
/* Drag-to-reorder batch file lists (order matters for the default "upload order" pairing mode). */
|
|
2179
|
+
onBatchDragStart(side, idx, ev) { ev.dataTransfer.effectAllowed = 'move'; ev.dataTransfer.setData('text/plain', side + ':' + idx); }
|
|
2180
|
+
onBatchDragOver(ev) { ev.preventDefault(); ev.dataTransfer.dropEffect = 'move'; }
|
|
2181
|
+
onBatchDrop(side, toIdx, ev) {
|
|
2182
|
+
ev.preventDefault();
|
|
2183
|
+
const data = ev.dataTransfer.getData('text/plain');
|
|
2184
|
+
const [fromSide, fromIdxStr] = data.split(':');
|
|
2185
|
+
if (fromSide !== side) return;
|
|
2186
|
+
const fromIdx = +fromIdxStr;
|
|
2187
|
+
if (fromIdx === toIdx) return;
|
|
2188
|
+
const key = side === 'A' ? 'batchFilesA' : 'batchFilesB';
|
|
2189
|
+
this.setState(s => {
|
|
2190
|
+
const files = s[key].slice();
|
|
2191
|
+
const [moved] = files.splice(fromIdx, 1);
|
|
2192
|
+
files.splice(toIdx, 0, moved);
|
|
2193
|
+
return { [key]: files };
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2196
|
+
/* Single batch-file removal is confirmation-gated (this session) — mirrors the request/cancel/
|
|
2197
|
+
confirmYes triad used elsewhere; batch files are easy to re-add by re-choosing them, but the
|
|
2198
|
+
drag-reordered position would be lost, so a confirm step avoids accidental mis-clicks. */
|
|
2199
|
+
removeBatchFile(side, idx, ev) {
|
|
2200
|
+
if (ev) ev.stopPropagation();
|
|
2201
|
+
const key = side === 'A' ? 'batchFilesA' : 'batchFilesB';
|
|
2202
|
+
this.setState(s => ({ [key]: s[key].filter((_, i) => i !== idx) }));
|
|
2203
|
+
}
|
|
2204
|
+
requestRemoveBatchFile(side, idx, ev) {
|
|
2205
|
+
if (ev) ev.stopPropagation();
|
|
2206
|
+
this.openConfirm(this.buildConfirmConfig('batchFile'), () => this.removeBatchFile(side, idx));
|
|
2207
|
+
}
|
|
2208
|
+
toggleBatchFileSelect(side, idx, ev) {
|
|
2209
|
+
if (ev) ev.stopPropagation();
|
|
2210
|
+
const key = side === 'A' ? 'selectedBatchFilesA' : 'selectedBatchFilesB';
|
|
2211
|
+
this.setState(s => ({ [key]: Object.assign({}, s[key], { [idx]: !s[key][idx] }) }));
|
|
2212
|
+
}
|
|
2213
|
+
selectAllBatchFiles(side) {
|
|
2214
|
+
const files = side === 'A' ? this.state.batchFilesA : this.state.batchFilesB;
|
|
2215
|
+
const key = side === 'A' ? 'selectedBatchFilesA' : 'selectedBatchFilesB';
|
|
2216
|
+
this.setState({ [key]: this.buildSelectAllMap(files.map((_, i) => i)) });
|
|
2217
|
+
}
|
|
2218
|
+
selectNoneBatchFiles(side) {
|
|
2219
|
+
const key = side === 'A' ? 'selectedBatchFilesA' : 'selectedBatchFilesB';
|
|
2220
|
+
this.setState({ [key]: this.buildSelectNoneMap() });
|
|
2221
|
+
}
|
|
2222
|
+
removeSelectedBatchFiles(side) {
|
|
2223
|
+
const filesKey = side === 'A' ? 'batchFilesA' : 'batchFilesB', selKey = side === 'A' ? 'selectedBatchFilesA' : 'selectedBatchFilesB';
|
|
2224
|
+
this.setState(s => ({ [filesKey]: s[filesKey].filter((_, i) => !s[selKey][i]), [selKey]: {} }));
|
|
2225
|
+
}
|
|
2226
|
+
/* Touch drag-to-reorder: native HTML5 DnD (onDragStart/onDrop above) doesn't fire on touch
|
|
2227
|
+
devices, so track touch position manually and reorder past the item currently under the
|
|
2228
|
+
finger. this.touchDrag holds { side, fromIdx } for the duration of the gesture. */
|
|
2229
|
+
onBatchTouchStart(side, idx, ev) {
|
|
2230
|
+
const touch = ev.touches[0];
|
|
2231
|
+
this.touchDrag = { side, fromIdx: idx };
|
|
2232
|
+
this.setState({ touchGhost: { side, name: this.state[side === 'A' ? 'batchFilesA' : 'batchFilesB'][idx].name, x: touch.clientX, y: touch.clientY } });
|
|
2233
|
+
}
|
|
2234
|
+
onBatchTouchMove(ev) {
|
|
2235
|
+
if (!this.touchDrag) return;
|
|
2236
|
+
ev.preventDefault();
|
|
2237
|
+
const touch = ev.touches[0];
|
|
2238
|
+
const el = document.elementFromPoint(touch.clientX, touch.clientY);
|
|
2239
|
+
const row = el && el.closest ? el.closest('[data-batch-row]') : null;
|
|
2240
|
+
if (row && row.dataset.batchSide === this.touchDrag.side) this.touchDrag.overIdx = +row.dataset.batchIdx;
|
|
2241
|
+
this.setState(s => ({ touchGhost: s.touchGhost ? Object.assign({}, s.touchGhost, { x: touch.clientX, y: touch.clientY }) : s.touchGhost }));
|
|
2242
|
+
}
|
|
2243
|
+
onBatchTouchEnd(ev) {
|
|
2244
|
+
this.setState({ touchGhost: null });
|
|
2245
|
+
if (!this.touchDrag) return;
|
|
2246
|
+
const { side, fromIdx, overIdx } = this.touchDrag;
|
|
2247
|
+
this.touchDrag = null;
|
|
2248
|
+
if (overIdx == null || overIdx === fromIdx) return;
|
|
2249
|
+
const key = side === 'A' ? 'batchFilesA' : 'batchFilesB';
|
|
2250
|
+
this.setState(s => {
|
|
2251
|
+
const files = s[key].slice();
|
|
2252
|
+
const [moved] = files.splice(fromIdx, 1);
|
|
2253
|
+
files.splice(overIdx, 0, moved);
|
|
2254
|
+
return { [key]: files };
|
|
2255
|
+
});
|
|
2256
|
+
}
|
|
2257
|
+
async runBatch() {
|
|
2258
|
+
const st = this.state;
|
|
2259
|
+
const pairs = st.batchPairMode === 'filename'
|
|
2260
|
+
? this.pairFilesByName(st.batchFilesA, st.batchFilesB)
|
|
2261
|
+
: (() => {
|
|
2262
|
+
const n = Math.max(st.batchFilesA.length, st.batchFilesB.length);
|
|
2263
|
+
const out = [];
|
|
2264
|
+
for (let i = 0; i < n; i++) out.push({ fa: st.batchFilesA[i] || null, fb: st.batchFilesB[i] || null });
|
|
2265
|
+
return out;
|
|
2266
|
+
})();
|
|
2267
|
+
if (!pairs.length) return;
|
|
2268
|
+
this.setState({ batchBusy: true, batchResults: [], batchProgress: 0, batchTotal: pairs.length });
|
|
2269
|
+
this.batchBuffers = [];
|
|
2270
|
+
const results = [];
|
|
2271
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
2272
|
+
const fa = pairs[i].fa, fb = pairs[i].fb;
|
|
2273
|
+
this.setState({ batchProgress: i });
|
|
2274
|
+
if (!fa || !fb) {
|
|
2275
|
+
this.batchBuffers.push(null);
|
|
2276
|
+
results.push({ nameA: fa ? fa.name : '(none)', nameB: fb ? fb.name : '(none)', nSlidesA: 0, nSlidesB: 0, totalDiffs: 0, changedSlides: 0, crossDupCount: null, error: 'No match found for this file' });
|
|
2277
|
+
continue;
|
|
2278
|
+
}
|
|
2279
|
+
try {
|
|
2280
|
+
const bufA = await fa.arrayBuffer(), bufB = await fb.arrayBuffer();
|
|
2281
|
+
const slidesA = await this.parseBuffer(bufA.slice(0)), slidesB = await this.parseBuffer(bufB.slice(0));
|
|
2282
|
+
const alignment = this.refineMoves(this.alignSlides(slidesA, slidesB), slidesA, slidesB);
|
|
2283
|
+
let total = 0, changedSlides = 0;
|
|
2284
|
+
alignment.forEach(al => {
|
|
2285
|
+
const pa = al.aIdx != null ? slidesA[al.aIdx] : null, pb = al.bIdx != null ? slidesB[al.bIdx] : null;
|
|
2286
|
+
const c = this.compareSlidePair(pa, pb);
|
|
2287
|
+
total += c.diffs.length; if (c.diffs.length) changedSlides++;
|
|
2288
|
+
});
|
|
2289
|
+
const crossDup = this.findCrossDeckDuplicates(slidesA, slidesB, alignment);
|
|
2290
|
+
this.batchBuffers.push({ bufA, bufB, nameA: fa.name, nameB: fb.name });
|
|
2291
|
+
results.push({ nameA: fa.name, nameB: fb.name, nSlidesA: slidesA.length, nSlidesB: slidesB.length, totalDiffs: total, changedSlides, crossDupCount: crossDup.skipped ? null : crossDup.pairs.length, error: null });
|
|
2292
|
+
} catch (e) {
|
|
2293
|
+
this.batchBuffers.push(null);
|
|
2294
|
+
results.push({ nameA: fa.name, nameB: fb.name, nSlidesA: 0, nSlidesB: 0, totalDiffs: 0, changedSlides: 0, crossDupCount: null, error: (e && e.message) || 'Failed to parse' });
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
this.setState({ batchResults: results, batchBusy: false, batchProgress: pairs.length });
|
|
2298
|
+
}
|
|
2299
|
+
exportBatchReportCsv() {
|
|
2300
|
+
const rows = this.state.batchResults;
|
|
2301
|
+
if (!rows.length) { this.showToast('Run a batch compare first'); return; }
|
|
2302
|
+
const lines = ['Before,After,Slides Before,Slides After,Total Diffs,Changed Slides,Error'].concat(
|
|
2303
|
+
rows.map(r => [r.nameA, r.nameB, r.nSlidesA, r.nSlidesB, r.totalDiffs, r.changedSlides, r.error || ''].map(v => this.csvEscape(v)).join(','))
|
|
2304
|
+
);
|
|
2305
|
+
this.downloadBlob('batch-report.csv', lines.join('\n'), 'text/csv');
|
|
2306
|
+
}
|
|
2307
|
+
exportBatchReportJson() {
|
|
2308
|
+
const rows = this.state.batchResults;
|
|
2309
|
+
if (!rows.length) { this.showToast('Run a batch compare first'); return; }
|
|
2310
|
+
this.downloadBlob('batch-report.json', JSON.stringify(rows, null, 2), 'application/json');
|
|
2311
|
+
}
|
|
2312
|
+
async openBatchJob(i) {
|
|
2313
|
+
const job = this.batchBuffers[i];
|
|
2314
|
+
if (!job) return;
|
|
2315
|
+
await this.ingest('A', job.bufA, job.nameA);
|
|
2316
|
+
await this.ingest('B', job.bufB, job.nameB);
|
|
2317
|
+
this.setState({ viewMode: 'single' });
|
|
2318
|
+
}
|
|
2319
|
+
/* ---------- exports ---------- */
|
|
2320
|
+
downloadBlob(filename, content, mime) {
|
|
2321
|
+
const blob = new Blob([content], { type: mime });
|
|
2322
|
+
const url = URL.createObjectURL(blob);
|
|
2323
|
+
const a = document.createElement('a');
|
|
2324
|
+
a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove();
|
|
2325
|
+
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
|
2326
|
+
}
|
|
2327
|
+
csvEscape(v) { const s = String(v == null ? '' : v); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }
|
|
2328
|
+
buildReportRows() {
|
|
2329
|
+
const st = this.state;
|
|
2330
|
+
const alignment = this.refineMoves(this.alignSlides(st.slidesA, st.slidesB), st.slidesA, st.slidesB);
|
|
2331
|
+
return alignment.map((al) => {
|
|
2332
|
+
const pa = al.aIdx != null ? st.slidesA[al.aIdx] : null, pb = al.bIdx != null ? st.slidesB[al.bIdx] : null;
|
|
2333
|
+
const c = this.compareSlidePair(pa, pb);
|
|
2334
|
+
const key = this.pairKey(al.aIdx, al.bIdx);
|
|
2335
|
+
const label = pa && pb ? ('Slide ' + (al.aIdx + 1) + ' → ' + (al.bIdx + 1)) : pa ? ('Slide ' + (al.aIdx + 1) + ' (removed in After)') : ('Slide ' + (al.bIdx + 1) + ' (added in After)');
|
|
2336
|
+
return { key, label, diffs: c.diffs, decision: this.slideStatus(st.decisions[key]), reviewerVotes: st.decisions[key] || {}, comments: st.comments[key] || [] };
|
|
2337
|
+
});
|
|
2338
|
+
}
|
|
2339
|
+
exportJsonReport() {
|
|
2340
|
+
const st = this.state;
|
|
2341
|
+
const rows = this.buildReportRows();
|
|
2342
|
+
const report = {
|
|
2343
|
+
generatedAt: new Date().toISOString(), deckBefore: st.nameA, deckAfter: st.nameB,
|
|
2344
|
+
presentationDiffs: (this.state.fontsA.join(',') !== this.state.fontsB.join(',')) ? [{ type: 'Embedded fonts', before: st.fontsA, after: st.fontsB }] : [],
|
|
2345
|
+
slides: rows.map(r => ({ key: r.key, label: r.label, decision: r.decision, reviewerVotes: r.reviewerVotes, comments: r.comments, differences: r.diffs.map(d => ({ type: d.type, label: d.label, before: d.oldText, after: d.newText })) })),
|
|
2346
|
+
history: st.history,
|
|
2347
|
+
uiState: { collapsedPairs: st.collapsedPairs, selectedPairs: st.selectedPairs, mergeWinnerOverrides: st.mergeWinnerOverrides }
|
|
2348
|
+
};
|
|
2349
|
+
this.downloadBlob('slide-diff-report.json', JSON.stringify(report, null, 2), 'application/json');
|
|
2350
|
+
}
|
|
2351
|
+
/* ---------- report-JSON round-trip import ---------- */
|
|
2352
|
+
/* Pure parser (no DOM/state I/O) so it's directly unit-testable: takes a parsed report object
|
|
2353
|
+
(the same shape exportJsonReport produces) and returns the state patch to apply. Tolerant of
|
|
2354
|
+
older exports missing `key` (those slides are simply skipped — nothing to key them by). */
|
|
2355
|
+
parseReportForImport(report) {
|
|
2356
|
+
const decisions = {}, comments = {}, history = report.history || [];
|
|
2357
|
+
(report.slides || []).forEach(s => {
|
|
2358
|
+
if (!s.key) return;
|
|
2359
|
+
decisions[s.key] = s.reviewerVotes || {};
|
|
2360
|
+
comments[s.key] = s.comments || [];
|
|
2361
|
+
});
|
|
2362
|
+
const ui = report.uiState || {};
|
|
2363
|
+
return {
|
|
2364
|
+
decisions, comments, history,
|
|
2365
|
+
collapsedPairs: ui.collapsedPairs || {}, selectedPairs: ui.selectedPairs || {}, mergeWinnerOverrides: ui.mergeWinnerOverrides || {}
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
/* Combine an imported report's state with the CURRENT in-progress session. `conflictWinner`
|
|
2369
|
+
('current' | 'imported') decides which side wins a per-key conflict on singular-value fields
|
|
2370
|
+
(a reviewer's already-recorded vote, an already-set collapse/select/override) — defaults to
|
|
2371
|
+
'current' so an import can never silently clobber unsaved work unless the user explicitly
|
|
2372
|
+
asks the import to win. Comments are always unioned/de-duplicated regardless of winner (no
|
|
2373
|
+
"conflict" concept for a list); history is unioned, de-duplicated, and re-sorted newest-first. */
|
|
2374
|
+
mergeReportPatch(current, imported, conflictWinner) {
|
|
2375
|
+
const winner = conflictWinner === 'imported' ? 'imported' : 'current';
|
|
2376
|
+
const pick = (curVal, impVal) => winner === 'current' ? Object.assign({}, impVal, curVal) : Object.assign({}, curVal, impVal);
|
|
2377
|
+
|
|
2378
|
+
const decisionKeys = new Set([...Object.keys(current.decisions || {}), ...Object.keys(imported.decisions || {})]);
|
|
2379
|
+
const decisions = {};
|
|
2380
|
+
decisionKeys.forEach(k => { decisions[k] = pick((current.decisions || {})[k], (imported.decisions || {})[k]); });
|
|
2381
|
+
|
|
2382
|
+
const commentKeys = new Set([...Object.keys(current.comments || {}), ...Object.keys(imported.comments || {})]);
|
|
2383
|
+
const comments = {};
|
|
2384
|
+
commentKeys.forEach(k => {
|
|
2385
|
+
const cur = (current.comments || {})[k] || [];
|
|
2386
|
+
const imp = (imported.comments || {})[k] || [];
|
|
2387
|
+
const seen = new Set(cur.map(c => c.author + '|' + c.text + '|' + c.ts));
|
|
2388
|
+
comments[k] = cur.concat(imp.filter(c => !seen.has(c.author + '|' + c.text + '|' + c.ts)));
|
|
2389
|
+
});
|
|
2390
|
+
|
|
2391
|
+
const seenHist = new Set();
|
|
2392
|
+
const history = (current.history || []).concat(imported.history || []).filter(h => {
|
|
2393
|
+
const sig = h.ts + '|' + h.reviewer + '|' + h.action + '|' + h.label + '|' + (h.note || '');
|
|
2394
|
+
if (seenHist.has(sig)) return false;
|
|
2395
|
+
seenHist.add(sig); return true;
|
|
2396
|
+
}).sort((a, b) => b.ts - a.ts);
|
|
2397
|
+
|
|
2398
|
+
return {
|
|
2399
|
+
decisions, comments, history,
|
|
2400
|
+
collapsedPairs: pick(current.collapsedPairs || {}, imported.collapsedPairs || {}),
|
|
2401
|
+
selectedPairs: pick(current.selectedPairs || {}, imported.selectedPairs || {}),
|
|
2402
|
+
mergeWinnerOverrides: pick(current.mergeWinnerOverrides || {}, imported.mergeWinnerOverrides || {})
|
|
2403
|
+
};
|
|
2404
|
+
}
|
|
2405
|
+
hasInProgressReviewState() {
|
|
2406
|
+
const st = this.state;
|
|
2407
|
+
return Object.keys(st.decisions).length > 0 || Object.keys(st.comments).length > 0 || st.history.length > 0 ||
|
|
2408
|
+
Object.keys(st.collapsedPairs).length > 0 || Object.keys(st.selectedPairs).length > 0 || Object.keys(st.mergeWinnerOverrides).length > 0;
|
|
2409
|
+
}
|
|
2410
|
+
applyImportedReport(imported, name, mode) {
|
|
2411
|
+
const st = this.state;
|
|
2412
|
+
const patch = mode === 'merge'
|
|
2413
|
+
? this.mergeReportPatch({ decisions: st.decisions, comments: st.comments, history: st.history, collapsedPairs: st.collapsedPairs, selectedPairs: st.selectedPairs, mergeWinnerOverrides: st.mergeWinnerOverrides }, imported, st.importConflictWinner)
|
|
2414
|
+
: imported;
|
|
2415
|
+
this.setState(Object.assign({}, patch, { showImportChoice: false }));
|
|
2416
|
+
setTimeout(() => this.persistState(), 0);
|
|
2417
|
+
this.showToast((mode === 'merge' ? 'Merged (' + (st.importConflictWinner === 'imported' ? 'import' : 'current session') + ' wins on conflicts)' : 'Imported (overwrote current session)') + ' from ' + name);
|
|
2418
|
+
this.pendingImport = null; this.pendingImportName = null;
|
|
2419
|
+
}
|
|
2420
|
+
confirmImportChoice(mode) {
|
|
2421
|
+
if (!this.pendingImport) return;
|
|
2422
|
+
this.applyImportedReport(this.pendingImport, this.pendingImportName, mode);
|
|
2423
|
+
}
|
|
2424
|
+
cancelImportChoice() { this.pendingImport = null; this.pendingImportName = null; this.setState({ showImportChoice: false }); }
|
|
2425
|
+
async importReportJson(ev) {
|
|
2426
|
+
const file = ev.target && ev.target.files && ev.target.files[0];
|
|
2427
|
+
if (!file) return;
|
|
2428
|
+
try {
|
|
2429
|
+
const text = await file.text();
|
|
2430
|
+
const report = JSON.parse(text);
|
|
2431
|
+
const imported = this.parseReportForImport(report);
|
|
2432
|
+
if (this.hasInProgressReviewState()) {
|
|
2433
|
+
this.pendingImport = imported; this.pendingImportName = file.name;
|
|
2434
|
+
this.setState({ showImportChoice: true });
|
|
2435
|
+
} else {
|
|
2436
|
+
this.applyImportedReport(imported, file.name, 'overwrite');
|
|
2437
|
+
}
|
|
2438
|
+
} catch (e) { this.showToast('Could not import report JSON: ' + e.message); }
|
|
2439
|
+
ev.target.value = '';
|
|
2440
|
+
}
|
|
2441
|
+
exportHtmlReport() {
|
|
2442
|
+
const rows = this.buildReportRows();
|
|
2443
|
+
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
2444
|
+
const body = rows.map(r => `<section style="margin:0 0 28px;padding-bottom:20px;border-bottom:1px solid #E2DCCE">
|
|
2445
|
+
<h2 style="font-family:Georgia,serif;font-size:20px;margin:0 0 8px">${esc(r.label)} — <span style="text-transform:capitalize">${esc(r.decision)}</span></h2>
|
|
2446
|
+
${r.diffs.length ? '<table style="width:100%;border-collapse:collapse;font-size:13.5px"><tr style="text-align:left;color:#8A8273"><th style="padding:4px 8px 4px 0">Type</th><th style="padding:4px 8px">Before</th><th style="padding:4px 8px">After</th></tr>'
|
|
2447
|
+
+ r.diffs.map(d => `<tr><td style="padding:4px 8px 4px 0;font-weight:600">${esc(d.type)}</td><td style="padding:4px 8px;color:#9A9385">${esc(d.oldText)}</td><td style="padding:4px 8px">${esc(d.newText || '')}</td></tr>`).join('') + '</table>'
|
|
2448
|
+
: '<div style="color:#3E7C5A">Identical</div>'}
|
|
2449
|
+
${r.comments.length ? '<div style="margin-top:10px;font-size:13px;color:#4A463E"><strong>Comments:</strong>' + r.comments.map(c => `<div style="margin-top:4px">— ${esc(c.author)}: ${esc(c.text)}</div>`).join('') + '</div>' : ''}
|
|
2450
|
+
</section>`).join('');
|
|
2451
|
+
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Slide Diff Report</title></head>
|
|
2452
|
+
<body style="font-family:-apple-system,Helvetica,Arial,sans-serif;max-width:900px;margin:40px auto;padding:0 20px;color:#1F1E1B">
|
|
2453
|
+
<h1 style="font-family:Georgia,serif">Slide Diff Report</h1>
|
|
2454
|
+
<div style="color:#6B655A;margin-bottom:24px">${esc(this.state.nameA)} → ${esc(this.state.nameB)} · generated ${esc(new Date().toLocaleString())}</div>
|
|
2455
|
+
${body}</body></html>`;
|
|
2456
|
+
this.downloadBlob('slide-diff-report.html', html, 'text/html');
|
|
2457
|
+
}
|
|
2458
|
+
exportDecisionsJson() {
|
|
2459
|
+
this.downloadBlob('decisions.json', JSON.stringify(this.buildReportRows().map(r => ({ slide: r.label, decision: r.decision, reviewerVotes: r.reviewerVotes, comments: r.comments.map(c => c.author + ': ' + c.text) })), null, 2), 'application/json');
|
|
2460
|
+
}
|
|
2461
|
+
exportDecisionsCsv() {
|
|
2462
|
+
const rows = this.buildReportRows();
|
|
2463
|
+
const lines = ['Slide,Decision,Reviewers,Comments'].concat(rows.map(r => [r.label, r.decision, Object.entries(r.reviewerVotes).map(([h, v]) => h + ':' + v).join(' | '), r.comments.map(c => c.author + ': ' + c.text).join(' | ')].map(v => this.csvEscape(v)).join(',')));
|
|
2464
|
+
this.downloadBlob('decisions.csv', lines.join('\n'), 'text/csv');
|
|
2465
|
+
}
|
|
2466
|
+
/* ---------- Markdown / Notion / Confluence exports ---------- */
|
|
2467
|
+
buildMarkdownBody() {
|
|
2468
|
+
const st = this.state;
|
|
2469
|
+
const rows = this.buildReportRows();
|
|
2470
|
+
const lines = ['# Slide Diff report', '', '**Before:** ' + st.nameA, '**After:** ' + st.nameB, '**Generated:** ' + new Date().toLocaleString(), ''];
|
|
2471
|
+
rows.forEach(r => {
|
|
2472
|
+
lines.push('## ' + r.label + ' — ' + r.decision.charAt(0).toUpperCase() + r.decision.slice(1));
|
|
2473
|
+
if (!r.diffs.length) { lines.push('', '_Identical_', ''); }
|
|
2474
|
+
else {
|
|
2475
|
+
lines.push('', '| Type | Before | After |', '|---|---|---|');
|
|
2476
|
+
r.diffs.forEach(d => lines.push('| ' + d.type + ' | ' + (d.oldText || '').replace(/\|/g, '\\|') + ' | ' + (d.newText || '').replace(/\|/g, '\\|') + ' |'));
|
|
2477
|
+
lines.push('');
|
|
2478
|
+
}
|
|
2479
|
+
if (r.comments.length) {
|
|
2480
|
+
lines.push('**Comments:**');
|
|
2481
|
+
r.comments.forEach(c => lines.push('- ' + c.author + ': ' + c.text));
|
|
2482
|
+
lines.push('');
|
|
2483
|
+
}
|
|
2484
|
+
});
|
|
2485
|
+
return lines.join('\n');
|
|
2486
|
+
}
|
|
2487
|
+
exportMarkdownSummary() {
|
|
2488
|
+
this.downloadBlob('slide-diff-report.md', this.buildMarkdownBody(), 'text/markdown');
|
|
2489
|
+
}
|
|
2490
|
+
/* Notion's importer (Settings → Import → Markdown) reads standard Markdown/tables directly —
|
|
2491
|
+
the same body works, saved with a name that signals its purpose. */
|
|
2492
|
+
exportNotionMarkdown() {
|
|
2493
|
+
this.downloadBlob('slide-diff-report.notion.md', this.buildMarkdownBody(), 'text/markdown');
|
|
2494
|
+
this.showToast('Markdown saved — in Notion use Settings & members → Import → Markdown to bring it in as a page');
|
|
2495
|
+
}
|
|
2496
|
+
/* Confluence's editor accepts pasted "wiki markup" via Insert → Markup, or import via Import Word
|
|
2497
|
+
Document for .doc; wiki markup is the most faithful plain-text option for tables here. */
|
|
2498
|
+
buildConfluenceWikiMarkup() {
|
|
2499
|
+
const st = this.state;
|
|
2500
|
+
const rows = this.buildReportRows();
|
|
2501
|
+
const esc = (s) => String(s == null ? '' : s).replace(/\|/g, '\\|');
|
|
2502
|
+
const lines = ['h1. Slide Diff report', '', '*Before:* ' + st.nameA, '*After:* ' + st.nameB, '*Generated:* ' + new Date().toLocaleString(), ''];
|
|
2503
|
+
rows.forEach(r => {
|
|
2504
|
+
lines.push('h2. ' + r.label + ' — ' + r.decision);
|
|
2505
|
+
if (!r.diffs.length) lines.push('', '_Identical_', '');
|
|
2506
|
+
else {
|
|
2507
|
+
lines.push('', '||Type||Before||After||');
|
|
2508
|
+
r.diffs.forEach(d => lines.push('|' + esc(d.type) + '|' + esc(d.oldText) + '|' + esc(d.newText) + '|'));
|
|
2509
|
+
lines.push('');
|
|
2510
|
+
}
|
|
2511
|
+
if (r.comments.length) {
|
|
2512
|
+
lines.push('*Comments:*');
|
|
2513
|
+
r.comments.forEach(c => lines.push('* ' + c.author + ': ' + c.text));
|
|
2514
|
+
lines.push('');
|
|
2515
|
+
}
|
|
2516
|
+
});
|
|
2517
|
+
return lines.join('\n');
|
|
2518
|
+
}
|
|
2519
|
+
exportConfluencePage() {
|
|
2520
|
+
this.downloadBlob('slide-diff-report.confluence.txt', this.buildConfluenceWikiMarkup(), 'text/plain');
|
|
2521
|
+
this.showToast('Wiki markup saved — in Confluence, open a page, choose Insert → Markup → Wiki Markup, and paste this file\'s contents');
|
|
2522
|
+
}
|
|
2523
|
+
/* ---------- live push: Slack webhook / Notion API / Confluence API ---------- */
|
|
2524
|
+
/* Pure, unit-testable payload builders — kept separate from the actual fetch() calls so tests
|
|
2525
|
+
can verify request SHAPE without needing network access. */
|
|
2526
|
+
buildSlackPayload(text) { return { text }; }
|
|
2527
|
+
/* Pure outcome interpreter for the Slack delivery-confirmation flow below — kept separate from
|
|
2528
|
+
the DOM/iframe plumbing so the three possible outcomes are unit-testable without a real POST. */
|
|
2529
|
+
interpretSlackDeliveryOutcome(kind) {
|
|
2530
|
+
if (kind === 'load') return { status: 'sent', message: 'Slack webhook request completed — check the channel to confirm delivery.' };
|
|
2531
|
+
if (kind === 'error') return { status: 'failed', message: 'Slack push failed — the request could not be sent (check the webhook URL and your connection).' };
|
|
2532
|
+
return { status: 'unknown', message: 'Slack push timed out waiting for a response — it may still have been delivered; check the channel.' };
|
|
2533
|
+
}
|
|
2534
|
+
/* Pure: decide the new Confluence page body given the existing stored body, freshly-built
|
|
2535
|
+
content, and the user's chosen replace/append mode. */
|
|
2536
|
+
buildConfluenceNewBody(existingBody, newContent, mode) {
|
|
2537
|
+
if (mode === 'replace') return newContent;
|
|
2538
|
+
return (existingBody || '') + '<hr/>' + newContent;
|
|
2539
|
+
}
|
|
2540
|
+
/* Pure: the exact shape persisted for live-push credentials (kept separate so tests can check
|
|
2541
|
+
precisely which fields are captured, without touching localStorage). */
|
|
2542
|
+
/* Pure: the exact shape persisted for live-push credentials, filtered to only the fields whose
|
|
2543
|
+
per-field "remember" checkbox is on (kept separate from DOM/localStorage so tests can check
|
|
2544
|
+
precisely which fields are captured under a given selection). */
|
|
2545
|
+
buildCredsPayload(st, fieldsToPersist) {
|
|
2546
|
+
const all = {
|
|
2547
|
+
notionToken: st.notionToken, notionPageId: st.notionPageId,
|
|
2548
|
+
confluenceBaseUrl: st.confluenceBaseUrl, confluenceEmail: st.confluenceEmail, confluenceToken: st.confluenceToken, confluencePageId: st.confluencePageId,
|
|
2549
|
+
slackWebhookUrl: st.slackWebhookUrl
|
|
2550
|
+
};
|
|
2551
|
+
const sel = fieldsToPersist || {};
|
|
2552
|
+
const out = {};
|
|
2553
|
+
Object.keys(all).forEach(k => { if (sel[k]) out[k] = all[k]; });
|
|
2554
|
+
return out;
|
|
2555
|
+
}
|
|
2556
|
+
buildNotionChildren(rows) {
|
|
2557
|
+
const blocks = [];
|
|
2558
|
+
rows.forEach(r => {
|
|
2559
|
+
blocks.push({ object: 'block', type: 'heading_2', heading_2: { rich_text: [{ type: 'text', text: { content: r.label + ' — ' + r.decision } }] } });
|
|
2560
|
+
if (!r.diffs.length) {
|
|
2561
|
+
blocks.push({ object: 'block', type: 'paragraph', paragraph: { rich_text: [{ type: 'text', text: { content: 'Identical' } }] } });
|
|
2562
|
+
} else {
|
|
2563
|
+
r.diffs.forEach(d => {
|
|
2564
|
+
blocks.push({ object: 'block', type: 'bulleted_list_item', bulleted_list_item: { rich_text: [{ type: 'text', text: { content: d.type + ': ' + (d.oldText || '') + ' → ' + (d.newText || '') } }] } });
|
|
2565
|
+
});
|
|
2566
|
+
}
|
|
2567
|
+
});
|
|
2568
|
+
return blocks;
|
|
2569
|
+
}
|
|
2570
|
+
buildConfluenceStorageBody(rows) {
|
|
2571
|
+
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
2572
|
+
return rows.map(r => {
|
|
2573
|
+
const heading = '<h2>' + esc(r.label) + ' — ' + esc(r.decision) + '</h2>';
|
|
2574
|
+
if (!r.diffs.length) return heading + '<p><em>Identical</em></p>';
|
|
2575
|
+
const table = '<table><tbody><tr><th>Type</th><th>Before</th><th>After</th></tr>' +
|
|
2576
|
+
r.diffs.map(d => '<tr><td>' + esc(d.type) + '</td><td>' + esc(d.oldText) + '</td><td>' + esc(d.newText) + '</td></tr>').join('') +
|
|
2577
|
+
'</tbody></table>';
|
|
2578
|
+
return heading + table;
|
|
2579
|
+
}).join('');
|
|
2580
|
+
}
|
|
2581
|
+
toggleIntegrations() { this.setState(s => ({ showIntegrations: !s.showIntegrations })); }
|
|
2582
|
+
async pushToSlack() {
|
|
2583
|
+
const url = this.state.slackWebhookUrl.trim();
|
|
2584
|
+
if (!url) { this.showToast('Enter a Slack webhook URL first'); return; }
|
|
2585
|
+
const payload = this.buildSlackPayload(this.buildPlainSummary());
|
|
2586
|
+
// Slack's incoming-webhook endpoint sends no CORS headers, so a plain fetch() can send the
|
|
2587
|
+
// request but can never read its response — reporting "success" would be a guess either way.
|
|
2588
|
+
// A hidden form posted into a hidden iframe gives a REAL (if coarse) confirmation signal:
|
|
2589
|
+
// the iframe's load/error events distinguish "the request round-tripped" from "it never even
|
|
2590
|
+
// went out" (bad URL, offline, DNS failure) — better than the previous no-cors fetch, which
|
|
2591
|
+
// had zero signal at all.
|
|
2592
|
+
const iframe = document.createElement('iframe');
|
|
2593
|
+
iframe.style.display = 'none';
|
|
2594
|
+
iframe.name = 'slack-push-target-' + Date.now();
|
|
2595
|
+
document.body.appendChild(iframe);
|
|
2596
|
+
const form = document.createElement('form');
|
|
2597
|
+
form.method = 'POST'; form.action = url; form.target = iframe.name; form.style.display = 'none';
|
|
2598
|
+
const input = document.createElement('input');
|
|
2599
|
+
input.type = 'hidden'; input.name = 'payload'; input.value = JSON.stringify(payload);
|
|
2600
|
+
form.appendChild(input);
|
|
2601
|
+
document.body.appendChild(form);
|
|
2602
|
+
let settled = false;
|
|
2603
|
+
const cleanup = () => { setTimeout(() => { form.remove(); iframe.remove(); }, 500); };
|
|
2604
|
+
const finish = (kind) => {
|
|
2605
|
+
if (settled) return;
|
|
2606
|
+
settled = true;
|
|
2607
|
+
this.showToast(this.interpretSlackDeliveryOutcome(kind).message);
|
|
2608
|
+
cleanup();
|
|
2609
|
+
};
|
|
2610
|
+
iframe.onload = () => finish('load');
|
|
2611
|
+
iframe.onerror = () => finish('error');
|
|
2612
|
+
form.submit();
|
|
2613
|
+
setTimeout(() => finish('timeout'), 6000);
|
|
2614
|
+
}
|
|
2615
|
+
async pushToNotion() {
|
|
2616
|
+
const st = this.state;
|
|
2617
|
+
if (!st.notionToken.trim() || !st.notionPageId.trim()) { this.showToast('Enter a Notion integration token and page ID first'); return; }
|
|
2618
|
+
const rows = this.buildReportRows();
|
|
2619
|
+
const children = this.buildNotionChildren(rows).slice(0, 100); // Notion's API caps 100 children per request
|
|
2620
|
+
try {
|
|
2621
|
+
const res = await fetch('https://api.notion.com/v1/blocks/' + encodeURIComponent(st.notionPageId.trim()) + '/children', {
|
|
2622
|
+
method: 'PATCH',
|
|
2623
|
+
headers: { 'Authorization': 'Bearer ' + st.notionToken.trim(), 'Notion-Version': '2022-06-28', 'Content-Type': 'application/json' },
|
|
2624
|
+
body: JSON.stringify({ children })
|
|
2625
|
+
});
|
|
2626
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
2627
|
+
this.showToast('Pushed to Notion page');
|
|
2628
|
+
} catch (e) {
|
|
2629
|
+
// Notion's API does not set Access-Control-Allow-Origin for browser-origin requests, so this
|
|
2630
|
+
// will typically fail with an opaque CORS error rather than a readable HTTP status — that is
|
|
2631
|
+
// expected from a pure client-side page (no backend to proxy the call). Fall back honestly.
|
|
2632
|
+
this.showToast('Notion push failed (likely blocked by CORS — the Notion API isn\'t reachable directly from a browser without a backend proxy). Use "Notion (Markdown import)" instead. Details: ' + e.message);
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
async pushToConfluence() {
|
|
2636
|
+
const st = this.state;
|
|
2637
|
+
if (!st.confluenceBaseUrl.trim() || !st.confluenceEmail.trim() || !st.confluenceToken.trim() || !st.confluencePageId.trim()) { this.showToast('Fill in all Confluence fields first'); return; }
|
|
2638
|
+
const auth = 'Basic ' + btoa(st.confluenceEmail.trim() + ':' + st.confluenceToken.trim());
|
|
2639
|
+
const base = st.confluenceBaseUrl.trim().replace(/\/$/, '');
|
|
2640
|
+
try {
|
|
2641
|
+
const getRes = await fetch(base + '/rest/api/content/' + encodeURIComponent(st.confluencePageId.trim()) + '?expand=version,body.storage', { headers: { 'Authorization': auth, 'Accept': 'application/json' } });
|
|
2642
|
+
if (!getRes.ok) throw new Error('HTTP ' + getRes.status + ' fetching current version');
|
|
2643
|
+
const current = await getRes.json();
|
|
2644
|
+
const rows = this.buildReportRows();
|
|
2645
|
+
const newBody = this.buildConfluenceNewBody(current.body && current.body.storage && current.body.storage.value, this.buildConfluenceStorageBody(rows), st.confluencePushMode);
|
|
2646
|
+
const putRes = await fetch(base + '/rest/api/content/' + encodeURIComponent(st.confluencePageId.trim()), {
|
|
2647
|
+
method: 'PUT',
|
|
2648
|
+
headers: { 'Authorization': auth, 'Content-Type': 'application/json' },
|
|
2649
|
+
body: JSON.stringify({ id: st.confluencePageId.trim(), type: 'page', title: current.title, version: { number: current.version.number + 1 }, body: { storage: { value: newBody, representation: 'storage' } } })
|
|
2650
|
+
});
|
|
2651
|
+
if (!putRes.ok) throw new Error('HTTP ' + putRes.status + ' updating page');
|
|
2652
|
+
this.showToast('Pushed to Confluence page');
|
|
2653
|
+
} catch (e) {
|
|
2654
|
+
// Same CORS caveat as Notion: Confluence Cloud's REST API does not allow arbitrary
|
|
2655
|
+
// browser-origin requests without a proxy. Expected to fail here without a backend.
|
|
2656
|
+
this.showToast('Confluence push failed (likely blocked by CORS without a backend proxy). Use "Confluence (wiki markup)" instead. Details: ' + e.message);
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
async onUpload(side, ev) {
|
|
2660
|
+
const file = ev.target && ev.target.files && ev.target.files[0];
|
|
2661
|
+
if (!file) return;
|
|
2662
|
+
if (side === 'A') this.setState({ loadingA: true }); else this.setState({ loadingB: true });
|
|
2663
|
+
try {
|
|
2664
|
+
if (typeof JSZip === 'undefined') throw new Error('Still loading — try again in a moment.');
|
|
2665
|
+
const buf = await file.arrayBuffer();
|
|
2666
|
+
await this.ingest(side, buf, file.name);
|
|
2667
|
+
} catch (e) {
|
|
2668
|
+
this.setState({ errorMsg: (e && e.message) || ('Could not read ' + file.name) });
|
|
2669
|
+
} finally {
|
|
2670
|
+
if (side === 'A') this.setState({ loadingA: false }); else this.setState({ loadingB: false });
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
/* ---------- high-fidelity render (keyed: supports many simultaneous thumbnails) ---------- */
|
|
2675
|
+
attachMount(key, side, idx, el) {
|
|
2676
|
+
this.meta.set(key, { side, idx });
|
|
2677
|
+
if (el) {
|
|
2678
|
+
this.mounts.set(key, el);
|
|
2679
|
+
if (!this._ro.has(el)) {
|
|
2680
|
+
this._ro.add(el);
|
|
2681
|
+
let raf = 0;
|
|
2682
|
+
const ro = new ResizeObserver(() => {
|
|
2683
|
+
cancelAnimationFrame(raf);
|
|
2684
|
+
raf = requestAnimationFrame(() => { this.sigs.delete(key); this.maybeRenderKey(key); });
|
|
2685
|
+
});
|
|
2686
|
+
try { ro.observe(el); } catch (e) {}
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
this.maybeRenderKey(key);
|
|
2690
|
+
}
|
|
2691
|
+
maybeRenderKey(key) {
|
|
2692
|
+
const meta = this.meta.get(key);
|
|
2693
|
+
if (!meta) return;
|
|
2694
|
+
const { side, idx } = meta;
|
|
2695
|
+
const pres = side === 'A' ? this.presA : this.presB;
|
|
2696
|
+
const viewer = side === 'A' ? this.viewerA : this.viewerB;
|
|
2697
|
+
const mount = this.mounts.get(key);
|
|
2698
|
+
if (!pres || !viewer || !mount) return;
|
|
2699
|
+
const tok = side === 'A' ? this.tokA : this.tokB;
|
|
2700
|
+
const w = Math.round(mount.clientWidth || 0);
|
|
2701
|
+
if (w < 10) { requestAnimationFrame(() => this.maybeRenderKey(key)); return; }
|
|
2702
|
+
const sig = tok + ':' + idx + ':' + w;
|
|
2703
|
+
if (this.sigs.get(key) === sig) return;
|
|
2704
|
+
this.sigs.set(key, sig);
|
|
2705
|
+
try { const h = this.handles.get(key); h && h.dispose(); } catch (e) {}
|
|
2706
|
+
mount.innerHTML = '';
|
|
2707
|
+
try {
|
|
2708
|
+
const safeIdx = Math.min(idx, pres.slides.length - 1);
|
|
2709
|
+
this.handles.set(key, viewer.renderThumbnailToContainer(safeIdx, mount, { width: w }));
|
|
2710
|
+
} catch (e) { console.warn('thumbnail render failed', e); }
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
/* ---------- pptx parsing (for diff) ---------- */
|
|
2714
|
+
qn(el, local) { return Array.from(el.getElementsByTagNameNS('*', local)); }
|
|
2715
|
+
async parseBuffer(buf) {
|
|
2716
|
+
const zip = await JSZip.loadAsync(buf);
|
|
2717
|
+
let w = Math.round(13.333 * this.EMU), h = Math.round(7.5 * this.EMU);
|
|
2718
|
+
const presF = zip.file('ppt/presentation.xml');
|
|
2719
|
+
if (presF) {
|
|
2720
|
+
const sz = new DOMParser().parseFromString(await presF.async('string'), 'application/xml').getElementsByTagNameNS('*', 'sldSz')[0];
|
|
2721
|
+
if (sz) { w = +sz.getAttribute('cx') || w; h = +sz.getAttribute('cy') || h; }
|
|
2722
|
+
}
|
|
2723
|
+
const names = Object.keys(zip.files).filter(n => /^ppt\/slides\/slide\d+\.xml$/.test(n))
|
|
2724
|
+
.sort((a, b) => (+a.match(/slide(\d+)/)[1]) - (+b.match(/slide(\d+)/)[1]));
|
|
2725
|
+
const slides = [];
|
|
2726
|
+
for (const n of names) slides.push(await this.parseSlide(zip, n.split('/').pop(), w, h));
|
|
2727
|
+
return slides;
|
|
2728
|
+
}
|
|
2729
|
+
async parseSlide(zip, fname, w, h) {
|
|
2730
|
+
const partPath = 'ppt/slides/' + fname;
|
|
2731
|
+
const xml = await zip.file(partPath).async('string');
|
|
2732
|
+
const doc = new DOMParser().parseFromString(xml, 'application/xml');
|
|
2733
|
+
const ridMap = await this.parseRelsFor(zip, partPath);
|
|
2734
|
+
let layoutName = '(unknown layout)', themeName = '(unknown theme)', layoutRects = [], masterRects = [], colorCtx = { themeColors: {}, colorMap: null };
|
|
2735
|
+
try { ({ layoutName, themeName, layoutRects, masterRects, colorCtx } = await this.parseInheritance(doc, ridMap, zip)); } catch (e) { console.warn('layout/master parse failed', e); }
|
|
2736
|
+
const shapes = this.parseShapesFromDoc(doc, w, h, ridMap, colorCtx);
|
|
2737
|
+
let images = [], media = [];
|
|
2738
|
+
try { ({ images, media } = await this.parsePics(doc, ridMap, zip)); } catch (e) { console.warn('image/media parse failed', e); }
|
|
2739
|
+
let tables = [], charts = [], smartArt = [];
|
|
2740
|
+
try { ({ tables, charts, smartArt } = await this.parseGraphicFrames(doc, ridMap, zip, colorCtx)); } catch (e) { console.warn('table/chart/smartart parse failed', e); }
|
|
2741
|
+
let background = { desc: 'Inherited from layout', color: null };
|
|
2742
|
+
try { background = this.parseBackground(doc, colorCtx); } catch (e) {}
|
|
2743
|
+
let animations = [];
|
|
2744
|
+
try { animations = this.parseAnimations(doc); } catch (e) {}
|
|
2745
|
+
let transition = { type: 'None', spd: null, advTm: null };
|
|
2746
|
+
try { transition = this.parseTransition(doc); } catch (e) {}
|
|
2747
|
+
let notes = { text: '', bold: false, italic: false, font: null, color: null };
|
|
2748
|
+
try { notes = await this.parseNotes(ridMap, zip); } catch (e) { console.warn('notes parse failed', e); }
|
|
2749
|
+
return { w, h, shapes, images, media, tables, charts, smartArt, background, animations, transition, notes, layoutName, themeName, layoutRects, masterRects };
|
|
2750
|
+
}
|
|
2751
|
+
async parseRelsFor(zip, partPath) {
|
|
2752
|
+
const dir = partPath.slice(0, partPath.lastIndexOf('/'));
|
|
2753
|
+
const base = partPath.slice(partPath.lastIndexOf('/') + 1);
|
|
2754
|
+
const f = zip.file(dir + '/_rels/' + base + '.rels');
|
|
2755
|
+
const map = {};
|
|
2756
|
+
if (!f) return map;
|
|
2757
|
+
const doc = new DOMParser().parseFromString(await f.async('string'), 'application/xml');
|
|
2758
|
+
this.qn(doc.documentElement, 'Relationship').forEach(r => {
|
|
2759
|
+
const raw = r.getAttribute('Target');
|
|
2760
|
+
const isExternal = r.getAttribute('TargetMode') === 'External' || /^[a-z][a-z0-9+.-]*:/i.test(raw || '');
|
|
2761
|
+
map[r.getAttribute('Id')] = { target: isExternal ? raw : this.resolvePath(dir, raw), type: r.getAttribute('Type') || '' };
|
|
2762
|
+
});
|
|
2763
|
+
return map;
|
|
2764
|
+
}
|
|
2765
|
+
findRel(ridMap, typeSuffix) { return Object.values(ridMap).find(r => r.type.endsWith(typeSuffix)); }
|
|
2766
|
+
parseTransition(doc) {
|
|
2767
|
+
const tr = this.qn(doc.documentElement, 'transition')[0];
|
|
2768
|
+
if (!tr) return { type: 'None', spd: null, advTm: null };
|
|
2769
|
+
let type = 'Cut';
|
|
2770
|
+
for (const child of Array.from(tr.children || [])) { if (child.localName && child.localName !== 'sndAc') { type = child.localName; break; } }
|
|
2771
|
+
return { type, spd: tr.getAttribute('spd') || null, advTm: tr.getAttribute('advTm') || null };
|
|
2772
|
+
}
|
|
2773
|
+
async parseNotes(ridMap, zip) {
|
|
2774
|
+
const empty = { text: '', bold: false, italic: false, font: null, color: null };
|
|
2775
|
+
const rel = this.findRel(ridMap, '/notesSlide');
|
|
2776
|
+
if (!rel) return empty;
|
|
2777
|
+
const f = zip.file(rel.target);
|
|
2778
|
+
if (!f) return empty;
|
|
2779
|
+
const doc = new DOMParser().parseFromString(await f.async('string'), 'application/xml');
|
|
2780
|
+
const texts = []; let fmt = null;
|
|
2781
|
+
this.qn(doc.documentElement, 'sp').forEach(sp => {
|
|
2782
|
+
const ph = this.qn(sp, 'ph')[0];
|
|
2783
|
+
if (ph && ph.getAttribute('type') === 'sldImg') return;
|
|
2784
|
+
this.qn(sp, 'p').forEach(p => {
|
|
2785
|
+
const runs = this.qn(p, 'r');
|
|
2786
|
+
const t = runs.map(r => { const tt = this.qn(r, 't')[0]; return tt ? tt.textContent : ''; }).join('');
|
|
2787
|
+
if (t.trim() && !fmt) fmt = this.readRunFmt(runs.length ? this.qn(runs[0], 'rPr')[0] : null, null);
|
|
2788
|
+
if (t) texts.push(t);
|
|
2789
|
+
});
|
|
2790
|
+
});
|
|
2791
|
+
if (!fmt) fmt = { font: null, size: null, bold: false, italic: false, color: null };
|
|
2792
|
+
return { text: texts.join('\n'), bold: fmt.bold, italic: fmt.italic, font: fmt.font, color: fmt.color };
|
|
2793
|
+
}
|
|
2794
|
+
rectsFromDoc(doc) {
|
|
2795
|
+
return this.qn(doc.documentElement, 'sp').map(sp => {
|
|
2796
|
+
const off = this.qn(sp, 'off')[0], ext = this.qn(sp, 'ext')[0];
|
|
2797
|
+
const cNv = this.qn(sp, 'cNvPr')[0];
|
|
2798
|
+
const ph = this.qn(sp, 'ph')[0];
|
|
2799
|
+
if (!off || !ext) return null;
|
|
2800
|
+
return {
|
|
2801
|
+
x: +off.getAttribute('x'), y: +off.getAttribute('y'), cx: +ext.getAttribute('cx'), cy: +ext.getAttribute('cy'),
|
|
2802
|
+
label: ph ? (ph.getAttribute('type') || 'body') : (cNv ? cNv.getAttribute('name') : '')
|
|
2803
|
+
};
|
|
2804
|
+
}).filter(Boolean);
|
|
2805
|
+
}
|
|
2806
|
+
colorMapFromAttrs(el) {
|
|
2807
|
+
if (!el) return null;
|
|
2808
|
+
const names = ['bg1', 'tx1', 'bg2', 'tx2', 'accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6', 'hlink', 'folHlink'];
|
|
2809
|
+
const m = {}; let any = false;
|
|
2810
|
+
names.forEach(n => { const v = el.getAttribute(n); if (v) { m[n] = v; any = true; } });
|
|
2811
|
+
return any ? m : null;
|
|
2812
|
+
}
|
|
2813
|
+
parseThemeColors(themeDoc) {
|
|
2814
|
+
const map = {};
|
|
2815
|
+
const scheme = this.qn(themeDoc.documentElement, 'clrScheme')[0];
|
|
2816
|
+
if (!scheme) return map;
|
|
2817
|
+
['dk1', 'lt1', 'dk2', 'lt2', 'accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6', 'hlink', 'folHlink'].forEach(name => {
|
|
2818
|
+
const node = this.qn(scheme, name)[0];
|
|
2819
|
+
if (!node) return;
|
|
2820
|
+
const srgb = this.qn(node, 'srgbClr')[0];
|
|
2821
|
+
const sys = this.qn(node, 'sysClr')[0];
|
|
2822
|
+
map[name] = srgb ? '#' + srgb.getAttribute('val') : sys ? '#' + (sys.getAttribute('lastClr') || '000000') : null;
|
|
2823
|
+
});
|
|
2824
|
+
return map;
|
|
2825
|
+
}
|
|
2826
|
+
resolveSchemeColor(name, colorCtx) {
|
|
2827
|
+
if (!colorCtx) return null;
|
|
2828
|
+
const slot = (colorCtx.colorMap && colorCtx.colorMap[name]) || name;
|
|
2829
|
+
return (colorCtx.themeColors && colorCtx.themeColors[slot]) || (colorCtx.themeColors && colorCtx.themeColors[name]) || null;
|
|
2830
|
+
}
|
|
2831
|
+
async parseInheritance(doc, ridMap, zip) {
|
|
2832
|
+
const layoutRel = this.findRel(ridMap, '/slideLayout');
|
|
2833
|
+
let layoutName = '(unknown layout)', themeName = '(unknown theme)', layoutRects = [], masterRects = [];
|
|
2834
|
+
let themeColors = {}, masterClrMap = null, layoutClrMapOvr = null;
|
|
2835
|
+
const slideClrMapOvr = this.colorMapFromAttrs(this.qn(doc.documentElement, 'overrideClrMapping')[0]);
|
|
2836
|
+
if (layoutRel) {
|
|
2837
|
+
const lf = zip.file(layoutRel.target);
|
|
2838
|
+
if (lf) {
|
|
2839
|
+
const ldoc = new DOMParser().parseFromString(await lf.async('string'), 'application/xml');
|
|
2840
|
+
const cSld = this.qn(ldoc.documentElement, 'cSld')[0];
|
|
2841
|
+
layoutName = (cSld && cSld.getAttribute('name')) || layoutRel.target.split('/').pop();
|
|
2842
|
+
layoutRects = this.rectsFromDoc(ldoc);
|
|
2843
|
+
layoutClrMapOvr = this.colorMapFromAttrs(this.qn(ldoc.documentElement, 'overrideClrMapping')[0]);
|
|
2844
|
+
const layoutRidMap = await this.parseRelsFor(zip, layoutRel.target);
|
|
2845
|
+
const masterRel = this.findRel(layoutRidMap, '/slideMaster');
|
|
2846
|
+
if (masterRel) {
|
|
2847
|
+
const mf = zip.file(masterRel.target);
|
|
2848
|
+
if (mf) {
|
|
2849
|
+
const mdoc = new DOMParser().parseFromString(await mf.async('string'), 'application/xml');
|
|
2850
|
+
masterRects = this.rectsFromDoc(mdoc);
|
|
2851
|
+
masterClrMap = this.colorMapFromAttrs(this.qn(mdoc.documentElement, 'clrMap')[0]);
|
|
2852
|
+
}
|
|
2853
|
+
const masterRidMap = await this.parseRelsFor(zip, masterRel.target);
|
|
2854
|
+
const themeRel = this.findRel(masterRidMap, '/theme');
|
|
2855
|
+
if (themeRel) {
|
|
2856
|
+
const tf = zip.file(themeRel.target);
|
|
2857
|
+
if (tf) {
|
|
2858
|
+
const tdoc = new DOMParser().parseFromString(await tf.async('string'), 'application/xml');
|
|
2859
|
+
themeName = tdoc.documentElement.getAttribute('name') || themeRel.target.split('/').pop();
|
|
2860
|
+
themeColors = this.parseThemeColors(tdoc);
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
const colorMap = slideClrMapOvr || layoutClrMapOvr || masterClrMap || { bg1: 'lt1', tx1: 'dk1', bg2: 'lt2', tx2: 'dk2', accent1: 'accent1', accent2: 'accent2', accent3: 'accent3', accent4: 'accent4', accent5: 'accent5', accent6: 'accent6', hlink: 'hlink', folHlink: 'folHlink' };
|
|
2867
|
+
return { layoutName, themeName, layoutRects, masterRects, colorCtx: { themeColors, colorMap } };
|
|
2868
|
+
}
|
|
2869
|
+
resolvePath(baseDir, rel) {
|
|
2870
|
+
if (!rel) return null;
|
|
2871
|
+
if (rel.startsWith('/')) return rel.slice(1);
|
|
2872
|
+
const parts = baseDir.split('/').filter(Boolean);
|
|
2873
|
+
rel.split('/').forEach(seg => { if (seg === '..') parts.pop(); else if (seg !== '.') parts.push(seg); });
|
|
2874
|
+
return parts.join('/');
|
|
2875
|
+
}
|
|
2876
|
+
async hashBytes(zip, path) {
|
|
2877
|
+
const f = zip.file(path);
|
|
2878
|
+
if (!f) return null;
|
|
2879
|
+
const u8 = await f.async('uint8array');
|
|
2880
|
+
let h = 0x811c9dc5;
|
|
2881
|
+
for (let i = 0; i < u8.length; i++) { h ^= u8[i]; h = Math.imul(h, 0x01000193); }
|
|
2882
|
+
return (h >>> 0).toString(16) + ':' + u8.length;
|
|
2883
|
+
}
|
|
2884
|
+
async parsePics(doc, ridMap, zip) {
|
|
2885
|
+
const images = [], media = []; let i = 0, mi = 0;
|
|
2886
|
+
for (const pic of this.qn(doc.documentElement, 'pic')) {
|
|
2887
|
+
const cNv = this.qn(pic, 'cNvPr')[0];
|
|
2888
|
+
const nvPr = this.qn(pic, 'nvPr')[0];
|
|
2889
|
+
const vid = nvPr ? this.qn(nvPr, 'videoFile')[0] : null;
|
|
2890
|
+
const aud = nvPr ? this.qn(nvPr, 'audioFile')[0] : null;
|
|
2891
|
+
const off = this.qn(pic, 'off')[0], ext = this.qn(pic, 'ext')[0];
|
|
2892
|
+
const x = off ? +off.getAttribute('x') : 0, y = off ? +off.getAttribute('y') : 0;
|
|
2893
|
+
const cx = ext ? +ext.getAttribute('cx') : 0, cy = ext ? +ext.getAttribute('cy') : 0;
|
|
2894
|
+
if (vid || aud) {
|
|
2895
|
+
mi++;
|
|
2896
|
+
const kind = vid ? 'video' : 'audio';
|
|
2897
|
+
const rId = (vid || aud).getAttribute('r:link');
|
|
2898
|
+
const target = rId && ridMap[rId] ? ridMap[rId].target : null;
|
|
2899
|
+
const hash = target ? await this.hashBytes(zip, target) : null;
|
|
2900
|
+
media.push({ kind, name: cNv ? (cNv.getAttribute('name') || (kind + ' ' + mi)) : (kind + ' ' + mi), x, y, cx, cy, target, hash });
|
|
2901
|
+
continue;
|
|
2902
|
+
}
|
|
2903
|
+
const blip = this.qn(pic, 'blip')[0];
|
|
2904
|
+
const rId = blip ? blip.getAttribute('r:embed') : null;
|
|
2905
|
+
const target = rId && ridMap[rId] ? ridMap[rId].target : null;
|
|
2906
|
+
const hash = target ? await this.hashBytes(zip, target) : null;
|
|
2907
|
+
i++;
|
|
2908
|
+
const link = cNv ? this.qn(cNv, 'hlinkClick')[0] : null;
|
|
2909
|
+
const linkTarget = link ? (link.getAttribute('r:id') && ridMap[link.getAttribute('r:id')] ? ridMap[link.getAttribute('r:id')].target : null) : null;
|
|
2910
|
+
images.push({
|
|
2911
|
+
kind: 'image', name: cNv ? (cNv.getAttribute('name') || ('Picture ' + i)) : ('Picture ' + i),
|
|
2912
|
+
x, y, cx, cy, target, hash, link: linkTarget
|
|
2913
|
+
});
|
|
2914
|
+
}
|
|
2915
|
+
return { images, media };
|
|
2916
|
+
}
|
|
2917
|
+
async parseGraphicFrames(doc, ridMap, zip, colorCtx) {
|
|
2918
|
+
const tables = [], charts = [], smartArt = [];
|
|
2919
|
+
let ti = 0, ci = 0, di = 0;
|
|
2920
|
+
for (const gf of this.qn(doc.documentElement, 'graphicFrame')) {
|
|
2921
|
+
const cNv = this.qn(gf, 'cNvPr')[0];
|
|
2922
|
+
const off = this.qn(gf, 'off')[0], ext = this.qn(gf, 'ext')[0];
|
|
2923
|
+
const x = off ? +off.getAttribute('x') : 0, y = off ? +off.getAttribute('y') : 0;
|
|
2924
|
+
const cx = ext ? +ext.getAttribute('cx') : 0, cy = ext ? +ext.getAttribute('cy') : 0;
|
|
2925
|
+
const tbl = this.qn(gf, 'tbl')[0];
|
|
2926
|
+
if (tbl) {
|
|
2927
|
+
ti++;
|
|
2928
|
+
const rows = this.qn(tbl, 'tr').map(tr => this.qn(tr, 'tc').map(tc => {
|
|
2929
|
+
const text = this.qn(tc, 't').map(t => t.textContent).join('').trim();
|
|
2930
|
+
const tcPr = this.qn(tc, 'tcPr')[0];
|
|
2931
|
+
let bg = null, border = null;
|
|
2932
|
+
if (tcPr) {
|
|
2933
|
+
const fill = this.qn(tcPr, 'solidFill')[0];
|
|
2934
|
+
if (fill) {
|
|
2935
|
+
const srgb = this.qn(fill, 'srgbClr')[0];
|
|
2936
|
+
if (srgb) bg = '#' + srgb.getAttribute('val');
|
|
2937
|
+
else { const sc = this.qn(fill, 'schemeClr')[0]; if (sc) bg = this.resolveSchemeColor(sc.getAttribute('val'), colorCtx) || bg; }
|
|
2938
|
+
}
|
|
2939
|
+
const lnL = this.qn(tcPr, 'lnL')[0];
|
|
2940
|
+
if (lnL) {
|
|
2941
|
+
const w = lnL.getAttribute('w');
|
|
2942
|
+
const srgb = this.qn(lnL, 'srgbClr')[0];
|
|
2943
|
+
let color = srgb ? '#' + srgb.getAttribute('val') : null;
|
|
2944
|
+
if (!color) { const sc = this.qn(lnL, 'schemeClr')[0]; if (sc) color = this.resolveSchemeColor(sc.getAttribute('val'), colorCtx); }
|
|
2945
|
+
border = { w: w ? (+w / 12700).toFixed(1) : null, color };
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
return { text, bg, border };
|
|
2949
|
+
}));
|
|
2950
|
+
tables.push({ kind: 'table', name: cNv ? (cNv.getAttribute('name') || ('Table ' + ti)) : ('Table ' + ti), x, y, cx, cy, rows, nRows: rows.length, nCols: rows[0] ? rows[0].length : 0 });
|
|
2951
|
+
continue;
|
|
2952
|
+
}
|
|
2953
|
+
const chartEl = this.qn(gf, 'chart')[0];
|
|
2954
|
+
if (chartEl) {
|
|
2955
|
+
ci++;
|
|
2956
|
+
const rId = chartEl.getAttribute('r:id');
|
|
2957
|
+
const target = rId && ridMap[rId] ? ridMap[rId].target : null;
|
|
2958
|
+
let chartType = 'Chart', series = [];
|
|
2959
|
+
const cf = target ? zip.file(target) : null;
|
|
2960
|
+
if (cf) {
|
|
2961
|
+
const cdoc = new DOMParser().parseFromString(await cf.async('string'), 'application/xml');
|
|
2962
|
+
const plotArea = this.qn(cdoc.documentElement, 'plotArea')[0] || cdoc.documentElement;
|
|
2963
|
+
for (const t of ['barChart', 'lineChart', 'pieChart', 'areaChart', 'scatterChart', 'bubbleChart', 'doughnutChart', 'radarChart']) {
|
|
2964
|
+
if (this.qn(plotArea, t).length) { chartType = t.replace('Chart', ''); break; }
|
|
2965
|
+
}
|
|
2966
|
+
this.qn(cdoc.documentElement, 'ser').forEach((ser, si) => {
|
|
2967
|
+
const txv = this.qn(ser, 'tx')[0]; const v0 = txv ? this.qn(txv, 'v')[0] : null;
|
|
2968
|
+
const vals = this.qn(ser, 'val')[0];
|
|
2969
|
+
const pts = vals ? this.qn(vals, 'pt').map(p => p.textContent) : [];
|
|
2970
|
+
series.push({ name: v0 ? v0.textContent : ('Series ' + (si + 1)), values: pts.join(',') });
|
|
2971
|
+
});
|
|
2972
|
+
}
|
|
2973
|
+
charts.push({ kind: 'chart', name: cNv ? (cNv.getAttribute('name') || ('Chart ' + ci)) : ('Chart ' + ci), x, y, cx, cy, chartType, series });
|
|
2974
|
+
continue;
|
|
2975
|
+
}
|
|
2976
|
+
const relIds = this.qn(gf, 'relIds')[0];
|
|
2977
|
+
if (relIds) {
|
|
2978
|
+
di++;
|
|
2979
|
+
const dmId = relIds.getAttribute('r:dm');
|
|
2980
|
+
const target = dmId && ridMap[dmId] ? ridMap[dmId].target : null;
|
|
2981
|
+
let text = '';
|
|
2982
|
+
const df = target ? zip.file(target) : null;
|
|
2983
|
+
if (df) {
|
|
2984
|
+
const ddoc = new DOMParser().parseFromString(await df.async('string'), 'application/xml');
|
|
2985
|
+
text = this.qn(ddoc.documentElement, 't').map(t => t.textContent).filter(Boolean).join(' | ');
|
|
2986
|
+
}
|
|
2987
|
+
smartArt.push({ kind: 'smartart', name: cNv ? (cNv.getAttribute('name') || ('SmartArt ' + di)) : ('SmartArt ' + di), x, y, cx, cy, text });
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
return { tables, charts, smartArt };
|
|
2991
|
+
}
|
|
2992
|
+
parseBackground(doc, colorCtx) {
|
|
2993
|
+
const bg = this.qn(doc.documentElement, 'bg')[0];
|
|
2994
|
+
if (!bg) return { desc: 'Inherited from layout', color: null };
|
|
2995
|
+
const solid = this.qn(bg, 'solidFill')[0];
|
|
2996
|
+
if (solid) {
|
|
2997
|
+
const srgb = this.qn(solid, 'srgbClr')[0];
|
|
2998
|
+
if (srgb) return { desc: 'Solid #' + srgb.getAttribute('val'), color: '#' + srgb.getAttribute('val') };
|
|
2999
|
+
const sc = this.qn(solid, 'schemeClr')[0];
|
|
3000
|
+
if (sc) {
|
|
3001
|
+
const resolved = this.resolveSchemeColor(sc.getAttribute('val'), colorCtx);
|
|
3002
|
+
return resolved ? { desc: 'Solid ' + resolved + ' (theme ' + sc.getAttribute('val') + ')', color: resolved } : { desc: 'Solid (theme ' + sc.getAttribute('val') + ')', color: null };
|
|
3003
|
+
}
|
|
3004
|
+
return { desc: 'Solid (theme color)', color: null };
|
|
3005
|
+
}
|
|
3006
|
+
const grad = this.qn(bg, 'gradFill')[0];
|
|
3007
|
+
if (grad) {
|
|
3008
|
+
const stops = this.qn(grad, 'gs');
|
|
3009
|
+
const first = stops[0];
|
|
3010
|
+
let stopColor = null;
|
|
3011
|
+
if (first) {
|
|
3012
|
+
const srgb = this.qn(first, 'srgbClr')[0];
|
|
3013
|
+
if (srgb) stopColor = '#' + srgb.getAttribute('val');
|
|
3014
|
+
else { const sc = this.qn(first, 'schemeClr')[0]; if (sc) stopColor = this.resolveSchemeColor(sc.getAttribute('val'), colorCtx); }
|
|
3015
|
+
}
|
|
3016
|
+
return { desc: stopColor ? 'Gradient (from ' + stopColor + ')' : 'Gradient fill', color: stopColor };
|
|
3017
|
+
}
|
|
3018
|
+
if (this.qn(bg, 'blipFill')[0]) return { desc: 'Image fill', color: null };
|
|
3019
|
+
const patt = this.qn(bg, 'pattFill')[0];
|
|
3020
|
+
if (patt) {
|
|
3021
|
+
const fg = this.qn(patt, 'fgClr')[0];
|
|
3022
|
+
let fgColor = null;
|
|
3023
|
+
if (fg) { const srgb = this.qn(fg, 'srgbClr')[0]; if (srgb) fgColor = '#' + srgb.getAttribute('val'); else { const sc = this.qn(fg, 'schemeClr')[0]; if (sc) fgColor = this.resolveSchemeColor(sc.getAttribute('val'), colorCtx); } }
|
|
3024
|
+
return { desc: fgColor ? 'Pattern (fg ' + fgColor + ')' : 'Pattern fill', color: fgColor };
|
|
3025
|
+
}
|
|
3026
|
+
return { desc: 'Custom background', color: null };
|
|
3027
|
+
}
|
|
3028
|
+
parseAnimations(doc) {
|
|
3029
|
+
const timing = this.qn(doc.documentElement, 'timing')[0];
|
|
3030
|
+
if (!timing) return [];
|
|
3031
|
+
const idToName = {};
|
|
3032
|
+
this.qn(doc.documentElement, 'cNvPr').forEach(c => { idToName[c.getAttribute('id')] = c.getAttribute('name'); });
|
|
3033
|
+
const nodes = [];
|
|
3034
|
+
['animEffect', 'anim', 'set', 'animClr', 'animScale', 'animRot', 'animMotion'].forEach(k => {
|
|
3035
|
+
this.qn(timing, k).forEach(n => {
|
|
3036
|
+
const spTgt = this.qn(n, 'spTgt')[0];
|
|
3037
|
+
const spid = spTgt ? spTgt.getAttribute('spid') : null;
|
|
3038
|
+
const cTn = this.qn(n, 'cTn')[0];
|
|
3039
|
+
const preset = cTn ? ((cTn.getAttribute('presetClass') || '') + '/' + (cTn.getAttribute('presetID') || '')) : '';
|
|
3040
|
+
nodes.push({ type: k, target: spid ? (idToName[spid] || ('#' + spid)) : '(unknown)', preset });
|
|
3041
|
+
});
|
|
3042
|
+
});
|
|
3043
|
+
return nodes;
|
|
3044
|
+
}
|
|
3045
|
+
parseShapesFromDoc(doc, w, h, ridMap, colorCtx) {
|
|
3046
|
+
const out = []; let i = 0;
|
|
3047
|
+
this.qn(doc.documentElement, 'sp').forEach(sp => {
|
|
3048
|
+
const txBody = this.qn(sp, 'txBody')[0];
|
|
3049
|
+
if (!txBody) return;
|
|
3050
|
+
const paras = []; let fmt = null; const links = [];
|
|
3051
|
+
this.qn(txBody, 'p').forEach(p => {
|
|
3052
|
+
const runs = this.qn(p, 'r');
|
|
3053
|
+
let txt = '';
|
|
3054
|
+
runs.forEach(r => {
|
|
3055
|
+
const t = this.qn(r, 't')[0]; if (t) txt += t.textContent;
|
|
3056
|
+
const rPr = this.qn(r, 'rPr')[0];
|
|
3057
|
+
const link = rPr ? this.qn(rPr, 'hlinkClick')[0] : null;
|
|
3058
|
+
if (link) { const rid = link.getAttribute('r:id'); if (rid && ridMap && ridMap[rid]) links.push(ridMap[rid].target); }
|
|
3059
|
+
});
|
|
3060
|
+
this.qn(p, 'fld').forEach(f => { const t = this.qn(f, 't')[0]; if (t) txt += t.textContent; });
|
|
3061
|
+
if (!txt && !runs.length) { const fld = this.qn(p, 't')[0]; if (fld) txt = fld.textContent; }
|
|
3062
|
+
const pPr = this.qn(p, 'pPr')[0];
|
|
3063
|
+
const algn = pPr ? pPr.getAttribute('algn') : null;
|
|
3064
|
+
const bullet = pPr ? (this.qn(pPr, 'buChar').length > 0 || this.qn(pPr, 'buAutoNum').length > 0) : false;
|
|
3065
|
+
if (txt.trim() === '') return;
|
|
3066
|
+
if (!fmt) fmt = this.readRunFmt(runs.length ? this.qn(runs[0], 'rPr')[0] : null, algn, colorCtx);
|
|
3067
|
+
paras.push({ text: txt, bullet });
|
|
3068
|
+
});
|
|
3069
|
+
if (!paras.length) return;
|
|
3070
|
+
if (!fmt) fmt = this.readRunFmt(null, null, colorCtx);
|
|
3071
|
+
const off = this.qn(sp, 'off')[0], ext = this.qn(sp, 'ext')[0], cNv = this.qn(sp, 'cNvPr')[0];
|
|
3072
|
+
if (cNv) { const shapeLink = this.qn(cNv, 'hlinkClick')[0]; if (shapeLink) { const rid = shapeLink.getAttribute('r:id'); if (rid && ridMap && ridMap[rid]) links.push(ridMap[rid].target); } }
|
|
3073
|
+
const bodyPr = this.qn(txBody, 'bodyPr')[0];
|
|
3074
|
+
const wrap = bodyPr ? (bodyPr.getAttribute('wrap') || 'square') : 'square';
|
|
3075
|
+
const spPr = this.qn(sp, 'spPr')[0];
|
|
3076
|
+
const ln = spPr ? this.qn(spPr, 'ln')[0] : null;
|
|
3077
|
+
let border = null;
|
|
3078
|
+
if (ln) { const w2 = ln.getAttribute('w'); const srgb = this.qn(ln, 'srgbClr')[0]; if (w2 || srgb) border = { w: w2 ? +(+w2 / 12700).toFixed(1) : null, color: srgb ? '#' + srgb.getAttribute('val') : null }; }
|
|
3079
|
+
const ph = this.qn(sp, 'ph')[0];
|
|
3080
|
+
const phLabel = ph ? ({ title: 'Title', subTitle: 'Subtitle', ftr: 'Footer', sldNum: 'Slide number', dt: 'Date', hdr: 'Header', body: 'Body' })[ph.getAttribute('type') || 'body'] : null;
|
|
3081
|
+
out.push(Object.assign({
|
|
3082
|
+
name: cNv ? (cNv.getAttribute('name') || phLabel || ('shape' + i)) : (phLabel || ('shape' + i)),
|
|
3083
|
+
x: off ? +off.getAttribute('x') : Math.round(0.6 * this.EMU),
|
|
3084
|
+
y: off ? +off.getAttribute('y') : Math.round((0.5 + i) * this.EMU),
|
|
3085
|
+
cx: ext ? +ext.getAttribute('cx') : Math.round(w * 0.8),
|
|
3086
|
+
cy: ext ? +ext.getAttribute('cy') : Math.round(this.EMU),
|
|
3087
|
+
paras, wrap, border, links
|
|
3088
|
+
}, fmt));
|
|
3089
|
+
i++;
|
|
3090
|
+
});
|
|
3091
|
+
return out;
|
|
3092
|
+
}
|
|
3093
|
+
readRunFmt(rPr, algn, colorCtx) {
|
|
3094
|
+
const f = { font: 'Calibri', size: 18, bold: false, italic: false, color: '#1F1E1B', align: 'l' };
|
|
3095
|
+
if (algn) f.align = algn;
|
|
3096
|
+
if (rPr) {
|
|
3097
|
+
const sz = rPr.getAttribute('sz'); if (sz) f.size = +sz / 100;
|
|
3098
|
+
if (rPr.getAttribute('b') === '1') f.bold = true;
|
|
3099
|
+
if (rPr.getAttribute('i') === '1') f.italic = true;
|
|
3100
|
+
const latin = this.qn(rPr, 'latin')[0]; if (latin && latin.getAttribute('typeface')) f.font = latin.getAttribute('typeface');
|
|
3101
|
+
const fill = this.qn(rPr, 'solidFill')[0];
|
|
3102
|
+
if (fill) {
|
|
3103
|
+
const srgb = this.qn(fill, 'srgbClr')[0];
|
|
3104
|
+
if (srgb) f.color = '#' + srgb.getAttribute('val');
|
|
3105
|
+
else {
|
|
3106
|
+
const sc = this.qn(fill, 'schemeClr')[0];
|
|
3107
|
+
if (sc) {
|
|
3108
|
+
const resolved = this.resolveSchemeColor(sc.getAttribute('val'), colorCtx);
|
|
3109
|
+
const fallback = { tx1: '#1F1E1B', dk1: '#1F1E1B', tx2: '#44413A', dk2: '#44413A', bg1: '#FFFFFF', lt1: '#FFFFFF', accent1: '#C9684A' };
|
|
3110
|
+
f.color = resolved || fallback[sc.getAttribute('val')] || f.color;
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
return f;
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
/* ---------- diff engine ---------- */
|
|
3119
|
+
shapeText(s) { return s.paras.map(p => p.text).join(' '); }
|
|
3120
|
+
shapeLabel(s) { const t = (s.paras[0] && s.paras[0].text || '').trim(); return t ? (t.length > 26 ? t.slice(0, 25) + '…' : t) : s.name; }
|
|
3121
|
+
inch(v) { return (v / this.EMU).toFixed(1); }
|
|
3122
|
+
alignName(a) { return ({ l: 'Left', ctr: 'Center', r: 'Right', just: 'Justified' })[a] || 'Left'; }
|
|
3123
|
+
compareShape(a, b, key) {
|
|
3124
|
+
const d = [], base = { key, severity: 'change', sev: this.accent(), label: this.shapeLabel(a) };
|
|
3125
|
+
const ta = this.shapeText(a), tb = this.shapeText(b);
|
|
3126
|
+
if (ta !== tb) d.push(Object.assign({}, base, { type: 'Text', kind: 'text', oldText: ta, newText: tb }));
|
|
3127
|
+
if (a.font !== b.font) d.push(Object.assign({}, base, { type: 'Font', kind: 'plain', oldText: a.font, newText: b.font }));
|
|
3128
|
+
if (a.size !== b.size) d.push(Object.assign({}, base, { type: 'Size', kind: 'plain', oldText: a.size + ' pt', newText: b.size + ' pt' }));
|
|
3129
|
+
if (a.bold !== b.bold) d.push(Object.assign({}, base, { type: 'Weight', kind: 'plain', oldText: a.bold ? 'Bold' : 'Regular', newText: b.bold ? 'Bold' : 'Regular' }));
|
|
3130
|
+
if (a.italic !== b.italic) d.push(Object.assign({}, base, { type: 'Style', kind: 'plain', oldText: a.italic ? 'Italic' : 'Regular', newText: b.italic ? 'Italic' : 'Regular' }));
|
|
3131
|
+
if ((a.color || '').toUpperCase() !== (b.color || '').toUpperCase()) d.push(Object.assign({}, base, { type: 'Color', kind: 'color', oldText: a.color, newText: b.color }));
|
|
3132
|
+
if (a.align !== b.align) d.push(Object.assign({}, base, { type: 'Align', kind: 'plain', oldText: this.alignName(a.align), newText: this.alignName(b.align) }));
|
|
3133
|
+
if (Math.abs(a.x - b.x) > this.EMU * 0.05 || Math.abs(a.y - b.y) > this.EMU * 0.05) d.push(Object.assign({}, base, { type: 'Position', kind: 'plain', oldText: this.inch(a.x) + '", ' + this.inch(a.y) + '"', newText: this.inch(b.x) + '", ' + this.inch(b.y) + '"' }));
|
|
3134
|
+
if (Math.abs(a.cx - b.cx) > this.EMU * 0.08 || Math.abs(a.cy - b.cy) > this.EMU * 0.08) d.push(Object.assign({}, base, { type: 'Box size', kind: 'plain', oldText: this.inch(a.cx) + ' × ' + this.inch(a.cy) + '"', newText: this.inch(b.cx) + ' × ' + this.inch(b.cy) + '"' }));
|
|
3135
|
+
if ((a.wrap || 'square') !== (b.wrap || 'square')) d.push(Object.assign({}, base, { type: 'Wrap', kind: 'plain', oldText: (a.wrap || 'square') === 'none' ? "Don't wrap" : 'Wrap text', newText: (b.wrap || 'square') === 'none' ? "Don't wrap" : 'Wrap text' }));
|
|
3136
|
+
const borderSig = (br) => br ? ((br.w || '?') + 'pt ' + (br.color || '')) : 'None';
|
|
3137
|
+
if (borderSig(a.border) !== borderSig(b.border)) d.push(Object.assign({}, base, { type: 'Border', kind: 'plain', oldText: borderSig(a.border), newText: borderSig(b.border) }));
|
|
3138
|
+
const linkSig = (l) => (l || []).join(', ') || '(none)';
|
|
3139
|
+
if (linkSig(a.links) !== linkSig(b.links)) d.push(Object.assign({}, base, { type: 'Hyperlinks', kind: 'plain', oldText: linkSig(a.links), newText: linkSig(b.links) }));
|
|
3140
|
+
return d;
|
|
3141
|
+
}
|
|
3142
|
+
/* Generic named-element matcher used for shapes/images/tables/charts. fieldDiffFn(a,b,key)->diff[] */
|
|
3143
|
+
matchByName(prefix, listA, listB, fieldDiffFn, labelFn) {
|
|
3144
|
+
const statusA = {}, statusB = {}, list = [];
|
|
3145
|
+
const idxB = {};
|
|
3146
|
+
listB.forEach((s, i) => { const nm = s.name || (prefix + 'B' + i); idxB[nm] = { s, k: prefix + nm, used: false }; });
|
|
3147
|
+
listA.forEach((sa, ai) => {
|
|
3148
|
+
const nm = sa.name || (prefix + 'A' + ai), key = prefix + nm, m = idxB[nm];
|
|
3149
|
+
if (!m) { statusA[key] = 'removed'; list.push({ key, severity: 'remove', sev: this.COL.remove, type: 'Removed', kind: 'note', label: labelFn(sa), oldText: 'Element only in Before' }); return; }
|
|
3150
|
+
m.used = true;
|
|
3151
|
+
const fd = fieldDiffFn(sa, m.s, key);
|
|
3152
|
+
if (fd.length) { statusA[key] = 'changed'; statusB[m.k] = 'changed'; fd.forEach(x => list.push(x)); }
|
|
3153
|
+
else { statusA[key] = 'same'; statusB[m.k] = 'same'; }
|
|
3154
|
+
});
|
|
3155
|
+
listB.forEach((sb, bi) => {
|
|
3156
|
+
const nm = sb.name || (prefix + 'B' + bi), key = prefix + nm;
|
|
3157
|
+
if (idxB[nm] && !idxB[nm].used) { statusB[key] = 'added'; list.push({ key, severity: 'add', sev: this.COL.add, type: 'Added', kind: 'note', label: labelFn(sb), oldText: 'New element only in After' }); }
|
|
3158
|
+
});
|
|
3159
|
+
return { statusA, statusB, diffs: list };
|
|
3160
|
+
}
|
|
3161
|
+
compareImage(a, b, key) {
|
|
3162
|
+
const d = [], base = { key, severity: 'change', sev: this.accent(), label: a.name };
|
|
3163
|
+
if (a.hash && b.hash && a.hash !== b.hash) d.push(Object.assign({}, base, { type: 'Image', kind: 'plain', oldText: 'Original image', newText: 'Different image content' }));
|
|
3164
|
+
else if (!a.hash !== !b.hash) d.push(Object.assign({}, base, { type: 'Image', kind: 'note', oldText: 'Image content could not be fully compared' }));
|
|
3165
|
+
if (Math.abs(a.x - b.x) > this.EMU * 0.05 || Math.abs(a.y - b.y) > this.EMU * 0.05) d.push(Object.assign({}, base, { type: 'Position', kind: 'plain', oldText: this.inch(a.x) + '", ' + this.inch(a.y) + '"', newText: this.inch(b.x) + '", ' + this.inch(b.y) + '"' }));
|
|
3166
|
+
if (Math.abs(a.cx - b.cx) > this.EMU * 0.08 || Math.abs(a.cy - b.cy) > this.EMU * 0.08) d.push(Object.assign({}, base, { type: 'Size', kind: 'plain', oldText: this.inch(a.cx) + ' × ' + this.inch(a.cy) + '"', newText: this.inch(b.cx) + ' × ' + this.inch(b.cy) + '"' }));
|
|
3167
|
+
if ((a.link || null) !== (b.link || null)) d.push(Object.assign({}, base, { type: 'Hyperlink', kind: 'plain', oldText: a.link || '(none)', newText: b.link || '(none)' }));
|
|
3168
|
+
return d;
|
|
3169
|
+
}
|
|
3170
|
+
cellText(c) { return typeof c === 'string' ? c : c.text; }
|
|
3171
|
+
compareTable(a, b, key) {
|
|
3172
|
+
const d = [], base = { key, severity: 'change', sev: this.accent(), label: a.name };
|
|
3173
|
+
if (a.nRows !== b.nRows || a.nCols !== b.nCols) d.push(Object.assign({}, base, { type: 'Table shape', kind: 'plain', oldText: a.nRows + ' × ' + a.nCols, newText: b.nRows + ' × ' + b.nCols }));
|
|
3174
|
+
const ta = a.rows.map(r => r.map(c => this.cellText(c)).join(' | ')).join('\n'), tb = b.rows.map(r => r.map(c => this.cellText(c)).join(' | ')).join('\n');
|
|
3175
|
+
if (ta !== tb) d.push(Object.assign({}, base, { type: 'Table cells', kind: 'text', oldText: ta, newText: tb }));
|
|
3176
|
+
const rN = Math.min(a.rows.length, b.rows.length);
|
|
3177
|
+
let shown = 0, extra = 0;
|
|
3178
|
+
for (let r = 0; r < rN; r++) {
|
|
3179
|
+
const cN = Math.min(a.rows[r].length, b.rows[r].length);
|
|
3180
|
+
for (let c = 0; c < cN; c++) {
|
|
3181
|
+
const ca = a.rows[r][c], cb = b.rows[r][c];
|
|
3182
|
+
if (typeof ca === 'string' || typeof cb === 'string') continue;
|
|
3183
|
+
const cellRef = 'R' + (r + 1) + 'C' + (c + 1);
|
|
3184
|
+
if ((ca.bg || null) !== (cb.bg || null)) {
|
|
3185
|
+
if (shown < 20) { d.push(Object.assign({}, base, { key: key + ':' + cellRef + ':bg', type: 'Cell fill ' + cellRef, kind: 'color', oldText: ca.bg || '#FFFFFF', newText: cb.bg || '#FFFFFF' })); shown++; } else extra++;
|
|
3186
|
+
}
|
|
3187
|
+
const bSig = (br) => br ? ((br.w || '?') + 'pt ' + (br.color || '')) : 'default';
|
|
3188
|
+
if (bSig(ca.border) !== bSig(cb.border)) {
|
|
3189
|
+
if (shown < 20) { d.push(Object.assign({}, base, { key: key + ':' + cellRef + ':bd', type: 'Cell border ' + cellRef, kind: 'plain', oldText: bSig(ca.border), newText: bSig(cb.border) })); shown++; } else extra++;
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
if (extra > 0) d.push(Object.assign({}, base, { type: 'Table formatting', kind: 'note', oldText: '+' + extra + ' more cell formatting change(s)' }));
|
|
3194
|
+
return d;
|
|
3195
|
+
}
|
|
3196
|
+
compareChart(a, b, key) {
|
|
3197
|
+
const d = [], base = { key, severity: 'change', sev: this.accent(), label: a.name };
|
|
3198
|
+
if (a.chartType !== b.chartType) d.push(Object.assign({}, base, { type: 'Chart type', kind: 'plain', oldText: a.chartType, newText: b.chartType }));
|
|
3199
|
+
const sa = a.series.map(s => s.name + ':' + s.values).join(' | '), sb = b.series.map(s => s.name + ':' + s.values).join(' | ');
|
|
3200
|
+
if (sa !== sb) d.push(Object.assign({}, base, { type: 'Chart data', kind: 'text', oldText: sa || '(no data)', newText: sb || '(no data)' }));
|
|
3201
|
+
return d;
|
|
3202
|
+
}
|
|
3203
|
+
compareMedia(a, b, key) {
|
|
3204
|
+
const d = [], base = { key, severity: 'change', sev: this.accent(), label: a.name };
|
|
3205
|
+
const kindLabel = a.kind === 'video' ? 'Video' : 'Audio';
|
|
3206
|
+
if (a.hash && b.hash && a.hash !== b.hash) d.push(Object.assign({}, base, { type: kindLabel, kind: 'plain', oldText: 'Original ' + a.kind, newText: 'Different ' + a.kind + ' content' }));
|
|
3207
|
+
if (Math.abs(a.x - b.x) > this.EMU * 0.05 || Math.abs(a.y - b.y) > this.EMU * 0.05) d.push(Object.assign({}, base, { type: 'Position', kind: 'plain', oldText: this.inch(a.x) + '", ' + this.inch(a.y) + '"', newText: this.inch(b.x) + '", ' + this.inch(b.y) + '"' }));
|
|
3208
|
+
if (Math.abs(a.cx - b.cx) > this.EMU * 0.08 || Math.abs(a.cy - b.cy) > this.EMU * 0.08) d.push(Object.assign({}, base, { type: 'Size', kind: 'plain', oldText: this.inch(a.cx) + ' × ' + this.inch(a.cy) + '"', newText: this.inch(b.cx) + ' × ' + this.inch(b.cy) + '"' }));
|
|
3209
|
+
return d;
|
|
3210
|
+
}
|
|
3211
|
+
compareSmartArt(a, b, key) {
|
|
3212
|
+
const d = [], base = { key, severity: 'change', sev: this.accent(), label: a.name };
|
|
3213
|
+
if ((a.text || '') !== (b.text || '')) d.push(Object.assign({}, base, { type: 'SmartArt text', kind: 'text', oldText: a.text || '(no text)', newText: b.text || '(no text)' }));
|
|
3214
|
+
if (Math.abs(a.x - b.x) > this.EMU * 0.05 || Math.abs(a.y - b.y) > this.EMU * 0.05) d.push(Object.assign({}, base, { type: 'Position', kind: 'plain', oldText: this.inch(a.x) + '", ' + this.inch(a.y) + '"', newText: this.inch(b.x) + '", ' + this.inch(b.y) + '"' }));
|
|
3215
|
+
if (Math.abs(a.cx - b.cx) > this.EMU * 0.08 || Math.abs(a.cy - b.cy) > this.EMU * 0.08) d.push(Object.assign({}, base, { type: 'Size', kind: 'plain', oldText: this.inch(a.cx) + ' × ' + this.inch(a.cy) + '"', newText: this.inch(b.cx) + ' × ' + this.inch(b.cy) + '"' }));
|
|
3216
|
+
return d;
|
|
3217
|
+
}
|
|
3218
|
+
compareNotes(a, b) {
|
|
3219
|
+
a = a || { text: '', bold: false, italic: false, font: null, color: null };
|
|
3220
|
+
b = b || { text: '', bold: false, italic: false, font: null, color: null };
|
|
3221
|
+
const d = [];
|
|
3222
|
+
if ((a.text || '') !== (b.text || '')) d.push({ key: 'notes', severity: 'change', sev: this.accent(), label: 'Speaker notes', type: 'Speaker notes', kind: 'text', oldText: a.text || '(none)', newText: b.text || '(none)' });
|
|
3223
|
+
const fmtSig = (n) => (n.bold ? 'Bold ' : '') + (n.italic ? 'Italic ' : '') + (n.font || '') + ' ' + (n.color || '');
|
|
3224
|
+
if (a.text && b.text && fmtSig(a) !== fmtSig(b)) {
|
|
3225
|
+
const parts = [];
|
|
3226
|
+
if (!!a.bold !== !!b.bold) parts.push((a.bold ? 'Bold' : 'Regular') + ' → ' + (b.bold ? 'Bold' : 'Regular'));
|
|
3227
|
+
if (!!a.italic !== !!b.italic) parts.push((a.italic ? 'Italic' : 'Upright') + ' → ' + (b.italic ? 'Italic' : 'Upright'));
|
|
3228
|
+
if ((a.font || '') !== (b.font || '')) parts.push((a.font || 'default') + ' → ' + (b.font || 'default'));
|
|
3229
|
+
if ((a.color || '') !== (b.color || '')) parts.push((a.color || 'default') + ' → ' + (b.color || 'default'));
|
|
3230
|
+
d.push({ key: 'notes-fmt', severity: 'change', sev: this.accent(), label: 'Speaker notes', type: 'Speaker notes formatting', kind: 'note', oldText: parts.join('; ') });
|
|
3231
|
+
}
|
|
3232
|
+
return d;
|
|
3233
|
+
}
|
|
3234
|
+
compareTransition(a, b) {
|
|
3235
|
+
const d = [];
|
|
3236
|
+
if (a.type !== b.type) d.push({ key: 'transition', severity: 'change', sev: this.accent(), label: 'Slide transition', type: 'Transition', kind: 'plain', oldText: a.type, newText: b.type });
|
|
3237
|
+
if (a.spd !== b.spd || a.advTm !== b.advTm) {
|
|
3238
|
+
const fmt = (t) => (t.spd || 'default speed') + (t.advTm ? ', auto-advance ' + (t.advTm / 1000) + 's' : ', on click');
|
|
3239
|
+
d.push({ key: 'transition-timing', severity: 'change', sev: this.accent(), label: 'Slide transition', type: 'Transition timing', kind: 'plain', oldText: fmt(a), newText: fmt(b) });
|
|
3240
|
+
}
|
|
3241
|
+
return d;
|
|
3242
|
+
}
|
|
3243
|
+
compareInheritance(a, b) {
|
|
3244
|
+
const d = [];
|
|
3245
|
+
if (a.layoutName !== b.layoutName) d.push({ key: 'layout', severity: 'change', sev: this.accent(), label: 'Slide layout', type: 'Layout', kind: 'plain', oldText: a.layoutName, newText: b.layoutName });
|
|
3246
|
+
if (a.themeName !== b.themeName) d.push({ key: 'theme', severity: 'change', sev: this.accent(), label: 'Master theme', type: 'Theme', kind: 'plain', oldText: a.themeName, newText: b.themeName });
|
|
3247
|
+
return d;
|
|
3248
|
+
}
|
|
3249
|
+
compareBackground(a, b) {
|
|
3250
|
+
if ((a.color || a.desc) === (b.color || b.desc) && a.desc === b.desc) return [];
|
|
3251
|
+
if (a.color && b.color) return [{ key: 'bg', severity: 'change', sev: this.accent(), label: 'Slide background', type: 'Background', kind: 'color', oldText: a.color, newText: b.color }];
|
|
3252
|
+
return [{ key: 'bg', severity: 'change', sev: this.accent(), label: 'Slide background', type: 'Background', kind: 'plain', oldText: a.desc, newText: b.desc }];
|
|
3253
|
+
}
|
|
3254
|
+
compareAnimations(a, b) {
|
|
3255
|
+
const sig = (arr) => arr.map(n => n.type + ':' + n.target + ':' + n.preset).join(' → ');
|
|
3256
|
+
const sa = sig(a || []), sb = sig(b || []);
|
|
3257
|
+
if (sa === sb) return [];
|
|
3258
|
+
return [{ key: 'anim', severity: 'change', sev: this.accent(), label: 'Animation sequence', type: 'Animations', kind: 'plain',
|
|
3259
|
+
oldText: (a && a.length) ? (a.length + ' effect' + (a.length === 1 ? '' : 's')) : 'No animation', newText: (b && b.length) ? (b.length + ' effect' + (b.length === 1 ? '' : 's')) : 'No animation' }];
|
|
3260
|
+
}
|
|
3261
|
+
compare(sA, sB) {
|
|
3262
|
+
const shapeR = this.matchByName('text:', sA.shapes, sB.shapes, (a, b, k) => this.compareShape(a, b, k), (s) => this.shapeLabel(s));
|
|
3263
|
+
const imgR = this.matchByName('image:', sA.images || [], sB.images || [], (a, b, k) => this.compareImage(a, b, k), (s) => s.name);
|
|
3264
|
+
const tblR = this.matchByName('table:', sA.tables || [], sB.tables || [], (a, b, k) => this.compareTable(a, b, k), (s) => s.name);
|
|
3265
|
+
const chartR = this.matchByName('chart:', sA.charts || [], sB.charts || [], (a, b, k) => this.compareChart(a, b, k), (s) => s.name);
|
|
3266
|
+
const dgmR = this.matchByName('smartart:', sA.smartArt || [], sB.smartArt || [], (a, b, k) => this.compareSmartArt(a, b, k), (s) => s.name);
|
|
3267
|
+
const vidA = (sA.media || []).filter(m => m.kind === 'video'), vidB = (sB.media || []).filter(m => m.kind === 'video');
|
|
3268
|
+
const audA = (sA.media || []).filter(m => m.kind === 'audio'), audB = (sB.media || []).filter(m => m.kind === 'audio');
|
|
3269
|
+
const vidR = this.matchByName('video:', vidA, vidB, (a, b, k) => this.compareMedia(a, b, k), (s) => s.name);
|
|
3270
|
+
const audR = this.matchByName('audio:', audA, audB, (a, b, k) => this.compareMedia(a, b, k), (s) => s.name);
|
|
3271
|
+
const bgDiffs = this.compareBackground(sA.background || { desc: 'Inherited from layout', color: null }, sB.background || { desc: 'Inherited from layout', color: null });
|
|
3272
|
+
const animDiffs = this.compareAnimations(sA.animations, sB.animations);
|
|
3273
|
+
const notesDiffs = this.compareNotes(sA.notes, sB.notes);
|
|
3274
|
+
const transDiffs = this.compareTransition(sA.transition || { type: 'None' }, sB.transition || { type: 'None' });
|
|
3275
|
+
const inheritDiffs = this.compareInheritance(sA.layoutName ? sA : { layoutName: '(unknown)', themeName: '(unknown)' }, sB.layoutName ? sB : { layoutName: '(unknown)', themeName: '(unknown)' });
|
|
3276
|
+
return {
|
|
3277
|
+
statusA: Object.assign({}, shapeR.statusA, imgR.statusA, tblR.statusA, chartR.statusA, dgmR.statusA, vidR.statusA, audR.statusA),
|
|
3278
|
+
statusB: Object.assign({}, shapeR.statusB, imgR.statusB, tblR.statusB, chartR.statusB, dgmR.statusB, vidR.statusB, audR.statusB),
|
|
3279
|
+
diffs: [].concat(shapeR.diffs, imgR.diffs, tblR.diffs, chartR.diffs, dgmR.diffs, vidR.diffs, audR.diffs, bgDiffs, animDiffs, notesDiffs, transDiffs, inheritDiffs)
|
|
3280
|
+
};
|
|
3281
|
+
}
|
|
3282
|
+
compareSlidePair(sA, sB) {
|
|
3283
|
+
if (sA && sB) return this.compare(sA, sB);
|
|
3284
|
+
if (!sA && !sB) return { statusA: {}, statusB: {}, diffs: [] };
|
|
3285
|
+
if (!sA) {
|
|
3286
|
+
const statusB = {}, diffs = [];
|
|
3287
|
+
sB.shapes.forEach((s, i) => { const k = s.name || ('B' + i); statusB[k] = 'added'; diffs.push({ key: k, severity: 'add', sev: this.COL.add, type: 'Added', kind: 'note', label: this.shapeLabel(s), oldText: 'New slide' }); });
|
|
3288
|
+
return { statusA: {}, statusB, diffs };
|
|
3289
|
+
}
|
|
3290
|
+
const statusA = {}, diffs = [];
|
|
3291
|
+
sA.shapes.forEach((s, i) => { const k = s.name || ('A' + i); statusA[k] = 'removed'; diffs.push({ key: k, severity: 'remove', sev: this.COL.remove, type: 'Removed', kind: 'note', label: this.shapeLabel(s), oldText: 'Slide removed' }); });
|
|
3292
|
+
return { statusA, statusB: {}, diffs };
|
|
3293
|
+
}
|
|
3294
|
+
wordDiff(o, n) {
|
|
3295
|
+
const A = (o || '').split(/\s+/).filter(Boolean), B = (n || '').split(/\s+/).filter(Boolean);
|
|
3296
|
+
const m = A.length, k = B.length, dp = Array.from({ length: m + 1 }, () => new Array(k + 1).fill(0));
|
|
3297
|
+
for (let i = m - 1; i >= 0; i--) for (let j = k - 1; j >= 0; j--) dp[i][j] = A[i] === B[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
3298
|
+
const oldT = [], newT = []; let i = 0, j = 0;
|
|
3299
|
+
while (i < m && j < k) {
|
|
3300
|
+
if (A[i] === B[j]) { oldT.push({ w: A[i], ch: false }); newT.push({ w: B[j], ch: false }); i++; j++; }
|
|
3301
|
+
else if (dp[i + 1][j] >= dp[i][j + 1]) { oldT.push({ w: A[i], ch: true }); i++; }
|
|
3302
|
+
else { newT.push({ w: B[j], ch: true }); j++; }
|
|
3303
|
+
}
|
|
3304
|
+
while (i < m) { oldT.push({ w: A[i], ch: true }); i++; }
|
|
3305
|
+
while (j < k) { newT.push({ w: B[j], ch: true }); j++; }
|
|
3306
|
+
return { oldT, newT };
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
/* ---------- render mapping ---------- */
|
|
3310
|
+
shapeBoxStyle(status, selected) {
|
|
3311
|
+
const sev = status === 'added' ? this.COL.add : status === 'removed' ? this.COL.remove : this.accent();
|
|
3312
|
+
let outline = '1.5px solid transparent', boxShadow = 'none', bg = 'transparent';
|
|
3313
|
+
if (status === 'changed' || status === 'added') outline = '1.5px solid ' + sev + 'CC';
|
|
3314
|
+
if (status === 'removed') outline = '1.5px dashed ' + sev + 'CC';
|
|
3315
|
+
if (selected) { outline = '2px solid ' + sev; boxShadow = '0 0 0 4px ' + sev + '40'; bg = sev + '1F'; }
|
|
3316
|
+
return { outline, boxShadow, bg };
|
|
3317
|
+
}
|
|
3318
|
+
mediaBoxes(slide, statusMap, interactiveFn, selKey) {
|
|
3319
|
+
const list = [];
|
|
3320
|
+
(slide.images || []).forEach((s, i) => list.push(this.mediaBox(s, 'image:' + (s.name || ('B' + i)), statusMap, interactiveFn, selKey, 'Image')));
|
|
3321
|
+
(slide.tables || []).forEach((s, i) => list.push(this.mediaBox(s, 'table:' + (s.name || ('B' + i)), statusMap, interactiveFn, selKey, 'Table')));
|
|
3322
|
+
(slide.charts || []).forEach((s, i) => list.push(this.mediaBox(s, 'chart:' + (s.name || ('B' + i)), statusMap, interactiveFn, selKey, 'Chart')));
|
|
3323
|
+
(slide.smartArt || []).forEach((s, i) => list.push(this.mediaBox(s, 'smartart:' + (s.name || ('B' + i)), statusMap, interactiveFn, selKey, 'SmartArt')));
|
|
3324
|
+
(slide.media || []).filter(m => m.kind === 'video').forEach((s, i) => list.push(this.mediaBox(s, 'video:' + (s.name || ('B' + i)), statusMap, interactiveFn, selKey, 'Video')));
|
|
3325
|
+
(slide.media || []).filter(m => m.kind === 'audio').forEach((s, i) => list.push(this.mediaBox(s, 'audio:' + (s.name || ('B' + i)), statusMap, interactiveFn, selKey, 'Audio')));
|
|
3326
|
+
return list;
|
|
3327
|
+
}
|
|
3328
|
+
mediaBox(s, key, statusMap, interactiveFn, selKey, kindLabel) {
|
|
3329
|
+
const status = statusMap[key] || 'same';
|
|
3330
|
+
const selected = selKey === key;
|
|
3331
|
+
const bx = this.shapeBoxStyle(status, selected);
|
|
3332
|
+
const interactive = status !== 'same';
|
|
3333
|
+
return {
|
|
3334
|
+
key, kindLabel, name: s.name,
|
|
3335
|
+
lpct: s.x / (s._pw || 1) * 100, tpct: s.y / (s._ph || 1) * 100, wpct: s.cx / (s._pw || 1) * 100, hpct: s.cy / (s._ph || 1) * 100,
|
|
3336
|
+
outline: bx.outline, boxShadow: bx.boxShadow, bg: bx.bg,
|
|
3337
|
+
pe: interactiveFn && interactive ? 'auto' : 'none', cursor: interactiveFn && interactive ? 'pointer' : 'default',
|
|
3338
|
+
title: status === 'same' ? '' : (s.name + ' — ' + status),
|
|
3339
|
+
onClick: interactiveFn ? interactiveFn(key) : (() => {})
|
|
3340
|
+
};
|
|
3341
|
+
}
|
|
3342
|
+
withSlideDims(slide) {
|
|
3343
|
+
(slide.images || []).forEach(s => { s._pw = slide.w; s._ph = slide.h; });
|
|
3344
|
+
(slide.tables || []).forEach(s => { s._pw = slide.w; s._ph = slide.h; });
|
|
3345
|
+
(slide.charts || []).forEach(s => { s._pw = slide.w; s._ph = slide.h; });
|
|
3346
|
+
(slide.smartArt || []).forEach(s => { s._pw = slide.w; s._ph = slide.h; });
|
|
3347
|
+
(slide.media || []).forEach(s => { s._pw = slide.w; s._ph = slide.h; });
|
|
3348
|
+
return slide;
|
|
3349
|
+
}
|
|
3350
|
+
mapStage(slide, statusMap, side, name, idx, count, libOk, loading) {
|
|
3351
|
+
slide = this.withSlideDims(slide);
|
|
3352
|
+
const setSel = (k) => this.setState(s => ({ selectedKey: s.selectedKey === k ? null : k }));
|
|
3353
|
+
const shapes = slide.shapes.map((s, i) => {
|
|
3354
|
+
const key = 'text:' + (s.name || (side + i));
|
|
3355
|
+
const status = statusMap[key] || 'same';
|
|
3356
|
+
const selected = this.state.selectedKey === key;
|
|
3357
|
+
const bx = this.shapeBoxStyle(status, selected);
|
|
3358
|
+
const interactive = status !== 'same';
|
|
3359
|
+
return {
|
|
3360
|
+
key, lpct: s.x / slide.w * 100, tpct: s.y / slide.h * 100,
|
|
3361
|
+
wpct: s.cx / slide.w * 100, hpct: s.cy / slide.h * 100,
|
|
3362
|
+
fontCqw: Math.max(1.4, (s.size / 72) / (slide.w / this.EMU) * 100),
|
|
3363
|
+
color: s.color, weight: s.bold ? '700' : '400',
|
|
3364
|
+
fontStyle: s.italic ? 'italic' : 'normal',
|
|
3365
|
+
fontFamily: "'" + s.font + "', Georgia, sans-serif",
|
|
3366
|
+
textAlign: ({ l: 'left', ctr: 'center', r: 'right', just: 'justify' })[s.align] || 'left',
|
|
3367
|
+
outline: bx.outline, boxShadow: bx.boxShadow, bg: bx.bg,
|
|
3368
|
+
pe: interactive ? 'auto' : 'none', cursor: interactive ? 'pointer' : 'default',
|
|
3369
|
+
title: status === 'same' ? '' : (this.shapeLabel(s) + ' — ' + status),
|
|
3370
|
+
onClick: () => setSel(key),
|
|
3371
|
+
paras: s.paras.map(p => ({ prefix: p.bullet ? '• ' : '', text: p.text }))
|
|
3372
|
+
};
|
|
3373
|
+
});
|
|
3374
|
+
const media = this.mediaBoxes(slide, statusMap, (k) => (() => setSel(k)), this.state.selectedKey);
|
|
3375
|
+
return {
|
|
3376
|
+
role: side === 'A' ? 'Before' : 'After',
|
|
3377
|
+
dot: side === 'A' ? '#8A8273' : this.accent(),
|
|
3378
|
+
name, cur: idx + 1, count, aspect: (slide.w / slide.h).toFixed(4),
|
|
3379
|
+
empty: shapes.length === 0 && media.length === 0, shapes, media, schematic: !libOk, loading,
|
|
3380
|
+
modeLabel: libOk ? 'Rendered' : 'Schematic',
|
|
3381
|
+
modeBg: libOk ? '#EAF3EE' : '#F1ECDE', modeFg: libOk ? '#2F6347' : '#8A8273', modeBorder: libOk ? '#CDE3D6' : '#E2DCCE',
|
|
3382
|
+
mountRef: (el) => this.attachMount(side + ':single', side, idx, el),
|
|
3383
|
+
prev: () => this.move(side, -1), next: () => this.move(side, 1)
|
|
3384
|
+
};
|
|
3385
|
+
}
|
|
3386
|
+
mapCompactStage(slide, statusMap, side, idx, libOk) {
|
|
3387
|
+
if (!slide) return { missing: true, empty: true, shapes: [], media: [], schematic: true, aspect: '1.7778', mountRef: () => {}, modeLabel: '', modeBg: 'transparent', modeFg: 'transparent', modeBorder: 'transparent' };
|
|
3388
|
+
slide = this.withSlideDims(slide);
|
|
3389
|
+
const shapes = slide.shapes.map((s, i) => {
|
|
3390
|
+
const key = 'text:' + (s.name || (side + i));
|
|
3391
|
+
const status = statusMap[key] || 'same';
|
|
3392
|
+
const bx = this.shapeBoxStyle(status, false);
|
|
3393
|
+
return {
|
|
3394
|
+
key, lpct: s.x / slide.w * 100, tpct: s.y / slide.h * 100, wpct: s.cx / slide.w * 100, hpct: s.cy / slide.h * 100,
|
|
3395
|
+
fontCqw: Math.max(1.4, (s.size / 72) / (slide.w / this.EMU) * 100),
|
|
3396
|
+
color: s.color, weight: s.bold ? '700' : '400', fontStyle: s.italic ? 'italic' : 'normal',
|
|
3397
|
+
fontFamily: "'" + s.font + "', Georgia, sans-serif",
|
|
3398
|
+
textAlign: ({ l: 'left', ctr: 'center', r: 'right', just: 'justify' })[s.align] || 'left',
|
|
3399
|
+
outline: bx.outline, boxShadow: 'none', bg: 'transparent', pe: 'none', cursor: 'default', title: '', onClick: () => {},
|
|
3400
|
+
paras: s.paras.map(p => ({ prefix: p.bullet ? '• ' : '', text: p.text }))
|
|
3401
|
+
};
|
|
3402
|
+
});
|
|
3403
|
+
const media = this.mediaBoxes(slide, statusMap, null, null);
|
|
3404
|
+
return {
|
|
3405
|
+
missing: false, empty: shapes.length === 0 && media.length === 0, shapes, media, schematic: !libOk,
|
|
3406
|
+
aspect: (slide.w / slide.h).toFixed(4),
|
|
3407
|
+
modeLabel: libOk ? 'Rendered' : 'Schematic',
|
|
3408
|
+
modeBg: libOk ? '#EAF3EE' : '#F1ECDE', modeFg: libOk ? '#2F6347' : '#8A8273', modeBorder: libOk ? '#CDE3D6' : '#E2DCCE',
|
|
3409
|
+
mountRef: (el) => this.attachMount(side + ':pair:' + idx, side, idx, el)
|
|
3410
|
+
};
|
|
3411
|
+
}
|
|
3412
|
+
move(side, dir) {
|
|
3413
|
+
if (side === 'A') this.setState(st => ({ idxA: Math.max(0, Math.min(st.slidesA.length - 1, st.idxA + dir)), selectedKey: null }));
|
|
3414
|
+
else this.setState(st => ({ idxB: Math.max(0, Math.min(st.slidesB.length - 1, st.idxB + dir)), selectedKey: null }));
|
|
3415
|
+
}
|
|
3416
|
+
loadSample = () => { this.loadSampleBuffers().catch(e => console.warn(e)); };
|
|
3417
|
+
setView(mode) { this.setState({ viewMode: mode }); }
|
|
3418
|
+
toggleThemeMode() { this.setState(s => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })); }
|
|
3419
|
+
focusComments() {
|
|
3420
|
+
this.setState({ viewMode: 'single' });
|
|
3421
|
+
requestAnimationFrame(() => { if (this.commentInputEl) this.commentInputEl.focus(); });
|
|
3422
|
+
}
|
|
3423
|
+
computeAlignment() {
|
|
3424
|
+
const st = this.state;
|
|
3425
|
+
return this.refineMoves(this.alignSlides(st.slidesA, st.slidesB), st.slidesA, st.slidesB);
|
|
3426
|
+
}
|
|
3427
|
+
goToAlignmentEntry(al) {
|
|
3428
|
+
this.setState(st => ({ viewMode: 'single', idxA: al.aIdx != null ? al.aIdx : st.idxA, idxB: al.bIdx != null ? al.bIdx : st.idxB, selectedKey: null }));
|
|
3429
|
+
}
|
|
3430
|
+
jumpToNextChanged() {
|
|
3431
|
+
const st = this.state;
|
|
3432
|
+
if (!st.slidesA.length && !st.slidesB.length) return;
|
|
3433
|
+
const alignment = this.computeAlignment();
|
|
3434
|
+
const curA = Math.min(st.idxA, st.slidesA.length - 1), curB = Math.min(st.idxB, st.slidesB.length - 1);
|
|
3435
|
+
const curPos = alignment.findIndex(al => al.aIdx === curA && al.bIdx === curB);
|
|
3436
|
+
for (let i = curPos + 1; i < alignment.length; i++) {
|
|
3437
|
+
const al = alignment[i];
|
|
3438
|
+
const pa = al.aIdx != null ? st.slidesA[al.aIdx] : null, pb = al.bIdx != null ? st.slidesB[al.bIdx] : null;
|
|
3439
|
+
const changed = !pa || !pb || this.compareSlidePair(pa, pb).diffs.length > 0;
|
|
3440
|
+
if (changed) { this.goToAlignmentEntry(al); return; }
|
|
3441
|
+
}
|
|
3442
|
+
this.showToast('No more changed slides after this one');
|
|
3443
|
+
}
|
|
3444
|
+
toggleAllPairsCollapsed() {
|
|
3445
|
+
this.setState(s => {
|
|
3446
|
+
const next = !s.allPairsCollapsed;
|
|
3447
|
+
const alignment = this.computeAlignment();
|
|
3448
|
+
const collapsedPairs = {};
|
|
3449
|
+
alignment.forEach(al => { collapsedPairs[this.pairKey(al.aIdx, al.bIdx)] = next; });
|
|
3450
|
+
return { allPairsCollapsed: next, collapsedPairs };
|
|
3451
|
+
});
|
|
3452
|
+
setTimeout(() => this.persistState(), 0);
|
|
3453
|
+
}
|
|
3454
|
+
toggleMergePreview() { this.setState(s => ({ showMergePreview: !s.showMergePreview })); }
|
|
3455
|
+
toggleShortcutsHelp() { this.setState(s => ({ showShortcutsHelp: !s.showShortcutsHelp })); }
|
|
3456
|
+
mergePreviewRows() {
|
|
3457
|
+
const st = this.state;
|
|
3458
|
+
const alignment = this.computeAlignment();
|
|
3459
|
+
const crossDupPairs = st.mergeDuplicateSlides ? this.findCrossDeckDuplicates(st.slidesA, st.slidesB, alignment, st.crossDupThreshold, undefined, true).pairs.filter(d => !st.ignoredCrossDupKeys[d.aIdx + ':' + d.bIdx]) : [];
|
|
3460
|
+
return alignment.map(al => {
|
|
3461
|
+
const pa = al.aIdx != null ? st.slidesA[al.aIdx] : null, pb = al.bIdx != null ? st.slidesB[al.bIdx] : null;
|
|
3462
|
+
const label = pa && pb ? ('Slide ' + (al.aIdx + 1) + ' → ' + (al.bIdx + 1)) : pa ? ('Slide ' + (al.aIdx + 1) + ' (removed)') : ('Slide ' + (al.bIdx + 1) + ' (added)');
|
|
3463
|
+
const key = this.pairKey(al.aIdx, al.bIdx);
|
|
3464
|
+
let winnerLabel, winnerColor;
|
|
3465
|
+
const override = st.mergeWinnerOverrides[key] || null;
|
|
3466
|
+
const overridable = !!(pa && pb);
|
|
3467
|
+
if (pa && pb) {
|
|
3468
|
+
const winner = this.pickMergeWinner(pa, pb, key);
|
|
3469
|
+
const isAfter = winner === pb;
|
|
3470
|
+
winnerLabel = isAfter ? 'After' : 'Before';
|
|
3471
|
+
winnerColor = isAfter ? '#2A6FDB' : '#8A6D3B';
|
|
3472
|
+
} else if (pa) {
|
|
3473
|
+
const dup = crossDupPairs.find(d => d.aIdx === al.aIdx);
|
|
3474
|
+
if (dup) { winnerLabel = 'Skipped — duplicate of After slide ' + (dup.bIdx + 1); winnerColor = '#0E7C86'; }
|
|
3475
|
+
else { winnerLabel = 'Dropped (only in Before)'; winnerColor = '#B23A2E'; }
|
|
3476
|
+
}
|
|
3477
|
+
else { winnerLabel = 'After (new slide)'; winnerColor = '#3E7C5A'; }
|
|
3478
|
+
return {
|
|
3479
|
+
label, winnerLabel, winnerColor, overridable, isOverridden: !!override,
|
|
3480
|
+
beforeBg: override === 'before' ? '#8A6D3B' : this.themeCardColor(), beforeFg: override === 'before' ? '#fff' : '#8A6D3B',
|
|
3481
|
+
afterBg: override === 'after' ? '#2A6FDB' : this.themeCardColor(), afterFg: override === 'after' ? '#fff' : '#2A6FDB',
|
|
3482
|
+
onOverrideBefore: (ev) => this.setMergeWinnerOverride(key, 'before', ev),
|
|
3483
|
+
onOverrideAfter: (ev) => this.setMergeWinnerOverride(key, 'after', ev)
|
|
3484
|
+
};
|
|
3485
|
+
});
|
|
3486
|
+
}
|
|
3487
|
+
themeCardColor() { return this.state.uiTheme === 'dark' ? '#252420' : '#FBFAF5'; }
|
|
3488
|
+
/* For keyboard approve/reject: current single-view slide pair, or null in batch view. */
|
|
3489
|
+
pairKeyForCurrentView() {
|
|
3490
|
+
const st = this.state;
|
|
3491
|
+
if (st.viewMode === 'batch' || !st.slidesA.length || !st.slidesB.length) return null;
|
|
3492
|
+
const idxA = Math.min(st.idxA, st.slidesA.length - 1), idxB = Math.min(st.idxB, st.slidesB.length - 1);
|
|
3493
|
+
const key = this.pairKey(idxA, idxB);
|
|
3494
|
+
const label = 'Slide ' + (idxA + 1) + ' → ' + (idxB + 1);
|
|
3495
|
+
return { key, label };
|
|
3496
|
+
}
|
|
3497
|
+
|
|
3498
|
+
renderVals() {
|
|
3499
|
+
const st = this.state;
|
|
3500
|
+
const isDark = st.uiTheme === 'dark';
|
|
3501
|
+
const theme = isDark
|
|
3502
|
+
? { bg: '#1C1B18', card: '#252420', border: '#3A382F', text: '#EDE9DE', muted: '#A79F8E', codeBg: '#33322B', chip: '#2B2A25' }
|
|
3503
|
+
: { bg: '#F4F1E9', card: '#FBFAF5', border: '#E2DCCE', text: '#1F1E1B', muted: '#6B655A', codeBg: '#EBE6D8', chip: '#EFEADB' };
|
|
3504
|
+
const toggleTheme = () => this.setState({ uiTheme: isDark ? 'light' : 'dark' });
|
|
3505
|
+
const word = this.props.wordLevelDiff !== false;
|
|
3506
|
+
const sA = st.slidesA[Math.min(st.idxA, st.slidesA.length - 1)];
|
|
3507
|
+
const sB = st.slidesB[Math.min(st.idxB, st.slidesB.length - 1)];
|
|
3508
|
+
const cmp = this.compare(sA, sB);
|
|
3509
|
+
const curIdx = Math.min(st.idxA, st.slidesA.length - 1);
|
|
3510
|
+
const curIdxB = Math.min(st.idxB, st.slidesB.length - 1);
|
|
3511
|
+
const curKey = this.pairKey(curIdx, curIdxB);
|
|
3512
|
+
const tokStyle = (ch, removed) => !ch ? 'color:#6B655A'
|
|
3513
|
+
: removed ? 'background:#B23A2E1f;color:#B23A2E;text-decoration:line-through;border-radius:3px;padding:0 2px;margin:0 1px'
|
|
3514
|
+
: 'background:#3E7C5A1f;color:#2F6347;font-weight:600;border-radius:3px;padding:0 2px;margin:0 1px';
|
|
3515
|
+
const diffs = cmp.diffs.map((d) => {
|
|
3516
|
+
const selected = st.selectedKey === d.key;
|
|
3517
|
+
const dKey = curKey + '::' + d.key;
|
|
3518
|
+
const dVotes = st.diffDecisions[dKey] || {};
|
|
3519
|
+
const dActiveVote = dVotes[st.activeHandle || 'anonymous'] || null;
|
|
3520
|
+
const mergeChoice = st.mergeChoices[dKey] || null;
|
|
3521
|
+
const o = { type: d.type, label: d.label, sev: d.sev, sevTint: d.sev + '14', sevBorder: d.sev + '30',
|
|
3522
|
+
rowBg: selected ? d.sev + '12' : 'transparent', isText: false, isColor: false, isPlain: false, isNote: false,
|
|
3523
|
+
oldText: d.oldText, newText: d.newText, oldTokens: [], newTokens: [], onClick: () => this.setState({ selectedKey: d.key }),
|
|
3524
|
+
approveBg: dActiveVote === 'approved' ? '#3E7C5A' : 'transparent', approveFg: dActiveVote === 'approved' ? '#fff' : '#3E7C5A',
|
|
3525
|
+
rejectBg: dActiveVote === 'rejected' ? '#B23A2E' : 'transparent', rejectFg: dActiveVote === 'rejected' ? '#fff' : '#B23A2E',
|
|
3526
|
+
onApproveDiff: (ev) => this.setDiffDecision(dKey, 'approved', ev), onRejectDiff: (ev) => this.setDiffDecision(dKey, 'rejected', ev),
|
|
3527
|
+
keepBeforeBg: mergeChoice === 'before' ? this.accent() : 'transparent', keepAfterBg: mergeChoice === 'after' ? this.accent() : 'transparent', keepCustomBg: mergeChoice === 'custom' ? this.accent() : 'transparent',
|
|
3528
|
+
keepBeforeFg: mergeChoice === 'before' ? '#fff' : this.accent(), keepAfterFg: mergeChoice === 'after' ? '#fff' : this.accent(), keepCustomFg: mergeChoice === 'custom' ? '#fff' : this.accent(),
|
|
3529
|
+
onKeepBefore: (ev) => this.setMergeChoice(dKey, 'before', ev), onKeepAfter: (ev) => this.setMergeChoice(dKey, 'after', ev), onKeepCustom: (ev) => this.setMergeChoice(dKey, 'custom', ev),
|
|
3530
|
+
dKey, selected2: !!st.selectedDiffs[dKey], onToggleSelect: (ev) => this.toggleDiffSelect(dKey, ev)
|
|
3531
|
+
};
|
|
3532
|
+
if (d.kind === 'note') o.isNote = true;
|
|
3533
|
+
else if (d.kind === 'color') o.isColor = true;
|
|
3534
|
+
else if (d.kind === 'text' && word) {
|
|
3535
|
+
const wd = this.wordDiff(d.oldText, d.newText); o.isText = true;
|
|
3536
|
+
o.oldTokens = wd.oldT.map(t => ({ w: t.w + ' ', style: tokStyle(t.ch, true) }));
|
|
3537
|
+
o.newTokens = wd.newT.map(t => ({ w: t.w + ' ', style: tokStyle(t.ch, false) }));
|
|
3538
|
+
} else o.isPlain = true;
|
|
3539
|
+
return o;
|
|
3540
|
+
});
|
|
3541
|
+
const allDiffKeys = cmp.diffs.map(d => curKey + '::' + d.key);
|
|
3542
|
+
const nSelectedDiffs = allDiffKeys.filter(k => st.selectedDiffs[k]).length;
|
|
3543
|
+
const selectAllDiffsCur = () => this.selectAllDiffs(allDiffKeys);
|
|
3544
|
+
const nChanges = cmp.diffs.filter(d => d.severity === 'change').length;
|
|
3545
|
+
const nAdds = cmp.diffs.filter(d => d.severity === 'add').length;
|
|
3546
|
+
const nRemoves = cmp.diffs.filter(d => d.severity === 'remove').length;
|
|
3547
|
+
|
|
3548
|
+
const alignment = this.refineMoves(this.alignSlides(st.slidesA, st.slidesB), st.slidesA, st.slidesB);
|
|
3549
|
+
const pairs = [];
|
|
3550
|
+
let nApproved = 0, nRejected = 0, nPending = 0;
|
|
3551
|
+
const dupIndicesA = new Set(); this.findDuplicateSlides(st.slidesA).forEach(g => g.forEach(i => dupIndicesA.add(i)));
|
|
3552
|
+
const dupIndicesB = new Set(); this.findDuplicateSlides(st.slidesB).forEach(g => g.forEach(i => dupIndicesB.add(i)));
|
|
3553
|
+
alignment.forEach((al) => {
|
|
3554
|
+
const pa = al.aIdx != null ? st.slidesA[al.aIdx] : null, pb = al.bIdx != null ? st.slidesB[al.bIdx] : null;
|
|
3555
|
+
const c = this.compareSlidePair(pa, pb);
|
|
3556
|
+
const total = c.diffs.length;
|
|
3557
|
+
const key = this.pairKey(al.aIdx, al.bIdx);
|
|
3558
|
+
const stA = pa ? this.mapCompactStage(pa, c.statusA, 'A', al.aIdx, st.libOkA) : this.mapCompactStage(null, {}, 'A', 0, st.libOkA);
|
|
3559
|
+
const stB = pb ? this.mapCompactStage(pb, c.statusB, 'B', al.bIdx, st.libOkB) : this.mapCompactStage(null, {}, 'B', 0, st.libOkB);
|
|
3560
|
+
const votes = st.decisions[key] || {};
|
|
3561
|
+
const decision = this.slideStatus(votes);
|
|
3562
|
+
const activeVote = votes[st.activeHandle || 'anonymous'] || null;
|
|
3563
|
+
if (decision === 'approved') nApproved++; else if (decision === 'rejected') nRejected++; else nPending++;
|
|
3564
|
+
const label = al.moved ? ('Slide ' + (al.aIdx + 1) + ' moved → ' + (al.bIdx + 1)) : pa && pb ? ('Slide ' + (al.aIdx + 1) + ' → ' + (al.bIdx + 1)) : pa ? ('Slide ' + (al.aIdx + 1) + ' · removed') : ('Slide ' + (al.bIdx + 1) + ' · added');
|
|
3565
|
+
const badges = Object.entries(votes).map(([h, v]) => ({ handle: h, action: v, initial: h.replace(/^[@]/, '').slice(0, 2).toUpperCase(), bg: v === 'approved' ? '#3E7C5A' : '#B23A2E' }));
|
|
3566
|
+
const identical = total === 0 && pa && pb;
|
|
3567
|
+
const primaryTag = !pa ? 'ADDED' : !pb ? 'DELETED' : identical ? 'IDENTICAL' : 'CHANGED';
|
|
3568
|
+
const extraTags = this.computeExtraTags(al, dupIndicesA, dupIndicesB);
|
|
3569
|
+
const primaryTagStyle = { IDENTICAL: '#2F6347', CHANGED: this.accent(), ADDED: '#3E7C5A', DELETED: '#B23A2E' };
|
|
3570
|
+
const tagBg = { IDENTICAL: '#EAF3EE', CHANGED: theme.card, ADDED: '#EAF3EE', DELETED: '#FBEFEC' };
|
|
3571
|
+
const isCollapsed = !!st.collapsedPairs[key], isSelected = !!st.selectedPairs[key];
|
|
3572
|
+
pairs.push({
|
|
3573
|
+
idx: key, label, total, moved: !!al.moved,
|
|
3574
|
+
identical,
|
|
3575
|
+
hasChanges: total > 0,
|
|
3576
|
+
primaryTag, hasExtraTags: extraTags.length > 0, extraTags,
|
|
3577
|
+
primaryTagColor: primaryTagStyle[primaryTag], primaryTagBg: tagBg[primaryTag],
|
|
3578
|
+
onClick: () => this.setState({ viewMode: 'single', idxA: al.aIdx != null ? al.aIdx : st.idxA, idxB: al.bIdx != null ? al.bIdx : st.idxB, selectedKey: null }),
|
|
3579
|
+
pairStages: [stA, stB],
|
|
3580
|
+
decision, isApproved: decision === 'approved', isRejected: decision === 'rejected',
|
|
3581
|
+
approveBg: activeVote === 'approved' ? '#3E7C5A' : theme.card, approveFg: activeVote === 'approved' ? '#fff' : '#3E7C5A',
|
|
3582
|
+
rejectBg: activeVote === 'rejected' ? '#B23A2E' : theme.card, rejectFg: activeVote === 'rejected' ? '#fff' : '#B23A2E',
|
|
3583
|
+
onApprove: (ev) => this.setDecision(key, 'approved', label, ev), onReject: (ev) => this.setDecision(key, 'rejected', label, ev),
|
|
3584
|
+
rollup: (() => { const ru = this.diffRollup(c.diffs, key); return { text: ru.total ? (ru.nA + ru.nR) + '/' + ru.total + ' diffs reviewed' + (ru.nR ? ', ' + ru.nR + ' rejected' : '') : '', hasRollup: ru.total > 0, bg: ru.nR > 0 ? '#B23A2E' : '#8A8273', nR: ru.nR, nP: ru.nP }; })(),
|
|
3585
|
+
commentCount: (st.comments[key] || []).length, badges, hasBadges: badges.length > 0,
|
|
3586
|
+
isCollapsed, isExpanded: !isCollapsed, isSelected,
|
|
3587
|
+
onToggleCollapse: (ev) => this.togglePairCollapse(key, ev), onToggleSelect: (ev) => this.togglePairSelect(key, ev),
|
|
3588
|
+
collapseIcon: isCollapsed ? '▸' : '▾'
|
|
3589
|
+
});
|
|
3590
|
+
});
|
|
3591
|
+
const nPairs = alignment.length;
|
|
3592
|
+
const nSelected = pairs.filter(p => p.isSelected).length;
|
|
3593
|
+
const pairsShown = (st.showSelectedOnly ? pairs.filter(p => p.isSelected) : pairs).filter(p => {
|
|
3594
|
+
if (st.pairsFilter === 'changed') return p.hasChanges;
|
|
3595
|
+
if (st.pairsFilter === 'pending') return p.decision === 'pending';
|
|
3596
|
+
if (st.pairsFilter === 'approved') return p.decision === 'approved';
|
|
3597
|
+
if (st.pairsFilter === 'rejected') return p.decision === 'rejected';
|
|
3598
|
+
if (st.pairsFilter === 'diffRejected') return p.rollup && p.rollup.nR > 0;
|
|
3599
|
+
if (st.pairsFilter === 'diffPending') return p.rollup && p.rollup.nP > 0 && p.hasChanges;
|
|
3600
|
+
return true;
|
|
3601
|
+
});
|
|
3602
|
+
const nDiffRejected = pairs.filter(p => p.rollup && p.rollup.nR > 0).length;
|
|
3603
|
+
const nDiffPending = pairs.filter(p => p.rollup && p.rollup.nP > 0 && p.hasChanges).length;
|
|
3604
|
+
|
|
3605
|
+
const curVotes = st.decisions[curKey] || {};
|
|
3606
|
+
const curDecision = curVotes[st.activeHandle || 'anonymous'] || null;
|
|
3607
|
+
const curRollup = this.diffRollup(cmp.diffs, curKey);
|
|
3608
|
+
const curRollupText = curRollup.total ? (curRollup.nA + curRollup.nR) + ' of ' + curRollup.total + ' diffs reviewed' + (curRollup.nR ? ' — ' + curRollup.nR + ' rejected' : curRollup.nA === curRollup.total ? ' — all approved' : '') : 'No individual diffs to review';
|
|
3609
|
+
const curBadges = Object.entries(curVotes).map(([h, v]) => ({ handle: h, action: v, label: (this.state.reviewers.find(r => r.handle === h) || {}).name || h, bg: v === 'approved' ? '#3E7C5A' : '#B23A2E' }));
|
|
3610
|
+
const curComments = (st.comments[curKey] || []).map((c, ci) => {
|
|
3611
|
+
const replyKey = curKey + ':' + ci;
|
|
3612
|
+
return {
|
|
3613
|
+
author: c.author, text: c.text, timeStr: new Date(c.ts).toLocaleString(),
|
|
3614
|
+
hasReplies: (c.replies || []).length > 0,
|
|
3615
|
+
isReplying: st.replyingKey === replyKey, notReplying: st.replyingKey !== replyKey,
|
|
3616
|
+
onStartReply: () => this.startReply(replyKey), onCancelReply: () => this.cancelReply(),
|
|
3617
|
+
onSubmitReply: () => this.submitReply(curKey, ci, curLabel),
|
|
3618
|
+
cKey: replyKey, selected2: !!st.selectedComments[replyKey], onToggleSelect: (ev) => this.toggleCommentSelect(replyKey, ev),
|
|
3619
|
+
onDelete: (ev) => { if (ev) ev.stopPropagation(); this.requestDeleteComment(curKey, ci); },
|
|
3620
|
+
replies: (c.replies || []).map((r, ri) => ({ author: r.author, text: r.text, timeStr: new Date(r.ts).toLocaleString(), onDelete: (ev) => { if (ev) ev.stopPropagation(); this.deleteReply(curKey, ci, ri); } }))
|
|
3621
|
+
};
|
|
3622
|
+
});
|
|
3623
|
+
const curLabel = 'Slide ' + (curIdx + 1);
|
|
3624
|
+
const reviewerChips = st.reviewers.map(r => ({
|
|
3625
|
+
handle: r.handle, name: r.name, display: r.name ? (r.name + ' (' + r.handle + ')') : r.handle,
|
|
3626
|
+
active: st.activeHandle === r.handle,
|
|
3627
|
+
bg: st.activeHandle === r.handle ? this.accent() : theme.card, fg: st.activeHandle === r.handle ? '#fff' : theme.text,
|
|
3628
|
+
onClick: () => this.setActiveReviewer(r.handle), onRemove: (ev) => this.removeReviewer(r.handle, ev),
|
|
3629
|
+
selected: !!st.selectedReviewers[r.handle], onToggleSelect: (ev) => this.toggleReviewerSelect(r.handle, ev)
|
|
3630
|
+
}));
|
|
3631
|
+
const historyRows = st.history.map(h => ({
|
|
3632
|
+
timeStr: new Date(h.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
|
3633
|
+
text: h.reviewer + (h.action === 'comment' ? ' commented on ' + h.label + ': "' + h.note + '"' : h.action === 'reply' ? ' replied on ' + h.label + ': "' + h.note + '"' : ' ' + h.action + ' ' + h.label),
|
|
3634
|
+
sig: this.historySig(h), selected2: !!st.selectedHistory[this.historySig(h)], onToggleSelect: (ev) => this.toggleHistorySelect(this.historySig(h), ev)
|
|
3635
|
+
}));
|
|
3636
|
+
|
|
3637
|
+
const fontsA = st.fontsA || [], fontsB = st.fontsB || [];
|
|
3638
|
+
const fontSig = (f) => f.slice().sort().join(', ') || '(none)';
|
|
3639
|
+
const deckDiffs = [];
|
|
3640
|
+
if (fontSig(fontsA) !== fontSig(fontsB)) deckDiffs.push({ type: 'Embedded fonts', label: 'Presentation', oldText: fontSig(fontsA), newText: fontSig(fontsB) });
|
|
3641
|
+
const sectionsA = st.sectionsA || [], sectionsB = st.sectionsB || [];
|
|
3642
|
+
const sectionSig = (secs) => secs.map(s => s.name).join(' → ') || '(no sections)';
|
|
3643
|
+
if (sectionSig(sectionsA) !== sectionSig(sectionsB)) deckDiffs.push({ type: 'Section headers', label: 'Presentation', oldText: sectionSig(sectionsA), newText: sectionSig(sectionsB) });
|
|
3644
|
+
const dupA = this.findDuplicateSlides(st.slidesA).map(g => 'Before: slide ' + g.map(i => i + 1).join(', slide '));
|
|
3645
|
+
const dupB = this.findDuplicateSlides(st.slidesB).map(g => 'After: slide ' + g.map(i => i + 1).join(', slide '));
|
|
3646
|
+
const duplicateNotes = dupA.concat(dupB);
|
|
3647
|
+
const crossDupResult = this.findCrossDeckDuplicates(st.slidesA, st.slidesB, alignment, st.crossDupThreshold, undefined, st.crossDupForceLarge);
|
|
3648
|
+
const crossDeckDuplicatePairs = crossDupResult.pairs.filter(d => !st.ignoredCrossDupKeys[d.aIdx + ':' + d.bIdx]);
|
|
3649
|
+
const crossDeckDuplicateNotes = crossDeckDuplicatePairs.map(d => {
|
|
3650
|
+
const secA = this.sectionForSlideIndex(st.sectionsA, d.aIdx), secB = this.sectionForSlideIndex(st.sectionsB, d.bIdx);
|
|
3651
|
+
const key = d.aIdx + ':' + d.bIdx;
|
|
3652
|
+
return {
|
|
3653
|
+
text: 'Before slide ' + (d.aIdx + 1) + (secA ? ' (' + secA + ')' : '') + ' closely resembles After slide ' + (d.bIdx + 1) + (secB ? ' (' + secB + ')' : '') + ' (not aligned to each other)',
|
|
3654
|
+
onIgnore: () => this.setState(s => ({ ignoredCrossDupKeys: Object.assign({}, s.ignoredCrossDupKeys, { [key]: true }) }))
|
|
3655
|
+
};
|
|
3656
|
+
});
|
|
3657
|
+
// Section-aware move detection: a named section whose slide range shifted position (start
|
|
3658
|
+
// index changed) between decks, even if every slide inside it individually diffed as "moved."
|
|
3659
|
+
const sectionMoveNotes = [];
|
|
3660
|
+
sectionsA.forEach(secA => {
|
|
3661
|
+
const secB = sectionsB.find(s => s.name === secA.name);
|
|
3662
|
+
if (secB && secA.slideIndices.length && secB.slideIndices.length) {
|
|
3663
|
+
const startA = Math.min(...secA.slideIndices), startB = Math.min(...secB.slideIndices);
|
|
3664
|
+
if (startA !== startB) sectionMoveNotes.push('Section "' + secA.name + '" moved from slide ' + (startA + 1) + ' to slide ' + (startB + 1));
|
|
3665
|
+
}
|
|
3666
|
+
});
|
|
3667
|
+
|
|
3668
|
+
const layoutBox = (slide) => ({
|
|
3669
|
+
rects: (slide && slide.layoutRects || []).map(r => ({ lpct: r.x / slide.w * 100, tpct: r.y / slide.h * 100, wpct: r.cx / slide.w * 100, hpct: r.cy / slide.h * 100 })),
|
|
3670
|
+
aspect: slide ? (slide.w / slide.h).toFixed(4) : '1.7778',
|
|
3671
|
+
layoutName: (slide && slide.layoutName) || '—', themeName: (slide && slide.themeName) || '—'
|
|
3672
|
+
});
|
|
3673
|
+
const masterA = layoutBox(sA), masterB = layoutBox(sB);
|
|
3674
|
+
const layoutDiffers = masterA.layoutName !== masterB.layoutName;
|
|
3675
|
+
const themeDiffers = masterA.themeName !== masterB.themeName;
|
|
3676
|
+
|
|
3677
|
+
return {
|
|
3678
|
+
accent: this.accent(),
|
|
3679
|
+
uploaders: [
|
|
3680
|
+
{ tag: 'A', role: 'Before', dot: '#8A8273', name: st.nameA, onChange: (e) => this.onUpload('A', e) },
|
|
3681
|
+
{ tag: 'B', role: 'After', dot: this.accent(), name: st.nameB, onChange: (e) => this.onUpload('B', e) }
|
|
3682
|
+
],
|
|
3683
|
+
viewModeSingle: st.viewMode === 'single', viewModeAll: st.viewMode === 'all',
|
|
3684
|
+
goSingle: () => this.setView('single'), goAll: () => this.setView('all'),
|
|
3685
|
+
singleTabBg: st.viewMode === 'single' ? this.accent() : theme.card, singleTabFg: st.viewMode === 'single' ? '#fff' : theme.muted,
|
|
3686
|
+
allTabBg: st.viewMode === 'all' ? this.accent() : theme.card, allTabFg: st.viewMode === 'all' ? '#fff' : theme.muted,
|
|
3687
|
+
nPairsLabel: String(nPairs),
|
|
3688
|
+
pairs: pairsShown,
|
|
3689
|
+
pairsFilterAll: st.pairsFilter === 'all', pairsFilterChanged: st.pairsFilter === 'changed',
|
|
3690
|
+
setFilterAll: () => this.setState({ pairsFilter: 'all' }), setFilterChanged: () => this.setState({ pairsFilter: 'changed' }),
|
|
3691
|
+
filterAllBg: st.pairsFilter === 'all' ? this.accent() : theme.card, filterAllFg: st.pairsFilter === 'all' ? '#fff' : theme.muted,
|
|
3692
|
+
filterChangedBg: st.pairsFilter === 'changed' ? this.accent() : theme.card, filterChangedFg: st.pairsFilter === 'changed' ? '#fff' : theme.muted,
|
|
3693
|
+
setFilterPending: () => this.setState({ pairsFilter: 'pending' }), setFilterApproved: () => this.setState({ pairsFilter: 'approved' }), setFilterRejected: () => this.setState({ pairsFilter: 'rejected' }),
|
|
3694
|
+
filterPendingBg: st.pairsFilter === 'pending' ? '#8A8273' : theme.card, filterPendingFg: st.pairsFilter === 'pending' ? '#fff' : theme.muted,
|
|
3695
|
+
filterApprovedBg: st.pairsFilter === 'approved' ? '#3E7C5A' : theme.card, filterApprovedFg: st.pairsFilter === 'approved' ? '#fff' : '#3E7C5A',
|
|
3696
|
+
filterRejectedBg: st.pairsFilter === 'rejected' ? '#B23A2E' : theme.card, filterRejectedFg: st.pairsFilter === 'rejected' ? '#fff' : '#B23A2E',
|
|
3697
|
+
setFilterDiffRejected: () => this.setState({ pairsFilter: 'diffRejected' }), setFilterDiffPending: () => this.setState({ pairsFilter: 'diffPending' }),
|
|
3698
|
+
filterDiffRejectedBg: st.pairsFilter === 'diffRejected' ? '#B23A2E' : theme.card, filterDiffRejectedFg: st.pairsFilter === 'diffRejected' ? '#fff' : '#B23A2E',
|
|
3699
|
+
filterDiffPendingBg: st.pairsFilter === 'diffPending' ? '#8A8273' : theme.card, filterDiffPendingFg: st.pairsFilter === 'diffPending' ? '#fff' : theme.muted,
|
|
3700
|
+
nDiffRejected, nDiffPending,
|
|
3701
|
+
nApproved, nRejected, nPending,
|
|
3702
|
+
nSelected, hasSelectedPairs: nSelected > 0, showSelectedOnly: st.showSelectedOnly,
|
|
3703
|
+
toggleShowSelectedOnly: () => this.toggleShowSelectedOnly(),
|
|
3704
|
+
toggleAllPairsCollapsed: () => this.toggleAllPairsCollapsed(), collapseAllLabel: st.allPairsCollapsed ? 'Expand all' : 'Collapse all',
|
|
3705
|
+
selectedOnlyBg: st.showSelectedOnly ? this.accent() : theme.card, selectedOnlyFg: st.showSelectedOnly ? '#fff' : theme.muted,
|
|
3706
|
+
historyCollapsed: st.historyCollapsed, toggleHistoryCollapse: () => this.toggleHistoryCollapse(), historyChevron: st.historyCollapsed ? '▸' : '▾',
|
|
3707
|
+
selfTestsCollapsed: st.selfTestsCollapsed, toggleSelfTestsCollapse: () => this.toggleSelfTestsCollapse(), selfTestsChevron: st.selfTestsCollapsed ? '▸' : '▾',
|
|
3708
|
+
historyExpanded: !st.historyCollapsed, selfTestsExpanded: !st.selfTestsCollapsed,
|
|
3709
|
+
curDecision, curIsApproved: curDecision === 'approved', curIsRejected: curDecision === 'rejected',
|
|
3710
|
+
curApproveBg: curDecision === 'approved' ? '#3E7C5A' : theme.card, curApproveFg: curDecision === 'approved' ? '#fff' : '#3E7C5A',
|
|
3711
|
+
curRejectBg: curDecision === 'rejected' ? '#B23A2E' : theme.card, curRejectFg: curDecision === 'rejected' ? '#fff' : '#B23A2E',
|
|
3712
|
+
onApproveCur: () => this.setDecision(curKey, 'approved', curLabel), onRejectCur: () => this.setDecision(curKey, 'rejected', curLabel),
|
|
3713
|
+
reviewerChips, hasReviewers: reviewerChips.length > 0, activeLabel: this.activeReviewerLabel(),
|
|
3714
|
+
nSelectedReviewers: Object.values(st.selectedReviewers).filter(Boolean).length,
|
|
3715
|
+
selectAllReviewers: () => this.selectAllReviewers(), selectNoneReviewers: () => this.selectNoneReviewers(),
|
|
3716
|
+
myNotifications: (st.notifications[st.activeHandle] || []).slice().reverse().map(n => ({ from: n.from || 'Reviewer', text: n.text, slideLabel: n.slideLabel, read: n.read, timeStr: new Date(n.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) })),
|
|
3717
|
+
unreadCount: (st.notifications[st.activeHandle] || []).filter(n => !n.read).length,
|
|
3718
|
+
hasNotifications: (st.notifications[st.activeHandle] || []).length > 0,
|
|
3719
|
+
noNotifications: (st.notifications[st.activeHandle] || []).length === 0,
|
|
3720
|
+
showNotifications: st.showNotifications,
|
|
3721
|
+
toggleNotifications: () => { this.toggleNotifications(); if (st.activeHandle) this.markNotificationsRead(st.activeHandle); },
|
|
3722
|
+
handleDraft: st.handleDraft, onHandleChange: (e) => this.setState({ handleDraft: e.target.value }),
|
|
3723
|
+
nameDraft: st.nameDraft, onNameChange: (e) => this.setState({ nameDraft: e.target.value }),
|
|
3724
|
+
onAddReviewer: () => this.addReviewer(),
|
|
3725
|
+
curBadges, hasCurBadges: curBadges.length > 0,
|
|
3726
|
+
curRollupText, curRollupShow: curRollup.total > 0,
|
|
3727
|
+
curRollupColor: curRollup.nR > 0 ? '#B23A2E' : theme.muted,
|
|
3728
|
+
commentDraft: st.commentDraft, onCommentChange: (e) => this.setState({ commentDraft: e.target.value }),
|
|
3729
|
+
commentInputRef: (el) => { this.commentInputEl = el; },
|
|
3730
|
+
onAddComment: () => this.addComment(curKey, curLabel),
|
|
3731
|
+
curComments, hasCurComments: curComments.length > 0,
|
|
3732
|
+
nSelectedDiffs, selectAllDiffsCur, selectNoneDiffs: () => this.selectNoneDiffs(),
|
|
3733
|
+
bulkApproveDiffs: () => this.bulkSetDiffDecision('approved'), bulkRejectDiffs: () => this.bulkSetDiffDecision('rejected'),
|
|
3734
|
+
nSelectedComments: Object.values(st.selectedComments).filter(Boolean).length,
|
|
3735
|
+
selectAllCommentsCur: () => this.selectAllComments(curComments.map(c => c.cKey)), selectNoneComments: () => this.selectNoneComments(),
|
|
3736
|
+
deleteSelectedCommentsCur: () => this.requestDeleteSelectedComments(curKey),
|
|
3737
|
+
replyDraft: st.replyDraft, onReplyChange: (e) => this.setState({ replyDraft: e.target.value }),
|
|
3738
|
+
showHistory: st.showHistory, toggleHistory: () => this.setState(s => ({ showHistory: !s.showHistory })),
|
|
3739
|
+
historyRows, hasHistory: historyRows.length > 0, noHistory: historyRows.length === 0,
|
|
3740
|
+
nSelectedHistory: Object.values(st.selectedHistory).filter(Boolean).length,
|
|
3741
|
+
selectAllHistory: () => this.selectAllHistory(), selectNoneHistory: () => this.selectNoneHistory(), clearSelectedHistory: () => this.requestClearSelectedHistory(),
|
|
3742
|
+
hasPendingConfirm: !!st.pendingConfirm,
|
|
3743
|
+
pendingConfirmTitle: st.pendingConfirm ? st.pendingConfirm.title : '', pendingConfirmBody: st.pendingConfirm ? st.pendingConfirm.body : '', pendingConfirmLabel: st.pendingConfirm ? st.pendingConfirm.confirmLabel : 'OK',
|
|
3744
|
+
cancelConfirm: () => this.closeConfirm(), confirmPendingYes: () => this.confirmPendingYes(),
|
|
3745
|
+
hasAnyDecisions: Object.keys(st.decisions).length > 0,
|
|
3746
|
+
requestClearAllDecisions: () => this.requestClearAllDecisions(),
|
|
3747
|
+
clearDecisionsScope: st.clearDecisionsScope,
|
|
3748
|
+
setClearScopeAll: () => this.setState({ clearDecisionsScope: 'all' }), setClearScopeMine: () => this.setState({ clearDecisionsScope: 'mine' }),
|
|
3749
|
+
setClearScopeReviewer: () => this.setState({ clearDecisionsScope: 'reviewer' }), setClearScopeRange: () => this.setState({ clearDecisionsScope: 'range' }),
|
|
3750
|
+
isClearScopeReviewer: st.clearDecisionsScope === 'reviewer', isClearScopeRange: st.clearDecisionsScope === 'range',
|
|
3751
|
+
clearScopeAllChecked: st.clearDecisionsScope === 'all', clearScopeMineChecked: st.clearDecisionsScope === 'mine',
|
|
3752
|
+
clearScopeReviewerChecked: st.clearDecisionsScope === 'reviewer', clearScopeRangeChecked: st.clearDecisionsScope === 'range',
|
|
3753
|
+
clearDecisionsReviewerHandle: st.clearDecisionsReviewerHandle, onClearReviewerHandleChange: (e) => this.setState({ clearDecisionsReviewerHandle: e.target.value }),
|
|
3754
|
+
reviewerHandleOptions: st.reviewers.map(r => ({ handle: r.handle })),
|
|
3755
|
+
clearDecisionsRangeFrom: st.clearDecisionsRangeFrom, onClearRangeFromChange: (e) => this.setState({ clearDecisionsRangeFrom: +e.target.value || 1 }),
|
|
3756
|
+
clearDecisionsRangeTo: st.clearDecisionsRangeTo, onClearRangeToChange: (e) => this.setState({ clearDecisionsRangeTo: +e.target.value || 1 }),
|
|
3757
|
+
isClearRangeInvalid: st.clearDecisionsRangeFrom > st.clearDecisionsRangeTo,
|
|
3758
|
+
clearRangeBorderColor: st.clearDecisionsRangeFrom > st.clearDecisionsRangeTo ? '#B23A2E' : theme.border,
|
|
3759
|
+
requestRemoveSelectedReviewers: () => this.requestRemoveSelectedReviewers(),
|
|
3760
|
+
exportJsonReport: () => this.exportJsonReport(), exportHtmlReport: () => this.exportHtmlReport(),
|
|
3761
|
+
exportDecisionsCsv: () => this.exportDecisionsCsv(), exportDecisionsJson: () => this.exportDecisionsJson(),
|
|
3762
|
+
exportMarkdownSummary: () => this.exportMarkdownSummary(), exportNotionMarkdown: () => this.exportNotionMarkdown(), exportConfluencePage: () => this.exportConfluencePage(),
|
|
3763
|
+
importReportJson: (ev) => this.importReportJson(ev),
|
|
3764
|
+
showImportChoice: st.showImportChoice, pendingImportName: this.pendingImportName || '',
|
|
3765
|
+
onImportMerge: () => this.confirmImportChoice('merge'), onImportOverwrite: () => this.confirmImportChoice('overwrite'), onImportCancel: () => this.cancelImportChoice(),
|
|
3766
|
+
importConflictWinner: st.importConflictWinner,
|
|
3767
|
+
setImportWinnerCurrent: () => this.setState({ importConflictWinner: 'current' }), setImportWinnerImported: () => this.setState({ importConflictWinner: 'imported' }),
|
|
3768
|
+
importWinnerCurrentBg: st.importConflictWinner === 'current' ? this.accent() : theme.chip, importWinnerCurrentFg: st.importConflictWinner === 'current' ? '#fff' : theme.muted,
|
|
3769
|
+
importWinnerImportedBg: st.importConflictWinner === 'imported' ? this.accent() : theme.chip, importWinnerImportedFg: st.importConflictWinner === 'imported' ? '#fff' : theme.muted,
|
|
3770
|
+
toggleIntegrations: () => this.toggleIntegrations(), showIntegrations: st.showIntegrations,
|
|
3771
|
+
slackWebhookUrl: st.slackWebhookUrl, onSlackUrlChange: (e) => { this.setState({ slackWebhookUrl: e.target.value }); setTimeout(() => this.persistCredsIfOptedIn(), 0); }, pushToSlack: () => this.pushToSlack(),
|
|
3772
|
+
notionToken: st.notionToken, onNotionTokenChange: (e) => { this.setState({ notionToken: e.target.value }); setTimeout(() => this.persistCredsIfOptedIn(), 0); },
|
|
3773
|
+
notionPageId: st.notionPageId, onNotionPageIdChange: (e) => { this.setState({ notionPageId: e.target.value }); setTimeout(() => this.persistCredsIfOptedIn(), 0); }, pushToNotion: () => this.pushToNotion(),
|
|
3774
|
+
confluenceBaseUrl: st.confluenceBaseUrl, onConfluenceBaseUrlChange: (e) => { this.setState({ confluenceBaseUrl: e.target.value }); setTimeout(() => this.persistCredsIfOptedIn(), 0); },
|
|
3775
|
+
confluenceEmail: st.confluenceEmail, onConfluenceEmailChange: (e) => { this.setState({ confluenceEmail: e.target.value }); setTimeout(() => this.persistCredsIfOptedIn(), 0); },
|
|
3776
|
+
confluenceToken: st.confluenceToken, onConfluenceTokenChange: (e) => { this.setState({ confluenceToken: e.target.value }); setTimeout(() => this.persistCredsIfOptedIn(), 0); },
|
|
3777
|
+
confluencePageId: st.confluencePageId, onConfluencePageIdChange: (e) => { this.setState({ confluencePageId: e.target.value }); setTimeout(() => this.persistCredsIfOptedIn(), 0); }, pushToConfluence: () => this.pushToConfluence(),
|
|
3778
|
+
confluencePushMode: st.confluencePushMode,
|
|
3779
|
+
setConfluenceAppend: () => this.setState({ confluencePushMode: 'append' }), setConfluenceReplace: () => this.setState({ confluencePushMode: 'replace' }),
|
|
3780
|
+
confluenceAppendBg: st.confluencePushMode === 'append' ? this.accent() : theme.card, confluenceAppendFg: st.confluencePushMode === 'append' ? '#fff' : theme.muted,
|
|
3781
|
+
confluenceReplaceBg: st.confluencePushMode === 'replace' ? this.accent() : theme.card, confluenceReplaceFg: st.confluencePushMode === 'replace' ? '#fff' : theme.muted,
|
|
3782
|
+
credRememberSlackWebhookUrl: !!st.persistedCredFields.slackWebhookUrl, onRememberSlackWebhookUrl: (e) => this.toggleFieldPersistence('slackWebhookUrl', e),
|
|
3783
|
+
credRememberNotionToken: !!st.persistedCredFields.notionToken, onRememberNotionToken: (e) => this.toggleFieldPersistence('notionToken', e),
|
|
3784
|
+
credRememberNotionPageId: !!st.persistedCredFields.notionPageId, onRememberNotionPageId: (e) => this.toggleFieldPersistence('notionPageId', e),
|
|
3785
|
+
credRememberConfluenceBaseUrl: !!st.persistedCredFields.confluenceBaseUrl, onRememberConfluenceBaseUrl: (e) => this.toggleFieldPersistence('confluenceBaseUrl', e),
|
|
3786
|
+
credRememberConfluenceEmail: !!st.persistedCredFields.confluenceEmail, onRememberConfluenceEmail: (e) => this.toggleFieldPersistence('confluenceEmail', e),
|
|
3787
|
+
credRememberConfluenceToken: !!st.persistedCredFields.confluenceToken, onRememberConfluenceToken: (e) => this.toggleFieldPersistence('confluenceToken', e),
|
|
3788
|
+
credRememberConfluencePageId: !!st.persistedCredFields.confluencePageId, onRememberConfluencePageId: (e) => this.toggleFieldPersistence('confluencePageId', e),
|
|
3789
|
+
selectAllCredFields: () => this.selectAllCredFields(), selectNoneCredFields: () => this.selectNoneCredFields(),
|
|
3790
|
+
exportPdf: () => window.print(),
|
|
3791
|
+
hasDeckDiffs: deckDiffs.length > 0,
|
|
3792
|
+
deckDiffs: deckDiffs.map(d => ({ type: d.type, label: d.label, oldText: d.oldText, newText: d.newText })),
|
|
3793
|
+
duplicateNotes: duplicateNotes.concat(sectionMoveNotes), hasDuplicates: duplicateNotes.length > 0 || sectionMoveNotes.length > 0,
|
|
3794
|
+
crossDeckDuplicateNotes, hasCrossDeckDuplicates: crossDeckDuplicateNotes.length > 0 || crossDupResult.skipped,
|
|
3795
|
+
crossDupSkipped: crossDupResult.skipped, crossDupTotalComparisons: crossDupResult.totalComparisons,
|
|
3796
|
+
crossDupForceLarge: () => this.setState({ crossDupForceLarge: true }),
|
|
3797
|
+
crossDupThreshold: st.crossDupThreshold, onCrossDupThresholdChange: (e) => this.setState({ crossDupThreshold: +e.target.value }),
|
|
3798
|
+
crossDupThresholdPct: Math.round(st.crossDupThreshold * 100),
|
|
3799
|
+
batchProgress: st.batchTotal ? Math.round((st.batchProgress / st.batchTotal) * 100) : 0, batchProgressLabel: st.batchTotal ? (st.batchProgress + ' / ' + st.batchTotal) : '',
|
|
3800
|
+
exportBatchReportCsv: () => this.exportBatchReportCsv(), exportBatchReportJson: () => this.exportBatchReportJson(),
|
|
3801
|
+
stages: [
|
|
3802
|
+
this.mapStage(sA, cmp.statusA, 'A', st.nameA, Math.min(st.idxA, st.slidesA.length - 1), st.slidesA.length, st.libOkA, st.loadingA),
|
|
3803
|
+
this.mapStage(sB, cmp.statusB, 'B', st.nameB, Math.min(st.idxB, st.slidesB.length - 1), st.slidesB.length, st.libOkB, st.loadingB)
|
|
3804
|
+
],
|
|
3805
|
+
diffs, diffCount: diffs.length, hasDiffs: diffs.length > 0, noDiffs: diffs.length === 0,
|
|
3806
|
+
nChanges, nAdds, nRemoves,
|
|
3807
|
+
summaryText: diffs.length === 0 ? 'No differences' : (diffs.length + (diffs.length === 1 ? ' difference' : ' differences')),
|
|
3808
|
+
hasError: !!st.errorMsg, errorMsg: st.errorMsg,
|
|
3809
|
+
theme, isDark, toggleTheme, themeToggleLabel: isDark ? 'Light mode' : 'Dark mode',
|
|
3810
|
+
viewModeBatch: st.viewMode === 'batch', goBatch: () => this.setState({ viewMode: 'batch' }),
|
|
3811
|
+
batchTabBg: st.viewMode === 'batch' ? this.accent() : theme.card, batchTabFg: st.viewMode === 'batch' ? '#fff' : theme.muted,
|
|
3812
|
+
onBatchA: (e) => this.onBatchFiles('A', e), onBatchB: (e) => this.onBatchFiles('B', e),
|
|
3813
|
+
batchNamesA: st.batchFilesA.map(f => f.name).join(', ') || 'No files chosen',
|
|
3814
|
+
batchNamesB: st.batchFilesB.map(f => f.name).join(', ') || 'No files chosen',
|
|
3815
|
+
batchListA: st.batchFilesA.map((f, i) => ({
|
|
3816
|
+
name: f.name, idx: i,
|
|
3817
|
+
onDragStart: (ev) => this.onBatchDragStart('A', i, ev), onDragOver: (ev) => this.onBatchDragOver(ev), onDrop: (ev) => this.onBatchDrop('A', i, ev),
|
|
3818
|
+
onTouchStart: (ev) => this.onBatchTouchStart('A', i, ev), onTouchMove: (ev) => this.onBatchTouchMove(ev), onTouchEnd: (ev) => this.onBatchTouchEnd(ev),
|
|
3819
|
+
onRemove: (ev) => this.requestRemoveBatchFile('A', i, ev),
|
|
3820
|
+
selected: !!st.selectedBatchFilesA[i], onToggleSelect: (ev) => this.toggleBatchFileSelect('A', i, ev)
|
|
3821
|
+
})),
|
|
3822
|
+
batchListB: st.batchFilesB.map((f, i) => ({
|
|
3823
|
+
name: f.name, idx: i,
|
|
3824
|
+
onDragStart: (ev) => this.onBatchDragStart('B', i, ev), onDragOver: (ev) => this.onBatchDragOver(ev), onDrop: (ev) => this.onBatchDrop('B', i, ev),
|
|
3825
|
+
onTouchStart: (ev) => this.onBatchTouchStart('B', i, ev), onTouchMove: (ev) => this.onBatchTouchMove(ev), onTouchEnd: (ev) => this.onBatchTouchEnd(ev),
|
|
3826
|
+
onRemove: (ev) => this.requestRemoveBatchFile('B', i, ev),
|
|
3827
|
+
selected: !!st.selectedBatchFilesB[i], onToggleSelect: (ev) => this.toggleBatchFileSelect('B', i, ev)
|
|
3828
|
+
})),
|
|
3829
|
+
nSelectedBatchA: Object.values(st.selectedBatchFilesA).filter(Boolean).length, nSelectedBatchB: Object.values(st.selectedBatchFilesB).filter(Boolean).length,
|
|
3830
|
+
selectAllBatchA: () => this.selectAllBatchFiles('A'), selectNoneBatchA: () => this.selectNoneBatchFiles('A'), removeSelectedBatchA: () => this.removeSelectedBatchFiles('A'),
|
|
3831
|
+
selectAllBatchB: () => this.selectAllBatchFiles('B'), selectNoneBatchB: () => this.selectNoneBatchFiles('B'), removeSelectedBatchB: () => this.removeSelectedBatchFiles('B'),
|
|
3832
|
+
|
|
3833
|
+
hasBatchListA: st.batchFilesA.length > 0, hasBatchListB: st.batchFilesB.length > 0,
|
|
3834
|
+
runBatch: () => this.runBatch(), batchBusy: st.batchBusy,
|
|
3835
|
+
batchCanRun: st.batchFilesA.length > 0 && st.batchFilesB.length > 0,
|
|
3836
|
+
setBatchOrder: () => this.setState({ batchPairMode: 'order' }), setBatchFilename: () => this.setState({ batchPairMode: 'filename' }),
|
|
3837
|
+
batchOrderBg: st.batchPairMode === 'order' ? this.accent() : theme.card, batchOrderFg: st.batchPairMode === 'order' ? '#fff' : theme.muted,
|
|
3838
|
+
batchFilenameBg: st.batchPairMode === 'filename' ? this.accent() : theme.card, batchFilenameFg: st.batchPairMode === 'filename' ? '#fff' : theme.muted,
|
|
3839
|
+
batchResults: st.batchResults.map((r, i) => ({
|
|
3840
|
+
nameA: r.nameA, nameB: r.nameB, nSlidesA: r.nSlidesA, nSlidesB: r.nSlidesB,
|
|
3841
|
+
totalDiffs: r.totalDiffs, changedSlides: r.changedSlides, hasError: !!r.error, error: r.error,
|
|
3842
|
+
summary: r.error ? r.error : (r.totalDiffs === 0 ? 'Identical' : r.changedSlides + ' slide(s) changed, ' + r.totalDiffs + ' total difference(s)') + (r.crossDupCount ? ' · ' + r.crossDupCount + ' cross-deck duplicate(s)' : ''),
|
|
3843
|
+
onOpen: () => this.openBatchJob(i)
|
|
3844
|
+
})),
|
|
3845
|
+
hasBatchResults: st.batchResults.length > 0,
|
|
3846
|
+
toastMsg: st.toastMsg, hasToast: !!st.toastMsg,
|
|
3847
|
+
previewPlaying: st.previewPlaying, onPlayPreview: () => this.playTransitionPreview(),
|
|
3848
|
+
previewAfterOpacity: st.previewPlaying ? 1 : 0,
|
|
3849
|
+
transitionTypeB: (sB && sB.transition && sB.transition.type) || 'None',
|
|
3850
|
+
exportSlackMessage: () => this.exportSlackMessage(), exportEmailSummary: () => this.exportEmailSummary(),
|
|
3851
|
+
exportShareLink: () => this.exportShareLink(), exportMergePlan: () => this.exportMergePlan(),
|
|
3852
|
+
exportMergedPptx: () => this.exportMergedPptx(),
|
|
3853
|
+
toggleMergePreview: () => this.toggleMergePreview(), showMergePreview: st.showMergePreview,
|
|
3854
|
+
showShortcutsHelp: st.showShortcutsHelp, toggleShortcutsHelp: () => this.toggleShortcutsHelp(),
|
|
3855
|
+
stopPropagationNoop: (e) => e.stopPropagation(),
|
|
3856
|
+
hasTouchGhost: !!st.touchGhost, touchGhostName: st.touchGhost ? st.touchGhost.name : '',
|
|
3857
|
+
touchGhostStyle: st.touchGhost ? { left: (st.touchGhost.x + 14) + 'px', top: (st.touchGhost.y + 14) + 'px' } : { left: '-9999px', top: '-9999px' },
|
|
3858
|
+
mergePreviewRows: st.showMergePreview ? this.mergePreviewRows() : [],
|
|
3859
|
+
mergeDuplicateSlides: st.mergeDuplicateSlides, onToggleMergeDuplicateSlides: (e) => this.setState({ mergeDuplicateSlides: e.target.checked }),
|
|
3860
|
+
runSelfTests: () => this.triggerSelfTests(),
|
|
3861
|
+
showSelfTests: st.showSelfTests,
|
|
3862
|
+
selfTestRows: (st.selfTestResults || []).map(t => ({ name: t.name, pass: t.pass, detail: t.detail, icon: t.pass ? '✓' : '✕', color: t.pass ? '#3E7C5A' : '#B23A2E' })),
|
|
3863
|
+
hasSelfTests: !!st.selfTestResults,
|
|
3864
|
+
selfTestSummary: st.selfTestResults ? (st.selfTestResults.filter(t => t.pass).length + ' / ' + st.selfTestResults.length + ' passed') : '',
|
|
3865
|
+
masterA, masterB, layoutBorderA: layoutDiffers ? this.accent() : theme.border, layoutBorderB: layoutDiffers ? this.accent() : theme.border,
|
|
3866
|
+
themeMismatchNote: themeDiffers ? 'Master theme differs' : ''
|
|
3867
|
+
};
|
|
3868
|
+
}
|
|
3869
|
+
}
|
|
3870
|
+
</script>
|
|
3871
|
+
</body>
|
|
3872
|
+
</html>
|