Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
257 changes: 215 additions & 42 deletions src/dex/components/dex-tree-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string, number> = new Map();
@state() private _columnOrder: string[] = [...DEFAULT_COLUMN_ORDER];
Expand Down Expand Up @@ -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,
}),
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -2173,10 +2319,37 @@ export class DexTreeTable extends LitElement {
</table>
</div>
</div>
${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`<div
class="drop-tooltip ${this._dropForbidden ? 'forbidden' : ''}"
style="left:${this._dropTooltipX + 14}px; top:${this._dropTooltipY + 16}px"
>
${this._dropForbidden ? html`<span class="drop-tooltip-icon">⊘</span>` : ''}${this._dragIconId
? html`<dex-icon class="drop-tooltip-row-icon" .iconId=${this._dragIconId} .size=${14}></dex-icon>`
: ''}${hasLabel ? html`<span class="drop-tooltip-name">${this._dragLabel}${countSuffix}</span>` : ''}${this
._dropTooltip
? html`<span class="drop-tooltip-action">${this._dropTooltip}</span>`
: ''}
</div>`;
}

private _renderColumnsButton() {
return html`
<button
Expand Down
Loading
Loading