diff --git a/package-lock.json b/package-lock.json index 25c7d13..393333d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simulink-data-explorer", - "version": "1.2.13", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simulink-data-explorer", - "version": "1.2.13", + "version": "1.3.0", "license": "BSD-3-Clause", "dependencies": { "@lit/context": "^1.1.6", diff --git a/package.json b/package.json index a967bb1..535a0fb 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "simulink-data-explorer", "displayName": "Simulink Data Explorer", "description": "Explore Simulink models, data dictionaries, MAT-files, and projects as interactive tables and relationship trees.", - "version": "1.2.13", + "version": "1.3.0", "publisher": "mathworks", "icon": "media/icon.png", "private": true, diff --git a/src/dex/components/dex-tree-table.ts b/src/dex/components/dex-tree-table.ts index 0e047f4..f0c5d60 100644 --- a/src/dex/components/dex-tree-table.ts +++ b/src/dex/components/dex-tree-table.ts @@ -3,6 +3,7 @@ import { LitElement, html, css, nothing } from 'lit'; import { customElement, property, state, query } from 'lit/decorators.js'; import { highContrastStyles } from '../styles/high-contrast.styles.js'; +import { dragModeFromModifiers, type DragMode } from '../../webview/dragMode.js'; import './dex-icon.js'; export interface TreeTableRow { @@ -376,6 +377,56 @@ export class DexTreeTable extends LitElement { border-radius: 2px; } + tr.data-row.drop-target-forbidden { + box-shadow: inset 0 0 0 2px var(--vscode-errorForeground, #f14c4c); + border-radius: 2px; + cursor: no-drop; + } + + /* Floating drag tooltip that trails the cursor; fixed so it can overhang + the scroll container. Non-interactive so it never eats drag events. */ + .drop-tooltip { + position: fixed; + z-index: 1000; + pointer-events: none; + display: flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + font-size: 12px; + font-family: var(--vscode-font-family, system-ui, sans-serif); + color: var(--vscode-editorHoverWidget-foreground, #fff); + background: var(--vscode-editorHoverWidget-background, #252526); + border: 1px solid var(--vscode-editorHoverWidget-border, #454545); + border-radius: 3px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); + white-space: nowrap; + max-width: 360px; + } + + .drop-tooltip.forbidden { + color: var(--vscode-errorForeground, #f14c4c); + } + + .drop-tooltip-icon { + font-weight: bold; + } + + /* The dragged row's name; the action text is dimmed and separated so the + box reads "[icon] Name — Move Bus" as one affordance. */ + .drop-tooltip-name { + font-weight: 600; + } + + .drop-tooltip-action { + opacity: 0.85; + } + /* Separator between the name and the action, only when a name precedes it. */ + .drop-tooltip-name + .drop-tooltip-action::before { + content: '— '; + opacity: 0.6; + } + td { padding: 3px 8px; border-right: 1px solid var(--dex-border-color-light, #e0e0e0); @@ -563,6 +614,38 @@ export class DexTreeTable extends LitElement { @state() private _dragSourceId: string | null = null; @state() private _dropTargetId: string | null = null; @state() private _dropPosition: 'above' | 'on' | null = null; + // Live drop feedback driven by the injected dropPredictor: whether the row + // under the cursor rejects the drop, plus the hover tooltip ("Convert Bus to + // Data Interface" / "Simulink Parameter cannot be in Architectural Data") and + // its screen position. Empty tooltip renders nothing. + @state() private _dropForbidden = false; + @state() private _dropTooltip = ''; + @state() private _dropTooltipX = 0; + @state() private _dropTooltipY = 0; + // The dragged row's icon + name, captured at drag start. They ride in the same + // floating box as the action text so there is ONE cursor affordance (icon + + // name + "Move/Copy…"), not a separate native ghost. For a multi-drag the + // count drives a "(+N)" suffix. Empty label = no active local drag. + @state() private _dragIconId = ''; + @state() private _dragLabel = ''; + @state() private _dragCount = 0; + // The copy/move mode observed on the LAST dragover. The `drop` event's modifier + // state (ctrlKey/metaKey) is unreliable on Chromium/macOS — it can read false + // even while Cmd is held — but dragover, which fires continuously, reports it + // correctly. So we stash it here on dragover and use it at drop time, keeping + // the completed action in sync with the "Move"/"Copy" the tooltip showed. + private _lastDragMode: 'copy' | 'move' = 'move'; + + // Predicts a drop for the row under the cursor, injected by the host-side glue + // (table-main) and backed by the pure dropDecision. Returns null when there is + // no active drag or the target can't be resolved, in which case the table + // falls back to its built-in permissive drag behavior. + @property({ attribute: false }) dropPredictor: + | (( + targetRowId: string, + mode: 'copy' | 'move', + ) => { canDrop: boolean; cursor: string; tooltip: string; noop: boolean } | null) + | null = null; @state() private _columnWidths: Map = new Map(); @state() private _columnOrder: string[] = [...DEFAULT_COLUMN_ORDER]; @@ -1425,98 +1508,159 @@ export class DexTreeTable extends LitElement { // --- Row Drag and Drop --- - private _onRowDragStart(rowId: string, e: DragEvent): void { - this._dragSourceId = rowId; - const rowIds = - this.selectedRowIds.length > 1 && this.selectedRowIds.includes(rowId) ? this.selectedRowIds : [rowId]; + // The copy-vs-move modifier follows the platform convention (Option on macOS, + // Ctrl elsewhere); see dragMode.ts for why matching the OS key is required. The + // mapping lives there as a pure, unit-tested function; this just supplies the + // live platform + modifier state. + private _dragModeFrom(e: DragEvent | MouseEvent): DragMode { + return dragModeFromModifiers(navigator.platform, { altKey: e.altKey, ctrlKey: e.ctrlKey }); + } - const classNames: string[] = []; - for (const id of rowIds) { - const row = this.rows.find((r) => r.ID === id); - if (row) { - const dt = typeof row.DataType === 'object' ? row.DataType.text : String(row.DataType || ''); - if (dt) classNames.push(dt); - } + private _onRowDragStart(rowId: string, e: DragEvent): void { + // Section headers aren't draggable entries. If the grabbed row is a header, + // there's nothing to drag; otherwise drag the multi-selection (when the + // grabbed row is part of it) or just the grabbed row, minus any headers. + if (rowId.indexOf('section:') === 0) { + e.preventDefault(); + return; } + this._dragSourceId = rowId; + this._lastDragMode = 'move'; + const rowIds = ( + this.selectedRowIds.length > 1 && this.selectedRowIds.includes(rowId) ? this.selectedRowIds : [rowId] + ).filter((id) => id.indexOf('section:') !== 0); if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'copyMove'; - e.dataTransfer.setData('application/dex-rows', JSON.stringify({ rowIds, classNames })); + // A payload is still set so the browser treats this as a real drag across + // the webview boundary, but the authoritative source lives in the host + // drag register (dataTransfer doesn't reliably survive the boundary). + e.dataTransfer.setData('application/dex-rows', JSON.stringify({ rowIds })); this._setDragImage(e, rowId, rowIds.length); } this.dispatchEvent( new CustomEvent('dex-row-drag-start', { - detail: { rowIds, classNames }, + detail: { rowIds }, bubbles: true, composed: true, }), ); } + // Capture the dragged row's icon + name into state (so the single floating box + // in _renderDropTooltip can show them alongside the action text) and SUPPRESS + // the browser's native drag image, so there is exactly one affordance at the + // cursor rather than two overlapping boxes. The native image is replaced with a + // 1x1 transparent pixel positioned off the cursor. private _setDragImage(e: DragEvent, rowId: string, count: number): void { const row = this.rows.find((r) => r.ID === rowId); if (!row || !e.dataTransfer) return; + this._dragIconId = row.Name?.iconId || ''; + this._dragLabel = row.Name?.label || rowId; + this._dragCount = count; - const ghost = document.createElement('div'); - ghost.style.cssText = - 'position:absolute;top:-1000px;left:-1000px;display:flex;align-items:center;gap:6px;padding:4px 10px;background:#fff;border:1px solid #d0d0d0;border-radius:4px;font-size:12px;font-family:system-ui,sans-serif;box-shadow:0 2px 8px rgba(0,0,0,.15);white-space:nowrap;'; - const iconId = row.Name?.iconId; - if (iconId) { - const icon = document.createElement('dex-icon') as HTMLElement; - (icon as unknown as { iconId: string }).iconId = iconId; - (icon as unknown as { size: number }).size = 16; - ghost.appendChild(icon); - } - const label = document.createElement('span'); - label.textContent = count > 1 ? `${row.Name?.label || rowId} (+${count - 1})` : row.Name?.label || rowId; - ghost.appendChild(label); - document.body.appendChild(ghost); - e.dataTransfer.setDragImage(ghost, 0, 0); - requestAnimationFrame(() => document.body.removeChild(ghost)); + // A 1x1 transparent image hides the native ghost without cancelling the drag. + const blank = new Image(); + blank.src = + 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + e.dataTransfer.setDragImage(blank, -10, -10); } private _onRowDragEnd(_e: DragEvent): void { this._dragSourceId = null; this._dropTargetId = null; this._dropPosition = null; + this._dropForbidden = false; + this._dropTooltip = ''; + this._dragIconId = ''; + this._dragLabel = ''; + this._dragCount = 0; + // Tell the host the drag is over so it can clear the register + broadcast. + this.dispatchEvent(new CustomEvent('dex-row-drag-end', { bubbles: true, composed: true })); } private _onRowDragOver(rowId: string, e: DragEvent): void { - if (rowId === this._dragSourceId) return; + // Keep the floating affordance trailing the cursor even over the source row + // (where the drop itself is a no-op) so the icon + name box never freezes. + this._dropTooltipX = e.clientX; + this._dropTooltipY = e.clientY; + if (rowId === this._dragSourceId) { + // Over the drag origin the drop is a no-op; show just the icon + name (no + // action verb) and don't accept a drop here. + this._dropTooltip = ''; + this._dropForbidden = false; + return; + } + const mode = this._dragModeFrom(e); + // Remember the mode from this dragover; the drop event can't read it reliably. + this._lastDragMode = mode; + + // Ask the injected predictor whether this drop is allowed and what to show. + // With no predictor (or no active drag it recognizes), fall back to the + // built-in permissive behavior so drag still works without the host glue. + const decision = this.dropPredictor ? this.dropPredictor(rowId, mode) : null; + if (decision) { + this._dropTargetId = rowId; + this._dropPosition = 'on'; + this._dropForbidden = !decision.canDrop; + this._dropTooltip = decision.tooltip; + this._dropTooltipX = e.clientX; + this._dropTooltipY = e.clientY; + if (decision.canDrop) { + // A droppable target must preventDefault so the browser fires `drop`. + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = decision.cursor === 'copy' ? 'copy' : 'move'; + } else if (e.dataTransfer) { + // Not droppable: leave the drop un-accepted (no preventDefault) and show + // the no-drop cursor. A no-op (same-section move) reads as "none" too. + e.dataTransfer.dropEffect = 'none'; + } + return; + } + e.preventDefault(); if (e.dataTransfer) { - e.dataTransfer.dropEffect = e.ctrlKey || e.metaKey ? 'copy' : 'move'; + e.dataTransfer.dropEffect = mode; } this._dropTargetId = rowId; this._dropPosition = 'on'; + this._dropForbidden = false; + this._dropTooltip = ''; } private _onRowDragLeave(_e: DragEvent): void { this._dropTargetId = null; this._dropPosition = null; + this._dropForbidden = false; + this._dropTooltip = ''; } private _onRowDrop(targetRowId: string, e: DragEvent): void { e.preventDefault(); e.stopPropagation(); - const data = e.dataTransfer?.getData('application/dex-rows'); - if (!data) return; + // Use the mode from the last dragover, not the drop event: Chromium/macOS + // often reports the modifier as released on `drop`, which would silently + // downgrade a Cmd-drag Copy to a Move. dragover tracked it correctly. + const mode = this._lastDragMode; - const { rowIds } = JSON.parse(data); - const isCopy = e.ctrlKey || e.metaKey; + // Respect the predictor: never complete a rejected or no-op drop. The drag + // may have started in ANOTHER webview, so the source rows come from the host + // drag register, not local dataTransfer (which doesn't cross the boundary) — + // the drop event only needs to carry the target + mode. + const decision = this.dropPredictor ? this.dropPredictor(targetRowId, mode) : null; this._dragSourceId = null; this._dropTargetId = null; this._dropPosition = null; + this._dropForbidden = false; + this._dropTooltip = ''; + + if (decision && !decision.canDrop) return; this.dispatchEvent( new CustomEvent('dex-row-drop', { - detail: { - sourceRowIds: rowIds, - targetRowId, - mode: isCopy ? 'copy' : 'move', - }, + detail: { targetRowId, mode }, bubbles: true, composed: true, }), @@ -2143,7 +2287,9 @@ export class DexTreeTable extends LitElement { 'copy' ? 'copied' : ''} ${isDragSource ? 'drag-source' : ''} ${isDropTarget && this._dropPosition === 'on' - ? 'drop-target-on' + ? this._dropForbidden + ? 'drop-target-forbidden' + : 'drop-target-on' : ''} ${isDropTarget && this._dropPosition === 'above' ? 'drop-target-above' : ''}" data-row-id="${row.ID}" draggable="true" @@ -2173,10 +2319,37 @@ export class DexTreeTable extends LitElement { - ${this._renderColumnMenu()} + ${this._renderColumnMenu()} ${this._renderDropTooltip()} `; } + // The floating drag tooltip that follows the cursor while dragging, describing + // what the drop will do ("Convert Bus to Data Interface") or why it can't + // ("Simulink Parameter cannot be in Architectural Data"). Rendered only while a + // predictor has produced a tooltip; the forbidden variant is styled distinctly. + private _renderDropTooltip() { + // One floating affordance at the cursor: the dragged row's icon + name (from + // drag start) plus the live action text ("Move Bus", "Convert …", or the + // forbidden reason). Shown whenever a local drag carries a label, so the box + // is visible even before the cursor reaches a droppable row. A cross-tab drag + // has no local label here, so it only appears once over this table (with the + // action text and no icon), which is the expected behavior. + const hasLabel = !!this._dragLabel; + if (!hasLabel && !this._dropTooltip) return html``; + const countSuffix = this._dragCount > 1 ? ` (+${this._dragCount - 1})` : ''; + return html`
+ ${this._dropForbidden ? html`` : ''}${this._dragIconId + ? html`` + : ''}${hasLabel ? html`${this._dragLabel}${countSuffix}` : ''}${this + ._dropTooltip + ? html`${this._dropTooltip}` + : ''} +
`; + } + private _renderColumnsButton() { return html`