feat(sub12a+): drop-position simulator in Simple Recipe Editor

Replaces the per-row 'highlight whole row' feedback (operator
couldn't tell whether the new step would land before or after the
hovered row) with a precise insertion-point indicator.

How it works:
- Each row's onDragOver computes ev.clientY vs row midpoint.
  Above midpoint → insertion index = rowIndex (BEFORE).
  Below midpoint → insertion index = rowIndex + 1 (AFTER).
- An <o_fp_drop_indicator> div lives BEFORE the first row and
  AFTER every row. When state.dragOverIndex matches that slot's
  index, the div expands from height:0 to a 2.25rem dashed-green
  reservation strip with a ghost-preview chip ('↓ insert here →
  <icon> <step name>').
- onDragStart captures the dragged step/template's name + icon
  into state.dragPreviewLabel/Icon for the chip text.
- Smooth 80ms height/margin transition so the line glides between
  slots as the cursor moves rather than blinking.
- Trailing dropzone retains its existing 'Drop here to add at end'
  styling. Empty list shows 'Drag a library step here to start'.
- onDrop reads from state.dragOverIndex (set by the most-recent
  onDragOver) so we drop at the simulated position exactly.
- onDragLeave guards against child-element flicker via
  relatedTarget contains() check.
- onDragEnd clears state.dragPreviewLabel/Icon so a half-completed
  drag (cancelled by Esc) doesn't leave the chip stuck on screen.

fusion_plating → 19.0.10.4.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-27 22:08:51 -04:00
parent 7d3b8f132a
commit 3098fcfaf9
4 changed files with 130 additions and 16 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.10.3.0',
'version': '19.0.10.4.0',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """

View File

@@ -32,7 +32,11 @@ export class FpSimpleRecipeEditor extends Component {
librarySearch: "",
templateOptions: [],
selectedTemplate: "",
dragOverIndex: null,
// Drop-position simulator (snaps to line above/below the
// hovered row based on cursor Y vs row midpoint).
dragOverIndex: null, // 0..N (insertion index)
dragPreviewLabel: "", // shown next to the indicator line
dragPreviewIcon: "fa-cog",
});
this._recipeId = null;
@@ -155,25 +159,51 @@ export class FpSimpleRecipeEditor extends Component {
ev.dataTransfer.effectAllowed = "move";
ev.dataTransfer.setData("application/x-fp-step", String(stepId));
ev.dataTransfer.setData("text/plain", String(stepId));
const step = this.state.steps.find((s) => s.id === stepId);
this.state.dragPreviewLabel = step ? step.name : "";
this.state.dragPreviewIcon = (step && step.icon) || "fa-cog";
}
onLibraryDragStart(templateId, ev) {
ev.dataTransfer.effectAllowed = "copy";
ev.dataTransfer.setData("application/x-fp-library", String(templateId));
ev.dataTransfer.setData("text/plain", "library");
const tpl = this.state.library.find((t) => t.id === templateId);
this.state.dragPreviewLabel = tpl ? tpl.name : "";
this.state.dragPreviewIcon = (tpl && tpl.icon) || "fa-cog";
}
onDragOver(index, ev) {
/**
* Compute the insertion index from the cursor Y vs row midpoint:
* above midpoint → insert BEFORE this row (index = rowIndex)
* below midpoint → insert AFTER this row (index = rowIndex + 1)
*/
onRowDragOver(rowIndex, ev) {
ev.preventDefault();
ev.dataTransfer.dropEffect =
ev.dataTransfer.types.includes("application/x-fp-library")
? "copy"
: "move";
this.state.dragOverIndex = index;
const rect = ev.currentTarget.getBoundingClientRect();
const before = (ev.clientY - rect.top) < (rect.height / 2);
this.state.dragOverIndex = before ? rowIndex : rowIndex + 1;
}
async onDrop(targetIndex, ev) {
/** Trailing dropzone — always inserts at the end. */
onTailDragOver(ev) {
ev.preventDefault();
ev.dataTransfer.dropEffect =
ev.dataTransfer.types.includes("application/x-fp-library")
? "copy"
: "move";
this.state.dragOverIndex = this.state.steps.length;
}
async onDrop(ev) {
ev.preventDefault();
const targetIndex = this.state.dragOverIndex !== null
? this.state.dragOverIndex
: this.state.steps.length;
const fromLibrary = ev.dataTransfer.getData("application/x-fp-library");
if (fromLibrary) {
await this.insertFromLibrary(parseInt(fromLibrary, 10), targetIndex);
@@ -184,11 +214,26 @@ export class FpSimpleRecipeEditor extends Component {
await this.reorderStep(draggedId, targetIndex);
}
}
this.state.dragOverIndex = null;
this._clearDragState();
}
onDragLeave() {
onDragLeave(ev) {
// Only clear when leaving the panel entirely. Browser fires
// dragleave when crossing into a child element too — guard against
// that by checking relatedTarget.
if (!ev.currentTarget.contains(ev.relatedTarget)) {
this.state.dragOverIndex = null;
}
}
onDragEnd() {
this._clearDragState();
}
_clearDragState() {
this.state.dragOverIndex = null;
this.state.dragPreviewLabel = "";
this.state.dragPreviewIcon = "fa-cog";
}
// --------------------------------------------------------------- helpers

View File

@@ -93,6 +93,50 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
}
}
// ===================================================== Drop simulator
//
// Thin reservation line between rows that activates only when the
// cursor crosses a row's vertical midpoint. The active indicator
// expands to show a ghost-preview chip with the dragged step's icon
// + name so the operator knows EXACTLY where the drop lands.
.o_fp_drop_indicator {
height: 0;
margin: 0;
border: 0;
transition: height .08s ease, margin .08s ease, background .08s;
background: transparent;
border-radius: 2px;
overflow: hidden;
&.o_fp_drop_indicator_active {
height: 2.25rem;
margin: .25rem 0;
background: $fp-se-drop;
border: 2px dashed $fp-se-accent;
display: flex;
align-items: center;
padding: 0 .75rem;
}
.o_fp_drop_label {
display: flex;
align-items: center;
gap: .5rem;
font-weight: 600;
color: $fp-se-accent;
font-size: .85rem;
&::before {
content: "↓ insert here →";
font-weight: 500;
color: $fp-se-muted;
font-size: .75rem;
margin-right: .5rem;
}
}
}
.o_fp_step_row {
display: flex;
align-items: center;

View File

@@ -33,17 +33,29 @@
</div>
<div class="o_fp_simple_editor_body" t-if="!state.loading">
<div class="o_fp_selected_panel">
<div class="o_fp_selected_panel"
t-on-dragleave="(ev) => this.onDragLeave(ev)"
t-on-dragend="() => this.onDragEnd()"
t-on-drop="(ev) => this.onDrop(ev)">
<h3>Selected (drag to reorder)</h3>
<div class="o_fp_steps_list">
<!-- Top drop indicator (insertion at index 0). Visible
only when dragOverIndex === 0 — i.e. cursor is
hovering above the first row's midpoint. -->
<div class="o_fp_drop_indicator"
t-att-class="state.dragOverIndex === 0 ? 'o_fp_drop_indicator_active' : ''">
<span class="o_fp_drop_label" t-if="state.dragOverIndex === 0">
<i t-att-class="'fa ' + (state.dragPreviewIcon || 'fa-cog')"/>
<span t-esc="state.dragPreviewLabel"/>
</span>
</div>
<t t-foreach="state.steps" t-as="step" t-key="step.id">
<div class="o_fp_step_row"
t-att-class="state.dragOverIndex === step_index ? 'o_fp_drag_over' : ''"
draggable="true"
t-on-dragstart="(ev) => this.onSelectedDragStart(step.id, ev)"
t-on-dragover="(ev) => this.onDragOver(step_index, ev)"
t-on-dragleave="() => this.onDragLeave()"
t-on-drop="(ev) => this.onDrop(step_index, ev)">
t-on-dragover="(ev) => this.onRowDragOver(step_index, ev)">
<span class="o_fp_drag_handle"></span>
<span class="o_fp_step_position"><t t-esc="step_index + 1"/>.</span>
<i t-att-class="'fa ' + (step.icon || 'fa-cog')"/>
@@ -57,13 +69,26 @@
×
</button>
</div>
<!-- Indicator AFTER each row (insertion at index = step_index + 1) -->
<div class="o_fp_drop_indicator"
t-att-class="state.dragOverIndex === (step_index + 1) ? 'o_fp_drop_indicator_active' : ''">
<span class="o_fp_drop_label" t-if="state.dragOverIndex === (step_index + 1)">
<i t-att-class="'fa ' + (state.dragPreviewIcon || 'fa-cog')"/>
<span t-esc="state.dragPreviewLabel"/>
</span>
</div>
</t>
<div class="o_fp_step_dropzone"
t-att-class="state.dragOverIndex === state.steps.length ? 'o_fp_drag_over' : ''"
t-on-dragover="(ev) => this.onDragOver(state.steps.length, ev)"
t-on-dragleave="() => this.onDragLeave()"
t-on-drop="(ev) => this.onDrop(state.steps.length, ev)">
Drop here to add at end
t-on-dragover="(ev) => this.onTailDragOver(ev)">
<t t-if="state.steps.length === 0">
Drag a library step here to start
</t>
<t t-else="">
Drop here to add at end
</t>
</div>
</div>
<button class="btn btn-secondary o_fp_inline_add"