openclacky 1.5.21 → 1.5.22
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -0
- data/lib/clacky/billing/platform_billing.rb +3 -0
- data/lib/clacky/default_extensions/git/panels/git/view.js +1911 -398
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +199 -34
- data/lib/clacky/providers.rb +43 -17
- data/lib/clacky/search_config.rb +33 -11
- data/lib/clacky/server/git_panel.rb +360 -34
- data/lib/clacky/server/http_server.rb +145 -47
- data/lib/clacky/server/preview.rb +118 -17
- data/lib/clacky/server/scheduler.rb +81 -2
- data/lib/clacky/server/web_ui_controller.rb +35 -27
- data/lib/clacky/session_manager.rb +52 -5
- data/lib/clacky/utils/mac_app_detector.rb +8 -7
- data/lib/clacky/utils/model_pricing.rb +60 -2
- data/lib/clacky/utils/windows_app_detector.rb +3 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +650 -96
- data/lib/clacky/web/app.js +59 -6
- data/lib/clacky/web/auth.js +1 -1
- data/lib/clacky/web/components/composer.js +107 -8
- data/lib/clacky/web/components/image-zoom.js +165 -0
- data/lib/clacky/web/components/model-picker.js +6 -4
- data/lib/clacky/web/components/notify.js +2 -2
- data/lib/clacky/web/core/aside.js +60 -3
- data/lib/clacky/web/features/billing/view.js +2 -2
- data/lib/clacky/web/features/channels/view.js +5 -2
- data/lib/clacky/web/features/extensions/view.js +2 -2
- data/lib/clacky/web/features/new-session/store.js +79 -2
- data/lib/clacky/web/features/new-session/view.js +496 -58
- data/lib/clacky/web/features/skills/store.js +1 -1
- data/lib/clacky/web/features/tasks/view.js +162 -51
- data/lib/clacky/web/features/workspace/store.js +13 -6
- data/lib/clacky/web/features/workspace/view.js +153 -163
- data/lib/clacky/web/i18n.js +238 -56
- data/lib/clacky/web/index.html +173 -157
- data/lib/clacky/web/projects.js +37 -4
- data/lib/clacky/web/sessions.js +181 -105
- data/lib/clacky/web/settings.js +26 -8
- data/lib/clacky/web/skills.js +2 -2
- data/lib/clacky/web/theme.js +17 -2
- data/lib/clacky/web/utils.js +17 -2
- data/lib/clacky/web/weixin-qr.html +2 -1
- data/lib/clacky/web/ws-dispatcher.js +8 -29
- metadata +2 -1
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// it shows for every session regardless of agent profile. All I/O goes through
|
|
12
12
|
// WorkspaceStore.
|
|
13
13
|
//
|
|
14
|
-
// Depends on: WorkspaceStore, Clacky.ext, I18n, Modal, CodeEditor
|
|
14
|
+
// Depends on: WorkspaceStore, Clacky.ext, Composer, I18n, Modal, CodeEditor,
|
|
15
|
+
// ImageZoom.
|
|
15
16
|
// ───────────────────────────────────────────────────────────────────────────
|
|
16
17
|
"use strict";
|
|
17
18
|
|
|
@@ -143,6 +144,30 @@ const WorkspaceView = (() => {
|
|
|
143
144
|
'<path fill="#c3ccd4" d="M9.7 1.5v3.3H13z"/>'
|
|
144
145
|
);
|
|
145
146
|
|
|
147
|
+
const ICON_TEXT = svgWrap(
|
|
148
|
+
'<path fill="#90a4ae" d="M3.5 1.5h6.2L13 4.8v9.2a1 1 0 0 1-1 1H3.5a1 1 0 0 1-1-1v-11a1 1 0 0 1 1-1z"/>' +
|
|
149
|
+
'<path fill="#c3ccd4" d="M9.7 1.5v3.3H13z"/>' +
|
|
150
|
+
'<path d="M4.8 7.5h5.4M4.8 9.9h5.4M4.8 12.3h3.4" stroke="#fff" stroke-width="1.1" stroke-linecap="round"/>'
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const ICON_PDF = svgWrap(
|
|
154
|
+
'<rect x="1" y="1" width="14" height="14" rx="3" fill="#e5383b"/>' +
|
|
155
|
+
`<text x="8" y="10.4" text-anchor="middle" ${ICON_FONT} font-size="5.6" font-weight="700" letter-spacing="-0.2" fill="#fff">PDF</text>`
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// Office-style tile: a lettered square overlapping a tinted page
|
|
159
|
+
function officeIcon(label, color) {
|
|
160
|
+
return svgWrap(
|
|
161
|
+
`<path fill="${color}" opacity="0.35" d="M5.5 1.5h6.2L14.5 4.3v9.7a.9.9 0 0 1-.9.9H5.5a.9.9 0 0 1-.9-.9V2.4a.9.9 0 0 1 .9-.9z"/>` +
|
|
162
|
+
`<rect x="1" y="4" width="9" height="9" rx="1.6" fill="${color}"/>` +
|
|
163
|
+
`<text x="5.5" y="11.1" text-anchor="middle" ${ICON_FONT} font-size="7" font-weight="800" fill="#fff">${label}</text>`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const ICON_WORD = officeIcon("W", "#2b579a");
|
|
168
|
+
const ICON_EXCEL = officeIcon("X", "#217346");
|
|
169
|
+
const ICON_PPT = officeIcon("P", "#d24726");
|
|
170
|
+
|
|
146
171
|
const ICON_VIDEO = svgWrap(
|
|
147
172
|
'<rect x="1.5" y="3" width="13" height="10" rx="2" fill="#5c8ee6"/>' +
|
|
148
173
|
'<path d="m6.5 5.8 4 2.2-4 2.2z" fill="#fff"/>'
|
|
@@ -186,7 +211,11 @@ const WorkspaceView = (() => {
|
|
|
186
211
|
gif: imgIcon("#a074c4"), webp: imgIcon("#a074c4"), ico: imgIcon("#a074c4"), bmp: imgIcon("#a074c4"),
|
|
187
212
|
mp4: ICON_VIDEO, webm: ICON_VIDEO, mov: ICON_VIDEO,
|
|
188
213
|
mp3: ICON_AUDIO, wav: ICON_AUDIO, ogg: ICON_AUDIO, aac: ICON_AUDIO, flac: ICON_AUDIO, m4a: ICON_AUDIO,
|
|
189
|
-
pdf:
|
|
214
|
+
pdf: ICON_PDF,
|
|
215
|
+
doc: ICON_WORD, docx: ICON_WORD, odt: ICON_WORD, rtf: ICON_WORD, wps: ICON_WORD,
|
|
216
|
+
xls: ICON_EXCEL, xlsx: ICON_EXCEL, ods: ICON_EXCEL, numbers: ICON_EXCEL,
|
|
217
|
+
ppt: ICON_PPT, pptx: ICON_PPT, odp: ICON_PPT, key: ICON_PPT,
|
|
218
|
+
txt: ICON_TEXT, log: ICON_TEXT,
|
|
190
219
|
zip: boxIcon("Z", "#dba15d"), tar: boxIcon("Z", "#dba15d"), gz: boxIcon("Z", "#dba15d"),
|
|
191
220
|
"7z": boxIcon("Z", "#dba15d"), rar: boxIcon("Z", "#dba15d"), dmg: boxIcon("Z", "#dba15d"), iso: boxIcon("Z", "#dba15d"),
|
|
192
221
|
lock: ICON_LOCK,
|
|
@@ -312,6 +341,16 @@ const WorkspaceView = (() => {
|
|
|
312
341
|
node.appendChild(children);
|
|
313
342
|
row.addEventListener("click", () => toggleDir(entry, caret, children, icon, openFile));
|
|
314
343
|
} else {
|
|
344
|
+
row.draggable = true;
|
|
345
|
+
row.addEventListener("dragstart", (e) => {
|
|
346
|
+
if (!e.dataTransfer || typeof Composer === "undefined" ||
|
|
347
|
+
typeof Composer.beginWorkspaceFileDrag !== "function") {
|
|
348
|
+
e.preventDefault();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const started = Composer.beginWorkspaceFileDrag(e.dataTransfer, chatAttachment(entry));
|
|
352
|
+
if (!started) e.preventDefault();
|
|
353
|
+
});
|
|
315
354
|
row.addEventListener("click", () => openFile(entry));
|
|
316
355
|
}
|
|
317
356
|
|
|
@@ -505,6 +544,15 @@ const WorkspaceView = (() => {
|
|
|
505
544
|
}
|
|
506
545
|
}
|
|
507
546
|
|
|
547
|
+
function chatAttachment(entry) {
|
|
548
|
+
return {
|
|
549
|
+
type: "file",
|
|
550
|
+
name: entry.name,
|
|
551
|
+
path: Workspace.absPath(entry.path),
|
|
552
|
+
size: entry.size,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
508
556
|
// Hand the file to the composer, which stages it and focuses the input. The
|
|
509
557
|
// absolute path is what the agent ultimately reads, so resolve it here where
|
|
510
558
|
// the session's working directory is known.
|
|
@@ -513,11 +561,7 @@ const WorkspaceView = (() => {
|
|
|
513
561
|
console.error("add to chat failed: Sessions.attachWorkspaceFile is unavailable");
|
|
514
562
|
return;
|
|
515
563
|
}
|
|
516
|
-
Sessions.attachWorkspaceFile(
|
|
517
|
-
name: entry.name,
|
|
518
|
-
path: Workspace.absPath(entry.path),
|
|
519
|
-
size: entry.size,
|
|
520
|
-
});
|
|
564
|
+
Sessions.attachWorkspaceFile(chatAttachment(entry));
|
|
521
565
|
}
|
|
522
566
|
|
|
523
567
|
async function toggleDir(entry, caret, children, iconEl, openFile) {
|
|
@@ -542,7 +586,8 @@ const WorkspaceView = (() => {
|
|
|
542
586
|
highlightActiveRow();
|
|
543
587
|
} catch (err) {
|
|
544
588
|
console.error("workspace load failed:", err);
|
|
545
|
-
children.
|
|
589
|
+
children.replaceChildren();
|
|
590
|
+
renderLoadError(children, err);
|
|
546
591
|
}
|
|
547
592
|
}
|
|
548
593
|
|
|
@@ -563,6 +608,52 @@ const WorkspaceView = (() => {
|
|
|
563
608
|
}
|
|
564
609
|
}
|
|
565
610
|
|
|
611
|
+
// A session outlives its working dir all the time (deleted temp clone,
|
|
612
|
+
// removed workspace, renamed folder). The API flags that case with a code;
|
|
613
|
+
// "Failed to load files" alone leaves the user with nothing to act on, so
|
|
614
|
+
// name the cause and offer the folder picker the status bar already owns.
|
|
615
|
+
function isDirGone(err) {
|
|
616
|
+
const code = err && err.code;
|
|
617
|
+
return code === "working_dir_missing" || code === "directory_missing";
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function renderLoadError(container, err) {
|
|
621
|
+
if (!isDirGone(err)) {
|
|
622
|
+
container.innerHTML = `<div class="wt-error">${t("workspace.error")}</div>`;
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const workingDirGone = err.code === "working_dir_missing";
|
|
626
|
+
const card = el("div", "wt-error-card");
|
|
627
|
+
const title = el("div", "wt-error-card-title");
|
|
628
|
+
title.textContent = t(workingDirGone ? "workspace.dirMissingTitle" : "workspace.dirGoneTitle");
|
|
629
|
+
const desc = el("div", "wt-error-card-desc");
|
|
630
|
+
desc.textContent = t(workingDirGone ? "workspace.dirMissingDesc" : "workspace.dirGoneDesc");
|
|
631
|
+
card.appendChild(title);
|
|
632
|
+
card.appendChild(desc);
|
|
633
|
+
|
|
634
|
+
if (workingDirGone) {
|
|
635
|
+
const fullPath = Workspace.state.workingDir || "";
|
|
636
|
+
const pathEl = el("div", "wt-error-card-path");
|
|
637
|
+
// A full path in a 168px-wide pane hides the segment that differs from
|
|
638
|
+
// other checkouts, which is the one worth reading.
|
|
639
|
+
pathEl.textContent = fullPath.length > 44 ? `…${fullPath.slice(-43)}` : fullPath;
|
|
640
|
+
pathEl.title = fullPath;
|
|
641
|
+
card.appendChild(pathEl);
|
|
642
|
+
|
|
643
|
+
const action = el("button", "btn-card-grid-action wt-error-card-action");
|
|
644
|
+
action.type = "button";
|
|
645
|
+
action.textContent = t("workspace.dirMissingAction");
|
|
646
|
+
// Reuse the status-bar picker's click: it owns the dialog, the PATCH and
|
|
647
|
+
// the "this folder no longer exists" wording for a missing bound path.
|
|
648
|
+
action.addEventListener("click", () => {
|
|
649
|
+
const statusBarDir = document.getElementById("sib-dir");
|
|
650
|
+
if (statusBarDir) statusBarDir.click();
|
|
651
|
+
});
|
|
652
|
+
card.appendChild(action);
|
|
653
|
+
}
|
|
654
|
+
container.replaceChildren(card);
|
|
655
|
+
}
|
|
656
|
+
|
|
566
657
|
async function loadRoot(tree, openFile, opts) {
|
|
567
658
|
if (!tree || !Workspace.state.hasSession()) return;
|
|
568
659
|
// A different working dir (session switch) shares no history with what is
|
|
@@ -578,7 +669,9 @@ const WorkspaceView = (() => {
|
|
|
578
669
|
entries = await Workspace.fetchEntries("");
|
|
579
670
|
} catch (err) {
|
|
580
671
|
console.error("workspace load failed:", err);
|
|
581
|
-
|
|
672
|
+
// Rows that stay on screen after the dir disappeared claim files exist
|
|
673
|
+
// where they don't; a transient failure keeps the listing it already has.
|
|
674
|
+
if (firstLoad || isDirGone(err)) renderLoadError(tree, err);
|
|
582
675
|
return false;
|
|
583
676
|
}
|
|
584
677
|
reconcileEntries(tree, entries, openFile);
|
|
@@ -680,12 +773,6 @@ const WorkspaceView = (() => {
|
|
|
680
773
|
|
|
681
774
|
const VIEWER_ASIDE_WIDTH = 700;
|
|
682
775
|
|
|
683
|
-
// Image zoom is expressed against the picture's own pixels, so 100% always
|
|
684
|
-
// means "one screen pixel per image pixel" whatever the pane is doing.
|
|
685
|
-
const IMAGE_MIN_ZOOM = 0.1;
|
|
686
|
-
const IMAGE_MAX_ZOOM = 8;
|
|
687
|
-
const IMAGE_ZOOM_STEP = 1.25;
|
|
688
|
-
|
|
689
776
|
// Open files outlive a reload: the strip is rebuilt from a per-session record
|
|
690
777
|
// in localStorage, so refreshing the page (or coming back to a session) lands
|
|
691
778
|
// on the same files. Paths only — every tab body is fetched from disk again.
|
|
@@ -806,38 +893,45 @@ const WorkspaceView = (() => {
|
|
|
806
893
|
|
|
807
894
|
const content = el("div", "wv-content");
|
|
808
895
|
|
|
809
|
-
|
|
896
|
+
// `key` is the i18n lookup so a language switch can rewrite the hint
|
|
897
|
+
// after the toolbar has already been built.
|
|
898
|
+
function actionBtn(icon, key, cls) {
|
|
810
899
|
const btn = el("button", "wv-action" + (cls ? " " + cls : ""));
|
|
811
900
|
btn.type = "button";
|
|
812
|
-
btn.
|
|
813
|
-
btn.setAttribute("
|
|
901
|
+
btn.setAttribute("data-tooltip", t(key));
|
|
902
|
+
btn.setAttribute("data-i18n-tooltip", key);
|
|
903
|
+
btn.setAttribute("aria-label", t(key));
|
|
814
904
|
btn.innerHTML = icon;
|
|
815
905
|
return btn;
|
|
816
906
|
}
|
|
817
907
|
|
|
818
|
-
const toggleTreeBtn = actionBtn(ICON_PANEL_LEFT,
|
|
908
|
+
const toggleTreeBtn = actionBtn(ICON_PANEL_LEFT, "workspace.toggleTree");
|
|
819
909
|
toggleTreeBtn.classList.add("wv-toggle-tree", "active");
|
|
820
910
|
toggleTreeBtn.addEventListener("click", () => {
|
|
821
911
|
const hidden = onToggleTree ? onToggleTree() : false;
|
|
822
912
|
toggleTreeBtn.classList.toggle("active", !hidden);
|
|
823
913
|
});
|
|
824
914
|
|
|
825
|
-
const mobileBackBtn = actionBtn(ICON_ARROW_LEFT,
|
|
915
|
+
const mobileBackBtn = actionBtn(ICON_ARROW_LEFT, "workspace.backToFiles", "wv-mobile-back");
|
|
826
916
|
mobileBackBtn.addEventListener("click", () => {
|
|
827
917
|
if (onShowMobileTree) onShowMobileTree();
|
|
828
918
|
});
|
|
829
919
|
|
|
830
|
-
const previewBtn = actionBtn(ICON_EYE,
|
|
831
|
-
const splitBtn = actionBtn(ICON_COLUMNS,
|
|
832
|
-
const copyBtn = actionBtn(ICON_COPY,
|
|
833
|
-
const saveBtn = actionBtn(ICON_SAVE,
|
|
920
|
+
const previewBtn = actionBtn(ICON_EYE, "workspace.preview");
|
|
921
|
+
const splitBtn = actionBtn(ICON_COLUMNS, "workspace.splitPreview");
|
|
922
|
+
const copyBtn = actionBtn(ICON_COPY, "workspace.copyContent");
|
|
923
|
+
const saveBtn = actionBtn(ICON_SAVE, "modal.save");
|
|
834
924
|
|
|
835
925
|
// Split button shown for binary (non-previewable) tabs: text part opens
|
|
836
926
|
// with the OS default app, the chevron opens the "open with" dropdown.
|
|
837
927
|
const openGroup = el("div", "wv-action-group");
|
|
838
928
|
const openMainBtn = el("button", "wv-action wv-action--open");
|
|
839
929
|
openMainBtn.type = "button";
|
|
840
|
-
|
|
930
|
+
const setOpenHint = (hint) => {
|
|
931
|
+
openMainBtn.setAttribute("data-tooltip", hint);
|
|
932
|
+
openMainBtn.setAttribute("aria-label", hint);
|
|
933
|
+
};
|
|
934
|
+
setOpenHint(t("workspace.openDefault"));
|
|
841
935
|
const openIconEl = el("span", "wv-main-app-icon");
|
|
842
936
|
openIconEl.innerHTML = ICON_EXTERNAL;
|
|
843
937
|
const openLabel = el("span");
|
|
@@ -849,7 +943,7 @@ const WorkspaceView = (() => {
|
|
|
849
943
|
if (!tab) return;
|
|
850
944
|
await openWithSystem(tab.entry);
|
|
851
945
|
});
|
|
852
|
-
const openArrowBtn = actionBtn(ICON_CHEVRON_DOWN,
|
|
946
|
+
const openArrowBtn = actionBtn(ICON_CHEVRON_DOWN, "workspace.openWithMenu");
|
|
853
947
|
openArrowBtn.addEventListener("click", (e) => {
|
|
854
948
|
e.stopPropagation();
|
|
855
949
|
const tab = activeTab();
|
|
@@ -904,7 +998,8 @@ const WorkspaceView = (() => {
|
|
|
904
998
|
|
|
905
999
|
const close = el("span", "wv-tab-close");
|
|
906
1000
|
close.innerHTML = ICON_CLOSE;
|
|
907
|
-
close.
|
|
1001
|
+
close.setAttribute("data-tooltip", t("workspace.closeTab"));
|
|
1002
|
+
close.setAttribute("data-i18n-tooltip", "workspace.closeTab");
|
|
908
1003
|
|
|
909
1004
|
btn.appendChild(label);
|
|
910
1005
|
btn.appendChild(dot);
|
|
@@ -952,7 +1047,7 @@ const WorkspaceView = (() => {
|
|
|
952
1047
|
if (tab && tab.id === _openIconTabId) return;
|
|
953
1048
|
_openIconTabId = tab ? tab.id : null;
|
|
954
1049
|
openIconEl.innerHTML = ICON_EXTERNAL;
|
|
955
|
-
|
|
1050
|
+
setOpenHint(t("workspace.openDefault"));
|
|
956
1051
|
if (!tab) return;
|
|
957
1052
|
Workspace.fetchDefaultApp(tab.entry).then((app) => {
|
|
958
1053
|
if (!app || _openIconTabId !== tab.id) return;
|
|
@@ -963,7 +1058,7 @@ const WorkspaceView = (() => {
|
|
|
963
1058
|
if (_openIconTabId === tab.id) openIconEl.innerHTML = ICON_EXTERNAL;
|
|
964
1059
|
});
|
|
965
1060
|
openIconEl.replaceChildren(img);
|
|
966
|
-
|
|
1061
|
+
setOpenHint(t("workspace.openWith", { app: app.name }));
|
|
967
1062
|
}).catch(() => { /* keep generic icon */ });
|
|
968
1063
|
}
|
|
969
1064
|
|
|
@@ -991,7 +1086,7 @@ const WorkspaceView = (() => {
|
|
|
991
1086
|
|
|
992
1087
|
function disposeTab(tab) {
|
|
993
1088
|
if (tab.editor) tab.editor.destroy();
|
|
994
|
-
if (tab.
|
|
1089
|
+
if (tab.zoomController) tab.zoomController.destroy();
|
|
995
1090
|
if (tab.imageUrl) URL.revokeObjectURL(tab.imageUrl);
|
|
996
1091
|
if (tab.mediaEl) {
|
|
997
1092
|
tab.mediaEl.pause();
|
|
@@ -1237,139 +1332,25 @@ const WorkspaceView = (() => {
|
|
|
1237
1332
|
viewport.appendChild(img);
|
|
1238
1333
|
|
|
1239
1334
|
const bar = el("div", "wv-zoom-bar");
|
|
1240
|
-
const outBtn = actionBtn(ICON_ZOOM_OUT,
|
|
1335
|
+
const outBtn = actionBtn(ICON_ZOOM_OUT, "workspace.zoomOut", "wv-zoom-btn");
|
|
1241
1336
|
const level = el("span", "wv-zoom-level");
|
|
1242
|
-
const inBtn = actionBtn(ICON_ZOOM_IN,
|
|
1243
|
-
const fitBtn = actionBtn(ICON_ZOOM_FIT,
|
|
1337
|
+
const inBtn = actionBtn(ICON_ZOOM_IN, "workspace.zoomIn", "wv-zoom-btn");
|
|
1338
|
+
const fitBtn = actionBtn(ICON_ZOOM_FIT, "workspace.zoomFit", "wv-zoom-btn");
|
|
1244
1339
|
bar.append(outBtn, level, inBtn, fitBtn);
|
|
1245
1340
|
pane.append(viewport, bar);
|
|
1246
1341
|
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
function fitScale() {
|
|
1257
|
-
const vw = viewport.clientWidth;
|
|
1258
|
-
const vh = viewport.clientHeight;
|
|
1259
|
-
// A pane inside a hidden tab measures as zero, and a zero ratio would
|
|
1260
|
-
// park the picture at an invisible size — stay neutral until it is real.
|
|
1261
|
-
if (!vw || !vh) return 1;
|
|
1262
|
-
const nw = img.naturalWidth || vw;
|
|
1263
|
-
const nh = img.naturalHeight || vh;
|
|
1264
|
-
return Math.min(1, vw / nw, vh / nh);
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
function pan(offsetX, offsetY) {
|
|
1268
|
-
// Nothing to reveal beyond the edges, so an image narrower than the
|
|
1269
|
-
// pane stays centred instead of being dragged off into a corner.
|
|
1270
|
-
const maxX = Math.max(0, (img.naturalWidth * scale - viewport.clientWidth) / 2);
|
|
1271
|
-
const maxY = Math.max(0, (img.naturalHeight * scale - viewport.clientHeight) / 2);
|
|
1272
|
-
x = Math.max(-maxX, Math.min(maxX, offsetX));
|
|
1273
|
-
y = Math.max(-maxY, Math.min(maxY, offsetY));
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
function render() {
|
|
1277
|
-
pan(x, y);
|
|
1278
|
-
img.style.transform = "translate(" + x + "px, " + y + "px) scale(" + scale + ")";
|
|
1279
|
-
level.textContent = Math.round(scale * 100) + "%";
|
|
1280
|
-
outBtn.disabled = scale <= IMAGE_MIN_ZOOM + 1e-6;
|
|
1281
|
-
inBtn.disabled = scale >= IMAGE_MAX_ZOOM - 1e-6;
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
// Zoom around a point (pane centre by default) so the pixels under the
|
|
1285
|
-
// cursor stay put instead of the picture drifting toward a corner.
|
|
1286
|
-
function zoomAt(target, px, py) {
|
|
1287
|
-
const next = Math.max(IMAGE_MIN_ZOOM, Math.min(IMAGE_MAX_ZOOM, target));
|
|
1288
|
-
if (next === scale) return;
|
|
1289
|
-
const k = next / scale;
|
|
1290
|
-
x = px - k * (px - x);
|
|
1291
|
-
y = py - k * (py - y);
|
|
1292
|
-
scale = next;
|
|
1293
|
-
render();
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
function anchorOf(ev) {
|
|
1297
|
-
const rect = viewport.getBoundingClientRect();
|
|
1298
|
-
return [ev.clientX - (rect.left + rect.width / 2), ev.clientY - (rect.top + rect.height / 2)];
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
function reset() {
|
|
1302
|
-
fit = fitScale();
|
|
1303
|
-
scale = fit;
|
|
1304
|
-
x = 0;
|
|
1305
|
-
y = 0;
|
|
1306
|
-
render();
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
const atFit = () => Math.abs(scale - fit) < 1e-6;
|
|
1310
|
-
|
|
1311
|
-
// A double click is the shortcut for "show me the real pixels" — or, for a
|
|
1312
|
-
// picture that already fits, for "closer than that".
|
|
1313
|
-
const closerTarget = () => (fit < 1 ? 1 : fit * 2);
|
|
1314
|
-
|
|
1315
|
-
inBtn.addEventListener("click", () => zoomAt(scale * IMAGE_ZOOM_STEP, 0, 0));
|
|
1316
|
-
outBtn.addEventListener("click", () => zoomAt(scale / IMAGE_ZOOM_STEP, 0, 0));
|
|
1317
|
-
fitBtn.addEventListener("click", reset);
|
|
1318
|
-
|
|
1319
|
-
// A picture has nothing to scroll, so the wheel belongs to the zoom —
|
|
1320
|
-
// including the pinch gesture a trackpad reports as ctrl+wheel. The
|
|
1321
|
-
// damping keeps one mouse notch (±100) near a quarter turn while the many
|
|
1322
|
-
// small deltas a trackpad sends add up to a glide instead of a jump.
|
|
1323
|
-
viewport.addEventListener("wheel", (ev) => {
|
|
1324
|
-
ev.preventDefault();
|
|
1325
|
-
const anchor = anchorOf(ev);
|
|
1326
|
-
zoomAt(scale * Math.exp(-ev.deltaY * 0.002), anchor[0], anchor[1]);
|
|
1327
|
-
}, { passive: false });
|
|
1328
|
-
|
|
1329
|
-
viewport.addEventListener("dblclick", (ev) => {
|
|
1330
|
-
ev.preventDefault();
|
|
1331
|
-
if (atFit()) {
|
|
1332
|
-
const anchor = anchorOf(ev);
|
|
1333
|
-
zoomAt(closerTarget(), anchor[0], anchor[1]);
|
|
1334
|
-
} else {
|
|
1335
|
-
reset();
|
|
1336
|
-
}
|
|
1337
|
-
});
|
|
1338
|
-
|
|
1339
|
-
let drag = null;
|
|
1340
|
-
viewport.addEventListener("pointerdown", (ev) => {
|
|
1341
|
-
if (ev.button) return;
|
|
1342
|
-
drag = { id: ev.pointerId, x: ev.clientX, y: ev.clientY };
|
|
1343
|
-
viewport.classList.add("is-panning");
|
|
1344
|
-
viewport.setPointerCapture(ev.pointerId);
|
|
1345
|
-
ev.preventDefault();
|
|
1346
|
-
});
|
|
1347
|
-
viewport.addEventListener("pointermove", (ev) => {
|
|
1348
|
-
if (!drag || ev.pointerId !== drag.id) return;
|
|
1349
|
-
pan(x + ev.clientX - drag.x, y + ev.clientY - drag.y);
|
|
1350
|
-
drag.x = ev.clientX;
|
|
1351
|
-
drag.y = ev.clientY;
|
|
1352
|
-
img.style.transform = "translate(" + x + "px, " + y + "px) scale(" + scale + ")";
|
|
1342
|
+
const zoomController = ImageZoom.create({
|
|
1343
|
+
viewport,
|
|
1344
|
+
image: img,
|
|
1345
|
+
onChange(state) {
|
|
1346
|
+
level.textContent = Math.round(state.scale * 100) + "%";
|
|
1347
|
+
outBtn.disabled = !state.canZoomOut;
|
|
1348
|
+
inBtn.disabled = !state.canZoomIn;
|
|
1349
|
+
},
|
|
1353
1350
|
});
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
viewport.classList.remove("is-panning");
|
|
1358
|
-
};
|
|
1359
|
-
viewport.addEventListener("pointerup", endDrag);
|
|
1360
|
-
viewport.addEventListener("pointercancel", endDrag);
|
|
1361
|
-
|
|
1362
|
-
img.addEventListener("load", reset);
|
|
1363
|
-
if (img.complete) reset();
|
|
1364
|
-
render();
|
|
1365
|
-
|
|
1366
|
-
// Dragging the aside or the window changes the fit ratio, and a tab that
|
|
1367
|
-
// was opened in the background only finds its real size once it is shown —
|
|
1368
|
-
// so refit on every new size, but leave a deliberate zoom alone.
|
|
1369
|
-
const observer = typeof ResizeObserver === "function"
|
|
1370
|
-
? new ResizeObserver(() => { if (atFit()) reset(); })
|
|
1371
|
-
: null;
|
|
1372
|
-
if (observer) observer.observe(viewport);
|
|
1351
|
+
inBtn.addEventListener("click", zoomController.zoomIn);
|
|
1352
|
+
outBtn.addEventListener("click", zoomController.zoomOut);
|
|
1353
|
+
fitBtn.addEventListener("click", zoomController.reset);
|
|
1373
1354
|
|
|
1374
1355
|
return {
|
|
1375
1356
|
id: entry.path,
|
|
@@ -1382,7 +1363,7 @@ const WorkspaceView = (() => {
|
|
|
1382
1363
|
previewEl: null,
|
|
1383
1364
|
editor: null,
|
|
1384
1365
|
imageUrl: url,
|
|
1385
|
-
|
|
1366
|
+
zoomController,
|
|
1386
1367
|
};
|
|
1387
1368
|
}
|
|
1388
1369
|
|
|
@@ -1669,7 +1650,8 @@ const WorkspaceView = (() => {
|
|
|
1669
1650
|
searchInput.setAttribute("aria-label", t("workspace.search"));
|
|
1670
1651
|
const searchClear = el("button", "wt-search-clear");
|
|
1671
1652
|
searchClear.type = "button";
|
|
1672
|
-
searchClear.
|
|
1653
|
+
searchClear.setAttribute("data-tooltip", t("workspace.closeTab"));
|
|
1654
|
+
searchClear.setAttribute("data-i18n-tooltip", "workspace.closeTab");
|
|
1673
1655
|
searchClear.innerHTML = ICON_CLOSE;
|
|
1674
1656
|
searchWrap.appendChild(searchIcon);
|
|
1675
1657
|
searchWrap.appendChild(searchInput);
|
|
@@ -1677,7 +1659,8 @@ const WorkspaceView = (() => {
|
|
|
1677
1659
|
|
|
1678
1660
|
const refresh = el("button", "wt-bar-btn");
|
|
1679
1661
|
refresh.type = "button";
|
|
1680
|
-
refresh.
|
|
1662
|
+
refresh.setAttribute("data-tooltip", t("workspace.refresh"));
|
|
1663
|
+
refresh.setAttribute("data-i18n-tooltip", "workspace.refresh");
|
|
1681
1664
|
refresh.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/></svg>';
|
|
1682
1665
|
bar.appendChild(searchWrap);
|
|
1683
1666
|
bar.appendChild(refresh);
|
|
@@ -1923,8 +1906,15 @@ const WorkspaceView = (() => {
|
|
|
1923
1906
|
return true;
|
|
1924
1907
|
}
|
|
1925
1908
|
|
|
1926
|
-
// autoOpenWithSystem is exported for the regression harness
|
|
1927
|
-
|
|
1909
|
+
// autoOpenWithSystem is exported for the regression harness; the icon
|
|
1910
|
+
// helpers are reused by the git panel so both trees show the same file
|
|
1911
|
+
// glyphs.
|
|
1912
|
+
return {
|
|
1913
|
+
mount, openFile, autoOpenWithSystem,
|
|
1914
|
+
fileIcon: fileIconSvg,
|
|
1915
|
+
iconFolder: ICON_FOLDER,
|
|
1916
|
+
iconFolderOpen: ICON_FOLDER_OPEN,
|
|
1917
|
+
};
|
|
1928
1918
|
})();
|
|
1929
1919
|
|
|
1930
1920
|
if (window.Clacky) Clacky.WorkspaceView = WorkspaceView;
|