// Moonlight - 軽量 SVG エディタ

///|
using @element {div, button, text, text_dyn, events, show, input}

///|
/// キャンバスサイズ
let canvas_width : Int = 400

///|
/// クリップボード(コピーした要素を保持)
let clipboard : Ref[Array[@model.Element]] = { val: [] }

///|
let canvas_height : Int = 300

///|
/// IndexedDB 設定
let db_name : String = "moonlight-editor"

///|
let store_name : String = "scenes"

///|
let scene_key : String = "current"

///|
/// ID カウンター(Signal で管理)
fn create_id_counter() -> @luna.Signal[Int] {
  @luna.signal(0)
}

// ============================================================
// JavaScript 埋め込み用 API
// ============================================================

///|
/// エディタオプション(JavaScript から渡される)
pub struct EmbedOptions {
  width : Int // キャンバス幅(ピクセル)
  height : Int // キャンバス高さ(ピクセル)
  doc_width : Int // ドキュメント幅(viewBox)
  doc_height : Int // ドキュメント高さ(viewBox)
  zoom : Double // 初期ズーム
  theme : String
  is_readonly : Bool
  initial_svg : String? // 初期 SVG(None ならデモ表示)
}

///|
/// デフォルトオプション
pub fn EmbedOptions::default() -> EmbedOptions {
  {
    width: 400,
    height: 300,
    doc_width: 400,
    doc_height: 300,
    zoom: 1.0,
    theme: "light",
    is_readonly: false,
    initial_svg: None,
  }
}

///|
/// JavaScript オブジェクトからオプションを作成
pub fn EmbedOptions::from_js(js_obj : @js.Any) -> EmbedOptions {
  let width = get_int_or_default(js_obj, "width", 400)
  let height = get_int_or_default(js_obj, "height", 300)
  let doc_width = get_int_or_default(js_obj, "doc_width", width)
  let doc_height = get_int_or_default(js_obj, "doc_height", height)
  let zoom = get_double_or_default(js_obj, "zoom", 1.0)
  let theme = get_string_or_default(js_obj, "theme", "light")
  let is_readonly = get_bool_or_default(js_obj, "is_readonly", false)
  let initial_svg = get_string_option(js_obj, "initial_svg")
  {
    width,
    height,
    doc_width,
    doc_height,
    zoom,
    theme,
    is_readonly,
    initial_svg,
  }
}

///|
extern "js" fn get_int_or_default(
  obj : @js.Any,
  key : String,
  default_val : Int,
) -> Int =
  #| (obj, key, def) => typeof obj[key] === 'number' ? obj[key] : def

///|
extern "js" fn get_string_or_default(
  obj : @js.Any,
  key : String,
  default_val : String,
) -> String =
  #| (obj, key, def) => typeof obj[key] === 'string' ? obj[key] : def

///|
extern "js" fn get_bool_or_default(
  obj : @js.Any,
  key : String,
  default_val : Bool,
) -> Bool =
  #| (obj, key, def) => typeof obj[key] === 'boolean' ? obj[key] : def

///|
extern "js" fn get_double_or_default(
  obj : @js.Any,
  key : String,
  default_val : Double,
) -> Double =
  #| (obj, key, def) => typeof obj[key] === 'number' ? obj[key] : def

///|
extern "js" fn get_string_option_ffi(obj : @js.Any, key : String) -> @js.Any =
  #| (obj, key) => obj[key]

///|
extern "js" fn is_string_ffi(val : @js.Any) -> Bool =
  #| (val) => typeof val === 'string'

///|
/// PointerEvent から pointerId を取得
extern "js" fn get_pointer_id(e : @js.Any) -> Int =
  #| (e) => e.pointerId || 0

///|
fn get_string_option(obj : @js.Any, key : String) -> String? {
  let val = get_string_option_ffi(obj, key)
  if is_string_ffi(val) {
    Some(val.cast())
  } else {
    None
  }
}

///|
/// JavaScript 埋め込み用エディタを作成
/// container: DOM コンテナ要素
/// options: エディタオプション
/// 戻り値: エディタハンドル
pub fn create_js_editor(container : @js.Any, options : EmbedOptions) -> @js.Any {
  let id_counter = create_id_counter()
  let new_id = fn() -> String {
    let current = id_counter.get()
    id_counter.set(current + 1)
    "el-\{current + 1}"
  }

  // 状態を作成(キャンバスサイズを使用)
  let state = @core.EditorState::new_with_mode(
    options.width,
    options.height,
    @core.Embedded,
  )
  let history = @model.History::new()

  // ドキュメントサイズを設定
  state.doc_width.set(options.doc_width.to_double())
  state.doc_height.set(options.doc_height.to_double())

  // ズームを設定(プレビューと同じスケールを維持)
  let viewport = state.viewport.get()
  state.viewport.set({ ..viewport, zoom: options.zoom })

  // テーマを設定
  let theme = if options.theme == "dark" { @model.Dark } else { @model.Light }
  state.theme_mode.set(theme)
  state.is_readonly.set(options.is_readonly)

  // 初期 SVG が指定されていればパース、なければサンプル要素を追加
  match options.initial_svg {
    Some(svg_str) => {
      let result = import_svg_validated(svg_str, new_id)
      if result.elements.length() > 0 {
        state.elements.set(result.elements)
        // プレーンSVG要素を登録
        if result.plain_svg_ids.length() > 0 {
          state.register_plain_svg_ids(result.plain_svg_ids)
        }
        // バリデーション実行
        state.revalidate()
      }
      // initial_svg が指定されていれば、空でもサンプルは表示しない
    }
    None => add_sample_shapes(state, new_id)
  }

  // コンテナ要素を状態に保存(キーボードハンドラで使用)
  state.container_el.set(container)

  // Luna でレンダリング
  let editor_node = embedded_editor_app(state, history, new_id)
  let dom_container : @js_dom.Element = @js.identity(container)
  @element.render(dom_container |> @element.DomElement::from_dom, editor_node)

  // フォーカス管理をセットアップ(キーボードイベントの制御用)
  let focus_state : @lib.FocusState = {
    has_focus: state.has_focus,
    container: @luna.signal(None),
  }
  @lib.setup_focus_management(focus_state, container)

  // キーボードハンドラをセットアップ(Delete キーなど)
  @lib.setup_keyboard_handler(state, new_id)

  // ハンドルを作成
  let handle = create_editor_handle_js(
    state,
    options.doc_width,
    options.doc_height,
  )

  // イベントリスナーをセットアップ
  setup_handle_events(state, handle)
  handle
}

///|
/// エディタハンドルを作成(JavaScript オブジェクト)
fn create_editor_handle_js(
  state : @core.EditorState,
  width : Int,
  height : Int,
) -> @js.Any {
  let export_svg = fn() -> String {
    let bg_color = match state.theme_mode.get() {
      @model.Dark => "#1a1a2e"
      @model.Light => "#ffffff"
    }
    elements_to_moonlight_svg(state.elements.get(), width, height, bg_color)
  }
  let import_svg = fn(svg_str : String) -> Unit {
    let id_counter = create_id_counter()
    let new_id = fn() -> String {
      let current = id_counter.get()
      id_counter.set(current + 1)
      "el-\{current + 1}"
    }
    let result = import_svg_validated(svg_str, new_id)
    if result.elements.length() > 0 {
      state.elements.set(result.elements)
      // プレーンSVG要素を登録
      state.clear_plain_svg_ids() // 既存のIDをクリア
      if result.plain_svg_ids.length() > 0 {
        state.register_plain_svg_ids(result.plain_svg_ids)
      }
      // バリデーション実行
      state.revalidate()
    }
  }
  let clear = fn() -> Unit { state.elements.set([]) }
  let has_focus = fn() -> Bool { state.has_focus.get() }
  // 選択 API
  let select = fn(ids : Array[String]) -> Unit { state.selected_ids.set(ids) }
  let select_all = fn() -> Unit {
    let all_ids = state.elements.get().map(fn(el) { el.id })
    state.selected_ids.set(all_ids)
  }
  let deselect = fn() -> Unit { state.selected_ids.set([]) }
  let get_selected_ids = fn() -> Array[String] { state.selected_ids.get() }
  // フォーカス API
  let focus = fn() -> Unit { state.has_focus.set(true) }
  let blur = fn() -> Unit { state.has_focus.set(false) }
  // 要素操作 API
  let get_elements = fn() -> Array[@model.Element] { state.elements.get() }
  let delete_elements = fn(ids : Array[String]) -> Unit {
    state.elements.update(fn(els) {
      els.filter(fn(el) { not(ids.contains(el.id)) })
    })
    // 削除された要素を選択から除外
    state.selected_ids.update(fn(sel) {
      sel.filter(fn(id) { not(ids.contains(id)) })
    })
  }
  // モード API
  let set_mode = fn(mode : String) -> Unit {
    let tool_mode = if mode == "freedraw" {
      @core.FreeDraw
    } else {
      @core.Select
    }
    state.tool_mode.set(tool_mode)
  }
  let get_mode = fn() -> String {
    match state.tool_mode.get() {
      @core.Select => "select"
      @core.FreeDraw => "freedraw"
    }
  }
  // readonly API
  let set_readonly = fn(value : Bool) -> Unit { state.is_readonly.set(value) }
  let is_readonly = fn() -> Bool { state.is_readonly.get() }
  create_editor_handle_ffi(
    export_svg, import_svg, clear, has_focus, select, select_all, deselect, get_selected_ids,
    focus, blur, get_elements, delete_elements, set_mode, get_mode, set_readonly,
    is_readonly,
  )
}

///|
/// FFI でハンドルオブジェクトを作成
extern "js" fn create_editor_handle_ffi(
  export_svg : () -> String,
  import_svg : (String) -> Unit,
  clear : () -> Unit,
  has_focus : () -> Bool,
  select : (Array[String]) -> Unit,
  select_all : () -> Unit,
  deselect : () -> Unit,
  get_selected_ids : () -> Array[String],
  focus : () -> Unit,
  blur : () -> Unit,
  get_elements : () -> Array[@model.Element],
  delete_elements : (Array[String]) -> Unit,
  set_mode : (String) -> Unit,
  get_mode : () -> String,
  set_readonly : (Bool) -> Unit,
  is_readonly : () -> Bool,
) -> @js.Any =
  #| (exportSvg, importSvg, clear, hasFocus, select, selectAll, deselect, getSelectedIds, focus, blur, getElements, deleteElements, setMode, getMode, setReadonly, isReadonly) => ({
  #|   // 内部コールバック配列
  #|   _changeCallbacks: [],
  #|   _selectCallbacks: [],
  #|   _deselectCallbacks: [],
  #|   _focusCallbacks: [],
  #|   _blurCallbacks: [],
  #|   _modeChangeCallbacks: [],
  #|   _elementAddCallbacks: [],
  #|   _elementDeleteCallbacks: [],
  #|   // 既存 API
  #|   exportSvg: exportSvg,
  #|   importSvg: importSvg,
  #|   clear: clear,
  #|   destroy: () => {},
  #|   hasFocus: hasFocus,
  #|   // 選択 API
  #|   select: select,
  #|   selectAll: selectAll,
  #|   deselect: deselect,
  #|   getSelectedIds: getSelectedIds,
  #|   // フォーカス API
  #|   focus: focus,
  #|   blur: blur,
  #|   // 要素操作 API
  #|   getElements: getElements,
  #|   getElementById: function(id) {
  #|     const els = getElements();
  #|     return els.find(el => el.id === id) || null;
  #|   },
  #|   deleteElements: deleteElements,
  #|   // モード API
  #|   setMode: setMode,
  #|   getMode: getMode,
  #|   // readonly API
  #|   setReadonly: setReadonly,
  #|   isReadonly: isReadonly,
  #|   // イベント購読 API
  #|   onChange: function(callback) {
  #|     this._changeCallbacks.push(callback);
  #|     return () => { this._changeCallbacks = this._changeCallbacks.filter(c => c !== callback); };
  #|   },
  #|   onSelect: function(callback) {
  #|     this._selectCallbacks.push(callback);
  #|     return () => { this._selectCallbacks = this._selectCallbacks.filter(c => c !== callback); };
  #|   },
  #|   onDeselect: function(callback) {
  #|     this._deselectCallbacks.push(callback);
  #|     return () => { this._deselectCallbacks = this._deselectCallbacks.filter(c => c !== callback); };
  #|   },
  #|   onFocus: function(callback) {
  #|     this._focusCallbacks.push(callback);
  #|     return () => { this._focusCallbacks = this._focusCallbacks.filter(c => c !== callback); };
  #|   },
  #|   onBlur: function(callback) {
  #|     this._blurCallbacks.push(callback);
  #|     return () => { this._blurCallbacks = this._blurCallbacks.filter(c => c !== callback); };
  #|   },
  #|   onModeChange: function(callback) {
  #|     this._modeChangeCallbacks.push(callback);
  #|     return () => { this._modeChangeCallbacks = this._modeChangeCallbacks.filter(c => c !== callback); };
  #|   },
  #|   onElementAdd: function(callback) {
  #|     this._elementAddCallbacks.push(callback);
  #|     return () => { this._elementAddCallbacks = this._elementAddCallbacks.filter(c => c !== callback); };
  #|   },
  #|   onElementDelete: function(callback) {
  #|     this._elementDeleteCallbacks.push(callback);
  #|     return () => { this._elementDeleteCallbacks = this._elementDeleteCallbacks.filter(c => c !== callback); };
  #|   },
  #|   // 内部: イベント発火用
  #|   _fireChange: function() { this._changeCallbacks.forEach(cb => cb()); },
  #|   _fireSelect: function(ids) { this._selectCallbacks.forEach(cb => cb(ids)); },
  #|   _fireDeselect: function() { this._deselectCallbacks.forEach(cb => cb()); },
  #|   _fireFocus: function() { this._focusCallbacks.forEach(cb => cb()); },
  #|   _fireBlur: function() { this._blurCallbacks.forEach(cb => cb()); },
  #|   _fireModeChange: function(mode) { this._modeChangeCallbacks.forEach(cb => cb(mode)); },
  #|   _fireElementAdd: function(id) { this._elementAddCallbacks.forEach(cb => cb(id)); },
  #|   _fireElementDelete: function(id) { this._elementDeleteCallbacks.forEach(cb => cb(id)); }
  #| })

///|
/// ハンドルにイベントリスナーをセットアップ
fn setup_handle_events(state : @core.EditorState, handle : @js.Any) -> Unit {
  // 要素変更イベント
  let prev_elements : Ref[Array[@model.Element]] = { val: [] }
  let _ = @luna.effect(fn() {
    let elements = state.elements.get()
    // onChange を発火
    fire_change(handle)
    // 要素追加/削除を検出
    let prev_ids = prev_elements.val.map(fn(el) { el.id })
    let curr_ids = elements.map(fn(el) { el.id })
    // 追加された要素
    for id in curr_ids {
      if not(prev_ids.contains(id)) {
        fire_element_add(handle, id)
      }
    }
    // 削除された要素
    for id in prev_ids {
      if not(curr_ids.contains(id)) {
        fire_element_delete(handle, id)
      }
    }
    prev_elements.val = elements
  })
  // 選択変更イベント
  let prev_selected : Ref[Array[String]] = { val: [] }
  let _ = @luna.effect(fn() {
    let selected = state.selected_ids.get()
    if selected.length() > 0 && prev_selected.val.length() == 0 {
      // 選択開始
      fire_select(handle, selected)
    } else if selected.length() == 0 && prev_selected.val.length() > 0 {
      // 選択解除
      fire_deselect(handle)
    } else if selected.length() > 0 {
      // 選択変更
      fire_select(handle, selected)
    }
    prev_selected.val = selected
  })
  // フォーカス変更イベント
  let prev_focus : Ref[Bool] = { val: false }
  let _ = @luna.effect(fn() {
    let focused = state.has_focus.get()
    if focused && not(prev_focus.val) {
      fire_focus(handle)
    } else if not(focused) && prev_focus.val {
      fire_blur(handle)
    }
    prev_focus.val = focused
  })
  // モード変更イベント
  let prev_mode : Ref[@core.ToolMode] = { val: @core.Select }
  let _ = @luna.effect(fn() {
    let mode = state.tool_mode.get()
    if mode != prev_mode.val {
      let mode_str = match mode {
        @core.Select => "select"
        @core.FreeDraw => "freedraw"
      }
      fire_mode_change(handle, mode_str)
    }
    prev_mode.val = mode
  })
}

///|
/// イベント発火用 FFI
extern "js" fn fire_change(handle : @js.Any) =
  #| (h) => h._fireChange()

///|
extern "js" fn fire_select(handle : @js.Any, ids : Array[String]) =
  #| (h, ids) => h._fireSelect(ids)

///|
extern "js" fn fire_deselect(handle : @js.Any) =
  #| (h) => h._fireDeselect()

///|
extern "js" fn fire_focus(handle : @js.Any) =
  #| (h) => h._fireFocus()

///|
extern "js" fn fire_blur(handle : @js.Any) =
  #| (h) => h._fireBlur()

///|
extern "js" fn fire_mode_change(handle : @js.Any, mode : String) =
  #| (h, mode) => h._fireModeChange(mode)

///|
extern "js" fn fire_element_add(handle : @js.Any, id : String) =
  #| (h, id) => h._fireElementAdd(id)

///|
extern "js" fn fire_element_delete(handle : @js.Any, id : String) =
  #| (h, id) => h._fireElementDelete(id)

///|
/// 現在の状態を IndexedDB に保存
fn save_state(state : @core.EditorState) -> Unit {
  let elements = state.elements.get()
  let theme = state.theme_mode.get()
  let bg_color = match theme {
    @model.Dark => "#1a1a2e"
    @model.Light => "#ffffff"
  }
  let svg = elements_to_moonlight_svg(
    elements, canvas_width, canvas_height, bg_color,
  )
  save_to_indexeddb(db_name, store_name, scene_key, svg, fn() { () }, fn() {
    ()
  })
}

///|
/// IndexedDB から状態を読み込み
fn load_state(
  state : @core.EditorState,
  new_id : () -> String,
  on_loaded : () -> Unit,
  on_not_found : () -> Unit,
) -> Unit {
  load_from_indexeddb(
    db_name,
    store_name,
    scene_key,
    fn(svg_str) {
      let result = import_svg_validated(svg_str, new_id)
      if result.elements.length() > 0 {
        state.elements.set(result.elements)
        // プレーンSVG要素を登録(IndexedDBから読み込みなので既存はクリア)
        state.clear_plain_svg_ids()
        if result.plain_svg_ids.length() > 0 {
          state.register_plain_svg_ids(result.plain_svg_ids)
        }
        // バリデーション実行
        state.revalidate()
      }
      on_loaded()
    },
    on_not_found,
    on_not_found,
  )
}

///|
/// 自動保存の Effect を設定
fn setup_auto_save(state : @core.EditorState) -> Unit {
  let _ = @luna.effect(fn() {
    let _ = state.elements.get() // 依存関係を登録
    save_state(state)
  })
}

///|
/// URL パラメータからモードを取得
fn get_mode_from_url() -> @core.EditorMode {
  let search = get_url_search_ffi()
  if search.contains("mode=embed") {
    @core.Embedded
  } else {
    @core.Full
  }
}

///|

///|
/// アプリケーションのエントリポイント(外部から呼び出し可能)
pub fn app_main() -> Unit {
  guard @js_dom.document().getElementById("app") is Some(el)
  let mode = get_mode_from_url()
  match mode {
    @core.Full =>
      @element.render(el |> @element.DomElement::from_dom, editor_app())
    @core.Embedded =>
      @element.render(
        el |> @element.DomElement::from_dom,
        create_embedded_app(),
      )
  }
}

///|
/// 埋め込みモード用のアプリを作成(外部から呼び出し可能)
pub fn create_embedded_app() -> @element.DomNode {
  let id_counter = create_id_counter()
  let new_id = fn() -> String {
    let current = id_counter.get()
    id_counter.set(current + 1)
    "el-\{current + 1}"
  }
  let state = @core.EditorState::new_with_mode(
    canvas_width,
    canvas_height,
    @core.Embedded,
  )
  let history = @model.History::new()
  // IndexedDB から読み込み、なければサンプルを追加
  load_state(state, new_id, fn() { setup_auto_save(state) }, fn() {
    add_sample_shapes(state, new_id)
    setup_auto_save(state)
  })
  embedded_editor_app(state, history, new_id)
}

///|
fn editor_app() -> @element.DomNode {
  // ID カウンター
  let id_counter = create_id_counter()
  let new_id = fn() -> String {
    let current = id_counter.get()
    id_counter.set(current + 1)
    "el-\{current + 1}"
  }

  // エディタ状態を初期化
  let state = @core.EditorState::new(canvas_width, canvas_height)

  // 履歴管理
  let history = @model.History::new()

  // IndexedDB から読み込み、なければサンプルを追加
  load_state(state, new_id, fn() { setup_auto_save(state) }, fn() {
    add_sample_shapes(state, new_id)
    setup_auto_save(state)
  })

  // ウィンドウサイズを監視してモバイル判定を更新
  observe_window_resize_ffi(fn(width, _height) {
    state.is_mobile.set(width < 768)
  })

  // Space キーが押されているか
  let space_pressed : Ref[Bool] = { val: false }

  // Space キーの監視を設定
  setup_keyboard_listener(state, history, space_pressed)

  // 座標取得ヘルパー
  let get_screen_coords = fn(e : @js.Any) -> (Double, Double) {
    let mouse_event : @js_dom.MouseEvent = e.cast()
    let svg = e._get("currentTarget")
    let rect = svg._call("getBoundingClientRect", [])
    let screen_x = mouse_event.clientX - (rect._get("left").cast() : Double)
    let screen_y = mouse_event.clientY - (rect._get("top").cast() : Double)
    (screen_x, screen_y)
  }
  let get_scene_coords = fn(e : @js.Any) -> (Double, Double) {
    let (screen_x, screen_y) = get_screen_coords(e)
    let vp = state.viewport.get()
    let scene = vp.screen_to_scene(screen_x, screen_y)
    (scene.x, scene.y)
  }

  // ダブルクリック検出用の状態
  let last_click_time : Ref[Double] = { val: 0.0 }
  let last_click_element : Ref[String] = { val: "" }
  let double_click_threshold : Double = 300.0

  // アクティブポインター追跡(ピンチズーム用)
  let active_pointers : Ref[Map[Int, @model.Point]] = { val: {} }

  // テキスト入力を確定するコールバック
  let commit_text_input = fn(text_content : String) {
    commit_text_edit(state, history, new_id, text_content)
  }

  // ポインターイベントハンドラ(タッチ/マウス/ペン統一)
  let on_pointerdown = create_pointerdown_handler(
    state, history, new_id, get_screen_coords, get_scene_coords, space_pressed, last_click_time,
    last_click_element, double_click_threshold, active_pointers,
  )
  let on_pointermove = create_pointermove_handler(
    state, get_screen_coords, get_scene_coords, active_pointers,
  )
  let on_pointerup = create_pointerup_handler(
    state,
    history,
    get_screen_coords,
    active_pointers,
    new_id~,
  )
  let on_pointerleave = create_pointerleave_handler(state)
  let on_wheel = create_wheel_handler(state, get_screen_coords)
  let on_contextmenu = create_contextmenu_handler(state, get_scene_coords)

  // キーボードショートカット
  setup_keyboard_shortcuts(state, history, new_id)

  // SVG キャンバスを Luna VNode として作成(for_each による差分更新)
  let svg_canvas = render_svg_canvas_node(
    state, on_pointerdown, on_pointermove, on_pointerup, on_pointerleave, on_contextmenu,
    on_wheel,
  )

  // UI
  render_editor_ui_vnode(state, history, new_id, svg_canvas, commit_text_input)
}

// ============================================================
// 初期化ヘルパー
// ============================================================

///|
/// 矢印キー方向に応じて図形をリサイズ
/// 戻り値: (新しい shape, 新しい x, 新しい y)
fn resize_shape_by_arrow(
  shape : @model.ShapeType,
  x : Double,
  y : Double,
  key : String,
  step : Double,
) -> (@model.ShapeType, Double, Double) {
  let min_size = 10.0 // 最小サイズ
  match shape {
    @model.Rect(w, h, rx, ry) =>
      match key {
        "ArrowRight" => (@model.Rect(w + step, h, rx, ry), x, y)
        "ArrowLeft" => {
          let new_w = if w + step >= min_size { w + step } else { min_size }
          (@model.Rect(new_w, h, rx, ry), x - step, y)
        }
        "ArrowDown" => (@model.Rect(w, h + step, rx, ry), x, y)
        "ArrowUp" => {
          let new_h = if h + step >= min_size { h + step } else { min_size }
          (@model.Rect(w, new_h, rx, ry), x, y - step)
        }
        _ => (shape, x, y)
      }
    @model.Circle(r) => {
      // 円は全方向同じ(半径を増加)
      let new_r = if r + step >= min_size / 2.0 {
        r + step
      } else {
        min_size / 2.0
      }
      (@model.Circle(new_r), x, y)
    }
    @model.Ellipse(rx, ry) =>
      match key {
        "ArrowRight" | "ArrowLeft" => {
          let new_rx = if rx + step >= min_size / 2.0 {
            rx + step
          } else {
            min_size / 2.0
          }
          (@model.Ellipse(new_rx, ry), x, y)
        }
        "ArrowDown" | "ArrowUp" => {
          let new_ry = if ry + step >= min_size / 2.0 {
            ry + step
          } else {
            min_size / 2.0
          }
          (@model.Ellipse(rx, new_ry), x, y)
        }
        _ => (shape, x, y)
      }
    @model.Line(x2, y2) =>
      match key {
        "ArrowRight" => (@model.Line(x2 + step, y2), x, y)
        "ArrowLeft" => (@model.Line(x2, y2), x - step, y)
        "ArrowDown" => (@model.Line(x2, y2 + step), x, y)
        "ArrowUp" => (@model.Line(x2, y2), x, y - step)
        _ => (shape, x, y)
      }
    @model.Text(content, font_size) => {
      // テキストはフォントサイズを変更
      let size = font_size.unwrap_or(16.0)
      match key {
        "ArrowRight" | "ArrowDown" => {
          let new_size = size + step / 2.0
          (@model.Text(content, Some(new_size)), x, y)
        }
        "ArrowLeft" | "ArrowUp" => {
          let new_size = if size - step / 2.0 >= 8.0 {
            size - step / 2.0
          } else {
            8.0
          }
          (@model.Text(content, Some(new_size)), x, y)
        }
        _ => (shape, x, y)
      }
    }
    _ => (shape, x, y) // Polyline, Path はそのまま
  }
}

///|
/// 矢印キーで選択要素をリサイズ
fn resize_selected_by_arrow(
  state : @core.EditorState,
  history : @model.History,
  selected_ids : Array[String],
  key : String,
  step : Double,
) -> Unit {
  for id in selected_ids {
    guard state.find_element(id) is Some(el)
    let old_shape = el.shape
    let (new_shape, new_x, new_y) = resize_shape_by_arrow(
      el.shape,
      el.x,
      el.y,
      key,
      step,
    )
    // 子要素の現在位置を記録(履歴用)
    let children = state.get_children(id)
    let child_old_positions : Array[(String, Double, Double)] = []
    for child in children {
      child_old_positions.push((child.id, child.x, child.y))
    }
    // 要素をリサイズ
    state.update_element(id, fn(e) {
      { ..e, shape: new_shape, x: new_x, y: new_y }
    })
    // 位置も変わった場合は移動も記録
    if new_x != el.x || new_y != el.y {
      history.undo_stack.push(@model.MoveElement(id, el.x, el.y, new_x, new_y))
    }
    history.undo_stack.push(@model.ResizeElement(id, old_shape, new_shape))
    // 関連要素(子テキスト、接続ライン)を同期更新
    state.sync_related_elements(id)
    // 子要素の移動を履歴に記録
    for pos in child_old_positions {
      let (child_id, old_cx, old_cy) = pos
      if state.find_element(child_id) is Some(child) {
        if child.x != old_cx || child.y != old_cy {
          history.undo_stack.push(
            @model.MoveElement(child_id, old_cx, old_cy, child.x, child.y),
          )
        }
      }
    }
  }
  history.redo_stack.clear()
}

///|
/// 矢印キーで選択要素を移動
fn move_selected_by_arrow(
  state : @core.EditorState,
  history : @model.History,
  selected_ids : Array[String],
  key : String,
  step : Double,
) -> Unit {
  let (dx, dy) : (Double, Double) = match key {
    "ArrowUp" => (0.0, -step)
    "ArrowDown" => (0.0, step)
    "ArrowLeft" => (-step, 0.0)
    "ArrowRight" => (step, 0.0)
    _ => (0.0, 0.0)
  }
  for id in selected_ids {
    guard state.find_element(id) is Some(el)
    let (old_x, old_y) = (el.x, el.y)
    let (new_x, new_y) = (old_x + dx, old_y + dy)
    state.update_element(id, fn(e) { { ..e, x: new_x, y: new_y } })
    // 子要素(テキストなど)も一緒に移動
    for child in state.get_children(id) {
      let (cx, cy) = (child.x, child.y)
      state.update_element(child.id, fn(c) { { ..c, x: cx + dx, y: cy + dy } })
      history.undo_stack.push(
        @model.MoveElement(child.id, cx, cy, cx + dx, cy + dy),
      )
    }
    state.update_connected_lines(id)
    history.undo_stack.push(@model.MoveElement(id, old_x, old_y, new_x, new_y))
  }
  history.redo_stack.clear()
}

///|
/// 矢印キーでキャンバスをパン
fn pan_by_arrow(state : @core.EditorState, key : String) -> Unit {
  let zoom = state.viewport.get().zoom
  let pan_step = 12.0 / zoom
  match key {
    "ArrowUp" => state.pan(0.0, -pan_step)
    "ArrowDown" => state.pan(0.0, pan_step)
    "ArrowLeft" => state.pan(-pan_step, 0.0)
    "ArrowRight" => state.pan(pan_step, 0.0)
    _ => ()
  }
}

///|
/// 矢印キーイベントを処理
fn handle_arrow_key(
  state : @core.EditorState,
  history : @model.History,
  key : String,
  shift_key : Bool,
) -> Unit {
  let selected_ids = state.selected_ids.get()
  if selected_ids.length() == 0 {
    pan_by_arrow(state, key)
    return
  }
  let step = if state.grid_enabled.get() { 10.0 } else { 5.0 }
  if shift_key {
    resize_selected_by_arrow(state, history, selected_ids, key, step)
  } else {
    move_selected_by_arrow(state, history, selected_ids, key, step)
  }
}

///|
/// キーボードリスナーを設定(Space + Arrow)
fn setup_keyboard_listener(
  state : @core.EditorState,
  history : @model.History,
  space_pressed : Ref[Bool],
) -> Unit {
  let _ = add_keydown_listener(fn(e) {
    if is_input_focused_ffi() {
      return
    }
    let key : String = e._get("key").cast()
    let keyboard_event : @js_dom.KeyboardEvent = e.cast()
    // Escape キー
    if key == "Escape" {
      if state.is_placing() {
        state.cancel_placement()
      } else if state.context_menu.get() is Some(_) {
        state.hide_context_menu()
      } else if state.tool_mode.get() == @core.FreeDraw {
        // フリードローモードなら Select に戻る
        state.tool_mode.set(@core.Select)
      } else {
        return
      }
      keyboard_event.preventDefault()
      return
    }
    // Space キーでパンモード
    if key == " " {
      space_pressed.val = true
      return
    }
    // コンテキストメニュー表示中は他のキー操作を無効化
    if state.context_menu.get() is Some(_) {
      return
    }
    // 矢印キー
    match key {
      "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight" => {
        let shift_key : Bool = e._get("shiftKey").cast()
        handle_arrow_key(state, history, key, shift_key)
        keyboard_event.preventDefault()
      }
      _ => ()
    }
  })
  let _ = add_keyup_listener(fn(e) {
    let key : String = e._get("key").cast()
    if key == " " {
      space_pressed.val = false
      if state.is_panning.get() {
        state.end_pan()
      }
    }
  })
}

// ============================================================
// イベントハンドラ作成
// ============================================================

///|
/// pointerdown ハンドラを作成(タッチ/マウス/ペン統一)
fn create_pointerdown_handler(
  state : @core.EditorState,
  history : @model.History,
  new_id : () -> String,
  get_screen_coords : (@js.Any) -> (Double, Double),
  get_scene_coords : (@js.Any) -> (Double, Double),
  space_pressed : Ref[Bool],
  last_click_time : Ref[Double],
  last_click_element : Ref[String],
  double_click_threshold : Double,
  active_pointers : Ref[Map[Int, @model.Point]],
) -> (@js.Any) -> Unit {
  fn(e) {
    // ポインターを追跡(ピンチズーム用)
    let pointer_id = get_pointer_id(e)
    let (screen_x, screen_y) = get_screen_coords(e)
    active_pointers.val[pointer_id] = { x: screen_x, y: screen_y }

    // 2本指でピンチズーム開始
    if active_pointers.val.length() == 2 {
      let pointers : Array[(Int, @model.Point)] = active_pointers.val
        .iter()
        .collect()
      let (id1, pos1) = pointers[0]
      let (id2, pos2) = pointers[1]
      state.start_pinch(id1, id2, pos1, pos2)
      return
    }

    // コンテキストメニューが開いている場合は閉じるだけで他のアクションを実行しない
    if state.context_menu.get() is Some(_) {
      state.hide_context_menu()
      return
    }

    // 配置モード中はクリックで確定
    if state.is_placing() {
      state.end_placement()
      return
    }

    // マウスボタンをチェック
    let mouse_event : @js_dom.MouseEvent = e.cast()
    let button = mouse_event.button

    // 中クリック(button === 1)または Space キーでパン開始(埋め込みモードでは無効)
    if state.mode is @core.Full && (button == 1 || space_pressed.val) {
      state.start_pan(screen_x, screen_y)
      return
    }
    let target = e._get("target")
    let dataset = target._get("dataset")

    // アンカーポイントのクリックをチェック(ライン生成)- フリードローモードより優先
    if handle_anchor_click(state, history, new_id, get_scene_coords, e, dataset) {
      return
    }

    // フリードローモードの場合(アンカーポイント以外の場所をクリック)
    if state.tool_mode.get() == @core.FreeDraw {
      let (scene_x, scene_y) = get_scene_coords(e)
      @lib.start_free_draw(state, scene_x, scene_y)
      return
    }

    // リサイズハンドルのクリックをチェック
    if handle_resize_click(state, get_scene_coords, e, dataset) {
      return
    }

    // data-id を直接取得、なければ親要素から取得(tspan 対応)
    let data_id_raw = dataset._get("id")
    let data_id : String? = if @js.is_nullish(data_id_raw) {
      let closest_id = get_closest_data_id_ffi(target)
      if @js.is_nullish(closest_id) {
        None
      } else {
        Some(closest_id.cast())
      }
    } else {
      Some(data_id_raw.cast())
    }
    let (scene_x, scene_y) = get_scene_coords(e)
    let shift_key : Bool = e._get("shiftKey").cast()
    if data_id is Some(id) {
      handle_element_click(
        state, id, scene_x, scene_y, last_click_time, last_click_element, double_click_threshold,
        shift_key,
      )
    } else {
      // 空白をクリック - 矩形選択を開始
      state.select(None)
      state.start_box_select(scene_x, scene_y)
      last_click_time.val = 0.0
      last_click_element.val = ""
    }
  }
}

///|
/// アンカーポイントクリックを処理
fn handle_anchor_click(
  state : @core.EditorState,
  history : @model.History,
  new_id : () -> String,
  get_scene_coords : (@js.Any) -> (Double, Double),
  e : @js.Any,
  dataset : @js.Any,
) -> Bool {
  let data_anchor_raw = dataset._get("anchor")
  if @js.is_nullish(data_anchor_raw) {
    return false
  }
  let anchor_str : String = data_anchor_raw.cast()
  // data-anchor-element は dataset.anchorElement としてアクセス
  let element_id_raw = dataset._get("anchorElement")
  if @js.is_nullish(element_id_raw) {
    return false
  }
  let source_id : String = element_id_raw.cast()
  guard @model.parse_anchor(anchor_str) is Some(anchor) else { return false }
  guard state.find_element(source_id) is Some(source_el) else { return false }
  let anchor_point = source_el.get_anchor_point(anchor)
  let (scene_x, scene_y) = get_scene_coords(e)
  let line_id = new_id()
  // Arrow として作成(矢印付き)
  let new_line = @model.Element::new(
    line_id,
    anchor_point.x,
    anchor_point.y,
    @model.Line(anchor_point.x, anchor_point.y),
  ).with_style(state.get_arrow_style())
  let line_with_conn = {
    ..new_line,
    connections: Some({
      start: Some({ element_id: source_id, anchor }),
      end: None,
    }),
  }
  @core.execute_command(history, state, @model.AddElement(line_with_conn))
  // リサイズ開始
  state.start_resize(line_id, @model.LineEnd, scene_x, scene_y)
  true
}

///|
/// リサイズハンドルクリックを処理
fn handle_resize_click(
  state : @core.EditorState,
  get_scene_coords : (@js.Any) -> (Double, Double),
  e : @js.Any,
  dataset : @js.Any,
) -> Bool {
  let data_handle_raw = dataset._get("handle")
  if @js.is_nullish(data_handle_raw) {
    return false
  }
  let handle_str : String = data_handle_raw.cast()
  let element_id_raw = dataset._get("elementId")
  if @js.is_nullish(element_id_raw) {
    return false
  }
  let element_id : String = element_id_raw.cast()
  // Path 要素はリサイズ不可
  match state.find_element(element_id) {
    Some(el) => if el.shape is @model.Path(_, _, _, _, _) { return false }
    None => ()
  }
  let (scene_x, scene_y) = get_scene_coords(e)
  let handle = @model.parse_handle_position(handle_str)
  state.start_resize(element_id, handle, scene_x, scene_y)
  true
}

///|
/// 要素クリックを処理
fn handle_element_click(
  state : @core.EditorState,
  id : String,
  scene_x : Double,
  scene_y : Double,
  last_click_time : Ref[Double],
  last_click_element : Ref[String],
  double_click_threshold : Double,
  shift_key : Bool,
) -> Unit {
  // 子要素の場合は親を取得
  let target_id = match state.get_parent_id(id) {
    Some(parent_id) => parent_id
    None => id
  }
  let now = get_current_time()
  let is_child = state.is_child_element(id)
  let is_text_child = if is_child {
    match state.find_element(id) {
      Some(el) => el.shape is @model.Text(_, _)
      None => false
    }
  } else {
    false
  }
  let dblclick_target = if is_text_child { id } else { target_id }
  if last_click_element.val == dblclick_target &&
    now - last_click_time.val < double_click_threshold {
    // ダブルクリック検出
    last_click_time.val = 0.0
    last_click_element.val = ""
    state.end_drag()
    if is_text_child {
      handle_text_edit(state, id)
    } else {
      handle_double_click(state, target_id)
    }
    return
  }
  last_click_time.val = now
  last_click_element.val = dblclick_target
  let already_selected = state.is_selected(target_id)
  let selected_count = state.selected_ids.get().length()

  // Shift+クリック: 複数選択のトグル
  if shift_key {
    if already_selected {
      state.remove_from_selection(target_id)
    } else {
      state.add_to_selection(target_id)
    }
    // 複数選択中はドラッグを開始
    if state.selected_ids.get().length() > 1 {
      state.start_multi_drag(scene_x, scene_y)
    } else if state.selected_ids.get().length() == 1 {
      // 1つだけ選択中の場合は通常ドラッグ
      let selected_id = state.selected_ids.get()[0]
      state.start_drag(selected_id, scene_x, scene_y)
    }
    return
  }

  // 通常クリック
  if already_selected && selected_count > 1 {
    state.start_multi_drag(scene_x, scene_y)
  } else {
    state.select(Some(target_id))
    state.start_drag(target_id, scene_x, scene_y)
  }
}

///|
/// ダブルクリック時のテキスト編集開始
fn handle_double_click(state : @core.EditorState, element_id : String) -> Unit {
  guard state.find_element(element_id) is Some(el)
  // Path 要素はテキスト編集不可(位置計算が困難なため)
  if el.shape is @model.Path(_, _, _, _, _) {
    return
  }
  // テキスト要素の場合は直接編集
  if el.shape is @model.Text(content, font_size) {
    // parent_id がない場合は自分自身を親として扱う
    let parent_id = el.parent_id.unwrap_or(element_id)
    state.start_text_edit_existing(
      element_id,
      parent_id,
      el.x,
      el.y,
      content,
      font_size,
    )
    return
  }
  // 親要素の場合:既存のテキスト子要素があるかチェック
  let children = state.get_children(element_id)
  for child in children {
    if child.shape is @model.Text(content, font_size) {
      // 既存テキストを編集
      // 子テキストの座標は親からの相対座標の場合があるため、親要素の中心座標を使用する
      let bbox = el.bounding_box()
      let center_x = bbox.x + bbox.width / 2.0
      let center_y = bbox.y + bbox.height / 2.0
      state.start_text_edit_existing(
        child.id,
        element_id,
        center_x,
        center_y,
        content,
        font_size,
      )
      return
    }
  }
  // テキスト子要素がない場合は新規作成
  let bbox = el.bounding_box()
  let center_x = bbox.x + bbox.width / 2.0
  let center_y = bbox.y + bbox.height / 2.0
  state.start_text_edit(element_id, center_x, center_y)
}

///|
/// テキスト要素の編集開始
fn handle_text_edit(state : @core.EditorState, text_id : String) -> Unit {
  guard state.find_element(text_id) is Some(el)
  guard el.shape is @model.Text(content, font_size)
  // parent_id がない場合は自分自身を親として扱う
  let parent_id = el.parent_id.unwrap_or(text_id)
  // テキストの表示位置を計算
  // 親要素がある場合は親の中心座標、ない場合はテキスト自体の座標を使用
  let (text_x, text_y) = match el.parent_id {
    Some(pid) =>
      match state.find_element(pid) {
        Some(parent) => {
          let bbox = parent.bounding_box()
          (bbox.x + bbox.width / 2.0, bbox.y + bbox.height / 2.0)
        }
        None => (el.x, el.y)
      }
    None => (el.x, el.y)
  }
  state.start_text_edit_existing(
    text_id, parent_id, text_x, text_y, content, font_size,
  )
}

///|
/// テキスト入力を確定
fn commit_text_edit(
  state : @core.EditorState,
  history : @model.History,
  new_id : () -> String,
  text_content : String,
) -> Unit {
  guard state.text_edit.get() is Some(edit_state)
  match edit_state.editing_id {
    Some(text_id) =>
      if text_content == "" {
        if state.find_element(text_id) is Some(el) {
          @core.execute_command(history, state, @model.RemoveElement(el))
        }
      } else if text_content != edit_state.initial_text {
        // UpdateText コマンドを使ってUndoスタックに記録
        @core.execute_command(
          history,
          state,
          @model.UpdateText(text_id, edit_state.initial_text, text_content),
        )
      }
    None =>
      if text_content != "" {
        // 親要素がある場合、親のstrokeカラーを継承
        let text_style = if edit_state.parent_id != "" {
          match state.find_element(edit_state.parent_id) {
            Some(parent_el) =>
              match parent_el.style.stroke {
                Some(color) => { ..state.get_text_style(), fill: Some(color) }
                None => state.get_text_style()
              }
            None => state.get_text_style()
          }
        } else {
          state.get_text_style()
        }
        let text_el = @model.Element::new(
            new_id(),
            edit_state.x,
            edit_state.y,
            @model.Text(text_content, Some(16.0)),
          )
          .with_style(text_style)
          .with_parent(edit_state.parent_id)
        @core.execute_command(history, state, @model.AddElement(text_el))
      }
  }
  state.end_text_edit()
}

///|
/// pointermove ハンドラを作成
fn create_pointermove_handler(
  state : @core.EditorState,
  get_screen_coords : (@js.Any) -> (Double, Double),
  get_scene_coords : (@js.Any) -> (Double, Double),
  active_pointers : Ref[Map[Int, @model.Point]],
) -> (@js.Any) -> Unit {
  fn(e) {
    // ボタンが離されているのにドラッグ中の場合は終了(領域外でリリースされた場合)
    let buttons : Int = e._get("buttons").cast()
    if buttons == 0 {
      if state.drag_state.get() is Some(_) {
        state.end_drag()
      }
      if state.resize_state.get() is Some(_) {
        state.end_resize()
      }
      if state.multi_drag_state.get() is Some(_) {
        state.end_multi_drag()
      }
    }

    // ポインター位置を更新
    let pointer_id = get_pointer_id(e)
    let (screen_x, screen_y) = get_screen_coords(e)
    active_pointers.val[pointer_id] = { x: screen_x, y: screen_y }

    // ピンチズーム中の場合
    if state.is_pinching() {
      state.update_pinch(pointer_id, { x: screen_x, y: screen_y })
      return
    }

    // コンテキストメニューが開いている場合は移動操作を無効化
    if state.context_menu.get() is Some(_) {
      return
    }

    // マウス位置を更新(キーバインドでの挿入用)
    let (scene_x, scene_y) = get_scene_coords(e)
    state.mouse_scene_pos.set({ x: scene_x, y: scene_y })
    state.mouse_in_canvas.set(true)

    // フリードロー中の場合
    if @lib.is_drawing(state) {
      @lib.continue_free_draw(state, scene_x, scene_y)
      // プレビュー描画
      let svg = state.svg_el.get()
      if not(@js.is_nullish(svg)) {
        let points = @lib.get_preview_points(state)
        @lib.draw_free_draw_preview(svg, points)
      }
      return
    }
    if state.is_panning.get() {
      let (screen_x, screen_y) = get_screen_coords(e)
      state.pan_move(screen_x, screen_y)
      return
    }
    if state.is_placing() {
      // 配置モード中は要素をマウス位置に追従
      state.update_placement({ x: scene_x, y: scene_y })
    } else if state.is_resizing() {
      state.resize_move(scene_x, scene_y)
    } else if state.is_multi_dragging() {
      state.multi_drag_move(scene_x, scene_y)
    } else if state.is_dragging() {
      state.drag_move(scene_x, scene_y)
    } else if state.is_box_selecting() {
      state.update_box_select(scene_x, scene_y)
    }
  }
}

///|
/// リサイズ終了時のコマンド記録
fn record_resize_end(
  state : @core.EditorState,
  history : @model.History,
  resize : @model.ResizeState,
) -> Unit {
  guard state.find_element(resize.element_id) is Some(el) else { return }
  let changed = resize.start_shape != el.shape ||
    resize.start_x != el.x ||
    resize.start_y != el.y
  guard changed else { return }
  // 位置変更があれば記録
  if resize.start_x != el.x || resize.start_y != el.y {
    history.undo_stack.push(
      @model.MoveElement(
        resize.element_id,
        resize.start_x,
        resize.start_y,
        el.x,
        el.y,
      ),
    )
  }
  history.undo_stack.push(
    @model.ResizeElement(resize.element_id, resize.start_shape, el.shape),
  )
  history.redo_stack.clear()
}

///|
/// 複数要素ドラッグ終了時のコマンド記録
fn record_multi_drag_end(
  state : @core.EditorState,
  history : @model.History,
  multi_drag : @model.MultiDragState,
) -> Unit {
  for pos in multi_drag.start_positions {
    let (id, start_x, start_y) = pos
    guard state.find_element(id) is Some(el) else { continue }
    guard start_x != el.x || start_y != el.y else { continue }
    history.undo_stack.push(
      @model.MoveElement(id, start_x, start_y, el.x, el.y),
    )
  }
  history.redo_stack.clear()
}

///|
/// Line ドラッグ時の接続要素移動を記録
fn record_connected_moves(
  state : @core.EditorState,
  history : @model.History,
  positions : Array[(String, Double, Double)],
) -> Unit {
  for pos in positions {
    let (id, start_x, start_y) = pos
    guard state.find_element(id) is Some(el) else { continue }
    guard start_x != el.x || start_y != el.y else { continue }
    history.undo_stack.push(
      @model.MoveElement(id, start_x, start_y, el.x, el.y),
    )
  }
}

///|
/// ドラッグ終了時のコマンド記録
fn record_drag_end(
  state : @core.EditorState,
  history : @model.History,
  drag : @model.DragState,
) -> Unit {
  guard state.find_element(drag.element_id) is Some(el) else { return }
  guard drag.start_x != el.x || drag.start_y != el.y else { return }
  // Line の場合は終点の変更もコマンドとして記録
  match (drag.start_shape, el.shape) {
    (Some(@model.Line(_, _) as old_shape), @model.Line(_, _) as new_shape) => {
      record_connected_moves(state, history, drag.connected_start_positions)
      if old_shape != new_shape {
        history.undo_stack.push(
          @model.ResizeElement(drag.element_id, old_shape, new_shape),
        )
      }
    }
    _ => ()
  }
  history.undo_stack.push(
    @model.MoveElement(drag.element_id, drag.start_x, drag.start_y, el.x, el.y),
  )
  history.redo_stack.clear()
}

///|
/// pointerup ハンドラを作成
fn create_pointerup_handler(
  state : @core.EditorState,
  history : @model.History,
  get_screen_coords : (@js.Any) -> (Double, Double),
  active_pointers : Ref[Map[Int, @model.Point]],
  new_id? : () -> String = fn() { "" },
) -> (@js.Any) -> Unit {
  let _ = get_screen_coords // unused but kept for consistency
  fn(e) {
    // ポインターを削除
    let pointer_id = get_pointer_id(e)
    active_pointers.val.remove(pointer_id)

    // ピンチズーム終了
    state.end_pinch(pointer_id)
    // フリードロー中の場合
    if @lib.is_drawing(state) {
      // プレビューを削除
      let svg = state.svg_el.get()
      if not(@js.is_nullish(svg)) {
        @lib.remove_free_draw_preview(svg)
      }
      // Path 要素を作成
      @lib.finish_free_draw(state, new_id)
      return
    }
    if state.is_panning.get() {
      state.end_pan()
      return
    }
    if state.is_box_selecting() {
      state.end_box_select()
      return
    }
    if state.resize_state.get() is Some(resize) {
      record_resize_end(state, history, resize)
      state.end_resize()
      return
    }
    if state.multi_drag_state.get() is Some(multi_drag) {
      record_multi_drag_end(state, history, multi_drag)
      state.end_multi_drag()
      return
    }
    if state.drag_state.get() is Some(drag) {
      record_drag_end(state, history, drag)
    }
    state.end_drag()
  }
}

///|
/// pointerleave ハンドラを作成(ポインターが画面外に出た時に状態をリセット)
/// 注意: ドラッグ/リサイズ中の状態はリセットしない(pointerup で処理)
fn create_pointerleave_handler(state : @core.EditorState) -> (@js.Any) -> Unit {
  fn(_e) {
    // マウスがキャンバス外に出たことを記録
    state.mouse_in_canvas.set(false)
    // フリードロー中の場合はキャンセル
    if @lib.is_drawing(state) {
      let svg = state.svg_el.get()
      if not(@js.is_nullish(svg)) {
        @lib.remove_free_draw_preview(svg)
      }
      state.free_draw_state.set(None)
    }
    // パンと矩形選択のみリセット(ドラッグ/リサイズは継続可能に)
    if state.is_panning.get() {
      state.end_pan()
    }
    if state.is_box_selecting() {
      state.end_box_select()
    }
    // 配置モードはキャンセル
    if state.placement_state.get() is Some(_) {
      state.end_placement()
    }
    // ドラッグ/リサイズ/マルチドラッグは pointerup で終了するので
    // ここではリセットしない(領域外でもドラッグ継続可能に)
  }
}

///|
/// wheel ハンドラを作成(マウス位置を基準にズーム)
fn create_wheel_handler(
  state : @core.EditorState,
  get_screen_coords : (@js.Any) -> (Double, Double),
) -> (@js.Any) -> Unit {
  fn(e) {
    let _ = e._call("preventDefault", [])
    let delta_y : Double = e._get("deltaY").cast()
    let (screen_x, screen_y) = get_screen_coords(e)
    let zoom_in = delta_y < 0.0
    state.zoom_at(screen_x, screen_y, zoom_in)
  }
}

///|
/// contextmenu ハンドラを作成
fn create_contextmenu_handler(
  state : @core.EditorState,
  get_scene_coords : (@js.Any) -> (Double, Double),
) -> (@js.Any) -> Unit {
  fn(e) {
    let _ = e._call("preventDefault", [])
    let mouse_event : @js_dom.MouseEvent = e.cast()
    let (scene_x, scene_y) = get_scene_coords(e)
    let target = e._get("target")
    // data-id を直接取得、なければ親要素から取得(tspan 対応)
    let data_id_raw = target._get("dataset")._get("id")
    let target_id : String? = if @js.is_nullish(data_id_raw) {
      let closest_id = get_closest_data_id_ffi(target)
      if @js.is_nullish(closest_id) {
        None
      } else {
        Some(closest_id.cast())
      }
    } else {
      Some(data_id_raw.cast())
    }
    if target_id is Some(id) {
      state.select(Some(id))
    }
    state.show_context_menu(
      mouse_event.clientX,
      mouse_event.clientY,
      scene_x,
      scene_y,
      target_id,
    )
  }
}

///|
/// キーボードショートカットを設定
fn setup_keyboard_shortcuts(
  state : @core.EditorState,
  history : @model.History,
  new_id : () -> String,
) -> Unit {
  let handler : (@js.Any) -> Unit = fn(e) {
    // テキスト編集中または入力要素にフォーカスがある場合は無視
    if state.text_edit.get() is Some(_) {
      return
    }
    if is_input_focused_ffi() {
      return
    }
    let key : String = e._get("key").cast()
    let ctrl_key : Bool = e._get("ctrlKey").cast()
    let meta_key : Bool = e._get("metaKey").cast()
    let shift_key : Bool = e._get("shiftKey").cast()
    let is_cmd = ctrl_key || meta_key

    // マウス位置を取得
    let pos = state.mouse_scene_pos.get()
    match key {
      "Delete" | "Backspace" => {
        let selected_ids = state.selected_ids.get()
        let elements = state.elements.get()
        let all_ids : Array[String] = []

        // 選択された要素とその親子関係を収集
        for id in selected_ids {
          all_ids.push(id)
          // 子要素を収集
          for el in elements {
            if el.parent_id == Some(id) {
              all_ids.push(el.id)
            }
          }
          // 親要素を収集
          if state.find_element(id) is Some(el) {
            if el.parent_id is Some(parent_id) {
              all_ids.push(parent_id)
              // 親の他の子も収集
              for child in elements {
                if child.parent_id == Some(parent_id) {
                  all_ids.push(child.id)
                }
              }
            }
          }
        }

        // 重複を除去して削除
        let deleted : Array[String] = []
        for id in all_ids {
          if not(deleted.contains(id)) {
            deleted.push(id)
            if state.find_element(id) is Some(el) {
              @core.execute_command(history, state, @model.RemoveElement(el))
            }
          }
        }
      }
      "Escape" =>
        if state.tool_mode.get() == @core.FreeDraw {
          state.tool_mode.set(@core.Select)
        } else {
          state.select(None)
        }
      // 1: Rectangle をマウス位置に挿入(固定サイズ)
      "1" => {
        let el_id = new_id()
        let width = 80.0
        let height = 60.0
        let new_rect = @model.Element::new(
          el_id,
          pos.x - width / 2.0,
          pos.y - height / 2.0,
          @model.Rect(width, height, None, None),
        ).with_style(state.get_default_style())
        @core.execute_command(history, state, @model.AddElement(new_rect))
        state.select(Some(el_id))
      }
      // 2: Circle をマウス位置に挿入(固定サイズ)
      "2" => {
        let el_id = new_id()
        let new_circle = @model.Element::new(
          el_id,
          pos.x,
          pos.y,
          @model.Circle(40.0),
        ).with_style(state.get_default_style())
        @core.execute_command(history, state, @model.AddElement(new_circle))
        state.select(Some(el_id))
      }
      // 3: Ellipse をマウス位置に挿入(固定サイズ)
      "3" => {
        let el_id = new_id()
        let new_ellipse = @model.Element::new(
          el_id,
          pos.x,
          pos.y,
          @model.Ellipse(60.0, 35.0),
        ).with_style(state.get_default_style())
        @core.execute_command(history, state, @model.AddElement(new_ellipse))
        state.select(Some(el_id))
      }
      // 4: Line をマウス位置に挿入(固定サイズ)
      "4" => {
        let el_id = new_id()
        let dx = 100.0
        let dy = 50.0
        let new_line = @model.Element::new(
          el_id,
          pos.x - dx / 2.0,
          pos.y - dy / 2.0,
          @model.Line(pos.x + dx / 2.0, pos.y + dy / 2.0),
        ).with_style(state.get_line_style())
        @core.execute_command(history, state, @model.AddElement(new_line))
        state.select(Some(el_id))
      }
      // 5: Arrow をマウス位置に挿入(固定サイズ)
      "5" => {
        let el_id = new_id()
        let dx = 100.0
        let dy = 50.0
        let new_arrow = @model.Element::new(
          el_id,
          pos.x - dx / 2.0,
          pos.y - dy / 2.0,
          @model.Line(pos.x + dx / 2.0, pos.y + dy / 2.0),
        ).with_style(state.get_arrow_style())
        @core.execute_command(history, state, @model.AddElement(new_arrow))
        state.select(Some(el_id))
      }
      // 6: Text をマウス位置に挿入(固定サイズ)
      "6" => {
        let el_id = new_id()
        let new_text = @model.Element::new(
          el_id,
          pos.x,
          pos.y,
          @model.Text("Text", Some(16.0)),
        ).with_style(state.get_text_style())
        @core.execute_command(history, state, @model.AddElement(new_text))
        state.select(Some(el_id))
      }
      "a" =>
        if is_cmd {
          let _ = e._call("preventDefault", [])
          state.select_all()
        }
      "c" =>
        if is_cmd {
          let _ = e._call("preventDefault", [])
          // 選択中の要素をクリップボードにコピー
          let selected_ids = state.selected_ids.get()
          let copied : Array[@model.Element] = []
          for id in selected_ids {
            if state.find_element(id) is Some(el) {
              copied.push(el)
              // 子要素もコピー
              for child in state.get_children(id) {
                copied.push(child)
              }
            }
          }
          clipboard.val = copied
        }
      "v" =>
        if is_cmd {
          let _ = e._call("preventDefault", [])
          // クリップボードから貼り付け(右下に少しずらす)
          if clipboard.val.length() > 0 {
            let id_map : Array[(String, String)] = [] // (old_id, new_id)
            let new_ids : Array[String] = []
            // まず全要素の新しいIDを生成
            for el in clipboard.val {
              let old_id = el.id
              let dup_id = new_id()
              id_map.push((old_id, dup_id))
            }
            // IDマッピングを検索するヘルパー
            let find_new_id = fn(old_id : String) -> String {
              for mapping in id_map {
                let (o, n) = mapping
                if o == old_id {
                  return n
                }
              }
              old_id
            }
            // 要素を追加
            for el in clipboard.val {
              let new_el_id = find_new_id(el.id)
              // parent_id も更新
              let new_parent_id = match el.parent_id {
                Some(pid) => Some(find_new_id(pid))
                None => None
              }
              // 接続情報も更新
              let new_connections : @model.LineConnections? = match
                el.connections {
                Some(conns) => {
                  let new_start : @model.Connection? = match conns.start {
                    Some(conn) => {
                      let c : @model.Connection = {
                        element_id: find_new_id(conn.element_id),
                        anchor: conn.anchor,
                      }
                      Some(c)
                    }
                    None => None
                  }
                  let new_end : @model.Connection? = match conns.end {
                    Some(conn) => {
                      let c : @model.Connection = {
                        element_id: find_new_id(conn.element_id),
                        anchor: conn.anchor,
                      }
                      Some(c)
                    }
                    None => None
                  }
                  let lc : @model.LineConnections = {
                    start: new_start,
                    end: new_end,
                  }
                  Some(lc)
                }
                None => None
              }
              let new_el = {
                ..el,
                id: new_el_id,
                x: el.x + 20.0,
                y: el.y + 20.0,
                parent_id: new_parent_id,
                connections: new_connections,
              }
              // Line の場合は終点も移動
              let final_el = match new_el.shape {
                @model.Line(x2, y2) =>
                  { ..new_el, shape: @model.Line(x2 + 20.0, y2 + 20.0) }
                _ => new_el
              }
              @core.execute_command(history, state, @model.AddElement(final_el))
              // 子要素でなければ選択対象に追加
              if new_parent_id is None {
                new_ids.push(new_el_id)
              }
            }
            state.select_multiple(new_ids)
          }
        } else {
          // v: 選択モードに切り替え
          @lib.set_select_mode(state)
        }
      "d" =>
        if is_cmd {
          let _ = e._call("preventDefault", [])
          let selected_ids = state.selected_ids.get()
          let new_ids : Array[String] = []
          for id in selected_ids {
            if state.find_element(id) is Some(el) {
              let dup_id = new_id()
              let new_el = { ..el, id: dup_id, x: el.x + 20.0, y: el.y + 20.0 }
              @core.execute_command(history, state, @model.AddElement(new_el))
              new_ids.push(dup_id)
            }
          }
          state.select_multiple(new_ids)
        }
      "z" =>
        if is_cmd {
          let _ = e._call("preventDefault", [])
          let _ = @core.undo_command(history, state)
        }
      "y" =>
        if is_cmd {
          let _ = e._call("preventDefault", [])
          let _ = @core.redo_command(history, state)
        }
      "s" =>
        if is_cmd {
          let _ = e._call("preventDefault", [])
          // Ctrl+S: SVGファイルをダウンロード
          let svg_str = elements_to_moonlight_svg(
            state.elements.get(),
            state.doc_width.get().to_int(),
            state.doc_height.get().to_int(),
            state.get_theme().background,
          )
          download_file(svg_str, "moonlight.svg", "image/svg+xml")
        }
      "C" =>
        // Ctrl+Shift+C: SVGテキストをクリップボードにコピー
        if is_cmd && shift_key {
          let _ = e._call("preventDefault", [])
          let svg_str = elements_to_moonlight_svg(
            state.elements.get(),
            state.doc_width.get().to_int(),
            state.doc_height.get().to_int(),
            state.get_theme().background,
          )
          copy_text_to_clipboard(svg_str, fn() { () }, fn() { () })
        }
      "V" =>
        // Ctrl+Shift+V: クリップボードからSVGをインポート
        if is_cmd && shift_key {
          let _ = e._call("preventDefault", [])
          read_clipboard_text(
            fn(text) {
              let result = import_svg_validated(text, new_id)
              for el in result.elements {
                @core.execute_command(history, state, @model.AddElement(el))
              }
              if result.elements.length() > 0 {
                // プレーンSVG要素を登録(既存に追加)
                if result.plain_svg_ids.length() > 0 {
                  state.register_plain_svg_ids(result.plain_svg_ids)
                }
                // バリデーション実行
                state.revalidate()
                let ids = result.elements
                  .filter(fn(el) { el.parent_id is None })
                  .map(fn(el) { el.id })
                state.select_multiple(ids)
              }
            },
            fn() { () },
          )
        } else if not(is_cmd) {
          // V: 選択モードに切り替え
          @lib.set_select_mode(state)
        }
      // P: フリードローモード切替
      "p" | "P" => @lib.toggle_tool_mode(state)
      _ => ()
    }
  }
  add_keyboard_listener(handler)
}

// ============================================================
// UI レンダリング
// ============================================================

///|
/// エディタ UI をレンダリング(Luna VNode ベース)
fn render_editor_ui_vnode(
  state : @core.EditorState,
  history : @model.History,
  new_id : () -> String,
  svg_canvas : @element.DomNode,
  commit_text_input : (String) -> Unit,
) -> @element.DomNode {
  // メインレイアウト(全画面キャンバス + フローティングUI)
  div(
    style="position: relative; width: 100vw; height: 100vh; overflow: hidden;",
    [
      // キャンバス(画面全体)
      div(style="position: absolute; inset: 0;", [
        svg_canvas,
        // テキスト入力
        render_text_input_container(state, commit_text_input),
      ]),
      // フローティングツールバー(上部中央)
      div(
        dyn_style=fn() {
          let is_mobile = state.is_mobile.get()
          if is_mobile {
            "position: fixed; bottom: 60px; left: 50%; transform: translateX(-50%); display: flex; gap: 4px; align-items: center; background: rgba(255,255,255,0.95); padding: 6px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 11px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 100;"
          } else {
            "position: fixed; top: 12px; left: 50%; transform: translateX(-50%); display: flex; gap: 4px; align-items: center; background: rgba(255,255,255,0.95); padding: 6px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 11px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 100;"
          }
        },
        [render_embedded_toolbar(state, history, new_id)],
      ),
      // フローティングパネル(デスクトップ: 右端、モバイル: 下部)
      show(fn() { state.panel_visible.get() }, fn() {
        render_floating_panel(state, history)
      }),
      // パネル表示/非表示トグル
      render_panel_toggle(state),
      // コンテキストメニュー
      show(fn() { state.context_menu.get() is Some(_) }, fn() {
        render_context_menu(state, history, new_id)
      }),
    ],
  )
}

///|
/// パネル表示/非表示トグルボタン
fn render_panel_toggle(state : @core.EditorState) -> @element.DomNode {
  button(
    dyn_style=fn() {
      let is_mobile = state.is_mobile.get()
      let visible = state.panel_visible.get()
      let icon_transform = if visible {
        ""
      } else {
        "transform: rotate(180deg);"
      }
      if is_mobile {
        "position: fixed; bottom: 12px; right: 12px; width: 36px; height: 36px; border-radius: 50%; background: rgba(255,255,255,0.95); border: 1px solid #ddd; cursor: pointer; font-size: 14px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 101; " +
        icon_transform
      } else {
        "position: fixed; top: 12px; right: 12px; width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.95); border: 1px solid #ddd; cursor: pointer; font-size: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 101; " +
        icon_transform
      }
    },
    on=events().click(fn(_) { state.panel_visible.update(fn(v) { not(v) }) }),
    [text_dyn(fn() { if state.panel_visible.get() { "×" } else { "☰" } })],
  )
}

// ============================================================
// 埋め込みモード
// ============================================================

///|
/// 埋め込みビューをレンダリング(簡易モード)
/// 親要素のサイズにフィットし、aspect-ratio でアスペクト比を維持
fn render_embedded_view(
  state : @core.EditorState,
  svg_canvas : @element.DomNode,
  commit_text_input : (String) -> Unit,
) -> @element.DomNode {
  // コンテナ:aspect-ratio で比率を維持(フラットな構造)
  div(
    dyn_style=fn() {
      let doc_w = state.doc_width.get()
      let doc_h = state.doc_height.get()
      "position: relative; width: 100%; aspect-ratio: \{doc_w} / \{doc_h};"
    },
    [
      // SVG キャンバス
      svg_canvas,
      // テキスト入力(absolute positioned)
      render_text_input_container(state, commit_text_input),
      // 編集ボタン(右上固定)- アイコンのみ、aria-label付き
      @element.create_element(
        "button",
        [
          (
            "style",
            @element.Static(
              "position: absolute; top: 8px; right: 8px; padding: 10px; background: rgba(59, 130, 246, 0.95); border: none; border-radius: 8px; cursor: pointer; color: white; box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4); z-index: 10; display: flex; align-items: center; justify-content: center;",
            ),
          ),
          ("aria-label", @element.Static("Edit in fullscreen")),
          ("title", @element.Static("Edit in fullscreen")),
          ("click", @element.Handler(fn(_) { state.modal_open.set(true) })),
        ],
        [@components.svg_icon(@components.icon_edit_expand, 20)],
      ),
    ],
  )
}

///|
/// エディタモーダルをレンダリング(フルエディタを別ウィンドウ風に表示)
pub fn render_editor_modal(
  state : @core.EditorState,
  history : @model.History,
  new_id : () -> String,
) -> @element.DomNode {
  // フルエディタ用の SVG を作成
  let get_screen_coords = fn(e : @js.Any) -> (Double, Double) {
    let mouse_event : @js_dom.MouseEvent = e.cast()
    let svg = e._get("currentTarget")
    let rect = svg._call("getBoundingClientRect", [])
    let screen_x = mouse_event.clientX - (rect._get("left").cast() : Double)
    let screen_y = mouse_event.clientY - (rect._get("top").cast() : Double)
    (screen_x, screen_y)
  }
  let get_scene_coords = fn(e : @js.Any) -> (Double, Double) {
    let (screen_x, screen_y) = get_screen_coords(e)
    let vp = state.viewport.get()
    let scene = vp.screen_to_scene(screen_x, screen_y)
    (scene.x, scene.y)
  }
  let space_pressed : Ref[Bool] = { val: false }
  let last_click_time : Ref[Double] = { val: 0.0 }
  let last_click_element : Ref[String] = { val: "" }
  let double_click_threshold : Double = 300.0
  let active_pointers : Ref[Map[Int, @model.Point]] = { val: {} }
  let commit_text_input = fn(text_content : String) {
    commit_text_edit(state, history, new_id, text_content)
  }
  let on_pointerdown = create_pointerdown_handler(
    state, history, new_id, get_screen_coords, get_scene_coords, space_pressed, last_click_time,
    last_click_element, double_click_threshold, active_pointers,
  )
  let on_pointermove = create_pointermove_handler(
    state, get_screen_coords, get_scene_coords, active_pointers,
  )
  let on_pointerup = create_pointerup_handler(
    state,
    history,
    get_screen_coords,
    active_pointers,
    new_id~,
  )
  let on_pointerleave = create_pointerleave_handler(state)
  let on_wheel = create_wheel_handler(state, get_screen_coords)
  let on_contextmenu = create_contextmenu_handler(state, get_scene_coords)
  let modal_svg = render_svg_canvas_node(
    state, on_pointerdown, on_pointermove, on_pointerup, on_pointerleave, on_contextmenu,
    on_wheel,
  )

  // モーダル用キーボードハンドラ(Escape で閉じる、矢印キーで移動)
  let _ = @luna.effect(fn() {
    let _ = state.modal_open.get() // 依存関係を登録
    if state.modal_open.get() {
      let handler = fn(e : @js.Any) {
        // 入力フォーカス中は無視
        if is_input_focused_ffi() {
          return
        }
        let key : String = e._get("key").cast()
        let keyboard_event : @js_dom.KeyboardEvent = e.cast()
        // Escape で閉じる
        if key == "Escape" {
          state.modal_open.set(false)
          keyboard_event.preventDefault()
          return
        }
        // 矢印キーで選択要素を移動
        match key {
          "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight" => {
            let shift_key : Bool = e._get("shiftKey").cast()
            handle_arrow_key(state, history, key, shift_key)
            keyboard_event.preventDefault()
          }
          _ => ()
        }
      }
      let _ = add_keydown_listener(handler)
    }
  })

  // モーダルオーバーレイ(クリックで閉じる)
  div(
    style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center;",
    on=events().click(fn(_) { state.modal_open.set(false) }),
    [
      // モーダルウィンドウ(クリックイベントの伝播を止める)
      div(
        style="width: 90vw; max-width: 1200px; height: 85vh; background: white; border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 4px 24px rgba(0,0,0,0.3);",
        on=events().click(fn(e) {
          let _ = e.as_any()._call("stopPropagation", [])
        }),
        [
          // ヘッダー
          div(
            style="display: flex; justify-content: space-between; align-items: center; padding: 8px 16px; border-bottom: 1px solid #ddd; background: #f8f8f8; flex-shrink: 0;",
            [
              div(style="font-weight: 500; font-size: 14px;", [
                text("Moonlight Editor"),
              ]),
              // 閉じるボタン - アイコンのみ、aria-label付き
              @element.create_element(
                "button",
                [
                  (
                    "style",
                    @element.Static(
                      "padding: 8px; background: #f5f5f5; border: 1px solid #d0d0d0; border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: #555;",
                    ),
                  ),
                  ("aria-label", @element.Static("Close")),
                  ("title", @element.Static("Close")),
                  (
                    "click",
                    @element.Handler(fn(_) { state.modal_open.set(false) }),
                  ),
                ],
                [@components.svg_icon(@components.icon_close, 18)],
              ),
            ],
          ),
          // エディタ本体
          div(
            style="flex: 1; display: flex; overflow: hidden; min-height: 0;",
            [
              // キャンバス領域
              div(style="flex: 1; position: relative; min-width: 0;", [
                modal_svg,
                render_text_input_container(state, commit_text_input),
              ]),
              // サイドパネル(インライン版 - position: fixed なし)
              div(
                style="width: 240px; border-left: 1px solid #ddd; overflow-y: auto; background: #fafafa;",
                [render_inline_panel(state, history)],
              ),
            ],
          ),
          // ツールバー(下部)
          div(
            style="display: flex; gap: 4px; align-items: center; padding: 8px 12px; border-top: 1px solid #ddd; background: #f8f8f8; flex-shrink: 0;",
            [render_embedded_toolbar(state, history, new_id)],
          ),
          // コンテキストメニュー(表示時のみ)
          show(fn() { state.context_menu.get() is Some(_) }, fn() {
            render_context_menu(state, history, new_id)
          }),
        ],
      ),
    ],
  )
}

///|
/// 埋め込みエディタアプリをレンダリング
fn embedded_editor_app(
  state : @core.EditorState,
  history : @model.History,
  new_id : () -> String,
) -> @element.DomNode {
  // 埋め込み用の簡易イベントハンドラ(選択・ドラッグ・テキスト編集のみ)
  let get_screen_coords = fn(e : @js.Any) -> (Double, Double) {
    let mouse_event : @js_dom.MouseEvent = e.cast()
    let svg = e._get("currentTarget")
    let rect = svg._call("getBoundingClientRect", [])
    let screen_x = mouse_event.clientX - (rect._get("left").cast() : Double)
    let screen_y = mouse_event.clientY - (rect._get("top").cast() : Double)
    (screen_x, screen_y)
  }
  let get_scene_coords = fn(e : @js.Any) -> (Double, Double) {
    let (screen_x, screen_y) = get_screen_coords(e)
    let vp = state.viewport.get()
    let scene = vp.screen_to_scene(screen_x, screen_y)
    (scene.x, scene.y)
  }
  let space_pressed : Ref[Bool] = { val: false }
  let last_click_time : Ref[Double] = { val: 0.0 }
  let last_click_element : Ref[String] = { val: "" }
  let double_click_threshold : Double = 300.0
  let active_pointers : Ref[Map[Int, @model.Point]] = { val: {} }
  let commit_text_input = fn(text_content : String) {
    commit_text_edit(state, history, new_id, text_content)
  }

  // 簡易ハンドラ(選択・移動・テキスト編集のみ)
  let on_pointerdown = create_pointerdown_handler(
    state, history, new_id, get_screen_coords, get_scene_coords, space_pressed, last_click_time,
    last_click_element, double_click_threshold, active_pointers,
  )
  let on_pointermove = create_pointermove_handler(
    state, get_screen_coords, get_scene_coords, active_pointers,
  )
  let on_pointerup = create_pointerup_handler(
    state,
    history,
    get_screen_coords,
    active_pointers,
    new_id~,
  )
  let on_pointerleave = create_pointerleave_handler(state)
  // 埋め込みモードではズームを無効化
  let on_wheel = fn(_e : @js.Any) { () }
  // コンテキストメニューは有効化(要素の詳細表示・挿入機能)
  let on_contextmenu = create_contextmenu_handler(state, get_scene_coords)
  let svg_canvas = render_svg_canvas_node(
    state, on_pointerdown, on_pointermove, on_pointerup, on_pointerleave, on_contextmenu,
    on_wheel,
  )
  // 埋め込みビュー(フラットな構造)
  @element.fragment([
    render_embedded_view(state, svg_canvas, commit_text_input),
    // コンテキストメニュー(表示時のみ)
    show(fn() { state.context_menu.get() is Some(_) }, fn() {
      render_context_menu(state, history, new_id)
    }),
    // モーダル(表示時のみ)
    show(fn() { state.modal_open.get() }, fn() {
      render_editor_modal(state, history, new_id)
    }),
  ])
}

///|
/// 16進数カラーコードから明度を計算(0.0〜1.0)
fn get_color_luminance(hex : String) -> Double {
  // #RRGGBB 形式を想定、文字配列に変換
  let chars : Array[Char] = hex.to_upper().iter().collect()
  let start = if chars.length() > 0 && chars[0] == '#' { 1 } else { 0 }
  guard chars.length() >= start + 6 else { return 0.5 }
  // 各チャンネルを抽出
  let r = (hex_char_to_int(chars[start]) * 16 +
  hex_char_to_int(chars[start + 1])).to_double()
  let g = (hex_char_to_int(chars[start + 2]) * 16 +
  hex_char_to_int(chars[start + 3])).to_double()
  let b = (hex_char_to_int(chars[start + 4]) * 16 +
  hex_char_to_int(chars[start + 5])).to_double()
  // 相対輝度の計算(sRGB)
  (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
}

///|
/// 16進数文字を数値に変換
fn hex_char_to_int(c : Char) -> Int {
  match c {
    '0' => 0
    '1' => 1
    '2' => 2
    '3' => 3
    '4' => 4
    '5' => 5
    '6' => 6
    '7' => 7
    '8' => 8
    '9' => 9
    'A' => 10
    'B' => 11
    'C' => 12
    'D' => 13
    'E' => 14
    'F' => 15
    _ => 0
  }
}

///|
/// テキスト色に対してコントラストのある背景色を返す
fn get_contrasting_bg(text_color : String, theme : @model.ThemeMode) -> String {
  let luminance = get_color_luminance(text_color)
  // テキストが暗い(明度 < 0.5)なら明るい背景、逆なら暗い背景
  if luminance < 0.5 {
    // 暗いテキスト → 明るい背景
    match theme {
      @model.Light => "#ffffff"
      @model.Dark => "#e0e0e0"
    }
  } else {
    // 明るいテキスト → 暗い背景
    match theme {
      @model.Light => "#2a2a2a"
      @model.Dark => "#1a1a2e"
    }
  }
}

///|
/// テキスト入力コンテナ(FFI 要素を Effect で手動管理)
fn render_text_input_container(
  state : @core.EditorState,
  commit : (String) -> Unit,
) -> @element.DomNode {
  let container_ref : Ref[@js.Any?] = { val: None }
  let current_textarea : Ref[@js.Any?] = { val: None }

  // Effect で text_edit の変更を監視
  let _ = @luna.effect(fn() {
    let text_edit = state.text_edit.get()

    // コンテナがまだレンダリングされていない場合は何もしない
    let container = match container_ref.val {
      Some(c) => c
      None => return
    }

    // 既存の textarea を削除
    if current_textarea.val is Some(textarea) {
      remove_child_safe_ffi(container, textarea)
      current_textarea.val = None
    }

    // 新しい textarea を作成
    if text_edit is Some(edit_state) {
      let vp = state.viewport.get()
      let theme = state.theme_mode.get()
      let elements = state.elements.get()
      let theme_colors = state.get_theme()
      // テキスト色を取得(既存テキストの色または親要素のstroke)
      let default_text_color = theme_colors.stroke
      // CSS変数を実際の色に変換
      let resolve_color = fn(color : String) -> String {
        if color.has_prefix("var(") {
          theme_colors.stroke
        } else {
          color
        }
      }
      let text_color = match edit_state.editing_id {
        Some(id) =>
          // 既存テキストの編集
          match state.find_element(id) {
            Some(el) => {
              let parent_stroke = @model.get_parent_stroke(
                elements,
                el.parent_id,
              )
              resolve_color(@model.get_text_fill_color(el.style, parent_stroke))
            }
            None => default_text_color
          }
        None =>
          // 新規テキスト作成(親要素のstrokeを使用)
          match state.find_element(edit_state.parent_id) {
            Some(parent) =>
              resolve_color(parent.style.stroke.unwrap_or(default_text_color))
            None => default_text_color
          }
      }
      // テキスト入力の位置を決定
      // 常に親要素の中心座標を使用(子テキストの座標が相対座標の場合があるため)
      let (text_x, text_y) : (Double, Double) = match
        state.find_element(edit_state.parent_id) {
        Some(parent) => {
          let bbox = parent.bounding_box()
          let center_x = bbox.x + bbox.width / 2.0
          let center_y = bbox.y + bbox.height / 2.0
          (center_x, center_y)
        }
        None => (edit_state.x, edit_state.y)
      }
      // 背景色(テキスト色とのコントラストを考慮)
      // テキスト色が暗い(#000000に近い)場合は明るい背景、逆の場合は暗い背景
      let bg_color = get_contrasting_bg(text_color, theme)
      // Embed モード(モーダルなし)ではパーセント指定、それ以外はピクセル指定
      let is_embed_view = state.mode is @core.Embedded &&
        not(state.modal_open.get())
      let base_font_size = match edit_state.font_size {
        Some(size) => size
        None => 16.0
      }
      let style = if is_embed_view {
        // Embed: viewBox "0 0 doc_w doc_h" に対するパーセント位置
        let doc_w = state.doc_width.get()
        let doc_h = state.doc_height.get()
        let left_pct = text_x / doc_w * 100.0
        let top_pct = text_y / doc_h * 100.0
        "position: absolute; left: \{left_pct}%; top: \{top_pct}%; transform: translate(-50%, -50%); min-width: 40px; padding: 2px 4px; border: none; background: \{bg_color}; color: \{text_color}; font-family: sans-serif; font-size: \{base_font_size}px; line-height: 1.2; text-align: center; outline: none; box-sizing: border-box; resize: none; overflow: hidden; caret-color: currentColor; white-space: nowrap; border-radius: 2px;"
      } else {
        // Full/Modal: viewport に基づくピクセル位置
        let screen_pos = vp.scene_to_screen(text_x, text_y)
        let min_width = 40.0 * vp.zoom
        let scaled_font_size = base_font_size * vp.zoom
        let scaled_height = scaled_font_size * 1.5
        "position: absolute; left: \{screen_pos.x}px; top: \{screen_pos.y}px; transform: translate(-50%, -50%); min-width: \{min_width}px; height: \{scaled_height}px; padding: 2px 4px; border: none; background: \{bg_color}; color: \{text_color}; font-family: sans-serif; font-size: \{scaled_font_size}px; line-height: 1.2; text-align: center; outline: none; box-sizing: border-box; resize: none; overflow: hidden; caret-color: currentColor; white-space: nowrap; border-radius: 2px;"
      }
      // リアルタイム更新用のコールバック
      let editing_id = edit_state.editing_id
      let initial_text = edit_state.initial_text
      // 入力中のリアルタイム更新は一時的に無効化(textareaが消える問題を調査中)
      let on_input = fn(_value : String) { () }
      let on_escape = fn(_value : String) {
        // キャンセル時は元のテキストに戻す
        match editing_id {
          Some(id) => state.update_text_raw(id, initial_text)
          None => ()
        }
        state.text_edit.set(None)
      }
      let textarea = create_textarea_ffi(
        style,
        initial_text,
        fn(value) { commit(value) },
        on_escape,
        on_input,
      )
      append_child_ffi(container, textarea)
      current_textarea.val = Some(textarea)
    }
  })
  @element.create_element(
    "div",
    [
      (
        "style",
        @element.Static(
          "position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;",
        ),
      ),
      ("__ref", @element.Handler(fn(el) { container_ref.val = Some(el) })),
    ],
    [],
  )
}

///|
// svg_icon, toolbar_button は @components から利用

///|
/// 埋め込みツールバー(SVGアイコン + キーバインド表示)
fn render_embedded_toolbar(
  state : @core.EditorState,
  history : @model.History,
  new_id : () -> String,
) -> @element.DomNode {
  let theme = state.get_theme()
  div(style="display: flex; gap: 3px; align-items: center;", [
    // 図形追加ボタン
    @components.toolbar_button(
      @components.svg_icon(@components.icon_rect, 16),
      Some("1"),
      "Rectangle",
      fn() {
        let el_id = new_id()
        let pos = state.get_placement_position()
        let width = 80.0
        let height = 60.0
        let new_rect = @model.Element::new(
          el_id,
          pos.x - width / 2.0,
          pos.y - height / 2.0,
          @model.Rect(width, height, None, None),
        ).with_style(state.get_default_style())
        @core.execute_command(history, state, @model.AddElement(new_rect))
        state.select(Some(el_id))
      },
      theme~,
    ),
    @components.toolbar_button(
      @components.svg_icon(@components.icon_circle, 16),
      Some("2"),
      "Circle",
      fn() {
        let el_id = new_id()
        let pos = state.get_placement_position()
        let new_circle = @model.Element::new(
          el_id,
          pos.x,
          pos.y,
          @model.Circle(40.0),
        ).with_style(state.get_default_style())
        @core.execute_command(history, state, @model.AddElement(new_circle))
        state.select(Some(el_id))
      },
      theme~,
    ),
    @components.toolbar_button(
      @components.svg_icon(@components.icon_ellipse, 16),
      Some("3"),
      "Ellipse",
      fn() {
        let el_id = new_id()
        let pos = state.get_placement_position()
        let new_ellipse = @model.Element::new(
          el_id,
          pos.x,
          pos.y,
          @model.Ellipse(60.0, 35.0),
        ).with_style(state.get_default_style())
        @core.execute_command(history, state, @model.AddElement(new_ellipse))
        state.select(Some(el_id))
      },
      theme~,
    ),
    @components.toolbar_button(
      @components.svg_icon(@components.icon_line, 16),
      Some("4"),
      "Line",
      fn() {
        let el_id = new_id()
        let pos = state.get_placement_position()
        let dx = 100.0
        let dy = 50.0
        let new_line = @model.Element::new(
          el_id,
          pos.x - dx / 2.0,
          pos.y - dy / 2.0,
          @model.Line(pos.x + dx / 2.0, pos.y + dy / 2.0),
        ).with_style(state.get_line_style())
        @core.execute_command(history, state, @model.AddElement(new_line))
        state.select(Some(el_id))
      },
      theme~,
    ),
    @components.toolbar_button(
      @components.svg_icon(@components.icon_arrow, 16),
      Some("5"),
      "Arrow",
      fn() {
        let el_id = new_id()
        let pos = state.get_placement_position()
        let dx = 100.0
        let dy = 50.0
        let new_arrow = @model.Element::new(
          el_id,
          pos.x - dx / 2.0,
          pos.y - dy / 2.0,
          @model.Line(pos.x + dx / 2.0, pos.y + dy / 2.0),
        ).with_style(state.get_arrow_style())
        @core.execute_command(history, state, @model.AddElement(new_arrow))
        state.select(Some(el_id))
      },
      theme~,
    ),
    @components.toolbar_button(
      @components.svg_icon(@components.icon_text, 16),
      Some("6"),
      "Text",
      fn() {
        let el_id = new_id()
        let pos = state.get_placement_position()
        let font_size = 24.0
        // テキストは左上基準、概算で中央配置("Text"の幅≒50px)
        let new_text = @model.Element::new(
          el_id,
          pos.x - 25.0,
          pos.y + font_size / 3.0,
          @model.Text("Text", Some(font_size)),
        ).with_style(state.get_text_style())
        @core.execute_command(history, state, @model.AddElement(new_text))
        state.select(Some(el_id))
      },
      theme~,
    ),
    // セパレータ
    @components.toolbar_separator(theme),
    // ツールモード切替
    @components.toolbar_button(
      @components.svg_icon(
        if state.tool_mode.get() == @core.FreeDraw {
          @components.icon_pen
        } else {
          @components.icon_cursor
        },
        16,
      ),
      Some("P"),
      if state.tool_mode.get() == @core.FreeDraw {
        "Free Draw (active)"
      } else {
        "Free Draw"
      },
      fn() { @lib.toggle_tool_mode(state) },
      theme~,
    ),
    // セパレータ
    @components.toolbar_separator(theme),
    // Undo/Redo
    @components.toolbar_button(
      @components.svg_icon(@components.icon_undo, 16),
      Some("⌘Z"),
      "Undo",
      fn() { let _ = @core.undo_command(history, state) },
      theme~,
    ),
    @components.toolbar_button(
      @components.svg_icon(@components.icon_redo, 16),
      Some("⌘Y"),
      "Redo",
      fn() { let _ = @core.redo_command(history, state) },
      theme~,
    ),
    // セパレータ
    @components.toolbar_separator(theme),
    // ズームコントロール
    @components.toolbar_button(
      @components.svg_icon(@components.icon_zoom_out, 16),
      None,
      "Zoom Out",
      fn() { state.zoom_out() },
      theme~,
    ),
    @element.span(
      style="min-width: 44px; text-align: center; font-size: 11px; font-weight: 500; color: \{theme.ui_text};",
      [
        text_dyn(fn() {
          let zoom = state.viewport.get().zoom
          "\{(zoom * 100.0).to_int()}%"
        }),
      ],
    ),
    @components.toolbar_button(
      @components.svg_icon(@components.icon_zoom_in, 16),
      None,
      "Zoom In",
      fn() { state.zoom_in() },
      theme~,
    ),
    @components.toolbar_button(
      @components.svg_icon(@components.icon_reset, 16),
      None,
      "Fit to Canvas",
      fn() { state.reset_viewport() },
      theme~,
    ),
    // セパレータ
    @components.toolbar_separator(theme),
    // グリッドスナップ(トグルボタン + Popover)
    {
      let snap_popover_id = "tooltip-grid-snap"
      let snap_popover_style = "position: fixed; margin: 0; padding: 4px 8px; background: #333; color: #fff; font-size: 11px; border-radius: 4px; white-space: nowrap; border: none;"
      div(style="position: relative; display: inline-block;", [
        @element.create_element(
          "button",
          [
            (
              "style",
              @element.Dynamic(fn() {
                let enabled = state.grid_enabled.get()
                let is_dark = state.theme_mode.get() == @model.Dark
                let current_theme = if is_dark {
                  @model.Theme::dark()
                } else {
                  @model.Theme::light()
                }
                let base = "display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; padding: 4px 6px; cursor: pointer; border: 1px solid; border-radius: 4px; min-width: 36px; height: 40px;"
                if enabled {
                  let enabled_bg = if is_dark { "#1e3a5f" } else { "#e3f2fd" }
                  let enabled_border = if is_dark {
                    "#4a9eff"
                  } else {
                    "#2196f3"
                  }
                  let enabled_color = if is_dark {
                    "#60a5fa"
                  } else {
                    "#1976d2"
                  }
                  base +
                  "background: \{enabled_bg}; border-color: \{enabled_border}; color: \{enabled_color};"
                } else {
                  base +
                  "background: \{current_theme.ui_bg}; border-color: \{current_theme.ui_border}; color: \{current_theme.ui_text_muted};"
                }
              }),
            ),
            ("aria-label", @element.Static("Grid Snap")),
            ("click", @element.Handler(fn(_) { state.toggle_grid() })),
            (
              "mouseenter",
              @element.Handler(fn(e) {
                show_popover_at_button(snap_popover_id, e)
              }),
            ),
            (
              "mouseleave",
              @element.Handler(fn(_) { hide_popover(snap_popover_id) }),
            ),
          ],
          [
            @components.svg_icon(@components.icon_grid, 16),
            @element.span(
              style="color: inherit; font-size: 9px; line-height: 1;",
              [text("Snap")],
            ),
          ],
        ),
        div(
          style=snap_popover_style,
          attrs=[
            ("id", @element.AttrString(snap_popover_id)),
            ("popover", @element.AttrString("manual")),
          ],
          [text("Grid Snap")],
        ),
      ])
    },
    // セパレータ
    @components.toolbar_separator(theme),
    // エクスポート(Moonlight SVG形式でダウンロード)
    @components.toolbar_button(
      @components.svg_icon(@components.icon_export, 16),
      None,
      "Download SVG (Ctrl+S)",
      fn() {
        let svg_str = elements_to_moonlight_svg(
          state.elements.get(),
          state.doc_width.get().to_int(),
          state.doc_height.get().to_int(),
          state.get_theme().background,
        )
        download_file(svg_str, "moonlight.svg", "image/svg+xml")
      },
      theme~,
    ),
    // コピー(SVGテキスト)
    @components.toolbar_button(
      @components.svg_icon(@components.icon_copy, 16),
      None,
      "Copy SVG (Ctrl+Shift+C)",
      fn() {
        let svg_str = elements_to_moonlight_svg(
          state.elements.get(),
          state.doc_width.get().to_int(),
          state.doc_height.get().to_int(),
          state.get_theme().background,
        )
        copy_text_to_clipboard(svg_str, fn() { () }, fn() { () })
      },
      theme~,
    ),
    // インポート
    @components.toolbar_button(
      @components.svg_icon(@components.icon_import, 16),
      None,
      "Import SVG (Ctrl+Shift+V)",
      fn() {
        open_file_dialog(".svg", fn(text) {
          let result = import_svg_validated(text, new_id)
          for el in result.elements {
            @core.execute_command(history, state, @model.AddElement(el))
          }
          if result.elements.length() > 0 {
            // プレーンSVG要素を登録(既存に追加)
            if result.plain_svg_ids.length() > 0 {
              state.register_plain_svg_ids(result.plain_svg_ids)
            }
            // バリデーション実行
            state.revalidate()
            let ids = result.elements
              .filter(fn(el) { el.parent_id is None })
              .map(fn(el) { el.id })
            state.select_multiple(ids)
          }
        })
      },
      theme~,
    ),
    // GitHub リンク(フルモードのみ)
    @element.show(fn() { state.mode is @core.Full }, fn() {
      @element.fragment([
        @components.toolbar_separator(theme),
        @components.toolbar_button(
          @components.svg_icon(@components.icon_github, 16),
          None,
          "View on GitHub",
          fn() { open_github_url() },
          theme~,
        ),
      ])
    }),
  ])
}

///|
/// GitHub URL
let github_url : String = "https://github.com/mizchi/moonlight"

///|
/// GitHub ページを開く
fn open_github_url() -> Unit {
  open_url_ffi(github_url)
}

///|
extern "js" fn open_url_ffi(url : String) =
  #| (url) => window.open(url, '_blank')

// =============================================================================
// SVG キャンバス(Luna VNode + for_each による差分更新)
// =============================================================================

///|
/// SVG キャンバスを作成(Luna VNode + for_each による差分更新)
fn render_svg_canvas_node(
  state : @core.EditorState,
  on_pointerdown : (@js.Any) -> Unit,
  on_pointermove : (@js.Any) -> Unit,
  on_pointerup : (@js.Any) -> Unit,
  on_pointerleave : (@js.Any) -> Unit,
  on_contextmenu : (@js.Any) -> Unit,
  on_wheel : (@js.Any) -> Unit,
) -> @element.DomNode {
  // 要素 ID リスト(for_each のキーとして使用)
  // Line/Arrow を最後に描画(常に上に表示)するためソート
  let element_ids = @luna.memo(fn() {
    let elements = state.elements.get()
    let non_lines : Array[String] = []
    let lines : Array[String] = []
    for el in elements {
      if el.shape is @model.Line(_, _) {
        lines.push(el.id)
      } else {
        non_lines.push(el.id)
      }
    }
    non_lines.append(lines)
    non_lines
  })

  // SVG 子要素
  let children : Array[@element.DomNode] = []

  // 矢印マーカーの defs(静的)+ ドキュメントシャドウフィルタ
  let defs_node : @js_dom.Node = create_arrow_defs().cast()
  children.push(@element.static_node(defs_node))
  // ドキュメントシャドウフィルタ(テーマに応じて変化)
  children.push(
    create_doc_shadow_filter_node(state.theme_mode.get() == @model.Dark),
  )

  // Canvas 背景(境界外グレー + ドキュメント境界)
  children.push(render_canvas_background_vnode(state))

  // グリッド線(grid_enabled に応じて表示/非表示)
  children.push(
    @element.create_element_ns(
      @element.svg_ns,
      "g",
      [
        (
          "style",
          @element.Dynamic(fn() {
            if state.grid_enabled.get() {
              "visibility: visible;"
            } else {
              "visibility: hidden;"
            }
          }),
        ),
      ],
      [render_grid_vnode(state)],
    ),
  )

  // 要素を for_each で描画(ID ベースの差分更新)
  children.push(
    @element.for_each(fn() { element_ids() }, fn(id, _idx) {
      render_element_vnode(state, id)
    }),
  )

  // 選択ハンドル・アンカー
  children.push(render_selection_overlay(state))

  // 矩形選択ボックス
  children.push(
    @element.show(fn() { state.box_select.get() is Some(_) }, fn() {
      render_box_select_vnode(state)
    }),
  )

  // 線ドラッグ中のアンカーポイント
  children.push(
    @element.show(
      fn() {
        match state.resize_state.get() {
          Some(resize) => resize.start_shape is @model.Line(_, _)
          None => false
        }
      },
      fn() { render_all_anchors_vnode(state) },
    ),
  )

  // 接続ハイライト
  children.push(
    @element.show(fn() { state.pending_connection.get() is Some(_) }, fn() {
      render_connection_highlight_vnode(state)
    }),
  )

  // SVG 属性(100% サイズ、viewBox は動的)
  let svg_attrs : Array[(String, @element.AttrValue)] = [
    ("width", @element.Static("100%")),
    ("height", @element.Static("100%")),
    (
      "viewBox",
      @element.Dynamic(fn() {
        // Embedモードではドキュメント全体を表示(0, 0, doc_w, doc_h)
        // ただしモーダルが開いている場合はフルモードのviewBoxを使用
        if state.mode is @core.Embedded && not(state.modal_open.get()) {
          let doc_w = state.doc_width.get()
          let doc_h = state.doc_height.get()
          "0 0 \{doc_w} \{doc_h}"
        } else {
          let vp = state.viewport.get()
          let cw = state.canvas_width.get().to_double()
          let ch = state.canvas_height.get().to_double()
          let vb_width = cw / vp.zoom
          let vb_height = ch / vp.zoom
          "\{vp.scroll_x} \{vp.scroll_y} \{vb_width} \{vb_height}"
        }
      }),
    ),
    (
      "style",
      @element.Dynamic(fn() {
        let theme = state.get_theme()
        // 埋め込みモードではコンテナ側にボーダーがあるのでSVGのボーダーは不要
        let border = if state.mode is @core.Embedded {
          "none"
        } else {
          "1px solid #ccc"
        }
        // ツールモードに応じてカーソルを変更
        let cursor = match state.tool_mode.get() {
          @core.FreeDraw => "crosshair"
          @core.Select => "default"
        }
        "border: \{border}; user-select: none; touch-action: none; overflow: hidden; cursor: \{cursor}; --ml-stroke: \{theme.stroke}; --ml-fill: \{theme.fill}; --ml-text: \{theme.text};"
      }),
    ),
    ("pointerdown", @element.Handler(on_pointerdown)),
    ("pointermove", @element.Handler(on_pointermove)),
    ("pointerup", @element.Handler(on_pointerup)),
    ("pointerleave", @element.Handler(on_pointerleave)),
    ("contextmenu", @element.Handler(on_contextmenu)),
    ("wheel", @element.Handler(on_wheel)),
    // ResizeObserver で canvas サイズを更新し、初回はドキュメントを中央に配置(Fullモードのみ)
    (
      "__ref",
      @element.Handler(fn(svg_el) {
        // SVG 要素への参照を保存(フリードロープレビュー用)
        state.svg_el.set(svg_el)
        let is_first_resize : Ref[Bool] = { val: true }
        observe_resize_ffi(svg_el, fn(width : Double, height : Double) {
          let w = width.to_int()
          let h = height.to_int()
          if w > 0 && h > 0 {
            state.canvas_width.set(w)
            state.canvas_height.set(h)
            // 初回のみドキュメントを中央に配置(Fullモードまたはモーダル表示時)
            if is_first_resize.val {
              is_first_resize.val = false
              // Embedモードでモーダルが閉じている場合はビューポートを (0, 0, 1.0) に固定
              if state.mode is @core.Full || state.modal_open.get() {
                state.fit_to_canvas()
              }
            }
          }
        })
      }),
    ),
  ]
  @element.create_element_ns(@element.svg_ns, "svg", svg_attrs, children)
}

///|
/// メインエントリポイント
fn main {
  app_main()
}