Source: app/components/sidebar/ModuleEditor.vue

<!--
  Copyright 2026 The Ray Optics Simulation authors and contributors

  Licensed under the Apache License, Version 2.0 (the "License");
  you may not use this file except in compliance with the License.
  You may obtain a copy of the License at

      http://www.apache.org/licenses/LICENSE-2.0

  Unless required by applicable law or agreed to in writing, software
  distributed under the License is distributed on an "AS IS" BASIS,
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  See the License for the specific language governing permissions and
  limitations under the License.
-->

<template>
  <div class="module-editor" @click.capture="selectModuleInstance" @click="handleEditorClick">
    <p
      v-if="!hasModuleInstance"
      class="module-editor-warning is-highlighted"
      v-html="noInstancesWarningHtml"
    ></p>
    <p v-if="isModuleEmpty" class="module-editor-notice">
      {{ $t('simulator:sidebar.visual.moduleEditor.emptyModuleExamplesHint') }}
    </p>
    <div class="module-editor-section module-editor-control-points">
      <div class="module-editor-title module-editor-title-plain">
        <span class="module-editor-title-label">
          {{ $t('simulator:sidebar.visual.moduleEditor.controlPoints.title') }}
          <InfoPopoverIcon
            :content="$t('simulator:sidebar.visual.moduleEditor.controlPoints.info')"
          />
        </span>
      </div>
      <div class="module-editor-body">
        <SidebarItemList
          :items="controlPointItems"
          :show-add-button="true"
          :add-label="$t('simulator:sidebar.visual.moduleEditor.controlPoints.newItem')"
          :show-checkbox="false"
          :active-id="controlPointListActiveId"
          @remove="handleControlPointRemove"
          @duplicate="handleControlPointDuplicate"
          @reorder="handleControlPointReorder"
          @create="handleControlPointCreate"
          @hover="handleControlPointHover"
          @select="handleControlPointSelect"
        >
          <template #content="{ item, index }">
            <ModuleControlPointListItem
              :module-name="moduleName"
              :point-index="index"
            />
          </template>
        </SidebarItemList>
      </div>
    </div>
    <div class="module-editor-section module-editor-params">
      <div class="module-editor-title module-editor-title-plain">
        <span class="module-editor-title-label">
          {{ $t('simulator:sidebar.visual.moduleEditor.parameters.title') }}
          <InfoPopoverIcon
            :content="$t('simulator:sidebar.visual.moduleEditor.parameters.info')"
          />
        </span>
      </div>
      <div class="module-editor-body">
        <SidebarItemList
          :items="paramItems"
          :show-add-button="true"
          :add-label="$t('simulator:sidebar.visual.moduleEditor.parameters.newItem')"
          :show-checkbox="false"
          :active-id="paramActiveId"
          @remove="handleParamRemove"
          @duplicate="handleParamDuplicate"
          @reorder="handleParamReorder"
          @create="handleParamCreate"
          @select="handleParamSelect"
          @hover="handleParamListHover"
        >
          <template #content="{ item, index }">
            <ModuleParamListItem
              :module-name="moduleName"
              :name="item.name"
              :min="item.min"
              :max="item.max"
              :step="item.step"
              :default-val="item.defaultVal"
              @update:name="(v) => onParamNameUpdate(index, v)"
              @update:min="(v) => onParamMinUpdate(index, v)"
              @update:max="(v) => onParamMaxUpdate(index, v)"
              @update:step="(v) => onParamStepUpdate(index, v)"
              @update:default-val="(v) => onParamDefaultUpdate(index, v)"
              @commit="commitParamDefs"
            />
          </template>
        </SidebarItemList>
      </div>
    </div>
    <div class="module-editor-section module-editor-vars">
      <div class="module-editor-title module-editor-title-plain">
        <span class="module-editor-title-label">
          {{ $t('simulator:sidebar.visual.moduleEditor.variables.title') }}
          <InfoPopoverIcon
            :content="$t('simulator:sidebar.visual.moduleEditor.variables.info')"
          />
        </span>
      </div>
      <div class="module-editor-body">
        <SidebarItemList
          :items="variableItems"
          :show-add-button="true"
          :add-label="$t('simulator:sidebar.visual.moduleEditor.variables.newItem')"
          :show-checkbox="false"
          :active-id="variableActiveId"
          @remove="handleVarRemove"
          @duplicate="handleVarDuplicate"
          @reorder="handleVarReorder"
          @create="handleVarCreate"
          @select="handleVarSelect"
        >
          <template #content="{ item, index }">
            <ModuleVariableListItem
              :module-name="moduleName"
              :name="item.name"
              :expression="item.expression"
              @update:name="(v) => onVarNameUpdate(index, v)"
              @update:expression="(v) => onVarExprUpdate(index, v)"
              @commit="commitVariableDefs"
            />
          </template>
        </SidebarItemList>
      </div>
    </div>
    <div class="module-editor-title">
      <span class="module-editor-title-label">
        {{ $t('simulator:sidebar.visual.sceneObjects.title') }}
        <InfoPopoverIcon
          :content="$t('simulator:sidebar.visual.moduleEditor.objects.info')"
        />
      </span>
      <button
        class="module-editor-move-out-btn"
        type="button"
        :disabled="!hasSelection"
        @click.stop="onMoveOut"
      >
        {{ $t('simulator:sidebar.visual.moduleEditor.objects.moveOut') }}
      </button>
    </div>
    <div class="module-editor-body">
      <SidebarItemList
        :items="moduleItems"
        v-model:selectedIds="selectedIds"
        :showAddButton="false"
        :activeId="activeId"
        @remove="handleRemove"
        @duplicate="handleDuplicate"
        @reorder="handleReorder"
        @hover="handleHover"
        @selection-change="handleSelectionChange"
        @select="handleSelect"
      >
        <template #content="{ item, index }">
          <ObjTemplateListItemContent
            :item="item"
            :index="index"
            :module-name="moduleName"
            @update:name="(v) => onNameUpdate(item, index, v)"
            @update:obj-data="(raw) => onObjDataUpdate(index, raw)"
            @blur="commitName"
          />
        </template>
      </SidebarItemList>
      <p v-if="!canMoveSelectedObjIn" class="module-editor-move-in-hint">
        {{ $t('simulator:sidebar.visual.moduleEditor.objects.moveInHint') }}
      </p>
      <button
        v-else
        class="module-editor-move-in-btn is-highlighted"
        type="button"
        @click.stop="moveSelectedObjIn"
        v-html="$t('simulator:sidebar.visual.moduleEditor.objects.moveIntoModule', { name: selectedMoveInLabel })"
      ></button>
    </div>
    <div class="module-editor-section module-editor-settings">
      <div class="module-editor-title module-editor-title-plain">
        <span class="module-editor-title-label">
          {{ $t('simulator:sidebar.visual.moduleEditor.settings.title') }}
        </span>
      </div>
      <div class="module-editor-body">
        <div
          class="module-editor-max-loop-section"
          @focusout="onMaxLoopLengthFocusOut"
        >
          <div class="module-param-field">
            <span class="module-param-keyword">{{ $t('simulator:sidebar.visual.moduleEditor.settings.maxLoopLength') }}</span>
            <input
              class="module-param-input"
              v-model="maxLoopLengthInput"
              :style="{ width: Math.max(maxLoopLengthInput.length, 1) + 'ch' }"
              spellcheck="false"
              @keydown.stop
              @keydown.enter.prevent="onMaxLoopLengthEnter"
            />
          </div>
        </div>
        <div class="module-editor-settings-actions">
          <button type="button" class="module-editor-btn" @click="onRenameClick">
            {{ $t('simulator:sidebar.visual.moduleEditor.settings.renameButton') }}
          </button>
          <button type="button" class="module-editor-btn is-danger" @click="onRemoveClick">
            {{ $t('simulator:sidebar.visual.moduleEditor.settings.removeButton') }}
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { computed, nextTick, onActivated, onMounted, onUnmounted, ref, toRef, watch } from 'vue'
import i18next from 'i18next'
import escapeHtml from 'escape-html'
import { parseLinks } from '../../utils/links.js'
import * as math from 'mathjs'
import { useSceneStore } from '../../store/scene'
import { app } from '../../services/app'
import SidebarItemList from './SidebarItemList.vue'
import InfoPopoverIcon from '../InfoPopoverIcon.vue'
import ObjTemplateListItemContent from './ObjTemplateListItemContent.vue'
import ModuleVariableListItem from './ModuleVariableListItem.vue'
import ModuleParamListItem from './ModuleParamListItem.vue'
import ModuleControlPointListItem from './ModuleControlPointListItem.vue'

/** One sidebar list may have selection at a time; add keys when adding new lists (see clearOtherModuleSidebarLists). */
const MODULE_EDITOR_LIST = Object.freeze({
  OBJECTS: 'objects',
  VARIABLES: 'variables',
  PARAMS: 'params',
  CONTROL_POINTS: 'controlPoints'
})

function parseParamString(str) {
  const s = typeof str === 'string' ? str : String(str ?? '')
  const eq = s.indexOf('=')
  if (eq < 0) {
    return { name: '', min: '0', step: '1', max: '1', defaultExpr: '0', raw: s }
  }
  const name = s.slice(0, eq).trim()
  const rhs = s.slice(eq + 1).trim()
  const parts = rhs.split(':')
  const min = (parts[0] ?? '0').trim()
  const step = (parts[1] ?? '1').trim()
  const max = (parts[2] ?? min).trim()
  const defaultExpr = parts.length >= 4 ? (parts[3] ?? min).trim() : min
  return { name, min, step, max, defaultExpr, raw: s }
}

function serializeParamRow(row) {
  const n = (row.name ?? '').trim()
  if (!n) return null
  const min = (row.min ?? '').trim()
  const st = (row.step ?? '').trim()
  const max = (row.max ?? '').trim()
  const d = (row.defaultVal ?? '').trim()
  const defaultPart = d === '' ? min : d
  return `${n}=${min}:${st}:${max}:${defaultPart}`
}

function evalDefaultNumeric(paramStr) {
  const p = parseParamString(typeof paramStr === 'string' ? paramStr : String(paramStr ?? ''))
  try {
    return math.evaluate(p.defaultExpr, {})
  } catch {
    return NaN
  }
}

function nameSetFromParamStrings(strs) {
  const set = new Set()
  for (const s of strs || []) {
    const n = parseParamString(s).name
    if (n) set.add(n)
  }
  return set
}

function ensureModuleObjParams(ref) {
  if (!ref.params) {
    ref.params = {}
  }
}

function ensureModuleObjPointsArray(ref) {
  if (!Array.isArray(ref.points)) {
    ref.points = []
  }
}

function cloneModuleObjPoint(p) {
  const x = p != null && typeof p === 'object' ? Number(p.x) : 0
  const y = p != null && typeof p === 'object' ? Number(p.y) : 0
  return {
    x: Number.isFinite(x) ? x : 0,
    y: Number.isFinite(y) ? y : 0
  }
}

/**
 * Scene-space point at the viewport center (same formula as observer init when switching to observer mode).
 * @see app/store/scene.js — mode callback
 */
function viewportCenterSceneCoords(scene) {
  if (!scene) {
    return { x: 0, y: 0 }
  }
  const ox = scene.origin?.x ?? 0
  const oy = scene.origin?.y ?? 0
  const scale = scene.scale || 1
  return {
    x: (scene.width * 0.5 - ox) / scale,
    y: (scene.height * 0.5 - oy) / scale
  }
}

function controlPointId(moduleName, index) {
  return `${moduleName}-cp-${index}`
}

const DEFAULT_MODULE_MAX_LOOP_LENGTH = 1000

/** Index `s` after moving one item from `from` to `to`. */
function controlPointIndexAfterReorder(s, from, to) {
  if (s === from) {
    return to
  }
  if (from < to) {
    if (s > from && s <= to) {
      return s - 1
    }
    return s
  }
  if (from > to) {
    if (s >= to && s < from) {
      return s + 1
    }
    return s
  }
  return s
}

/**
 * Next unused identifier when duplicating a parameter or variable row (the symbol name used in math.js expressions).
 * - Increments a trailing integer (`a1` → `a2`), instead of appending (`a1` → `a11`).
 * - For function-style LHS `f(...)`, only the function symbol before `(` changes (`f(x)` → `f1(x)`).
 */
function nextDuplicateIdentifier(rawName, occupied) {
  const occ = new Set(
    (occupied ?? []).map((s) => String(s ?? '').trim()).filter(Boolean)
  )
  const trimmed = String(rawName ?? '').trim()
  const base = trimmed || 'p'

  const open = base.indexOf('(')
  const isFnForm =
    open > 0 &&
    base.lastIndexOf(')') === base.length - 1 &&
    base.lastIndexOf(')') > open

  const idSource = (isFnForm ? base.slice(0, open).trim() : base) || (isFnForm ? 'f' : 'p')

  const buildFull = (idPart) => {
    if (!isFnForm) return idPart
    return `${idPart}${base.slice(open)}`
  }

  const digitTail = /^(.*?)(\d+)$/.exec(idSource)
  let candidateId
  let guard = 0
  const max = 100_000

  if (digitTail) {
    const prefix = digitTail[1]
    let k = parseInt(digitTail[2], 10)
    if (!Number.isFinite(k)) k = 0
    do {
      k += 1
      candidateId = `${prefix}${k}`
      guard += 1
      if (guard > max) return `${buildFull(idSource)}x`
    } while (occ.has(buildFull(candidateId)))
  } else {
    let j = 1
    do {
      candidateId = `${idSource}${j}`
      j += 1
      guard += 1
      if (guard > max) return `${buildFull(idSource)}x`
    } while (occ.has(buildFull(candidateId)))
  }

  return buildFull(candidateId)
}

function parseVarDef(entry) {
  const str = typeof entry === 'string' ? entry : String(entry ?? '')
  const idx = str.indexOf('=')
  if (idx < 0) {
    return { name: '', expression: str.trim() }
  }
  return {
    name: str.slice(0, idx).trim(),
    expression: str.slice(idx + 1).trim()
  }
}

function serializeVarDef(name, expression) {
  const n = (name ?? '').trim()
  const e = (expression ?? '').trim()
  if (!n && !e) {
    return ''
  }
  if (!n) {
    return e
  }
  if (!e) {
    return n
  }
  return `${n} = ${e}`
}

/** Next unused single-letter `a`–`z`, then `n0`, `n1`, … — `existingNames` should include both module parameters and variables. */
function getNextModuleIdentifierName(existingNames) {
  const set = new Set(
    (existingNames ?? []).map((n) => String(n ?? '').trim()).filter(Boolean)
  )
  for (let c = 97; c <= 122; c++) {
    const name = String.fromCharCode(c)
    if (!set.has(name)) return name
  }
  for (let i = 0; i < 1_000_000; i++) {
    const name = `n${i}`
    if (!set.has(name)) return name
  }
  return 'n0'
}

export default {
  name: 'ModuleEditor',
  components: {
    SidebarItemList,
    InfoPopoverIcon,
    ObjTemplateListItemContent,
    ModuleVariableListItem,
    ModuleParamListItem,
    ModuleControlPointListItem
  },
  props: {
    moduleName: { type: String, required: true }
  },
  emits: ['module-renamed', 'module-removed'],
  setup(props, { emit }) {
    const scene = useSceneStore()
    const moduleIds = toRef(scene, 'moduleIds')
    const selectedIds = ref([])
    const moduleItems = ref([])
    const controlPointItems = ref([])
    /** Hovered control-point row — drives canvas highlight only. */
    const controlPointHoverIndex = ref(-1)
    /** Click-selected control-point row — drives list `active-id` / row background only. */
    const controlPointSelectedIndex = ref(-1)
    const controlPointListActiveId = computed(() => {
      const i = controlPointSelectedIndex.value
      if (!Number.isInteger(i) || i < 0 || i >= controlPointItems.value.length) {
        return null
      }
      return controlPointItems.value[i]?.id ?? null
    })
    const paramItems = ref([])
    const paramActiveId = ref(null)
    /** True only after an explicit param row click; cleared on row mouse-leave so hover never re-highlights the slider. */
    const paramSliderHighlightActive = ref(false)
    const variableItems = ref([])
    const variableActiveId = ref(null)
    const maxLoopLengthInput = ref(String(DEFAULT_MODULE_MAX_LOOP_LENGTH))
    const maxLoopLengthCommittedSnapshot = ref(maxLoopLengthInput.value)

    const syncMaxLoopLengthField = () => {
      const mod = app.scene?.modules?.[props.moduleName]
      const v = mod?.maxLoopLength
      maxLoopLengthInput.value = String(
        v !== undefined && v !== null ? v : DEFAULT_MODULE_MAX_LOOP_LENGTH
      )
    }

    const commitMaxLoopLength = () => {
      const mod = app.scene?.modules?.[props.moduleName]
      if (!mod) {
        return
      }
      const trimmed = maxLoopLengthInput.value.trim()
      const n = parseInt(trimmed, 10)
      if (trimmed === '' || !Number.isFinite(n) || n < 1) {
        syncMaxLoopLengthField()
        maxLoopLengthCommittedSnapshot.value = maxLoopLengthInput.value
        return
      }
      const had = Object.prototype.hasOwnProperty.call(mod, 'maxLoopLength')
      const beforeVal = mod.maxLoopLength
      if (n === DEFAULT_MODULE_MAX_LOOP_LENGTH) {
        if (had) {
          delete mod.maxLoopLength
        }
      } else {
        mod.maxLoopLength = n
      }
      const changed =
        (n === DEFAULT_MODULE_MAX_LOOP_LENGTH && had) ||
        (n !== DEFAULT_MODULE_MAX_LOOP_LENGTH && (!had || beforeVal !== n))
      syncMaxLoopLengthField()
      maxLoopLengthCommittedSnapshot.value = maxLoopLengthInput.value
      if (changed) {
        app.scene.reloadAllModules?.()
        app.simulator?.updateSimulation(false, true)
        app.editor?.onActionComplete()
      }
    }

    const onMaxLoopLengthFocusOut = (event) => {
      const container = event.currentTarget
      if (container && !container.contains(event.relatedTarget)) {
        if (maxLoopLengthInput.value !== maxLoopLengthCommittedSnapshot.value) {
          commitMaxLoopLength()
        }
      }
    }

    const onMaxLoopLengthEnter = () => {
      commitMaxLoopLength()
    }

    /** LHS identifier strings already used by this module’s parameter and variable rows (not the module’s id). */
    const getOccupiedIdentifiers = () => {
      const paramNames = paramItems.value.map((r) => String(r.name ?? '').trim()).filter(Boolean)
      const varNames = variableItems.value.map((r) => String(r.name ?? '').trim()).filter(Boolean)
      return [...paramNames, ...varNames]
    }

    const reselectEditorModuleInstanceIfNeeded = () => {
      const editor = app.editor
      const idx = editor?.selectedObjIndex ?? -1
      const objs = app.scene?.objs
      if (editor && Array.isArray(objs) && idx >= 0 && idx < objs.length) {
        const sel = objs[idx]
        if (sel?.constructor?.type === 'ModuleObj' && sel.module === props.moduleName) {
          editor.selectObj(idx)
        }
      }
    }

    const applyModuleParamHighlight = (paramName) => {
      const trimmed =
        paramName != null && String(paramName).trim() ? String(paramName).trim() : null
      const applyToObj = (obj) => {
        if (!obj || obj.constructor?.type !== 'ModuleObj') {
          return
        }
        if (obj.module === props.moduleName) {
          obj.setHighlightedParamName?.(trimmed)
        }
        if (Array.isArray(obj.objs)) {
          for (const child of obj.objs) {
            if (child?.constructor?.type === 'ModuleObj') {
              applyToObj(child)
            }
          }
        }
      }
      for (const obj of app.scene?.objs || []) {
        applyToObj(obj)
      }
      reselectEditorModuleInstanceIfNeeded()
    }

    const applyModulePointHighlight = (pointIndex) => {
      const idx =
        Number.isInteger(pointIndex) && pointIndex >= 0 ? pointIndex : null
      const applyToObj = (obj) => {
        if (!obj || obj.constructor?.type !== 'ModuleObj') {
          return
        }
        if (obj.module === props.moduleName) {
          obj.setHighlightedPointIndex?.(idx)
        }
        if (Array.isArray(obj.objs)) {
          for (const child of obj.objs) {
            if (child?.constructor?.type === 'ModuleObj') {
              applyToObj(child)
            }
          }
        }
      }
      for (const obj of app.scene?.objs || []) {
        applyToObj(obj)
      }
      app.simulator?.updateSimulation?.(true, true)
    }

    const hoveredIndex = ref(-1)
    const activeId = ref(null)
    const hasSelection = computed(() => selectedIds.value.length > 0 || activeId.value !== null)
    const editorSelectedIndex = ref(-1)
    const selectedMoveInObj = computed(() => {
      const selectedIndex = editorSelectedIndex.value
      if (selectedIndex < 0) return null
      return app.scene?.objs?.[selectedIndex] ?? null
    })

    const hasModuleInstance = computed(() => {
      // Use objList length to ensure reactive updates.
      const objList = scene.state?.objList || []
      void objList.length
      const moduleName = props.moduleName
      const visited = new Set()
      const hasInstanceInObjs = (objs) => {
        if (!Array.isArray(objs)) return false
        for (const obj of objs) {
          if (!obj || visited.has(obj)) continue
          visited.add(obj)
          if (obj.constructor?.type === 'ModuleObj') {
            if (obj.module === moduleName) {
              return true
            }
            if (hasInstanceInObjs(obj.objs)) {
              return true
            }
          }
        }
        return false
      }
      return hasInstanceInObjs(app.scene?.objs || [])
    })

    const noInstancesWarningHtml = computed(() => {
      const safeName = escapeHtml(String(props.moduleName ?? ''))
      const nameSpan = `<span class="module-editor-module-id">${safeName}</span>`
      return parseLinks(
        i18next.t('simulator:sidebar.visual.moduleEditor.noInstances', { name: nameSpan })
      )
    })

    const isModuleEmpty = computed(() => {
      return (
        controlPointItems.value.length === 0 &&
        paramItems.value.length === 0 &&
        variableItems.value.length === 0 &&
        moduleItems.value.length === 0
      )
    })

    const hasNestedModuleInstance = (obj, moduleName) => {
      const visited = new Set()
      const hasInstanceInObjs = (objs) => {
        if (!Array.isArray(objs)) return false
        for (const child of objs) {
          if (!child || visited.has(child)) continue
          visited.add(child)
          if (child.constructor?.type === 'ModuleObj') {
            if (child.module === moduleName) {
              return true
            }
            if (hasInstanceInObjs(child.objs)) {
              return true
            }
          }
        }
        return false
      }
      return hasInstanceInObjs(obj?.objs || [])
    }

    const MOVE_INTO_MODULE_EXCLUDED_TYPES = new Set(['Handle', 'CropBox'])

    const canMoveSelectedObjIn = computed(() => {
      const selectedIndex = editorSelectedIndex.value
      if (selectedIndex < 0) return false
      const obj = selectedMoveInObj.value
      if (!obj) return false
      const type = obj.constructor?.type
      if (type && MOVE_INTO_MODULE_EXCLUDED_TYPES.has(type)) {
        return false
      }
      if (obj.constructor?.type === 'ModuleObj' && obj.module === props.moduleName) {
        return false
      }
      if (
        obj.constructor?.type === 'ModuleObj' &&
        obj.module !== props.moduleName &&
        hasNestedModuleInstance(obj, props.moduleName)
      ) {
        return false
      }
      return true
    })

    const selectedMoveInLabel = computed(() => {
      const obj = selectedMoveInObj.value
      const fallback = i18next.t('simulator:sidebar.visual.sceneObjects.unknownType')
      if (!obj) return fallback
      if (obj.name) return escapeHtml(obj.name)
      const Ctor = obj?.constructor
      const scene = app.scene
      if (Ctor && scene && typeof Ctor.getDescription === 'function') {
        return Ctor.getDescription(obj, scene, false) || fallback
      }
      return Ctor?.type || fallback
    })

    const moduleNames = computed(() => {
      const raw = moduleIds.value ? moduleIds.value.split(',') : []
      return raw.map(s => s.trim()).filter(Boolean)
    })

    const onRenameClick = () => {
      const oldName = props.moduleName
      const proposed = window.prompt(i18next.t('simulator:sidebar.visual.moduleEditor.new.promptNewName'), oldName)
      if (proposed == null) return

      const newName = proposed.trim()
      if (!newName) {
        window.alert(i18next.t('simulator:sidebar.visual.moduleEditor.new.errorEmptyName'))
        return
      }

      if (newName.includes(',')) {
        window.alert(i18next.t('simulator:sidebar.visual.moduleEditor.new.errorComma'))
        return
      }

      if (newName === oldName) return

      // Conflict check must live in ModuleEditor (not in the store).
      if (moduleNames.value.includes(newName)) {
        window.alert(i18next.t('simulator:sidebar.visual.moduleEditor.new.errorNameExists', { name: newName }))
        return
      }

      scene.renameModule(oldName, newName)
      emit('module-renamed', newName)
    }

    const onRemoveClick = () => {
      const name = props.moduleName
      const ok = window.confirm(i18next.t('simulator:sidebar.visual.moduleEditor.settings.confirmRemove', { name }))
      if (!ok) return
      scene.removeModule(name)
      emit('module-removed', name)
    }

    const syncVariableItems = () => {
      const raw = app.scene?.modules?.[props.moduleName]?.vars || []
      variableItems.value = raw.map((entry, index) => ({
        id: `${props.moduleName}-var-${index}`,
        ...parseVarDef(entry)
      }))
      if (!variableItems.value.some((item) => item.id === variableActiveId.value)) {
        variableActiveId.value = null
      }
    }

    const syncParamItems = () => {
      const raw = app.scene?.modules?.[props.moduleName]?.params || []
      paramItems.value = raw.map((entry, index) => {
        const p = parseParamString(typeof entry === 'string' ? entry : String(entry ?? ''))
        return {
          id: `${props.moduleName}-param-${index}`,
          name: p.name,
          min: p.min,
          step: p.step,
          max: p.max,
          defaultVal: p.defaultExpr
        }
      })
      if (!paramItems.value.some((item) => item.id === paramActiveId.value)) {
        paramActiveId.value = null
        paramSliderHighlightActive.value = false
        applyModuleParamHighlight(null)
      }
    }

    const syncControlPointItems = () => {
      const mod = app.scene?.modules?.[props.moduleName]
      const n = mod?.numPoints ?? 0
      const safeN = Math.max(0, Number.isFinite(n) ? Math.floor(n) : 0)
      if (mod) {
        mod.numPoints = safeN
      }
      controlPointItems.value = Array.from({ length: safeN }, (_, i) => ({
        id: controlPointId(props.moduleName, i)
      }))
      if (controlPointHoverIndex.value >= controlPointItems.value.length) {
        controlPointHoverIndex.value = -1
        applyModulePointHighlight(null)
      }
      if (controlPointSelectedIndex.value >= controlPointItems.value.length) {
        controlPointSelectedIndex.value = -1
      }
    }

    const syncModuleItems = () => {
      const objs = app.scene?.modules?.[props.moduleName]?.objs || []
      moduleItems.value = objs.map((obj, index) => ({
        id: `${props.moduleName}-obj-${index}`,
        obj
      }))
      selectedIds.value = selectedIds.value.filter((id) =>
        moduleItems.value.some((item) => item.id === id)
      )
    }

    const commitVariableDefs = () => {
      if (!app.scene?.modules?.[props.moduleName]) {
        return
      }
      const next = variableItems.value
        .map((it) => serializeVarDef(it.name, it.expression))
        .filter((s) => s !== '')
      app.scene.modules[props.moduleName].vars = next
      syncVariableItems()
      app.scene.reloadAllModules?.()
      app.simulator?.updateSimulation(false, true)
      app.editor?.onActionComplete()
    }

    const commitControlPointGeometry = () => {
      if (!app.scene?.modules?.[props.moduleName]) {
        return
      }
      syncControlPointItems()
      app.scene.reloadAllModules?.()
      const activeN = controlPointHoverIndex.value >= 0 ? controlPointHoverIndex.value : null
      applyModulePointHighlight(activeN)
      app.simulator?.updateSimulation(false, true)
      app.editor?.onActionComplete()
    }

    const commitParamDefs = () => {
      if (!app.scene?.modules?.[props.moduleName]) {
        return
      }
      const mod = app.scene.modules[props.moduleName]
      const nextStrs = paramItems.value.map(serializeParamRow).filter(Boolean)
      const oldStrs = [...(mod.params || [])]
      const oldSet = nameSetFromParamStrings(oldStrs)
      const newSet = nameSetFromParamStrings(nextStrs)
      const refs = app.scene.getModuleObjRefsById?.(props.moduleName) || []

      const removed = [...oldSet].filter((n) => !newSet.has(n))
      const added = [...newSet].filter((n) => !oldSet.has(n))

      for (const n of oldSet) {
        if (!newSet.has(n)) continue
        const oldSpec = oldStrs.find((s) => parseParamString(s).name === n)
        const newSpec = nextStrs.find((s) => parseParamString(s).name === n)
        const dOld = evalDefaultNumeric(oldSpec)
        const dNew = evalDefaultNumeric(newSpec)
        if (dOld === dNew || Number.isNaN(dOld) || Number.isNaN(dNew)) continue
        for (const ref of refs) {
          if (ref.params && Object.prototype.hasOwnProperty.call(ref.params, n) && ref.params[n] === dOld) {
            ref.params[n] = dNew
          }
        }
      }

      if (removed.length === 1 && added.length === 1) {
        const oldN = removed[0]
        const newN = added[0]
        const oldSpec = oldStrs.find((s) => parseParamString(s).name === oldN)
        const newSpec = nextStrs.find((s) => parseParamString(s).name === newN)
        const dOld = evalDefaultNumeric(oldSpec)
        const dNew = evalDefaultNumeric(newSpec)
        for (const ref of refs) {
          ensureModuleObjParams(ref)
          const had = Object.prototype.hasOwnProperty.call(ref.params, oldN)
          let v = had ? ref.params[oldN] : dNew
          delete ref.params[oldN]
          if (had && dOld !== dNew && !Number.isNaN(dOld) && !Number.isNaN(dNew) && v === dOld) {
            v = dNew
          }
          ref.params[newN] = v
        }
      } else {
        for (const n of removed) {
          for (const ref of refs) {
            if (ref.params && Object.prototype.hasOwnProperty.call(ref.params, n)) {
              delete ref.params[n]
            }
          }
        }
        for (const n of added) {
          const specStr = nextStrs.find((s) => parseParamString(s).name === n)
          const dNew = evalDefaultNumeric(specStr)
          for (const ref of refs) {
            ensureModuleObjParams(ref)
            if (!(n in ref.params)) {
              ref.params[n] = dNew
            }
          }
        }
      }

      mod.params = nextStrs
      syncParamItems()
      app.scene.reloadAllModules?.()
      const activeParamRow = paramItems.value.find((r) => r.id === paramActiveId.value)
      if (paramSliderHighlightActive.value && activeParamRow) {
        applyModuleParamHighlight(activeParamRow.name)
      } else {
        applyModuleParamHighlight(null)
      }

      app.simulator?.updateSimulation(false, true)
      app.editor?.onActionComplete()
    }

    const onParamNameUpdate = (index, value) => {
      const row = paramItems.value[index]
      if (row) row.name = value
    }
    const onParamMinUpdate = (index, value) => {
      const row = paramItems.value[index]
      if (row) row.min = value
    }
    const onParamMaxUpdate = (index, value) => {
      const row = paramItems.value[index]
      if (row) row.max = value
    }
    const onParamStepUpdate = (index, value) => {
      const row = paramItems.value[index]
      if (row) row.step = value
    }
    const onParamDefaultUpdate = (index, value) => {
      const row = paramItems.value[index]
      if (row) row.defaultVal = value
    }

    const handleParamRemove = (item, index) => {
      paramItems.value = paramItems.value.filter((_, i) => i !== index)
      commitParamDefs()
    }

    const handleParamDuplicate = (item, index) => {
      const row = paramItems.value[index]
      if (!row) return
      const newName = nextDuplicateIdentifier(row.name, getOccupiedIdentifiers())
      const clone = {
        id: `${props.moduleName}-param-${paramItems.value.length}`,
        name: newName,
        min: row.min,
        step: row.step,
        max: row.max,
        defaultVal: row.defaultVal
      }
      const next = [...paramItems.value]
      next.splice(index + 1, 0, clone)
      paramItems.value = next
      commitParamDefs()
    }

    const handleParamReorder = ({ fromIndex, toIndex }) => {
      if (fromIndex === toIndex) return
      const next = [...paramItems.value]
      const [moved] = next.splice(fromIndex, 1)
      next.splice(toIndex, 0, moved)
      paramItems.value = next
      commitParamDefs()
    }

    const handleControlPointRemove = (item, index) => {
      const mod = app.scene?.modules?.[props.moduleName]
      if (!mod) {
        return
      }
      const refs = app.scene.getModuleObjRefsById?.(props.moduleName) || []
      const n = Math.max(0, (mod.numPoints ?? 0) - 1)
      mod.numPoints = n
      for (const ref of refs) {
        ensureModuleObjPointsArray(ref)
        if (ref.points.length > index) {
          ref.points.splice(index, 1)
        }
      }
      const hov = controlPointHoverIndex.value
      if (hov === index) {
        controlPointHoverIndex.value = -1
      } else if (hov > index) {
        controlPointHoverIndex.value = hov - 1
      }
      const sel = controlPointSelectedIndex.value
      if (sel === index) {
        controlPointSelectedIndex.value = -1
      } else if (sel > index) {
        controlPointSelectedIndex.value = sel - 1
      }
      commitControlPointGeometry()
    }

    const handleControlPointDuplicate = (item, index) => {
      const mod = app.scene?.modules?.[props.moduleName]
      if (!mod) {
        return
      }
      const refs = app.scene.getModuleObjRefsById?.(props.moduleName) || []
      mod.numPoints = (mod.numPoints ?? 0) + 1
      for (const ref of refs) {
        ensureModuleObjPointsArray(ref)
        const copy = cloneModuleObjPoint(ref.points[index])
        ref.points.splice(index + 1, 0, copy)
      }
      const hov = controlPointHoverIndex.value
      if (hov > index) {
        controlPointHoverIndex.value = hov + 1
      }
      const sel = controlPointSelectedIndex.value
      if (sel > index) {
        controlPointSelectedIndex.value = sel + 1
      }
      commitControlPointGeometry()
    }

    const handleControlPointReorder = ({ fromIndex, toIndex }) => {
      if (fromIndex === toIndex) {
        return
      }
      const mod = app.scene?.modules?.[props.moduleName]
      if (!mod) {
        return
      }
      const count = Math.max(0, mod.numPoints ?? 0)
      const refs = app.scene.getModuleObjRefsById?.(props.moduleName) || []
      for (const ref of refs) {
        ensureModuleObjPointsArray(ref)
        while (ref.points.length < count) {
          ref.points.push({ x: 0, y: 0 })
        }
        while (ref.points.length > count) {
          ref.points.pop()
        }
        if (ref.points.length <= Math.max(fromIndex, toIndex)) {
          continue
        }
        const next = [...ref.points]
        const [moved] = next.splice(fromIndex, 1)
        next.splice(toIndex, 0, moved)
        ref.points = next
      }
      const hov = controlPointHoverIndex.value
      if (hov >= 0) {
        controlPointHoverIndex.value = controlPointIndexAfterReorder(hov, fromIndex, toIndex)
      }
      const sel = controlPointSelectedIndex.value
      if (sel >= 0) {
        controlPointSelectedIndex.value = controlPointIndexAfterReorder(sel, fromIndex, toIndex)
      }
      commitControlPointGeometry()
    }

    const handleControlPointCreate = () => {
      const mod = app.scene?.modules?.[props.moduleName]
      if (!mod) {
        return
      }
      const scene = app.scene
      const refs = scene?.getModuleObjRefsById?.(props.moduleName) || []
      const prevCount = mod.numPoints ?? 0
      const isFirst = prevCount === 0
      const gridSize = scene?.gridSize ?? 20
      mod.numPoints = prevCount + 1
      for (const ref of refs) {
        ensureModuleObjPointsArray(ref)
        if (isFirst) {
          const c = viewportCenterSceneCoords(scene)
          ref.points.push({ x: c.x, y: c.y })
        } else {
          const last = ref.points.length > 0 ? ref.points[ref.points.length - 1] : null
          const b = cloneModuleObjPoint(last)
          ref.points.push({ x: b.x + gridSize, y: b.y + gridSize })
        }
      }
      commitControlPointGeometry()
      const lastIdx = controlPointItems.value.length - 1
      if (lastIdx >= 0) {
        clearOtherModuleSidebarLists(MODULE_EDITOR_LIST.CONTROL_POINTS)
        controlPointSelectedIndex.value = lastIdx
        applyModulePointHighlight(lastIdx)
      }
    }

    const handleControlPointHover = ({ item, index }) => {
      if (item == null || typeof index !== 'number' || index < 0) {
        controlPointHoverIndex.value = -1
        applyModulePointHighlight(null)
        return
      }
      controlPointHoverIndex.value = index
      applyModulePointHighlight(index)
    }

    const handleControlPointSelect = ({ item, index }) => {
      if (!item) {
        return
      }
      clearOtherModuleSidebarLists(MODULE_EDITOR_LIST.CONTROL_POINTS)
      controlPointSelectedIndex.value = typeof index === 'number' ? index : -1
    }

    const handleParamCreate = () => {
      const name = getNextModuleIdentifierName(getOccupiedIdentifiers())
      paramItems.value = [
        ...paramItems.value,
        {
          id: `${props.moduleName}-param-${paramItems.value.length}`,
          name,
          min: '1',
          step: '1',
          max: '10',
          defaultVal: '1'
        }
      ]
      commitParamDefs()
      const last = paramItems.value[paramItems.value.length - 1]
      if (last) {
        clearOtherModuleSidebarLists(MODULE_EDITOR_LIST.PARAMS)
        paramActiveId.value = last.id
        paramSliderHighlightActive.value = true
        applyModuleParamHighlight(last.name)
      }
    }

    const onVarNameUpdate = (index, value) => {
      const row = variableItems.value[index]
      if (row) {
        row.name = value
      }
    }

    const onVarExprUpdate = (index, value) => {
      const row = variableItems.value[index]
      if (row) {
        row.expression = value
      }
    }

    const handleVarRemove = (item, index) => {
      const next = variableItems.value.filter((_, i) => i !== index)
      variableItems.value = next
      commitVariableDefs()
    }

    const handleVarDuplicate = (item, index) => {
      const row = variableItems.value[index]
      if (!row) return
      const newName = nextDuplicateIdentifier(row.name, getOccupiedIdentifiers())
      const clone = { id: `${props.moduleName}-var-${variableItems.value.length}`, name: newName, expression: row.expression }
      const next = [...variableItems.value]
      next.splice(index + 1, 0, clone)
      variableItems.value = next
      commitVariableDefs()
    }

    const handleVarReorder = ({ fromIndex, toIndex }) => {
      if (fromIndex === toIndex) {
        return
      }
      const next = [...variableItems.value]
      const [moved] = next.splice(fromIndex, 1)
      next.splice(toIndex, 0, moved)
      variableItems.value = next
      commitVariableDefs()
    }

    const handleVarCreate = () => {
      variableItems.value = [
        ...variableItems.value,
        {
          id: `${props.moduleName}-var-${variableItems.value.length}`,
          name: getNextModuleIdentifierName(getOccupiedIdentifiers()),
          expression: '1'
        }
      ]
      commitVariableDefs()
      const last = variableItems.value[variableItems.value.length - 1]
      if (last) {
        clearOtherModuleSidebarLists(MODULE_EDITOR_LIST.VARIABLES)
        variableActiveId.value = last.id
      }
    }

    const updateModuleObjs = (nextObjs) => {
      if (!app.scene?.modules?.[props.moduleName]) {
        return
      }
      app.scene.modules[props.moduleName].objs = nextObjs
      syncModuleItems()
      app.scene.reloadAllModules?.()
      app.simulator?.updateSimulation(false, true)
      app.editor?.onActionComplete()
    }

    const handleRemove = (item, index) => {
      const current = app.scene?.modules?.[props.moduleName]?.objs || []
      const next = [...current]
      next.splice(index, 1)
      updateModuleObjs(next)
    }

    const handleDuplicate = (item, index) => {
      const current = app.scene?.modules?.[props.moduleName]?.objs || []
      const cloned = JSON.parse(JSON.stringify(item.obj))
      const next = [...current]
      next.splice(index + 1, 0, cloned)
      updateModuleObjs(next)
    }

    const handleReorder = ({ fromIndex, toIndex }) => {
      if (fromIndex === toIndex) {
        return
      }
      const current = app.scene?.modules?.[props.moduleName]?.objs || []
      const next = [...current]
      const [moved] = next.splice(fromIndex, 1)
      next.splice(toIndex, 0, moved)
      updateModuleObjs(next)
    }

    const getSelectedIndices = () => moduleItems.value
      .map((item, index) => (selectedIds.value.includes(item.id) ? index : -1))
      .filter((index) => index >= 0)

    const applyModuleHighlights = (indices) => {
      const activeInstances = app.editor?.getActiveModuleInstances?.(props.moduleName)
      const activeSet = activeInstances != null ? new Set(activeInstances) : null

      const applyToObj = (obj) => {
        if (!obj || obj.constructor?.type !== 'ModuleObj') {
          return
        }
        if (obj.module === props.moduleName) {
          if (activeSet && !activeSet.has(obj)) {
            obj.setHighlightedSourceIndices([])
          } else {
            obj.setHighlightedSourceIndices(indices)
          }
        }
        if (Array.isArray(obj.objs)) {
          for (const child of obj.objs) {
            if (child?.constructor?.type === 'ModuleObj') {
              applyToObj(child)
            }
          }
        }
      }
      const rootObjs = app.scene?.objs || []
      for (const obj of rootObjs) {
        applyToObj(obj)
      }
      app.simulator?.updateSimulation(true, true)
    }

    /**
     * Clears all module sidebar lists except `keepKind` (when adding a new list, add another branch).
     * Pass `null` to clear every list.
     */
    const clearOtherModuleSidebarLists = (keepKind) => {
      if (keepKind !== MODULE_EDITOR_LIST.VARIABLES) {
        variableActiveId.value = null
      }
      if (keepKind !== MODULE_EDITOR_LIST.PARAMS) {
        paramActiveId.value = null
        paramSliderHighlightActive.value = false
        applyModuleParamHighlight(null)
      }
      if (keepKind !== MODULE_EDITOR_LIST.CONTROL_POINTS) {
        controlPointHoverIndex.value = -1
        controlPointSelectedIndex.value = -1
        applyModulePointHighlight(null)
      }
      if (keepKind !== MODULE_EDITOR_LIST.OBJECTS) {
        selectedIds.value = []
        activeId.value = null
        hoveredIndex.value = -1
        applyModuleHighlights([])
      }
    }

    const updateHighlights = () => {
      const indices = getSelectedIndices()
      if (hoveredIndex.value >= 0 && !indices.includes(hoveredIndex.value)) {
        indices.push(hoveredIndex.value)
      }
      applyModuleHighlights(indices)
    }

    const handleHover = ({ index }) => {
      hoveredIndex.value = typeof index === 'number' ? index : -1
      updateHighlights()
    }

    const handleSelectionChange = () => {
      if (selectedIds.value.length > 0) {
        variableActiveId.value = null
        paramActiveId.value = null
        paramSliderHighlightActive.value = false
        applyModuleParamHighlight(null)
        controlPointHoverIndex.value = -1
        controlPointSelectedIndex.value = -1
        applyModulePointHighlight(null)
      }
      updateHighlights()
    }

    const handleSelect = ({ item }) => {
      if (!item) {
        return
      }
      clearOtherModuleSidebarLists(MODULE_EDITOR_LIST.OBJECTS)
      if (selectedIds.value.length) {
        selectedIds.value = []
      }
      activeId.value = item.id
      updateHighlights()
    }

    const handleVarSelect = ({ item }) => {
      if (!item) {
        return
      }
      clearOtherModuleSidebarLists(MODULE_EDITOR_LIST.VARIABLES)
      variableActiveId.value = item.id
    }

    const handleParamSelect = ({ item }) => {
      if (!item) {
        return
      }
      clearOtherModuleSidebarLists(MODULE_EDITOR_LIST.PARAMS)
      paramActiveId.value = item.id
      paramSliderHighlightActive.value = true
      applyModuleParamHighlight(item.name)
    }

    const handleParamListHover = ({ item, index }) => {
      if (item == null || typeof index !== 'number' || index < 0) {
        if (!paramSliderHighlightActive.value) {
          return
        }
        paramSliderHighlightActive.value = false
        applyModuleParamHighlight(null)
      }
    }

    const onMoveOut = () => {
      if (!hasSelection.value) {
        return
      }
      let indices = getSelectedIndices()
      if (!indices.length && activeId.value !== null) {
        const activeIndex = moduleItems.value.findIndex((item) => item.id === activeId.value)
        if (activeIndex >= 0) {
          indices = [activeIndex]
        }
      }
      if (!indices.length) {
        return
      }
      app.scene?.moveModuleObjsToScene?.(props.moduleName, indices)
      app.simulator?.updateSimulation(false, true)
      app.editor?.onActionComplete()
      syncModuleItems()
      scene.syncObjList?.()
      selectedIds.value = []
      activeId.value = null
      variableActiveId.value = null
      paramActiveId.value = null
      paramSliderHighlightActive.value = false
      controlPointHoverIndex.value = -1
      controlPointSelectedIndex.value = -1
      hoveredIndex.value = -1
      applyModuleHighlights([])
      applyModuleParamHighlight(null)
      applyModulePointHighlight(null)
    }

    const moveSelectedObjIn = () => {
      if (!canMoveSelectedObjIn.value) {
        return
      }
      const selectedIndex = editorSelectedIndex.value
      if (selectedIndex < 0) {
        return
      }
      const moveResult = app.scene?.moveObjsToModule?.([selectedIndex], props.moduleName)
      app.editor?.selectObj(-1)
      app.simulator?.updateSimulation(false, true)
      app.editor?.onActionComplete()
      syncModuleItems()
      if (moveResult && moveResult.moved > 0) {
        const last = moduleItems.value[moduleItems.value.length - 1]
        if (last) {
          clearOtherModuleSidebarLists(MODULE_EDITOR_LIST.OBJECTS)
          selectedIds.value = []
          activeId.value = last.id
          hoveredIndex.value = -1
          applyModuleHighlights([])
        }
      }
      nextTick(() => {
        selectModuleInstance()
      })
    }

    const resetAllModuleSidebarListSelections = () => {
      if (
        !selectedIds.value.length &&
        activeId.value === null &&
        hoveredIndex.value < 0 &&
        variableActiveId.value === null &&
        paramActiveId.value === null &&
        controlPointHoverIndex.value < 0 &&
        controlPointSelectedIndex.value < 0
      ) {
        return
      }
      variableActiveId.value = null
      paramActiveId.value = null
      paramSliderHighlightActive.value = false
      controlPointHoverIndex.value = -1
      controlPointSelectedIndex.value = -1
      selectedIds.value = []
      activeId.value = null
      hoveredIndex.value = -1
      applyModuleHighlights([])
      applyModuleParamHighlight(null)
      applyModulePointHighlight(null)
    }

    const handleEditorClick = (event) => {
      if (event?.target?.closest?.('.sidebar-item-list')) {
        return
      }
      resetAllModuleSidebarListSelections()
    }

    const onNameUpdate = (item, index, value) => {
      if (!item?.obj || !app.scene?.modules?.[props.moduleName]) {
        return
      }
      item.obj.name = value
      const moduleObj = app.scene.modules[props.moduleName].objs[index]
      if (moduleObj) {
        moduleObj.name = value
      }
    }

    const onObjDataUpdate = (index, raw) => {
      if (!app.scene?.modules?.[props.moduleName] || !raw) return
      const current = app.scene.modules[props.moduleName].objs || []
      const next = [...current]
      next[index] = raw
      updateModuleObjs(next)
    }

    const commitName = () => {
      app.simulator?.updateSimulation(true, true)
      app.editor?.onActionComplete()
    }

    const commitAndBlur = (event) => {
      commitName()
      if (event?.target?.blur) event.target.blur()
    }

    const selectModuleInstance = (event) => {
      if (event?.target?.closest?.('.module-editor-move-in-btn')) {
        return
      }
      const selectedIndex = app.editor?.selectedObjIndex ?? -1
      const selectedObj = selectedIndex >= 0 ? app.scene?.objs?.[selectedIndex] : null
      if (selectedObj?.constructor?.type === 'ModuleObj' && selectedObj?.module === props.moduleName) {
        return
      }
      const objs = app.scene?.objs || []
      let lastIndex = -1
      for (let i = 0; i < objs.length; i++) {
        if (objs[i]?.constructor?.type === 'ModuleObj' && objs[i]?.module === props.moduleName) {
          lastIndex = i
        }
      }
      app.editor?.selectObj(lastIndex)
    }

    const onClearVisualSelection = () => {
      resetAllModuleSidebarListSelections()
    }

    const onEditorSelectionChange = (event) => {
      editorSelectedIndex.value = event?.detail?.index ?? -1
      updateHighlights()
    }

    watch(
      () => props.moduleName,
      () => {
        syncModuleItems()
        syncControlPointItems()
        syncParamItems()
        syncVariableItems()
        syncMaxLoopLengthField()
        maxLoopLengthCommittedSnapshot.value = maxLoopLengthInput.value
        selectModuleInstance()
        hoveredIndex.value = -1
        variableActiveId.value = null
        paramActiveId.value = null
        paramSliderHighlightActive.value = false
        controlPointHoverIndex.value = -1
        controlPointSelectedIndex.value = -1
        applyModuleHighlights([])
        applyModuleParamHighlight(null)
        applyModulePointHighlight(null)
      },
      { immediate: true }
    )

    // KeepAlive deactivates when leaving this module subtab (Scene or another module);
    // `watch(moduleName)` does not re-run when returning to the same module.
    onActivated(() => {
      nextTick(() => {
        selectModuleInstance()
      })
    })

    const onSceneChanged = () => {
      syncModuleItems()
      syncControlPointItems()
      syncParamItems()
      syncVariableItems()
      syncMaxLoopLengthField()
      maxLoopLengthCommittedSnapshot.value = maxLoopLengthInput.value
    }

    onMounted(() => {
      document.addEventListener('clearVisualEditorSelection', onClearVisualSelection)
      document.addEventListener('sceneObjSelectionChanged', onEditorSelectionChange)
      document.addEventListener('sceneChanged', onSceneChanged)
      document.addEventListener('sceneObjsChanged', onSceneChanged)
      editorSelectedIndex.value = app.editor?.selectedObjIndex ?? -1
    })

    onUnmounted(() => {
      paramSliderHighlightActive.value = false
      applyModuleHighlights([])
      applyModuleParamHighlight(null)
      applyModulePointHighlight(null)
      document.removeEventListener('clearVisualEditorSelection', onClearVisualSelection)
      document.removeEventListener('sceneObjSelectionChanged', onEditorSelectionChange)
      document.removeEventListener('sceneChanged', onSceneChanged)
      document.removeEventListener('sceneObjsChanged', onSceneChanged)
    })

    return {
      selectedIds,
      moduleItems,
      controlPointItems,
      controlPointListActiveId,
      handleControlPointRemove,
      handleControlPointDuplicate,
      handleControlPointReorder,
      handleControlPointCreate,
      handleControlPointHover,
      handleControlPointSelect,
      paramItems,
      paramActiveId,
      variableItems,
      variableActiveId,
      commitParamDefs,
      onParamNameUpdate,
      onParamMinUpdate,
      onParamMaxUpdate,
      onParamStepUpdate,
      onParamDefaultUpdate,
      handleParamRemove,
      handleParamDuplicate,
      handleParamReorder,
      handleParamCreate,
      handleParamSelect,
      handleParamListHover,
      onVarNameUpdate,
      onVarExprUpdate,
      commitVariableDefs,
      handleVarRemove,
      handleVarDuplicate,
      handleVarReorder,
      handleVarCreate,
      handleVarSelect,
      onRenameClick,
      onRemoveClick,
      handleRemove,
      handleDuplicate,
      handleReorder,
      onNameUpdate,
      onObjDataUpdate,
      commitName,
      commitAndBlur,
      handleHover,
      handleSelectionChange,
      handleSelect,
      onMoveOut,
      hasSelection,
      activeId,
      canMoveSelectedObjIn,
      selectedMoveInLabel,
      hasModuleInstance,
      noInstancesWarningHtml,
      isModuleEmpty,
      moveSelectedObjIn,
      resetAllModuleSidebarListSelections,
      handleEditorClick,
      selectModuleInstance,
      maxLoopLengthInput,
      onMaxLoopLengthFocusOut,
      onMaxLoopLengthEnter
    }
  }
}
</script>

<style scoped>
.module-editor {
  display: flex;
  flex-direction: column;
  min-height: 0;
}

.module-editor-section {
  display: flex;
  flex-direction: column;
  min-height: 0;
}

/* Rhythm between major blocks only (not above the first block). */
.module-editor > .module-editor-warning + .module-editor-section,
.module-editor > .module-editor-warning + .module-editor-notice,
.module-editor > .module-editor-notice + .module-editor-section,
.module-editor > .module-editor-section + .module-editor-section,
.module-editor > .module-editor-section + .module-editor-title,
.module-editor > .module-editor-body + .module-editor-section {
  margin-top: 10px;
  margin-bottom: 4px;
}

.module-editor-title {
  display: flex;
  align-items: center;
  justify-content: space-between;
  font-weight: 600;
  color: rgba(255, 255, 255, 0.92);
}

.module-editor-title-plain {
  justify-content: flex-start;
}

.module-editor-title-label {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}

.module-editor-module-name {
  font-family: monospace;
}

.module-editor-move-out-btn {
  font-size: 12px;
  padding: 2px 8px;
  border-radius: 6px;
  background: rgba(55, 60, 65, 0.22);
  color: rgba(255, 255, 255, 0.84);
  border: 1px solid rgba(255, 255, 255, 0.12);
}

.module-editor-move-out-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.module-editor-move-in-btn {
  align-self: stretch;
  width: 100%;
  font-size: 12px;
  padding: 2px 8px;
  border-radius: 6px;
  background: rgba(55, 60, 65, 0.22);
  color: rgba(255, 255, 255, 0.84);
  border: 1px solid rgba(255, 255, 255, 0.12);
}

.module-editor-move-in-btn.is-highlighted {
  background: rgba(86, 219, 240, 0.35);
  border-color: rgba(86, 219, 240, 0.7);
  color: rgba(255, 255, 255, 0.96);
  box-shadow: 0 0 0 1px rgba(86, 219, 240, 0.25);
}

.module-editor-move-in-btn.is-highlighted:hover {
  background: rgba(96, 230, 250, 0.5);
  border-color: rgba(96, 230, 250, 0.8);
}

.module-editor-move-in-hint {
  margin: 0;
  font-size: 11px;
  color: rgba(255, 255, 255, 0.55);
}

.module-editor-notice {
  margin: 0;
  padding: 6px 8px;
  border-radius: 6px;
  font-size: 12px;
  line-height: 1.45;
  color: rgba(225, 246, 252, 0.94);
  background: rgba(100, 190, 220, 0.20);
  border: 1px solid rgba(120, 210, 235, 0.32);
  box-shadow: 0 0 0 1px rgba(100, 200, 230, 0.07);
}

.module-editor-warning {
  margin: 0;
  padding: 6px 8px;
  border-radius: 6px;
  margin-bottom: 6px;
  font-size: 12px;
  color: rgba(255, 235, 215, 0.96);
  background: rgba(255, 160, 60, 0.2);
  border: 1px solid rgba(255, 160, 60, 0.4);
  box-shadow: 0 0 0 1px rgba(255, 160, 60, 0.12);
}

.module-editor-warning.is-highlighted {
  background: rgba(255, 175, 70, 0.28);
  border-color: rgba(255, 190, 90, 0.6);
  color: rgba(255, 245, 235, 0.98);
}

.module-editor :deep(.module-editor-module-id) {
  font-family: monospace;
}

.module-editor-body {
  font-size: 12px;
  color: rgba(255, 255, 255, 0.75);
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.module-editor-settings-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.module-editor-btn {
  appearance: none;
  border: 1px solid rgba(255, 255, 255, 0.12);
  background: rgba(55, 60, 65, 0.22);
  color: rgba(255, 255, 255, 0.84);
  font-size: 12px;
  padding: 6px 10px;
  border-radius: 6px;
  cursor: pointer;
  transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}

.module-editor-btn:hover {
  background: rgba(60, 65, 70, 0.32);
  border-color: rgba(255, 255, 255, 0.16);
  color: rgba(255, 255, 255, 0.92);
}

.module-editor-btn:focus-visible {
  outline: 2px solid rgba(255, 255, 255, 0.22);
  outline-offset: 2px;
}

.module-editor-btn.is-danger {
  border-color: rgba(255, 90, 90, 0.35);
  color: rgba(255, 200, 200, 0.92);
}

.module-editor-btn.is-danger:hover {
  background: rgba(120, 40, 40, 0.22);
  border-color: rgba(255, 90, 90, 0.50);
}

.module-editor-max-loop-section {
  padding-left: 2px;
  padding-top: 4px;
}

.module-editor-max-loop-section .module-param-field {
  display: inline-flex;
  align-items: center;
  justify-content: flex-start;
  gap: 5px;
  flex-shrink: 0;
}

.module-editor-max-loop-section .module-param-keyword {
  white-space: nowrap;
  font-size: 12px;
  color: rgba(255, 255, 255, 0.72);
}

.module-editor-max-loop-section .module-param-input {
  font-size: 12px;
  font-family: monospace;
  padding: 2px 4px;
  background: rgba(255, 255, 255, 0.08);
  border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: 3px;
  color: #fff;
  box-sizing: content-box;
  min-width: 24px;
}

.module-editor-max-loop-section .module-param-input:focus {
  outline: none;
  border-color: rgba(120, 198, 255, 0.6);
}
</style>