diff --git a/package.json b/package.json index c57b9a88c8..25f0000d93 100644 --- a/package.json +++ b/package.json @@ -68,9 +68,9 @@ "build": "npm run buildMDViewer && npm run _create-src-node-pkg-lock && npm run _buildonly && npm run createJSDocs && npm run zipTestFiles && npm run lint && npm run _vulnerabilityCheck", "build:debug": "npm run buildMDViewer && npm run _create-src-node-pkg-lock && npm run _buildonlyDebug && npm run createJSDocs && npm run zipTestFiles && npm run lint && npm run _vulnerabilityCheck", "clean": "gulp clean && gulp reset", - "release:dev": "gulp releaseDev", - "release:staging": "gulp releaseStaging", - "release:prod": "gulp releaseProd", + "release:dev": "npm run buildMDViewer && gulp releaseDev", + "release:staging": "npm run buildMDViewer && gulp releaseStaging", + "release:prod": "npm run buildMDViewer && gulp releaseProd", "validate:dist-size": "gulp validateDistSizeRestrictions", "_releaseWebCache": "gulp releaseWebCache", "_patchVersionBump": "gulp patchVersionBump", diff --git a/src-mdviewer/src/components/editor.js b/src-mdviewer/src/components/editor.js index e07f73b85e..aadd1d08b8 100644 --- a/src-mdviewer/src/components/editor.js +++ b/src-mdviewer/src/components/editor.js @@ -735,6 +735,67 @@ function deleteTableColumn(table, colIdx) { }); } +// Module-level clipboard for table row/column copy-paste. +// Stores the inner HTML of each cell so they can be re-inserted into other rows/cols. +let _tableRowClipboard = null; // { cells: [html, ...] } +let _tableColClipboard = null; // { cells: [html, ...] } one per row in source table + +function copyTableRow(tr) { + _tableRowClipboard = { + cells: Array.from(tr.children).map(td => td.innerHTML) + }; +} + +function copyTableColumn(table, colIdx) { + const rows = table.querySelectorAll("tr"); + _tableColClipboard = { + cells: Array.from(rows).map(row => { + const cell = row.children[colIdx]; + return cell ? cell.innerHTML : ""; + }) + }; +} + +function pasteTableRow(table, afterRow) { + if (!_tableRowClipboard) return; + const tbody = table.querySelector("tbody") || table; + const refRow = afterRow || tbody.lastElementChild; + const colCount = refRow ? refRow.children.length : _tableRowClipboard.cells.length; + const newRow = document.createElement("tr"); + for (let i = 0; i < colCount; i++) { + const td = document.createElement("td"); + td.innerHTML = _tableRowClipboard.cells[i] != null ? _tableRowClipboard.cells[i] : " "; + newRow.appendChild(td); + } + if (afterRow && afterRow.nextSibling) { + afterRow.parentNode.insertBefore(newRow, afterRow.nextSibling); + } else if (afterRow) { + afterRow.parentNode.appendChild(newRow); + } else { + tbody.appendChild(newRow); + } + focusCell(newRow.firstElementChild); + return newRow; +} + +function pasteTableColumn(table, afterColIdx) { + if (!_tableColClipboard) return; + const rows = table.querySelectorAll("tr"); + const insertIdx = afterColIdx != null ? afterColIdx + 1 : (rows[0]?.children.length || 0); + rows.forEach((row, rowIdx) => { + const isHeader = row.parentElement.tagName === "THEAD"; + const cell = document.createElement(isHeader ? "th" : "td"); + const html = _tableColClipboard.cells[rowIdx]; + cell.innerHTML = (html != null && html !== "") ? html : (isHeader ? t("table.header") : " "); + const refCell = row.children[insertIdx]; + if (refCell) { + row.insertBefore(cell, refCell); + } else { + row.appendChild(cell); + } + }); +} + function deleteTable(table) { const wrapper = table.closest(".table-wrapper"); const target = wrapper || table; @@ -858,6 +919,10 @@ function showHandleMenu(anchor, type, ctx, contentEl, wrapper, clickX) { { label: t("table.insert_row_above"), action: () => { flushSnapshot(contentEl); addTableRow(ctx.table, null, ctx.tr); dispatchInputEvent(contentEl); } }, { label: t("table.insert_row_below"), action: () => { flushSnapshot(contentEl); addTableRow(ctx.table, ctx.tr); dispatchInputEvent(contentEl); } }, { divider: true }, + { label: t("table.copy_row"), action: () => { copyTableRow(ctx.tr); } }, + { label: t("table.cut_row"), disabled: ctx.isHeader, action: () => { flushSnapshot(contentEl); copyTableRow(ctx.tr); deleteTableRow(ctx.table, ctx.tr); dispatchInputEvent(contentEl); } }, + ...(_tableRowClipboard ? [{ label: t("table.paste_row"), action: () => { flushSnapshot(contentEl); pasteTableRow(ctx.table, ctx.tr); dispatchInputEvent(contentEl); } }] : []), + { divider: true }, { label: t("table.delete_row"), destructive: true, disabled: ctx.isHeader, action: () => { flushSnapshot(contentEl); deleteTableRow(ctx.table, ctx.tr); dispatchInputEvent(contentEl); } }, { divider: true }, { label: t("table.delete_table"), destructive: true, action: () => { flushSnapshot(contentEl); deleteTable(ctx.table); dispatchInputEvent(contentEl); } } @@ -867,6 +932,10 @@ function showHandleMenu(anchor, type, ctx, contentEl, wrapper, clickX) { { label: t("table.insert_col_left"), action: () => { flushSnapshot(contentEl); addTableColumn(ctx.table, ctx.colIdx - 1); dispatchInputEvent(contentEl); } }, { label: t("table.insert_col_right"), action: () => { flushSnapshot(contentEl); addTableColumn(ctx.table, ctx.colIdx); dispatchInputEvent(contentEl); } }, { divider: true }, + { label: t("table.copy_col"), action: () => { copyTableColumn(ctx.table, ctx.colIdx); } }, + { label: t("table.cut_col"), action: () => { flushSnapshot(contentEl); copyTableColumn(ctx.table, ctx.colIdx); deleteTableColumn(ctx.table, ctx.colIdx); dispatchInputEvent(contentEl); } }, + ...(_tableColClipboard ? [{ label: t("table.paste_col"), action: () => { flushSnapshot(contentEl); pasteTableColumn(ctx.table, ctx.colIdx); dispatchInputEvent(contentEl); } }] : []), + { divider: true }, { label: t("table.delete_col"), destructive: true, action: () => { flushSnapshot(contentEl); deleteTableColumn(ctx.table, ctx.colIdx); dispatchInputEvent(contentEl); } }, { divider: true }, { label: t("table.delete_table"), destructive: true, action: () => { flushSnapshot(contentEl); deleteTable(ctx.table); dispatchInputEvent(contentEl); } } @@ -1091,6 +1160,14 @@ function showTableContextMenu(x, y, ctx, contentEl) { { label: t("table.add_col_left"), action: () => { flushSnapshot(contentEl); addTableColumn(ctx.table, ctx.colIdx - 1); dispatchInputEvent(contentEl); } }, { label: t("table.add_col_right"), action: () => { flushSnapshot(contentEl); addTableColumn(ctx.table, ctx.colIdx); dispatchInputEvent(contentEl); } }, { divider: true }, + { label: t("table.copy_row"), action: () => { copyTableRow(ctx.tr); } }, + { label: t("table.cut_row"), action: () => { flushSnapshot(contentEl); copyTableRow(ctx.tr); deleteTableRow(ctx.table, ctx.tr); dispatchInputEvent(contentEl); } }, + ...(_tableRowClipboard ? [{ label: t("table.paste_row"), action: () => { flushSnapshot(contentEl); pasteTableRow(ctx.table, ctx.tr); dispatchInputEvent(contentEl); } }] : []), + { divider: true }, + { label: t("table.copy_col"), action: () => { copyTableColumn(ctx.table, ctx.colIdx); } }, + { label: t("table.cut_col"), action: () => { flushSnapshot(contentEl); copyTableColumn(ctx.table, ctx.colIdx); deleteTableColumn(ctx.table, ctx.colIdx); dispatchInputEvent(contentEl); } }, + ...(_tableColClipboard ? [{ label: t("table.paste_col"), action: () => { flushSnapshot(contentEl); pasteTableColumn(ctx.table, ctx.colIdx); dispatchInputEvent(contentEl); } }] : []), + { divider: true }, { label: t("table.delete_row"), destructive: true, action: () => { flushSnapshot(contentEl); deleteTableRow(ctx.table, ctx.tr); dispatchInputEvent(contentEl); } }, { label: t("table.delete_col"), destructive: true, action: () => { flushSnapshot(contentEl); deleteTableColumn(ctx.table, ctx.colIdx); dispatchInputEvent(contentEl); } }, { divider: true }, diff --git a/src-mdviewer/src/locales/en.json b/src-mdviewer/src/locales/en.json index d73a325393..17367ebb1e 100644 --- a/src-mdviewer/src/locales/en.json +++ b/src-mdviewer/src/locales/en.json @@ -161,6 +161,12 @@ "add_row_below": "Add row below", "add_col_left": "Add column left", "add_col_right": "Add column right", + "copy_row": "Copy row", + "cut_row": "Cut row", + "paste_row": "Paste row below", + "copy_col": "Copy column", + "cut_col": "Cut column", + "paste_col": "Paste column right", "delete_table": "Delete table" }, "dialog": { diff --git a/src/extensions/default/Git/src/Panel.js b/src/extensions/default/Git/src/Panel.js index a8c4d7c9bb..41a509cbb5 100644 --- a/src/extensions/default/Git/src/Panel.js +++ b/src/extensions/default/Git/src/Panel.js @@ -1240,7 +1240,7 @@ define(function (require, exports) { var $panelHtml = $(panelHtml); $panelHtml.find(".git-available, .git-not-available").hide(); - gitPanel = WorkspaceManager.createBottomPanel("main-git.panel", $panelHtml, 100, Strings.GIT_PANEL_TITLE, {iconClass: "fa-brands fa-git-alt"}); + gitPanel = WorkspaceManager.createBottomPanel("main-git.panel", $panelHtml, 100, Strings.GIT_PANEL_TITLE, {iconSvg: "styles/images/panel-icon-git.svg"}); $gitPanel = gitPanel.$panel; const resizeObserver = new ResizeObserver(_panelResized); resizeObserver.observe($gitPanel[0]); diff --git a/src/extensionsIntegrated/CustomSnippets/main.js b/src/extensionsIntegrated/CustomSnippets/main.js index d533e37701..3235942cba 100644 --- a/src/extensionsIntegrated/CustomSnippets/main.js +++ b/src/extensionsIntegrated/CustomSnippets/main.js @@ -59,7 +59,7 @@ define(function (require, exports, module) { */ function _createPanel() { customSnippetsPanel = WorkspaceManager.createBottomPanel(PANEL_ID, $snippetsPanel, PANEL_MIN_SIZE, - Strings.CUSTOM_SNIPPETS_PANEL_TITLE, {iconClass: "fa-solid fa-code"}); + Strings.CUSTOM_SNIPPETS_PANEL_TITLE, {iconSvg: "styles/images/panel-icon-snippets.svg"}); UIHelper.init(customSnippetsPanel); customSnippetsPanel.show(); diff --git a/src/extensionsIntegrated/DisplayShortcuts/main.js b/src/extensionsIntegrated/DisplayShortcuts/main.js index ceb6e1ca50..3c71d6b0c2 100644 --- a/src/extensionsIntegrated/DisplayShortcuts/main.js +++ b/src/extensionsIntegrated/DisplayShortcuts/main.js @@ -479,7 +479,7 @@ define(function (require, exports, module) { // AppInit.htmlReady() has already executed before extensions are loaded // so, for now, we need to call this ourself panel = WorkspaceManager.createBottomPanel(TOGGLE_SHORTCUTS_ID, $(s), 300, - Strings.KEYBOARD_SHORTCUT_PANEL_TITLE, {iconClass: "fa-solid fa-keyboard"}); + Strings.KEYBOARD_SHORTCUT_PANEL_TITLE, {iconSvg: "styles/images/panel-icon-shortcuts.svg"}); panel.hide(); $shortcutsPanel = $("#shortcuts-panel"); diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index b55323403c..4647304a6c 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -115,7 +115,7 @@ define(function (require, exports, module) { }; $panel = $(Mustache.render(panelHTML, templateVars)); - panel = WorkspaceManager.createBottomPanel(PANEL_ID, $panel, PANEL_MIN_SIZE, undefined, {iconClass: "fa-solid fa-terminal"}); + panel = WorkspaceManager.createBottomPanel(PANEL_ID, $panel, PANEL_MIN_SIZE, undefined, {iconSvg: "styles/images/panel-icon-terminal.svg"}); // Override focus() so Shift+Escape can transfer focus to the terminal panel.focus = function () { diff --git a/src/language/CodeInspection.js b/src/language/CodeInspection.js index afad847b08..151440ece9 100644 --- a/src/language/CodeInspection.js +++ b/src/language/CodeInspection.js @@ -1240,7 +1240,7 @@ define(function (require, exports, module) { Editor.registerGutter(CODE_INSPECTION_GUTTER, CODE_INSPECTION_GUTTER_PRIORITY); // Create bottom panel to list error details var panelHtml = Mustache.render(PanelTemplate, Strings); - problemsPanel = WorkspaceManager.createBottomPanel("errors", $(panelHtml), 100, Strings.CMD_VIEW_TOGGLE_PROBLEMS, {iconClass: "fa-solid fa-triangle-exclamation"}); + problemsPanel = WorkspaceManager.createBottomPanel("errors", $(panelHtml), 100, Strings.CMD_VIEW_TOGGLE_PROBLEMS, {iconSvg: "styles/images/panel-icon-problems.svg"}); $problemsPanel = $("#problems-panel"); $fixAllBtn = $problemsPanel.find(".problems-fix-all-btn"); $fixAllBtn.click(()=>{ diff --git a/src/search/SearchResultsView.js b/src/search/SearchResultsView.js index 0c8a8c0d16..a90cc63e2e 100644 --- a/src/search/SearchResultsView.js +++ b/src/search/SearchResultsView.js @@ -82,7 +82,7 @@ define(function (require, exports, module) { const self = this; let panelHtml = Mustache.render(searchPanelTemplate, {panelID: panelID}); - this._panel = WorkspaceManager.createBottomPanel(panelName, $(panelHtml), 100, title, {iconClass: "fa-solid fa-magnifying-glass"}); + this._panel = WorkspaceManager.createBottomPanel(panelName, $(panelHtml), 100, title, {iconSvg: "styles/images/panel-icon-search.svg"}); this._$summary = this._panel.$panel.find(".title"); this._$table = this._panel.$panel.find(".table-container"); this._$previewEditor = this._panel.$panel.find(".search-editor-preview"); diff --git a/src/styles/Extn-BottomPanelTabs.less b/src/styles/Extn-BottomPanelTabs.less index c9dc051604..f81bd6988b 100644 --- a/src/styles/Extn-BottomPanelTabs.less +++ b/src/styles/Extn-BottomPanelTabs.less @@ -144,31 +144,36 @@ } } -/* Tab icon: hidden by default, shown when tabs are collapsed */ -.bottom-panel-tab-icon { - display: none; - font-size: 1rem; +/* SVG icons rendered as masks so they inherit currentColor (works with light/dark themes). + The mask-image URL is set inline via JS (PanelView.js, DefaultPanelView.js). */ +.panel-titlebar-icon { + display: inline-block; width: 1rem; - text-align: center; + height: 1rem; + vertical-align: middle; + background-color: currentColor; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-size: contain; + mask-size: contain; pointer-events: none; } -/* Override any FA class specificity (e.g. fa-brands sets font-size: 1.2em) */ -i.panel-titlebar-icon.panel-titlebar-icon { - font-size: 1rem; -} - -img.panel-titlebar-icon { - width: 1rem; - height: 1rem; - vertical-align: middle; +/* Tab icon: hidden by default, shown when tabs are collapsed */ +.bottom-panel-tab-icon { + display: none; } -.default-panel-btn i.panel-titlebar-icon { - font-size: 20px; +/* Default panel (Tools) tab: always show the icon alongside the title, + in both expanded and collapsed modes, active or inactive. */ +.bottom-panel-tab.bottom-panel-tab-default .bottom-panel-tab-icon { + display: inline-flex; + margin-right: 0.4rem; } -.default-panel-btn img.panel-titlebar-icon { +.default-panel-btn .panel-titlebar-icon { width: 20px; height: 20px; } @@ -229,9 +234,27 @@ img.panel-titlebar-icon { .bottom-panel-tab-close-btn { margin-left: 0.8rem; } - /* Only show close button on the active tab to prevent accidental clicks */ + /* Fix all collapsed tabs to the same width so the UI doesn't shake + when switching active tab. The width matches the active tab's + natural content (icon + close button) using fixed px since the + icon has a fixed size. The Tools button (.bottom-panel-add-btn) + is a separate element and keeps its natural "Tools" text width. */ + .bottom-panel-tab { + min-width: 63px; + box-sizing: border-box; + justify-content: center; + } .bottom-panel-tab:not(.active) .bottom-panel-tab-close-btn { - visibility: hidden; + display: none; + } + /* Default panel (Tools) tab: keep natural width and show its title + even in collapsed mode (other tabs collapse to icon-only). */ + .bottom-panel-tab.bottom-panel-tab-default { + min-width: auto; + justify-content: flex-start; + } + .bottom-panel-tab.bottom-panel-tab-default .bottom-panel-tab-title { + display: inline; } } @@ -285,17 +308,24 @@ img.panel-titlebar-icon { display: flex; align-items: center; justify-content: center; - padding: 0 8px; + padding: 0 10px; height: 2rem; + min-width: 70px; line-height: 2rem; - overflow: hidden; cursor: pointer; color: #888; font-size: 0.82rem; flex: 0 0 auto; white-space: nowrap; + user-select: none; + -webkit-user-drag: none; transition: color 0.12s ease, background-color 0.12s ease; + img { + -webkit-user-drag: none; + pointer-events: none; + } + .dark & { color: #777; } diff --git a/src/styles/images/panel-icon-default.svg b/src/styles/images/panel-icon-default.svg new file mode 100644 index 0000000000..d1771f5f35 --- /dev/null +++ b/src/styles/images/panel-icon-default.svg @@ -0,0 +1 @@ + diff --git a/src/styles/images/panel-icon-git.svg b/src/styles/images/panel-icon-git.svg new file mode 100644 index 0000000000..999a7a8f68 --- /dev/null +++ b/src/styles/images/panel-icon-git.svg @@ -0,0 +1 @@ + diff --git a/src/styles/images/panel-icon-problems.svg b/src/styles/images/panel-icon-problems.svg new file mode 100644 index 0000000000..a493590a09 --- /dev/null +++ b/src/styles/images/panel-icon-problems.svg @@ -0,0 +1 @@ + diff --git a/src/styles/images/panel-icon-search.svg b/src/styles/images/panel-icon-search.svg new file mode 100644 index 0000000000..9aef29292d --- /dev/null +++ b/src/styles/images/panel-icon-search.svg @@ -0,0 +1 @@ + diff --git a/src/styles/images/panel-icon-shortcuts.svg b/src/styles/images/panel-icon-shortcuts.svg new file mode 100644 index 0000000000..3ce2af0ccd --- /dev/null +++ b/src/styles/images/panel-icon-shortcuts.svg @@ -0,0 +1 @@ + diff --git a/src/styles/images/panel-icon-snippets.svg b/src/styles/images/panel-icon-snippets.svg new file mode 100644 index 0000000000..09e7fd87ca --- /dev/null +++ b/src/styles/images/panel-icon-snippets.svg @@ -0,0 +1 @@ + diff --git a/src/styles/images/panel-icon-terminal.svg b/src/styles/images/panel-icon-terminal.svg new file mode 100644 index 0000000000..06ed2625ba --- /dev/null +++ b/src/styles/images/panel-icon-terminal.svg @@ -0,0 +1 @@ + diff --git a/src/view/DefaultPanelView.js b/src/view/DefaultPanelView.js index 8bbdfd52ba..03e3f9d3ed 100644 --- a/src/view/DefaultPanelView.js +++ b/src/view/DefaultPanelView.js @@ -39,32 +39,32 @@ define(function (require, exports, module) { const _panelButtons = [ { id: "problems", - icon: "fa-solid fa-triangle-exclamation", + iconSvg: "styles/images/panel-icon-problems.svg", label: Strings.CMD_VIEW_TOGGLE_PROBLEMS || "Problems", commandID: Commands.VIEW_TOGGLE_PROBLEMS }, { id: "git", - icon: "fa-brands fa-git-alt", + iconSvg: "styles/images/panel-icon-git.svg", label: Strings.GIT_PANEL_TITLE || "Git", commandID: Commands.CMD_GIT_TOGGLE_PANEL, nativeOnly: true }, { id: "snippets", - icon: "fa-solid fa-code", + iconSvg: "styles/images/panel-icon-snippets.svg", label: Strings.CUSTOM_SNIPPETS_PANEL_TITLE || "Custom Snippets", commandID: Commands.CMD_CUSTOM_SNIPPETS_PANEL }, { id: "shortcuts", - icon: "fa-solid fa-keyboard", + iconSvg: "styles/images/panel-icon-shortcuts.svg", label: Strings.KEYBOARD_SHORTCUT_PANEL_TITLE || "Keyboard Shortcuts", commandID: Commands.HELP_TOGGLE_SHORTCUTS_PANEL }, { id: "terminal", - icon: "fa-solid fa-terminal", + iconSvg: "styles/images/panel-icon-terminal.svg", label: "Terminal", commandID: Commands.VIEW_TERMINAL, nativeOnly: true @@ -99,7 +99,10 @@ define(function (require, exports, module) { .attr("data-command", btn.commandID) .attr("data-btn-id", btn.id) .attr("title", btn.label); - let $icon = $('').addClass(btn.icon); + let $icon = $(''); + const maskUrl = "url('" + btn.iconSvg + "')"; + $icon[0].style.maskImage = maskUrl; + $icon[0].style.webkitMaskImage = maskUrl; let $label = $('').text(btn.label); $button.append($icon).append($label); $buttonsRow.append($button); diff --git a/src/view/PanelView.js b/src/view/PanelView.js index f862e1a57e..6918b61b02 100644 --- a/src/view/PanelView.js +++ b/src/view/PanelView.js @@ -149,20 +149,19 @@ define(function (require, exports, module) { */ function _buildTab(panel, isActive) { let title = panel._tabTitle || _getPanelTitle(panel.panelID, panel.$panel); - let $tab = $('
') + // Default panel (Tools) tab is not draggable — it's a fixed slot, not a user tab + const isDefault = panel.panelID === _defaultPanelId; + let $tab = $('') + .toggleClass('bottom-panel-tab-default', isDefault) + .attr('draggable', isDefault ? 'false' : 'true') .toggleClass('active', isActive) .attr('data-panel-id', panel.panelID); - const opts = panel._options; - if (opts.iconClass) { - $tab.append($('') - .addClass(opts.iconClass)); - } else if (opts.iconSvg) { - $tab.append($('