reviw 0.6.0 → 0.7.1
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/README.md +47 -13
- package/cli.cjs +418 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
# reviw
|
|
2
2
|
|
|
3
|
-
A lightweight browser-based tool for reviewing and annotating tabular data, text, and
|
|
3
|
+
A lightweight browser-based tool for reviewing and annotating tabular data, text, Markdown, and diff files. Supports CSV, TSV, plain text, Markdown, and unified diff formats. Comments are output as YAML to stdout.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- **
|
|
9
|
-
- **
|
|
7
|
+
### File Format Support
|
|
8
|
+
- **CSV/TSV**: View tabular data with sticky headers, column freezing, filtering, and column resizing
|
|
9
|
+
- **Markdown**: Side-by-side preview with synchronized scrolling
|
|
10
|
+
- **Diff/Patch**: GitHub-style diff view with syntax highlighting, collapsible large files (500+ lines), and binary files sorted to end
|
|
11
|
+
- **Text**: Line-by-line commenting for plain text files
|
|
12
|
+
|
|
13
|
+
### Mermaid.js Diagrams
|
|
14
|
+
- Auto-detect and render Mermaid diagrams in Markdown files
|
|
15
|
+
- Click any diagram to open fullscreen viewer
|
|
16
|
+
- Zoom with mouse wheel (centered on cursor position, up to 10x)
|
|
17
|
+
- Pan with mouse drag
|
|
18
|
+
- Minimap showing current viewport position
|
|
19
|
+
- Syntax error display in toast notifications
|
|
20
|
+
|
|
21
|
+
### UI Features
|
|
22
|
+
- **Theme toggle**: Switch between light and dark modes
|
|
10
23
|
- **Multi-file support**: Open multiple files simultaneously on separate ports
|
|
11
24
|
- **Drag selection**: Select rectangular regions or multiple rows for batch comments
|
|
12
|
-
- **Real-time**: Hot reload on file changes via SSE
|
|
13
|
-
- **
|
|
25
|
+
- **Real-time updates**: Hot reload on file changes via SSE
|
|
26
|
+
- **Comment persistence**: Auto-save comments to localStorage with recovery modal
|
|
27
|
+
- **Keyboard shortcuts**: Cmd/Ctrl+Enter to open submit modal
|
|
28
|
+
|
|
29
|
+
### Output
|
|
30
|
+
- YAML format with file, mode, row, col, value, and comment text
|
|
31
|
+
- Overall summary field for review notes
|
|
14
32
|
|
|
15
33
|
## Installation
|
|
16
34
|
|
|
@@ -32,24 +50,39 @@ reviw <file> [--port 3000] [--encoding utf8|shift_jis|...]
|
|
|
32
50
|
|
|
33
51
|
# Multiple files (each opens on consecutive ports)
|
|
34
52
|
reviw file1.csv file2.md file3.tsv --port 3000
|
|
53
|
+
|
|
54
|
+
# Diff from stdin
|
|
55
|
+
git diff HEAD | reviw
|
|
56
|
+
|
|
57
|
+
# Diff file
|
|
58
|
+
reviw changes.diff
|
|
35
59
|
```
|
|
36
60
|
|
|
37
|
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
|
|
61
|
+
### Options
|
|
62
|
+
- `--port <number>`: Specify starting port (default: 3000)
|
|
63
|
+
- `--encoding <encoding>`: Force specific encoding (auto-detected by default)
|
|
64
|
+
- `--no-open`: Prevent automatic browser opening
|
|
65
|
+
|
|
66
|
+
### Workflow
|
|
67
|
+
1. Browser opens automatically (macOS: `open` / Linux: `xdg-open` / Windows: `start`)
|
|
68
|
+
2. Click cells/lines to add comments, or drag to select multiple
|
|
69
|
+
3. Use Cmd/Ctrl+Enter or click "Submit & Exit" to output comments
|
|
70
|
+
4. Comments are printed as YAML to stdout
|
|
42
71
|
|
|
43
72
|
## Screenshots
|
|
44
73
|
|
|
45
74
|
### CSV View
|
|
46
|
-
|
|
47
75
|

|
|
48
76
|
|
|
49
77
|
### Markdown View
|
|
50
|
-
|
|
51
78
|

|
|
52
79
|
|
|
80
|
+
### Diff View
|
|
81
|
+

|
|
82
|
+
|
|
83
|
+
### Mermaid Fullscreen
|
|
84
|
+

|
|
85
|
+
|
|
53
86
|
## Output Example
|
|
54
87
|
|
|
55
88
|
```yaml
|
|
@@ -62,6 +95,7 @@ comments:
|
|
|
62
95
|
col: 3
|
|
63
96
|
text: This value needs review
|
|
64
97
|
value: '150'
|
|
98
|
+
summary: Overall the data looks good, minor issues noted above.
|
|
65
99
|
```
|
|
66
100
|
|
|
67
101
|
## Development
|
package/cli.cjs
CHANGED
|
@@ -1842,7 +1842,141 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
|
|
|
1842
1842
|
header { flex-direction: column; align-items: flex-start; }
|
|
1843
1843
|
.comment-list { width: calc(100% - 24px); right: 12px; }
|
|
1844
1844
|
}
|
|
1845
|
+
/* Mermaid diagram styles */
|
|
1846
|
+
.mermaid-container {
|
|
1847
|
+
position: relative;
|
|
1848
|
+
margin: 16px 0;
|
|
1849
|
+
background: var(--panel);
|
|
1850
|
+
border: 1px solid var(--border);
|
|
1851
|
+
border-radius: 8px;
|
|
1852
|
+
padding: 16px;
|
|
1853
|
+
overflow: hidden;
|
|
1854
|
+
}
|
|
1855
|
+
.mermaid-container .mermaid {
|
|
1856
|
+
display: flex;
|
|
1857
|
+
justify-content: center;
|
|
1858
|
+
}
|
|
1859
|
+
.mermaid-container .mermaid svg {
|
|
1860
|
+
max-width: 100%;
|
|
1861
|
+
height: auto;
|
|
1862
|
+
}
|
|
1863
|
+
.mermaid-fullscreen-btn {
|
|
1864
|
+
position: absolute;
|
|
1865
|
+
top: 8px;
|
|
1866
|
+
right: 8px;
|
|
1867
|
+
background: var(--selected-bg);
|
|
1868
|
+
border: 1px solid var(--border);
|
|
1869
|
+
border-radius: 6px;
|
|
1870
|
+
padding: 6px 10px;
|
|
1871
|
+
cursor: pointer;
|
|
1872
|
+
color: var(--text);
|
|
1873
|
+
font-size: 12px;
|
|
1874
|
+
z-index: 2;
|
|
1875
|
+
display: flex;
|
|
1876
|
+
align-items: center;
|
|
1877
|
+
gap: 4px;
|
|
1878
|
+
}
|
|
1879
|
+
.mermaid-fullscreen-btn:hover { background: var(--hover-bg); }
|
|
1880
|
+
/* Fullscreen overlay */
|
|
1881
|
+
.fullscreen-overlay {
|
|
1882
|
+
position: fixed;
|
|
1883
|
+
inset: 0;
|
|
1884
|
+
background: var(--bg);
|
|
1885
|
+
z-index: 1000;
|
|
1886
|
+
display: none;
|
|
1887
|
+
flex-direction: column;
|
|
1888
|
+
}
|
|
1889
|
+
.fullscreen-overlay.visible { display: flex; }
|
|
1890
|
+
.fullscreen-header {
|
|
1891
|
+
display: flex;
|
|
1892
|
+
justify-content: space-between;
|
|
1893
|
+
align-items: center;
|
|
1894
|
+
padding: 12px 20px;
|
|
1895
|
+
background: var(--panel-alpha);
|
|
1896
|
+
border-bottom: 1px solid var(--border);
|
|
1897
|
+
}
|
|
1898
|
+
.fullscreen-header h3 { margin: 0; font-size: 14px; }
|
|
1899
|
+
.fullscreen-controls { display: flex; gap: 8px; align-items: center; }
|
|
1900
|
+
.fullscreen-controls button {
|
|
1901
|
+
background: var(--selected-bg);
|
|
1902
|
+
border: 1px solid var(--border);
|
|
1903
|
+
border-radius: 6px;
|
|
1904
|
+
padding: 6px 12px;
|
|
1905
|
+
cursor: pointer;
|
|
1906
|
+
color: var(--text);
|
|
1907
|
+
font-size: 13px;
|
|
1908
|
+
}
|
|
1909
|
+
.fullscreen-controls button:hover { background: var(--hover-bg); }
|
|
1910
|
+
.fullscreen-controls .zoom-info { font-size: 12px; color: var(--muted); min-width: 50px; text-align: center; }
|
|
1911
|
+
.fullscreen-content {
|
|
1912
|
+
flex: 1;
|
|
1913
|
+
overflow: hidden;
|
|
1914
|
+
position: relative;
|
|
1915
|
+
cursor: grab;
|
|
1916
|
+
}
|
|
1917
|
+
.fullscreen-content:active { cursor: grabbing; }
|
|
1918
|
+
.fullscreen-content .mermaid-wrapper {
|
|
1919
|
+
position: absolute;
|
|
1920
|
+
transform-origin: 0 0;
|
|
1921
|
+
padding: 40px;
|
|
1922
|
+
}
|
|
1923
|
+
.fullscreen-content .mermaid svg {
|
|
1924
|
+
display: block;
|
|
1925
|
+
}
|
|
1926
|
+
/* Minimap */
|
|
1927
|
+
.minimap {
|
|
1928
|
+
position: absolute;
|
|
1929
|
+
top: 70px;
|
|
1930
|
+
right: 20px;
|
|
1931
|
+
width: 200px;
|
|
1932
|
+
height: 150px;
|
|
1933
|
+
background: var(--panel-alpha);
|
|
1934
|
+
border: 1px solid var(--border);
|
|
1935
|
+
border-radius: 8px;
|
|
1936
|
+
overflow: hidden;
|
|
1937
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
1938
|
+
}
|
|
1939
|
+
.minimap-content {
|
|
1940
|
+
width: 100%;
|
|
1941
|
+
height: 100%;
|
|
1942
|
+
display: flex;
|
|
1943
|
+
align-items: center;
|
|
1944
|
+
justify-content: center;
|
|
1945
|
+
padding: 8px;
|
|
1946
|
+
}
|
|
1947
|
+
.minimap-content svg {
|
|
1948
|
+
max-width: 100%;
|
|
1949
|
+
max-height: 100%;
|
|
1950
|
+
opacity: 0.6;
|
|
1951
|
+
}
|
|
1952
|
+
.minimap-viewport {
|
|
1953
|
+
position: absolute;
|
|
1954
|
+
border: 2px solid var(--accent);
|
|
1955
|
+
background: rgba(102, 126, 234, 0.2);
|
|
1956
|
+
pointer-events: none;
|
|
1957
|
+
border-radius: 2px;
|
|
1958
|
+
}
|
|
1959
|
+
/* Error toast */
|
|
1960
|
+
.mermaid-error-toast {
|
|
1961
|
+
position: fixed;
|
|
1962
|
+
bottom: 20px;
|
|
1963
|
+
left: 50%;
|
|
1964
|
+
transform: translateX(-50%);
|
|
1965
|
+
background: #dc3545;
|
|
1966
|
+
color: white;
|
|
1967
|
+
padding: 12px 24px;
|
|
1968
|
+
border-radius: 8px;
|
|
1969
|
+
font-size: 13px;
|
|
1970
|
+
max-width: 80%;
|
|
1971
|
+
z-index: 2000;
|
|
1972
|
+
display: none;
|
|
1973
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
1974
|
+
white-space: pre-wrap;
|
|
1975
|
+
font-family: monospace;
|
|
1976
|
+
}
|
|
1977
|
+
.mermaid-error-toast.visible { display: block; }
|
|
1845
1978
|
</style>
|
|
1979
|
+
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
|
1846
1980
|
</head>
|
|
1847
1981
|
<body>
|
|
1848
1982
|
<header>
|
|
@@ -1958,6 +2092,27 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
|
|
|
1958
2092
|
</div>
|
|
1959
2093
|
</div>
|
|
1960
2094
|
|
|
2095
|
+
<div class="fullscreen-overlay" id="mermaid-fullscreen">
|
|
2096
|
+
<div class="fullscreen-header">
|
|
2097
|
+
<h3>Mermaid Diagram</h3>
|
|
2098
|
+
<div class="fullscreen-controls">
|
|
2099
|
+
<button id="fs-zoom-out">−</button>
|
|
2100
|
+
<span class="zoom-info" id="fs-zoom-info">100%</span>
|
|
2101
|
+
<button id="fs-zoom-in">+</button>
|
|
2102
|
+
<button id="fs-reset">Reset</button>
|
|
2103
|
+
<button id="fs-close">Close (ESC)</button>
|
|
2104
|
+
</div>
|
|
2105
|
+
</div>
|
|
2106
|
+
<div class="fullscreen-content" id="fs-content">
|
|
2107
|
+
<div class="mermaid-wrapper" id="fs-wrapper"></div>
|
|
2108
|
+
</div>
|
|
2109
|
+
<div class="minimap" id="fs-minimap">
|
|
2110
|
+
<div class="minimap-content" id="fs-minimap-content"></div>
|
|
2111
|
+
<div class="minimap-viewport" id="fs-minimap-viewport"></div>
|
|
2112
|
+
</div>
|
|
2113
|
+
</div>
|
|
2114
|
+
<div class="mermaid-error-toast" id="mermaid-error-toast"></div>
|
|
2115
|
+
|
|
1961
2116
|
<script>
|
|
1962
2117
|
const DATA = ${serialized};
|
|
1963
2118
|
const MAX_COLS = ${cols};
|
|
@@ -2932,6 +3087,269 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
|
|
|
2932
3087
|
mdRight.addEventListener('scroll', () => syncScroll(mdRight, mdLeft, 'right'), { passive: true });
|
|
2933
3088
|
}
|
|
2934
3089
|
}
|
|
3090
|
+
|
|
3091
|
+
// --- Mermaid Initialization ---
|
|
3092
|
+
(function initMermaid() {
|
|
3093
|
+
if (typeof mermaid === 'undefined') return;
|
|
3094
|
+
|
|
3095
|
+
const errorToast = document.getElementById('mermaid-error-toast');
|
|
3096
|
+
let errorTimeout;
|
|
3097
|
+
|
|
3098
|
+
function showError(msg) {
|
|
3099
|
+
errorToast.textContent = msg;
|
|
3100
|
+
errorToast.classList.add('visible');
|
|
3101
|
+
console.error('[Mermaid Error]', msg);
|
|
3102
|
+
clearTimeout(errorTimeout);
|
|
3103
|
+
errorTimeout = setTimeout(() => errorToast.classList.remove('visible'), 8000);
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
mermaid.initialize({
|
|
3107
|
+
startOnLoad: false,
|
|
3108
|
+
theme: document.documentElement.getAttribute('data-theme') === 'light' ? 'default' : 'dark',
|
|
3109
|
+
securityLevel: 'loose',
|
|
3110
|
+
logLevel: 'error'
|
|
3111
|
+
});
|
|
3112
|
+
|
|
3113
|
+
// Find all mermaid code blocks in preview
|
|
3114
|
+
const preview = document.querySelector('.md-preview');
|
|
3115
|
+
if (!preview) return;
|
|
3116
|
+
|
|
3117
|
+
const codeBlocks = preview.querySelectorAll('pre code.language-mermaid, pre code');
|
|
3118
|
+
codeBlocks.forEach((code, idx) => {
|
|
3119
|
+
const pre = code.parentElement;
|
|
3120
|
+
const text = code.textContent.trim();
|
|
3121
|
+
|
|
3122
|
+
// Check if it's mermaid content
|
|
3123
|
+
if (!code.classList.contains('language-mermaid') && !text.match(/^(graph|flowchart|sequenceDiagram|classDiagram|stateDiagram|erDiagram|journey|gantt|pie|gitGraph|mindmap|timeline)/)) {
|
|
3124
|
+
return;
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
// Create container
|
|
3128
|
+
const container = document.createElement('div');
|
|
3129
|
+
container.className = 'mermaid-container';
|
|
3130
|
+
container.style.cursor = 'pointer';
|
|
3131
|
+
container.title = 'Click to view fullscreen';
|
|
3132
|
+
|
|
3133
|
+
const mermaidDiv = document.createElement('div');
|
|
3134
|
+
mermaidDiv.className = 'mermaid';
|
|
3135
|
+
mermaidDiv.id = 'mermaid-' + idx;
|
|
3136
|
+
mermaidDiv.textContent = text;
|
|
3137
|
+
|
|
3138
|
+
// Click anywhere on container to open fullscreen
|
|
3139
|
+
container.addEventListener('click', () => openFullscreen(mermaidDiv));
|
|
3140
|
+
|
|
3141
|
+
container.appendChild(mermaidDiv);
|
|
3142
|
+
pre.replaceWith(container);
|
|
3143
|
+
});
|
|
3144
|
+
|
|
3145
|
+
// Render all mermaid diagrams with error handling
|
|
3146
|
+
mermaid.run().catch(err => {
|
|
3147
|
+
showError('Mermaid Syntax Error: ' + (err.message || err));
|
|
3148
|
+
});
|
|
3149
|
+
|
|
3150
|
+
// Watch for render errors in DOM
|
|
3151
|
+
setTimeout(() => {
|
|
3152
|
+
document.querySelectorAll('.mermaid').forEach(el => {
|
|
3153
|
+
if (el.querySelector('.error-text, .error-icon')) {
|
|
3154
|
+
const errText = el.textContent;
|
|
3155
|
+
showError('Mermaid Parse Error: ' + errText.slice(0, 200));
|
|
3156
|
+
}
|
|
3157
|
+
});
|
|
3158
|
+
}, 500);
|
|
3159
|
+
|
|
3160
|
+
// Fullscreen functionality
|
|
3161
|
+
const fsOverlay = document.getElementById('mermaid-fullscreen');
|
|
3162
|
+
const fsWrapper = document.getElementById('fs-wrapper');
|
|
3163
|
+
const fsContent = document.getElementById('fs-content');
|
|
3164
|
+
const fsZoomInfo = document.getElementById('fs-zoom-info');
|
|
3165
|
+
const minimapContent = document.getElementById('fs-minimap-content');
|
|
3166
|
+
const minimapViewport = document.getElementById('fs-minimap-viewport');
|
|
3167
|
+
let currentZoom = 1;
|
|
3168
|
+
let initialZoom = 1;
|
|
3169
|
+
let panX = 0, panY = 0;
|
|
3170
|
+
let isPanning = false;
|
|
3171
|
+
let startX, startY;
|
|
3172
|
+
let svgNaturalWidth = 0, svgNaturalHeight = 0;
|
|
3173
|
+
let minimapScale = 1;
|
|
3174
|
+
|
|
3175
|
+
function openFullscreen(mermaidEl) {
|
|
3176
|
+
const svg = mermaidEl.querySelector('svg');
|
|
3177
|
+
if (!svg) return;
|
|
3178
|
+
fsWrapper.innerHTML = '';
|
|
3179
|
+
const clonedSvg = svg.cloneNode(true);
|
|
3180
|
+
fsWrapper.appendChild(clonedSvg);
|
|
3181
|
+
|
|
3182
|
+
// Setup minimap
|
|
3183
|
+
minimapContent.innerHTML = '';
|
|
3184
|
+
const minimapSvg = svg.cloneNode(true);
|
|
3185
|
+
minimapContent.appendChild(minimapSvg);
|
|
3186
|
+
|
|
3187
|
+
// Get SVG's intrinsic/natural size from viewBox or attributes
|
|
3188
|
+
const viewBox = svg.getAttribute('viewBox');
|
|
3189
|
+
let naturalWidth, naturalHeight;
|
|
3190
|
+
|
|
3191
|
+
if (viewBox) {
|
|
3192
|
+
const parts = viewBox.split(/[\\s,]+/);
|
|
3193
|
+
naturalWidth = parseFloat(parts[2]) || 800;
|
|
3194
|
+
naturalHeight = parseFloat(parts[3]) || 600;
|
|
3195
|
+
} else {
|
|
3196
|
+
naturalWidth = parseFloat(svg.getAttribute('width')) || svg.getBoundingClientRect().width || 800;
|
|
3197
|
+
naturalHeight = parseFloat(svg.getAttribute('height')) || svg.getBoundingClientRect().height || 600;
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3200
|
+
svgNaturalWidth = naturalWidth;
|
|
3201
|
+
svgNaturalHeight = naturalHeight;
|
|
3202
|
+
|
|
3203
|
+
// Calculate minimap scale
|
|
3204
|
+
const minimapMaxWidth = 184; // 200 - 16 padding
|
|
3205
|
+
const minimapMaxHeight = 134; // 150 - 16 padding
|
|
3206
|
+
minimapScale = Math.min(minimapMaxWidth / naturalWidth, minimapMaxHeight / naturalHeight);
|
|
3207
|
+
|
|
3208
|
+
clonedSvg.style.width = naturalWidth + 'px';
|
|
3209
|
+
clonedSvg.style.height = naturalHeight + 'px';
|
|
3210
|
+
|
|
3211
|
+
// Calculate fit-to-viewport zoom
|
|
3212
|
+
const viewportHeight = window.innerHeight - 80;
|
|
3213
|
+
const viewportWidth = window.innerWidth - 40;
|
|
3214
|
+
|
|
3215
|
+
const zoomForHeight = viewportHeight / naturalHeight;
|
|
3216
|
+
const zoomForWidth = viewportWidth / naturalWidth;
|
|
3217
|
+
const fitZoom = Math.min(zoomForHeight, zoomForWidth);
|
|
3218
|
+
|
|
3219
|
+
currentZoom = fitZoom;
|
|
3220
|
+
initialZoom = fitZoom;
|
|
3221
|
+
|
|
3222
|
+
// Center the SVG in viewport
|
|
3223
|
+
const scaledWidth = naturalWidth * currentZoom;
|
|
3224
|
+
const scaledHeight = naturalHeight * currentZoom;
|
|
3225
|
+
panX = (viewportWidth - scaledWidth) / 2 + 20;
|
|
3226
|
+
panY = (viewportHeight - scaledHeight) / 2 + 60;
|
|
3227
|
+
|
|
3228
|
+
updateTransform();
|
|
3229
|
+
fsOverlay.classList.add('visible');
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
function closeFullscreen() {
|
|
3233
|
+
fsOverlay.classList.remove('visible');
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
function updateTransform() {
|
|
3237
|
+
fsWrapper.style.transform = 'translate(' + panX + 'px, ' + panY + 'px) scale(' + currentZoom + ')';
|
|
3238
|
+
fsZoomInfo.textContent = Math.round(currentZoom * 100) + '%';
|
|
3239
|
+
updateMinimap();
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
function updateMinimap() {
|
|
3243
|
+
if (!svgNaturalWidth || !svgNaturalHeight) return;
|
|
3244
|
+
|
|
3245
|
+
const viewportWidth = window.innerWidth - 40;
|
|
3246
|
+
const viewportHeight = window.innerHeight - 80;
|
|
3247
|
+
|
|
3248
|
+
// Minimap dimensions
|
|
3249
|
+
const mmWidth = 184;
|
|
3250
|
+
const mmHeight = 134;
|
|
3251
|
+
const mmPadding = 8;
|
|
3252
|
+
|
|
3253
|
+
// SVG size in minimap (centered)
|
|
3254
|
+
const mmSvgWidth = svgNaturalWidth * minimapScale;
|
|
3255
|
+
const mmSvgHeight = svgNaturalHeight * minimapScale;
|
|
3256
|
+
const mmSvgLeft = (mmWidth - mmSvgWidth) / 2 + mmPadding;
|
|
3257
|
+
const mmSvgTop = (mmHeight - mmSvgHeight) / 2 + mmPadding;
|
|
3258
|
+
|
|
3259
|
+
// Calculate visible area in SVG coordinates (accounting for transform origin at 0,0)
|
|
3260
|
+
// panX/panY are the translation values, currentZoom is the scale
|
|
3261
|
+
// The visible area starts at -panX/currentZoom in SVG coordinates
|
|
3262
|
+
const visibleLeft = Math.max(0, -panX / currentZoom);
|
|
3263
|
+
const visibleTop = Math.max(0, (-panY + 60) / currentZoom);
|
|
3264
|
+
const visibleWidth = viewportWidth / currentZoom;
|
|
3265
|
+
const visibleHeight = viewportHeight / currentZoom;
|
|
3266
|
+
|
|
3267
|
+
// Clamp to SVG bounds
|
|
3268
|
+
const clampedLeft = Math.min(visibleLeft, svgNaturalWidth);
|
|
3269
|
+
const clampedTop = Math.min(visibleTop, svgNaturalHeight);
|
|
3270
|
+
|
|
3271
|
+
// Position viewport indicator in minimap coordinates
|
|
3272
|
+
const vpLeft = mmSvgLeft + clampedLeft * minimapScale;
|
|
3273
|
+
const vpTop = mmSvgTop + clampedTop * minimapScale;
|
|
3274
|
+
const vpWidth = Math.min(mmWidth - vpLeft + mmPadding, visibleWidth * minimapScale);
|
|
3275
|
+
const vpHeight = Math.min(mmHeight - vpTop + mmPadding, visibleHeight * minimapScale);
|
|
3276
|
+
|
|
3277
|
+
minimapViewport.style.left = vpLeft + 'px';
|
|
3278
|
+
minimapViewport.style.top = vpTop + 'px';
|
|
3279
|
+
minimapViewport.style.width = Math.max(20, vpWidth) + 'px';
|
|
3280
|
+
minimapViewport.style.height = Math.max(15, vpHeight) + 'px';
|
|
3281
|
+
}
|
|
3282
|
+
|
|
3283
|
+
// Use multiplicative zoom for consistent behavior
|
|
3284
|
+
function zoomAt(factor, clientX, clientY) {
|
|
3285
|
+
const oldZoom = currentZoom;
|
|
3286
|
+
currentZoom = Math.max(0.1, Math.min(10, currentZoom * factor));
|
|
3287
|
+
|
|
3288
|
+
// Zoom around mouse position
|
|
3289
|
+
const fsRect = fsContent.getBoundingClientRect();
|
|
3290
|
+
const mouseX = clientX - fsRect.left;
|
|
3291
|
+
const mouseY = clientY - fsRect.top;
|
|
3292
|
+
|
|
3293
|
+
const zoomRatio = currentZoom / oldZoom;
|
|
3294
|
+
panX = mouseX - (mouseX - panX) * zoomRatio;
|
|
3295
|
+
panY = mouseY - (mouseY - panY) * zoomRatio;
|
|
3296
|
+
|
|
3297
|
+
updateTransform();
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
function zoom(factor) {
|
|
3301
|
+
const fsRect = fsContent.getBoundingClientRect();
|
|
3302
|
+
zoomAt(factor, fsRect.left + fsRect.width / 2, fsRect.top + fsRect.height / 2);
|
|
3303
|
+
}
|
|
3304
|
+
|
|
3305
|
+
document.getElementById('fs-zoom-in').addEventListener('click', () => zoom(1.25));
|
|
3306
|
+
document.getElementById('fs-zoom-out').addEventListener('click', () => zoom(0.8));
|
|
3307
|
+
document.getElementById('fs-reset').addEventListener('click', () => {
|
|
3308
|
+
currentZoom = initialZoom;
|
|
3309
|
+
const viewportHeight = window.innerHeight - 80;
|
|
3310
|
+
const viewportWidth = window.innerWidth - 40;
|
|
3311
|
+
const scaledWidth = svgNaturalWidth * currentZoom;
|
|
3312
|
+
const scaledHeight = svgNaturalHeight * currentZoom;
|
|
3313
|
+
panX = (viewportWidth - scaledWidth) / 2 + 20;
|
|
3314
|
+
panY = (viewportHeight - scaledHeight) / 2 + 60;
|
|
3315
|
+
updateTransform();
|
|
3316
|
+
});
|
|
3317
|
+
document.getElementById('fs-close').addEventListener('click', closeFullscreen);
|
|
3318
|
+
|
|
3319
|
+
// Pan with mouse drag
|
|
3320
|
+
fsContent.addEventListener('mousedown', (e) => {
|
|
3321
|
+
isPanning = true;
|
|
3322
|
+
startX = e.clientX - panX;
|
|
3323
|
+
startY = e.clientY - panY;
|
|
3324
|
+
fsContent.style.cursor = 'grabbing';
|
|
3325
|
+
});
|
|
3326
|
+
|
|
3327
|
+
document.addEventListener('mousemove', (e) => {
|
|
3328
|
+
if (!isPanning) return;
|
|
3329
|
+
panX = e.clientX - startX;
|
|
3330
|
+
panY = e.clientY - startY;
|
|
3331
|
+
updateTransform();
|
|
3332
|
+
});
|
|
3333
|
+
|
|
3334
|
+
document.addEventListener('mouseup', () => {
|
|
3335
|
+
isPanning = false;
|
|
3336
|
+
fsContent.style.cursor = 'grab';
|
|
3337
|
+
});
|
|
3338
|
+
|
|
3339
|
+
// Zoom with mouse wheel - use multiplicative factor
|
|
3340
|
+
fsContent.addEventListener('wheel', (e) => {
|
|
3341
|
+
e.preventDefault();
|
|
3342
|
+
const factor = e.deltaY > 0 ? 0.9 : 1.1;
|
|
3343
|
+
zoomAt(factor, e.clientX, e.clientY);
|
|
3344
|
+
}, { passive: false });
|
|
3345
|
+
|
|
3346
|
+
// ESC to close
|
|
3347
|
+
document.addEventListener('keydown', (e) => {
|
|
3348
|
+
if (e.key === 'Escape' && fsOverlay.classList.contains('visible')) {
|
|
3349
|
+
closeFullscreen();
|
|
3350
|
+
}
|
|
3351
|
+
});
|
|
3352
|
+
})();
|
|
2935
3353
|
</script>
|
|
2936
3354
|
</body>
|
|
2937
3355
|
</html>`;
|